通过.NET提高事件溯源投影到关系型数据库(SQL)的性能

7

我目前正在使用C#开发一个原型,利用CQRS和事件溯源技术,但在将投影到SQL数据库时遇到了性能瓶颈。

我的第一个原型是使用Entity Framework 6构建的,首先选择它是为了快速开始,并且因为读取方面可以从LINQ中受益。

每个(适用的)事件都被多个投影所消耗,这些投影要么创建要么更新相应的实体。

这样的投影目前看起来像这样:

public async Task HandleAsync(ItemPlacedIntoStock @event)
{
    var bookingList = new BookingList();
    bookingList.Date = @event.Date;
    bookingList.DeltaItemQuantity = @event.Quantity;
    bookingList.IncomingItemQuantity = @event.Quantity;
    bookingList.OutgoingItemQuantity = 0;
    bookingList.Item = @event.Item;
    bookingList.Location = @event.Location;
    bookingList.Warehouse = @event.Warehouse;

    using (var repository = new BookingListRepository())
    {
        repository.Add(bookingList);
        await repository.Save();
    }
}

这个性能不太好,很可能是因为我在IRepository.Save()方法中调用了DbContext.SaveChanges()。每个事件都会这样做。
接下来应该探索哪些选项呢?我不想花费几天时间追逐可能只是略微改进的想法。
目前我看到以下几个选项:
  • 坚持使用EF,但批量处理事件(即每X个事件进行一次新/保存上下文),只要投影落后。
  • 尝试做更低层次的SQL,例如使用ADO.NET。
  • 不使用SQL存储投影(即使用NoSQL)
我预计会看到数百万个事件,因为我们计划源自一个大型遗留应用程序,并以事件形式迁移数据。由于新的投影也经常被添加,所以处理速度是一个实际问题。
基准测试:
  • 当前解决方案(EF,在每个事件后保存)每秒处理约200个事件(每个投影)。它与活动投影数量的直接比例不成比例(即N个投影处理的事件量少于N * 200个事件/秒)。
  • 当投影不保存上下文时,每秒事件数略微增加(不到两倍)
  • 当投影什么也不做(单个返回语句)时,我的原型管道的处理速度为全局约30,000个事件/秒
更新的基准测试
  • 通过ADO.NET TableAdapter进行单线程插入(每次迭代都是新的DataSet和新的TableAdapter):每秒约2,500次插入。没有与投影管道一起测试,而是独立测试。
  • 通过ADO.NET TableAdapter进行单线程插入,在插入后不进行SELECT:每秒约3,000次插入
    • 单线程ADO.NET TableAdapter批量插入10,000行(单个数据集,在内存中有10,000行):每秒超过10,000次插入(我的样本大小和窗口太小了)

我不是 .NET 专家,但是类似 async repository.Save(); 这样的东西存在吗?我的意思是,如果你不需要 repository.Save(); 返回的内容,那么“最终”保存它对你来说应该是可行的,不是吗? - gtramontina
仅仅提交工作单元相对于其中所有跟踪所需的工作来说是小菜一碟。如果我根本不写入SQL(使用EntityFramework),那么我并没有真正提高速度。 - urbanhusky
3个回答

5
我曾经看到了性能提升数量级的改进,即使是在使用Entity Framework时,只需批量提交和改进整个投影引擎即可。
  • 每个投影都是事件存储库上的单独订阅。这允许每个投影以最大速度运行。在我的计算机上,管道的理论最大值为每秒40,000个事件(可能更多,我没有足够的事件进行采样)
  • 每个投影维护一个事件队列并反序列化JSON到POCOs。每次投影运行中有多个反序列化会并行进行。同时还从数据合同序列化切换到json.net。
  • 每个投影支持工作单元的概念。处理1000个事件或者反序列化队列为空(即位于头部位置或者经历了缓冲下溢)后,将提交工作单元。这意味着如果投影落后只有几个事件,则它会更频繁地提交。
  • 利用异步TPL处理,交错获取、排队、处理、跟踪和提交。

这是通过使用以下技术和工具实现的:

  • 使用TPL DataFlow TransformBlock对POCO进行排序、队列和并行反序列化,BoundedCapacity的容量大约超过100。最大并行度为Environment.ProcessorCount(即4或8)。当队列长度为100-200时,与10相比,性能显著提高:从200-300个事件每秒到10,000个事件每秒。这很可能意味着缓冲区大小为10导致太多的下溢,并因此过于频繁地提交工作单元。
  • 处理通过链接的ActionBlock异步调度
  • 每次反序列化一个事件时,我会增加待处理事件的计数器
  • 每次处理一个事件时,我会增加已处理事件的计数器
  • 在处理了1000个事件之后,将提交工作单元,或者在反序列化缓冲区耗尽时(待处理事件数=已处理事件数)。我将两个计数器减去已处理事件的数量。我不将其重置为0,因为其他线程可能会增加待处理事件的数量。

批处理大小为1000个事件和队列大小为200的值是实验的结果。这也显示了通过针对每个投影独立调整这些值来进行进一步改进的其他选项。当使用10,000个批处理大小添加每个事件的新行的投影明显变慢,而仅更新一些实体的其他投影则受益于较大的批处理大小。

反序列化队列大小也对性能有重要影响。

简而言之:

Entity Framework足够快,可以每秒处理多达10,000个修改 - 每个并行线程。 利用你的工作单元并避免提交每一个单独的更改 - 特别是在CQRS中,其中投影是唯一进行任何数据更改的线程。 适当地交错并行任务,不要只是盲目地async所有内容。


3
作为 Projac 的作者,我建议你看一下它提供了什么,并窃取适合自己的内容。我特意构建它是因为LINQ/EF在读模型/投影方面是不好的选择...

2
将一条记录一次性保存到SQL Server中总是性能不佳。您有两个选择:
  1. 表变量参数

使用表变量以单个调用将多个记录保存到存储过程中

  1. ADO批量复制

使用Bulk Insert ADO库批量复制数据

除了连接处理之外,EF无法从两者中受益。

如果您的数据是简单键值对,我将不会使用任何一种方法;使用RDBMS可能不是一个很好的选择。可能更适合使用Mongo\Raven或其他扁平数据存储。


对于纯插入操作,批量操作(或批处理)可能会有所帮助 - 但我也可能需要更新现有数据,每个操作都需要额外的提取... - urbanhusky
如果您想继续使用 SQL Server,只需使用 ADO 将数据批量插入临时表,然后在临时表上执行 SQL Merge 命令以将数据更新到目标表。这将是最快的批量插入/更新数据的方法。 - PhillipH
因此,这意味着我首先必须批处理我的操作 - 即只要我有多个事件要处理,我就应该在内存中准备数据,并定期将其刷新到我使用的任何存储中。如果我不以某种方式批处理事件,则永远无法执行任何形式的批量操作(或DataAdapter / TableAdapter 批处理)。除了批处理之外,我仍然不确定如何在必须编辑现有数据时提高性能。 - urbanhusky
是的,批处理是提高性能的关键。它消除了网络延迟,并减少了在SQL服务器端对锁定和日志记录的影响。我们必须记住,SQL Server是一个基于集合的数据存储 - 如果我们的操作只是“一个集合”,那么与较大的集合相比,它的性能会较差(在合理范围内)。如果您绝对需要执行每条记录的操作(而不进行批处理),那么您的代码看起来是最优的。 - PhillipH

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接