J'ai des problèmes de performances SQL majeurs lors de l'utilisation d'appels asynchrones. J'ai créé un petit cas pour démontrer le problème.
J'ai créé une base de données sur un SQL Server 2016 qui réside dans notre LAN (donc pas un localDB).
Dans cette base de données, j'ai une table WorkingCopy
avec 2 colonnes:
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]
Dans ce tableau, j'ai inséré un seul enregistrement ( id
= 'PerfUnitTest', Value
est une chaîne de 1,5 Mo (un zip d'un ensemble de données JSON plus grand)).
Maintenant, si j'exécute la requête dans SSMS:
SELECT [Value]
FROM [Workingcopy]
WHERE id = 'perfunittest'
J'obtiens immédiatement le résultat et je vois dans SQL Servre Profiler que le temps d'exécution était d'environ 20 millisecondes. Tout est normal.
Lors de l'exécution de la requête à partir du code .NET (4.6) en utilisant un simple 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;
Le temps d'exécution pour cela est également d'environ 20-30 millisecondes.
Mais lors du changement en code asynchrone:
string value = await command.ExecuteScalarAsync() as string;
Le temps d'exécution est soudainement de 1800 ms ! Également dans SQL Server Profiler, je vois que la durée d'exécution de la requête est supérieure à une seconde. Bien que la requête exécutée signalée par le profileur soit exactement la même que la version non asynchrone.
Mais ça empire. Si je joue avec la taille du paquet dans la chaîne de connexion, j'obtiens les résultats suivants:
Taille de paquet 32768: [TIMING]: ExecuteScalarAsync in SqlValueStore -> temps écoulé: 450 ms
Packet Size 4096: [TIMING]: ExecuteScalarAsync in SqlValueStore -> temps écoulé: 3667 ms
Taille de paquet 512: [TIMING]: ExecuteScalarAsync in SqlValueStore -> temps écoulé: 30776 ms
30 000 ms !! C'est plus de 1000 fois plus lent que la version non asynchrone. Et SQL Server Profiler signale que l'exécution de la requête a pris plus de 10 secondes. Cela n'explique même pas où sont passées les 20 autres secondes!
Ensuite, je suis revenu à la version de synchronisation et j'ai également joué avec la taille de paquet, et bien que cela ait eu un peu d'impact sur le temps d'exécution, ce n'était nulle part aussi dramatique qu'avec la version asynchrone.
En passant, s'il ne met qu'une petite chaîne (<100 octets) dans la valeur, l'exécution de la requête asynchrone est tout aussi rapide que la version de synchronisation (résultat en 1 ou 2 ms).
Je suis vraiment déconcerté par cela, d'autant plus que j'utilise le intégré SqlConnection
, pas même un ORM. Aussi lors de mes recherches, je n'ai rien trouvé qui puisse expliquer ce comportement. Des idées?
GetSqlChars
ouGetSqlBinary
pour les récupérer en continu. Pensez également à les stocker en tant que données FILESTREAM - il n'y a aucune raison de sauvegarder 1,5 Mo de données dans la page de données d'une tableRéponses:
Sur un système sans charge significative, un appel asynchrone a une surcharge légèrement plus importante. Bien que l'opération d'E / S elle-même soit asynchrone, le blocage peut être plus rapide que la commutation de tâches de pool de threads.
Combien de frais généraux? Regardons vos chiffres de timing. 30 ms pour un appel bloquant, 450 ms pour un appel asynchrone. Une taille de paquet de 32 kio signifie que vous avez besoin d'une cinquantaine d'opérations d'E / S individuelles. Cela signifie que nous avons environ 8 ms de surcharge sur chaque paquet, ce qui correspond assez bien à vos mesures sur différentes tailles de paquet. Cela ne ressemble pas à une surcharge simplement du fait d'être asynchrone, même si les versions asynchrones doivent faire beaucoup plus de travail que les versions synchrones. On dirait que la version synchrone est (simplifiée) 1 requête -> 50 réponses, tandis que la version asynchrone finit par être 1 requête -> 1 réponse -> 1 requête -> 1 réponse -> ..., payant le coût encore et encore encore.
Aller plus loin.
ExecuteReader
fonctionne aussi bien queExecuteReaderAsync
. L'opération suivante estRead
suivie d'unGetFieldValue
- et il se passe une chose intéressante. Si l'un des deux est asynchrone, toute l'opération est lente. Donc, il se passe certainement quelque chose de très différent une fois que vous commencez à rendre les choses vraiment asynchrones - unRead
sera rapide, puis l'asyncGetFieldValueAsync
sera lent, ou vous pouvez commencer par le lentReadAsync
, puis les deuxGetFieldValue
etGetFieldValueAsync
sont rapides. La première lecture asynchrone du flux est lente et la lenteur dépend entièrement de la taille de la ligne entière. Si j'ajoute plus de lignes de la même taille, la lecture de chaque ligne prend le même temps que si je n'avais qu'une seule ligne, il est donc évident que les données sonttoujours diffusé ligne par ligne - il semble simplement préférer lire la ligne entière à la fois une fois que vous démarrez une lecture asynchrone. Si je lis la première ligne de manière asynchrone et la seconde de manière synchrone, la deuxième ligne en cours de lecture sera à nouveau rapide.Nous pouvons donc voir que le problème est une grande taille d'une ligne et / ou d'une colonne individuelle. Peu importe la quantité de données dont vous disposez au total - la lecture d'un million de petites lignes de manière asynchrone est tout aussi rapide que synchrone. Mais ajoutez juste un seul champ qui est trop grand pour tenir dans un seul paquet, et vous engagez mystérieusement un coût de lecture asynchrone de ces données - comme si chaque paquet avait besoin d'un paquet de demande séparé, et le serveur ne pouvait pas simplement envoyer toutes les données à une fois que. L'utilisation
CommandBehavior.SequentialAccess
améliore les performances comme prévu, mais l'écart massif entre la synchronisation et l'asynchrone existe toujours.La meilleure performance que j'ai obtenue était de faire le tout correctement. Cela signifie utiliser
CommandBehavior.SequentialAccess
, ainsi que diffuser les données explicitement:Avec cela, la différence entre la synchronisation et l'asynchrone devient difficile à mesurer, et la modification de la taille du paquet n'entraîne plus la surcharge ridicule comme auparavant.
Si vous voulez de bonnes performances dans les cas extrêmes, assurez-vous d'utiliser les meilleurs outils disponibles - dans ce cas, diffusez des données de colonnes volumineuses plutôt que de vous fier à des assistants tels que
ExecuteScalar
ouGetFieldValue
.la source
using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))