为什么当每秒对我的 Asp.Net 核心 API 的并发请求数增加时,响应时间会增加



我正在负载下测试端点。对于每秒 1 个请求,平均响应时间约为 200 毫秒。端点执行一些数据库查找(全部读取),这些查找速度非常快,并且始终是异步的。

但是,当每秒执行几百个请求(req/sec)时,平均响应时间会超过一秒。

我查看了以下最佳实践指南:

https://learn.microsoft.com/en-us/aspnet/core/performance/performance-best-practices?view=aspnetcore-2.2

一些建议,如">避免阻止调用"和">最小化大型对象分配"似乎不适用,因为我已经在使用异步,并且单个请求的响应大小小于 50 KB。

有几个似乎很有用,例如:

https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-2.0#high-performance https://learn.microsoft.com/en-us/aspnet/core/performance/performance-best-practices?view=aspnetcore-2.2#pool-http-connections-with-httpclientfactory

问题:

  1. 为什么平均响应时间会随着频率/秒的增加而增加?
  2. 上面我标记为"可能有用"的建议可能会有所帮助吗?我问是因为虽然我想尝试所有方法,但不幸的是,我的时间有限,所以我想先尝试最有可能有帮助的选项。
  3. 还有其他值得考虑的选择吗?

我已经查看了这两个现有的线程,但没有回答我的问题:

每秒请求数与响应时间之间的相关性?

ASP.NET Web API - 每秒处理更多请求

如果不访问代码,将很难回答您的特定问题,但要考虑的主要事项是 EF 生成的数据库查询的大小和复杂性。使用 async/await 将提高 Web 服务器启动请求的响应能力,但加载下的请求处理时间将在很大程度上取决于数据库成为争用点时运行的查询。 您需要确保所有查询都尽可能简约。 例如,以下 3 个语句之间存在巨大差异:

var someData = context.SomeTable.Include(x => x.SomeOtherTable)
.ToList()
.Where(x => x.SomeCriteriaMethod())
.ToList();
var someData = context.SomeTable.Include(x => x.SomeOtherTable)
.Where(x => x.SomeField == someField && x.SomeOtherTable.SomeOtherField == someOtherField)
.ToList();
var someData = context.SomeTable
.Where(x => x.SomeField == someField && x.SomeOtherTable.SomeOtherField == someOtherField)
.Select(x => new SomeViewModel 
{
SomeTableId = x.SomeTableId,
SomeField = x.SomeField,
SomeOtherField = x.SomeOtherTable.SomeOtherField
}).ToList();

像上面第一个这样的示例效率极低,因为它们最终会在过滤行之前从数据库中加载相关表中的所有数据。即使您的 Web 服务器可能只传回几行,它也已从数据库中请求了所有内容。当开发人员遇到以下情况时,这些类型的方案会悄悄进入应用:他们想要筛选 EF 无法转换为 SQL 的值(例如函数),以便通过放置ToList调用来解决它,或者可以将其作为分离不良的副产品引入,例如返回 IEnumerable 的存储库模式。

第二个示例更好一些,他们避免使用read-all ToList()调用,但调用仍然加载回整行以获取不必要的数据。这会占用数据库和 Web 服务器上的资源。

第三个示例演示如何优化查询,以仅返回使用者所需的绝对最小数据。这样可以更好地利用数据库服务器上的索引和执行计划。

在负载下可能面临的其他性能陷阱是延迟负载等。数据库将执行有限数量的并发请求,因此,如果事实证明某些查询正在启动额外的延迟加载请求,则在没有负载时,这些请求将立即执行。但是,在负载下,它们与其他查询和延迟加载请求一起排队,这可能会束缚数据拉取。

最终,您应该针对数据库运行 SQL 探查器,以捕获正在执行的 SQL 查询的种类和数量。在测试环境中执行时,请密切注意读取计数和 CPU 开销,而不是总执行时间。作为一般经验法则,较高的读取和 CPU 开销查询将更容易受到负载下执行时间井喷的影响。它们需要更多的资源来运行,并且"接触"更多的行意味着更多的等待行/表锁定。

需要注意的另一件事是非常大的数据系统中的"繁重"查询,这些查询需要触及大量行,例如报告,在某些情况下,需要高度可定制的搜索查询。如果需要这些,应考虑规划数据库设计,以包括用于运行报表或大型搜索表达式的只读副本,以避免主数据库中的行锁定方案会降低典型读取和写入查询的响应能力。

编辑:识别延迟加载查询。

这些查询显示在探查器中,您可以在其中针对顶级表进行查询,但随后会看到针对该表后面的相关表的许多其他查询。

例如,假设您有一个名为"订单"的表,其中一个名为"产品"的相关表,另一个称为"客户",另一个称为"地址"作为交货地址。 要读取某个日期范围内的所有订单,您希望看到以下查询:

SELECT [OrderId], [Quantity], [OrderDate] [ProductId], [CustomerId], [DeliveryAddressId] FROM [dbo].[Orders] WHERE [OrderDate] >= '2019-01-01' AND [OrderDate] < '2020-01-01'

您只想加载订单并返回它们的地方。

当序列化程序循环访问这些字段时,它会找到引用的产品、客户和地址,并且通过尝试读取这些字段,将触发延迟加载,从而导致:

SELECT [CustomerId], [Name] FROM [dbo].[Customers] WHERE [CustomerId] = 22
SELECT [ProductId], [Name], [Price] FROM [dbo].[Products] WHERE [ProductId] = 1023
SELECT [AddressId], [StreetNumber], [City], [State], [PostCode] FROM [dbo].[Addresses] WHERE [AddressId] = 1211

如果您的原始查询返回 100 个订单,您将看到可能是上述查询集的 100 倍,每个订单一组,因为 1 个订单行上的延迟加载将尝试按客户 ID 查找相关客户,按产品 ID 查找相关产品,按交货地址 ID 查找相关地址。这可以,而且确实会变得昂贵。在测试环境中运行时,它可能不可见,但这会增加许多潜在的查询。

如果使用相关实体的.Include()预先加载,EF 将编写JOIN语句以一次性获取所有相关行,这比获取每个单独的相关实体要快得多。尽管如此,这可能会导致提取大量您不需要的数据。避免这种额外成本的最佳方法是通过Select利用投影来检索所需的列。

最新更新