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 WorkingCopy
com 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?
GetSqlChars
ouGetSqlBinary
recupere-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 tabelaRespostas:
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.
ExecuteReader
funciona tão bem quantoExecuteReaderAsync
. A próxima operação éRead
seguida porGetFieldValue
- 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 - aRead
será rápido e, em seguida, o assíncronoGetFieldValueAsync
será lento, ou você pode começar com o lentoReadAsync
, e então ambosGetFieldValue
eGetFieldValueAsync
sã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.SequentialAccess
melhora 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: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
ExecuteScalar
ouGetFieldValue
.fonte
using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))