使用异步调用时,我遇到了严重的SQL性能问题。我创建了一个小案例来证明这个问题。
我在SQL Server 2016上创建了一个数据库,它驻留在我们的局域网中(所以不是本地数据库)。
在该数据库中,我有一个包含2列的表WorkingCopy
:
Id (nvarchar(255, PK))
Value (nvarchar(max))
DDL
CREATE TABLE [dbo].[Workingcopy]
(
[Id] [nvarchar](255) NOT NULL,
[Value] [nvarchar](max) NULL,
CONSTRAINT [PK_Workingcopy]
PRIMARY KEY CLUSTERED ([Id] ASC)
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,
IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON,
ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
在该表中,我插入了一条记录(id
=' PerfUnitTest',Value
是一个1.5mb的字符串(一个较大的JSON数据集的zip))。
现在,如果我在SSMS中执行查询:
SELECT [Value]
FROM [Workingcopy]
WHERE id = 'perfunittest'
我立即得到结果,我在SQL Servre Profiler中看到执行时间大约是20毫秒。一切正常。
使用普通SqlConnection
:
// at this point, the connection is already open
var command = new SqlCommand($"SELECT Value FROM WorkingCopy WHERE Id = @Id", _connection);
command.Parameters.Add("@Id", SqlDbType.NVarChar, 255).Value = key;
string value = command.ExecuteScalar() as string;
此时的执行时间也约为20-30毫秒。
但是在将其更改为异步代码时:
string value = await command.ExecuteScalarAsync() as string;
执行时间突然 1800 ms !同样在SQL Server Profiler中,我看到查询执行持续时间超过一秒。虽然分析器报告的执行查询与非异步版本完全相同。
但它变得更糟。如果我在连接字符串中使用数据包大小,我会得到以下结果:
数据包大小32768:[TIMING]:SqlValueStore中的ExecuteScalarAsync - > 经过时间:450毫秒
数据包大小4096:[TIMING]:SqlValueStore中的ExecuteScalarAsync - > 经过时间:3667毫秒
数据包大小512:[TIMING]:SqlValueStore中的ExecuteScalarAsync - > 经过时间:30776毫秒
30,000 ms !!这比非异步版本慢了1000多倍。 SQL Server Profiler报告查询执行时间超过10秒。这甚至没有解释其他20秒的去向!
然后我已经切换回同步版本并且还使用了数据包大小,虽然它确实影响了一点执行时间,但它与async版本一样戏剧性。
作为旁注,如果它只将一个小字符串(<100bytes)放入值中,则异步查询执行速度与同步版本一样快(结果为1或2 ms)。
我对此感到非常困惑,尤其是因为我使用了内置的SqlConnection
,甚至没有使用ORM。在搜索时,我发现没有什么可以解释这种行为。有什么想法吗?
答案 0 :(得分:113)
在没有大量负载的系统上,异步调用的开销略大。虽然I / O操作本身是异步的,但阻塞可能比线程池任务切换更快。
多少开销?我们来看看你的时间数字吧。阻塞调用30ms,异步调用450ms。 32 kiB数据包大小意味着您需要大约50个单独的I / O操作。这意味着我们每个数据包的开销大约为8毫秒,这与您在不同数据包大小上的测量结果相当。即使异步版本需要比同步版本做更多的工作,这听起来并不像异步一样。听起来像同步版本是(简化的)1请求 - &gt; 50个响应,而异步版本最终成为1个请求 - &gt; 1回复 - &gt; 1个请求 - &gt; 1回复 - &gt; ......,一遍又一遍地支付费用。
更深入。 ExecuteReader
与ExecuteReaderAsync
一样有效。接下来的操作是Read
,然后是GetFieldValue
- 并且会发生一件有趣的事情。如果两者中的任何一个是异步的,则整个操作都很慢。因此,一旦你开始使事情变得异步,肯定会发生非常不同的事情 - Read
会很快,然后异步GetFieldValueAsync
会很慢,或者你可以从慢ReadAsync
,然后GetFieldValue
和GetFieldValueAsync
都很快。来自流的第一次异步读取很慢,并且缓慢完全取决于整行的大小。如果我添加更多相同大小的行,读取每一行需要相同的时间,就好像我只有一行一样,所以显然数据 仍然是逐行流式传输 - 它只是一旦启动任何异步读取,似乎更愿意立即读取整行。如果我异步读取第一行,第二行同步读取 - 读取的第二行将再次快速。
因此,我们可以看到问题是单个行和/或列的大小。总共有多少数据并不重要 - 异步读取一百万个小行的速度和同步一样快。但是只添加一个太大而无法容纳在单个数据包中的字段,并且在异步读取数据时会神秘地产生成本 - 就像每个数据包需要一个单独的请求数据包一样,并且服务器不能只发送所有数据一旦。使用CommandBehaviour.SequentialAccess
确实可以提高性能,但同步和异步之间的巨大差距仍然存在。
我得到的最佳表现是正确地完成所有事情。这意味着使用CommandBehaviour.SequentialAccess
,以及明确地传输数据:
using (var reader = await cmd.ExecuteReaderAsync(CommandBehaviour.SequentialAccess))
{
while (await reader.ReadAsync())
{
var data = await reader.GetTextReader(0).ReadToEndAsync();
}
}
有了这个,同步和异步之间的差异变得难以衡量,并且改变数据包大小不再像以前那样产生荒谬的开销。
如果您希望在边缘情况下获得良好的性能,请确保使用可用的最佳工具 - 在这种情况下,流式传输大型列数据,而不是依赖ExecuteScalar
或GetFieldValue
等帮助程序。