使用SqlCommand Async方法时出现可怕的性能

时间:2017-02-23 12:37:04

标签: c# .net sql-server asynchronous async-await

使用异步调用时,我遇到了严重的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

从.NET(4.6)代码执行查询时
// 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。在搜索时,我发现没有什么可以解释这种行为。有什么想法吗?

1 个答案:

答案 0 :(得分:113)

在没有大量负载的系统上,异步调用的开销略大。虽然I / O操作本身是异步的,但阻塞可能比线程池任务切换更快。

多少开销?我们来看看你的时间数字吧。阻塞调用30ms,异步调用450ms。 32 kiB数据包大小意味着您需要大约50个单独的I / O操作。这意味着我们每个数据包的开销大约为8毫秒,这与您在不同数据包大小上的测量结果相当。即使异步版本需要比同步版本做更多的工作,这听起来并不像异步一样。听起来像同步版本是(简化的)1请求 - &gt; 50个响应,而异步版本最终成为1个请求 - &gt; 1回复 - &gt; 1个请求 - &gt; 1回复 - &gt; ......,一遍又一遍地支付费用。

更深入。 ExecuteReaderExecuteReaderAsync一样有效。接下来的操作是Read,然后是GetFieldValue - 并且会发生一件有趣的事情。如果两者中的任何一个是异步的,则整个操作都很慢。因此,一旦你开始使事情变得异步,肯定会发生非常不同的事情 - Read会很快,然后异步GetFieldValueAsync会很慢,或者你可以从慢ReadAsync,然后GetFieldValueGetFieldValueAsync都很快。来自流的第一次异步读取很慢,并且缓慢完全取决于整行的大小。如果我添加更多相同大小的行,读取每一行需要相同的时间,就好像我只有一行一样,所以显然数据 仍然是逐行流式传输 - 它只是一旦启动任何异步读取,似乎更愿意立即读取整行。如果我异步读取第一行,第二行同步读取 - 读取的第二行将再次快速。

因此,我们可以看到问题是单个行和/或列的大小。总共有多少数据并不重要 - 异步读取一百万个小行的速度和同步一样快。但是只添加一个太大而无法容纳在单个数据包中的字段,并且在异步读取数据时会神秘地产生成本 - 就像每个数据包需要一个单独的请求数据包一样,并且服务器不能只发送所有数据一旦。使用CommandBehaviour.SequentialAccess确实可以提高性能,但同步和异步之间的巨大差距仍然存在。

我得到的最佳表现是正确地完成所有事情。这意味着使用CommandBehaviour.SequentialAccess,以及明确地传输数据:

using (var reader = await cmd.ExecuteReaderAsync(CommandBehaviour.SequentialAccess))
{
  while (await reader.ReadAsync())
  {
    var data = await reader.GetTextReader(0).ReadToEndAsync();
  }
}

有了这个,同步和异步之间的差异变得难以衡量,并且改变数据包大小不再像以前那样产生荒谬的开销。

如果您希望在边缘情况下获得良好的性能,请确保使用可用的最佳工具 - 在这种情况下,流式传输大型列数据,而不是依赖ExecuteScalarGetFieldValue等帮助程序。