使用注入的DbContext进行并行异步调用在EF Core中的最佳实践是什么?

54

我有一个.NET Core 1.1 API,其中包含EF Core 1.1,并使用Microsoft默认的依赖注入来为我的服务提供DbContext。(参考:https://learn.microsoft.com/en-us/aspnet/core/data/ef-mvc/intro#register-the-context-with-dependency-injection)

现在,我正在研究并行化数据库读取作为一种优化方法,使用WhenAll

所以不是:

var result1 = await _dbContext.TableModel1.FirstOrDefaultAsync(x => x.SomeId == AnId);
var result2 = await _dbContext.TableModel2.FirstOrDefaultAsync(x => x.SomeOtherProp == AProp); 

我使用:

var repositoryTask1 = _dbContext.TableModel1.FirstOrDefaultAsync(x => x.SomeId == AnId);     
var repositoryTask2 = _dbContext.TableModel2.FirstOrDefaultAsync(x => x.SomeOtherProp == AProp);   
(var result1, var result2) = await (repositoryTask1, repositoryTask2 ).WhenAll();

这都很好,但当我在这些数据库仓库访问类之外使用同样的策略,并在我的控制器中通过WhenAll调用这些相同的方法以跨多个服务时,情况就不同了:

var serviceTask1 = _service1.GetSomethingsFromDb(Id);
var serviceTask2 = _service2.GetSomeMoreThingsFromDb(Id);
(var dataForController1, var dataForController2) = await (serviceTask1, serviceTask2).WhenAll();

现在,当我从我的控制器调用它时,我随机地会遇到并发错误,例如:

System.InvalidOperationException: ExecuteReader requires an open and available Connection. The connection's current state is closed.

我认为原因是因为有时这些线程尝试同时访问相同的表。我知道这是 EF Core 的设计,如果我想的话,可以每次创建一个新的 dbContext,但我正在尝试看看是否有解决方法。这时我发现了 Mehdi El Gueddari 写的这篇好文章:http://mehdi.me/ambient-dbcontext-in-ef6/

在这篇文章中,他承认了这个限制:

注入的 DbContext 防止您能够在服务中引入多线程或任何形式的并行执行流。

并提供了使用 DbContextScope 的自定义解决方案。

但是,即使使用 DbContextScope,他也提出了一个警告,即它不能在并行情况下工作(我上面试图做的):

如果您尝试在 DbContextScope 的上下文中启动多个并行任务(例如通过创建多个线程或多个 TPL 任务),您将遇到大麻烦。这是因为环境 DbContextScope 将流经所有使用您的并行任务的线程。

他在这里的最后一点引导我提出了我的问题:

一般来说,在单个业务事务中并行访问数据库几乎没有任何好处,只会增加显着的复杂性。在业务事务的上下文中执行的任何并行操作都不应访问数据库。

在我的控制器中,我是否不应该在这种情况下使用 WhenAll,而应该一个接一个地使用 await?或者依赖注入 DbContext 在这里是更根本的问题,因此应该通过某种工厂每次创建/提供一个新的 DbContext?


2
Repository1是什么?如果你正在使用EF,你不需要使用存储库(反)模式,因为这就是你的dbContext。你可以有一个具有生成查询方法的类,但那不是一个存储库,它只是一个提供查询的包装器。 - Dai
听起来你正在从 using(){} 块内直接返回一个 Task<T> - 相反应该在 using(){} 块内使用 await - 这不会影响你的代码并行化能力。 - Dai
我从未表明我有一个using块。事实上,我明确表示我正在使用DI。请阅读我引用的链接。 - starmandeluxe
我已经更新了DbContext的“存储库”名称,以使其更清晰,它不是某种存储库类,而是指向模型类的DbSet。 - starmandeluxe
2个回答

52

如果您想使用任何 context.XyzAsync() 方法,只有在您等待调用的方法或者返回控制权到一个没有context的作用域的调用线程时才有用。

DbContext 实例不是线程安全的:您绝不能在并行线程中使用它。这意味着,为了确保安全,请不要在多个线程中使用它,即使它们不并行运行也是如此。不要试图绕过它。

如果出于某种原因您想要运行并行的数据库操作(并且认为您可以避免死锁、并发冲突等),请确保每个操作都有自己的DbContext实例。但是请注意,并行化主要对CPU密集型进程有用,而不是像数据库交互这样的IO密集型进程。也许您可以从并行独立的读取操作中受益,但我肯定永远不会执行并行写入进程。除了死锁等问题外,这还使得在一个事务中运行所有操作变得更加困难。

在ASP.Net Core中,通常会使用上下文每请求模式(ServiceLifetime.Scoped,请参见此处),但即使如此,也无法防止将上下文传递给多个线程。最终只有程序员才能防止这种情况发生。
如果您担心一直创建新的上下文会带来性能成本:不用担心。创建上下文是一个轻量级操作,因为底层模型(存储模型、概念模型+它们之间的映射)只会在应用程序域中创建一次并进行存储。此外,新的上下文不会创建到数据库的物理连接。所有ASP.Net数据库操作都通过管理物理连接池的连接池运行。
如果所有这些都意味着您必须重新配置DI以符合最佳实践,请这样做。如果当前的设置将上下文传递给多个线程,则过去存在错误的设计决策。抵制通过解决方法推迟必要的重构的诱惑。唯一的解决方法是取消代码的并行处理,所以最终可能比重新设计DI和代码以遵循每个线程上下文要慢。

4
谢谢。不幸的是,你提供的所有信息都是我已经知道或在我的问题中已经说明的内容。特别是当你说“如果出于某些原因你想要运行并行数据库操作”,我想知道是否有任何原因这样做(似乎与Microsoft的推荐示例不同)。这是因为我正在尝试避免每次创建新上下文,如我在问题中所述,因为这不仅可能会产生内存成本,而且还需要重新设计依赖注入代码。 - starmandeluxe
2
我想知道什么原因。好的,既然你提到了它,我猜你有你的理由。我尝试更详细地阐述一些,但恐怕这并没有改变什么。 - Gert Arnold
实际上,我没有任何好的理由。我觉得将这部分内容作为我的问题的一部分并不是必要的,但它并不像你所建议的那样简单:我有一个同事认为访问数据库的“唯一正确方式”是坚持使用并行访问,可能是出于性能原因。这个问题部分是为了探讨在这种情况下什么是最佳实践,因为我对它是否是最佳实践存在疑虑。 - starmandeluxe
2
不,我的意思是去除并行化代码中的并行部分可能会比必要时变慢,因为这也可能消除了在不会有害的情况下进行并行的可能性。至于“唯一正确的方法”,那相当荒谬。众所周知,并行化IO-bound进程的效果是有限的。这很简单:如果架构允许多个线程共享上下文,则设计存在缺陷,应予以重构。 - Gert Arnold
1
@DanChase 不会,因为每个操作方法都会有自己的控制器实例,因此有请求范围和上下文(如果你做得对的话)。只是不要从动作方法中分散到多个线程。 - Gert Arnold
显示剩余3条评论

44

到了一定程度,回答这个辩论的唯一方法就是进行性能/负载测试,以获得可比较的实证统计证据,这样我就可以一劳永逸地解决这个问题。

以下是我所测试的内容:

使用 VSTS 在标准 Azure Web 应用程序上进行云负载测试,最多 200 用户,在 4 分钟内完成。

测试 #1: 对于每个服务,使用依赖注入的 DbContext 和 async/await 进行 1 次 API 调用。

测试 #1 的结果:enter image description here

测试 #2: 对于每个服务方法调用,使用新创建的 DbContext,并使用 WhenAll 进行并行线程执行。

测试 #2 的结果:enter image description here

结论:

对于那些怀疑结果的人来说,我进行了多次测试,负载有所变化,但平均值基本相同。

在我看来,使用并行处理所带来的性能提升微不足道,这并不能证明放弃依赖注入的必要性,而后者将导致开发方面的负担和维护债务增加,如果处理不当,还可能引发错误,并远离 Microsoft 的官方建议。

还有一件事需要注意:即使确保每次创建新的上下文,使用 WhenAll 策略时仍然会出现一些失败的请求。我不确定原因是什么,但与 10ms 的性能提升相比,我更希望没有 500 错误。


不错!你在这里测试了什么类型的数据库调用? - Gert Arnold
2
抱歉,这是我们公司的机密信息...我可以告诉你,它正在访问多个服务中的多个表格,并使用各种复杂的查询和聚合结果。请参见我的问题以获取我正在使用的语法示例。 - starmandeluxe
好的,我明白了,但它是否也包括写入操作? - Gert Arnold
不。只查询。 - starmandeluxe
1
FYI,500错误是由于在新的DbContext using语句中跨多个查询使用WhenAll,所以我不得不在这里改为await。显然,即使您有一个新的上下文,也不能以这种方式并行访问DB。WhenAll将在控制器级别上正常工作,但在DbContext范围内无法工作。最后,更改为await似乎对性能没有任何影响,我的API调用平均值为164ms。总之,WhenAll似乎与DI(出于某种原因)兼容,但不适用于using语句中的上下文范围。 - starmandeluxe
15
这是你的结果,不应该是答案。其他人可能会得到不同的结果。有些数据库类型针对高度分布式查询进行了优化,并具有较高的延迟。网络条件(如延迟或重试)、数据库锁定、过载的数据库、大型查询等都可能导致延迟。当用户每天使用应用程序时,您可能希望应用程序尽可能快速和响应。 - Tedd Hansen

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