Desempenho horrível usando métodos SqlCommand Async com dados grandes

95

Estou tendo grandes problemas de desempenho de SQL ao usar chamadas assíncronas. Criei um pequeno caso para demonstrar o problema.

Eu criei um banco de dados em um SQL Server 2016 que reside em nossa LAN (portanto, não um localDB).

Nesse banco de dados, tenho uma tabela WorkingCopycom 2 colunas:

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]

Nessa tabela, inseri um único registro ( id= 'PerfUnitTest', Valueé uma string de 1,5 MB (um zip de um conjunto de dados JSON maior)).

Agora, se eu executar a consulta no SSMS:

SELECT [Value] 
FROM [Workingcopy] 
WHERE id = 'perfunittest'

Eu imediatamente obtenho o resultado e vejo no SQL Servre Profiler que o tempo de execução foi em torno de 20 milissegundos. Tudo normal.

Ao executar a consulta do código .NET (4.6) usando um simples 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;

O tempo de execução para isso também é cerca de 20-30 milissegundos.

Mas ao alterá-lo para o código assíncrono:

string value = await command.ExecuteScalarAsync() as string;

O tempo de execução é de repente 1800 ms ! Também no SQL Server Profiler, vejo que a duração da execução da consulta é de mais de um segundo. Embora a consulta executada relatada pelo criador de perfil seja exatamente a mesma que a versão não Async.

Mas fica pior. Se eu brincar com o tamanho do pacote na string de conexão, obtenho os seguintes resultados:

Tamanho do pacote 32768: [TIMING]: ExecuteScalarAsync em SqlValueStore -> tempo decorrido: 450 ms

Tamanho do pacote 4096: [TIMING]: ExecuteScalarAsync em SqlValueStore -> tempo decorrido: 3667 ms

Tamanho do pacote 512: [TIMING]: ExecuteScalarAsync em SqlValueStore -> tempo decorrido: 30776 ms

30.000 ms !! Isso é mais de 1000 vezes mais lento do que a versão não assíncrona. E o SQL Server Profiler relata que a execução da consulta levou mais de 10 segundos. Isso nem mesmo explica para onde foram os outros 20 segundos!

Então eu mudei de volta para a versão sincronizada e também brinquei com o tamanho do pacote, e embora tenha impactado um pouco o tempo de execução, não foi tão dramático quanto na versão assíncrona.

Como nota lateral, se colocar apenas uma pequena string (<100 bytes) no valor, a execução da consulta assíncrona será tão rápida quanto a versão de sincronização (resultado em 1 ou 2 ms).

Estou realmente perplexo com isso, especialmente porque estou usando o integrado SqlConnection, nem mesmo um ORM. Além disso, ao pesquisar por aí, não encontrei nada que pudesse explicar esse comportamento. Alguma ideia?

hcd
fonte
4
@hcd 1,5 MB ????? E você pergunta por que a recuperação fica mais lenta com a diminuição do tamanho dos pacotes? Especialmente quando você usa a consulta errada para BLOBs?
Panagiotis Kanavos
3
@PanagiotisKanavos Isso foi apenas uma brincadeira em nome do OP. A questão real é por que o async é muito mais lento em comparação com a sincronização com o mesmo tamanho de pacote.
Fildor
2
Verifique a modificação de dados de valor grande (máx.) No ADO.NET para obter a maneira correta de recuperar CLOBs e BLOBs. Em vez de tentar lê-los como um grande valor, use GetSqlCharsou GetSqlBinaryrecupere-os em um modo de streaming. Considere também armazená-los como dados FILESTREAM - não há razão para salvar 1,5 MB de dados na página de dados de uma tabela
Panagiotis Kanavos
8
@PanagiotisKanavos Incorreto. OP grava sincronismo: 20-30 ms e assíncrono com todo o resto mesmo 1800 ms. O efeito da alteração do tamanho do pacote é totalmente claro e esperado.
Fildor
5
@hcd parece que você poderia remover a parte sobre suas tentativas de alterar os tamanhos dos pacotes, uma vez que parece irrelevante para o problema e causa confusão entre alguns comentadores.
Kuba Wyrostek

Respostas:

140

Em um sistema sem carga significativa, uma chamada assíncrona tem uma sobrecarga um pouco maior. Embora a operação de E / S em si seja assíncrona independentemente, o bloqueio pode ser mais rápido do que a alternância de tarefas do pool de threads.

Quanta sobrecarga? Vejamos seus números de tempo. 30 ms para uma chamada de bloqueio, 450 ms para uma chamada assíncrona. O tamanho do pacote de 32 kiB significa que você precisa de cerca de cinquenta operações de E / S individuais. Isso significa que temos cerca de 8ms de sobrecarga em cada pacote, o que corresponde muito bem às suas medições em diferentes tamanhos de pacote. Isso não soa como sobrecarga apenas por ser assíncrona, embora as versões assíncronas precisem fazer muito mais trabalho do que as síncronas. Parece que a versão síncrona é (simplificada) 1 solicitação -> 50 respostas, enquanto a versão assíncrona acaba sendo 1 solicitação -> 1 resposta -> 1 solicitação -> 1 resposta -> ..., pagando o custo continuamente novamente.

Indo mais fundo. ExecuteReaderfunciona tão bem quanto ExecuteReaderAsync. A próxima operação é Readseguida por GetFieldValue- e uma coisa interessante acontece lá. Se um dos dois for assíncrono, toda a operação será lenta. Portanto, certamente há algo muito diferente acontecendo quando você começa a tornar as coisas realmente assíncronas - a Readserá rápido e, em seguida, o assíncrono GetFieldValueAsyncserá lento, ou você pode começar com o lento ReadAsync, e então ambos GetFieldValuee GetFieldValueAsyncsão rápidos. A primeira leitura assíncrona do fluxo é lenta, e a lentidão depende inteiramente do tamanho de toda a linha. Se eu adicionar mais linhas do mesmo tamanho, a leitura de cada linha leva o mesmo tempo como se eu tivesse apenas uma linha, então é óbvio que os dados sãoainda sendo transmitido linha por linha - parece preferir ler toda a linha de uma vez, uma vez que você iniciar qualquer leitura assíncrona. Se eu ler a primeira linha de forma assíncrona e a segunda de forma síncrona - a segunda linha sendo lida será rápida novamente.

Portanto, podemos ver que o problema é o tamanho grande de uma linha e / ou coluna individual. Não importa quantos dados você tem no total - ler um milhão de pequenas linhas de forma assíncrona é tão rápido quanto de forma síncrona. Mas adicione apenas um único campo que seja grande demais para caber em um único pacote e você misteriosamente incorrerá em um custo ao ler esses dados de forma assíncrona - como se cada pacote precisasse de um pacote de solicitação separado e o servidor não pudesse simplesmente enviar todos os dados em uma vez. O uso CommandBehavior.SequentialAccessmelhora o desempenho conforme o esperado, mas a grande lacuna entre sincronização e assíncrona ainda existe.

O melhor desempenho que obtive foi ao fazer tudo corretamente. Isso significa usar CommandBehavior.SequentialAccess, bem como transmitir os dados explicitamente:

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

Com isso, a diferença entre sync e async torna-se difícil de medir, e alterar o tamanho do pacote não incorre mais na sobrecarga ridícula de antes.

Se você deseja um bom desempenho em casos extremos, certifique-se de usar as melhores ferramentas disponíveis - neste caso, transmita grandes colunas de dados em vez de depender de auxiliares como ExecuteScalarou GetFieldValue.

Luaan
fonte
3
Ótima resposta. Reproduziu o cenário do OP. Para esta string de 1,5 m que o OP está mencionando, recebo 130 ms para a versão de sincronização e 2200 ms para assíncrona. Com sua abordagem, o tempo medido para a corda de 1,5 m é de 60 ms, nada mal.
Wiktor Zychla
4
Boas investigações lá, além disso, aprendi um punhado de outras técnicas de ajuste para nosso código DAL.
Adam Houldsworth
Acabei de voltar ao escritório e tentei o código no meu exemplo em vez de ExecuteScalarAsync, mas ainda tenho tempo de execução de 30 segundos com um tamanho de pacote de 512 bytes :(
hcd
6
Aha, funcionou afinal :) Mas eu tenho que adicionar CommandBehavior.SequentialAccess a esta linha: using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
hcd
@hcd Que pena, estava no texto, mas não no código de exemplo :)
Luaan