Performances horribles en utilisant les méthodes SqlCommand Async avec des données volumineuses

95

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 WorkingCopyavec 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', Valueest 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?

hcd
la source
5
@hcd 1,5 Mo ????? Et vous demandez pourquoi la récupération devient plus lente avec la diminution de la taille des paquets? Surtout lorsque vous utilisez la mauvaise requête pour les BLOB?
Panagiotis Kanavos
3
@PanagiotisKanavos C'était juste en train de jouer au nom d'OP. La vraie question est de savoir pourquoi l'async est tellement plus lent que la synchronisation avec la même taille de package.
Fildor
2
Vérifiez la modification des données de grande valeur (max) dans ADO.NET pour connaître la manière correcte de récupérer les CLOB et les BLOB. Au lieu d'essayer de les lire comme une grande valeur, utilisez GetSqlCharsou GetSqlBinarypour 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 table
Panagiotis Kanavos
8
@PanagiotisKanavos Ce n'est pas correct. OP écrit la synchronisation: 20-30 ms et asynchrone avec tout le reste même 1800 ms. L'effet du changement de la taille du paquet est totalement clair et attendu.
Fildor
5
@hcd, il semble que vous puissiez supprimer la partie concernant vos tentatives de modification de la taille des paquets, car cela ne semble pas pertinent pour le problème et sème la confusion chez certains commentateurs.
Kuba Wyrostek

Réponses:

140

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. ExecuteReaderfonctionne aussi bien que ExecuteReaderAsync. L'opération suivante est Readsuivie d'un GetFieldValue- 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 - un Readsera rapide, puis l'async GetFieldValueAsyncsera lent, ou vous pouvez commencer par le lent ReadAsync, puis les deux GetFieldValueet GetFieldValueAsyncsont 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.SequentialAccessamé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:

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

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 ExecuteScalarou GetFieldValue.

Luaan
la source
3
Très bonne réponse. Reproduit le scénario du PO. Pour cette chaîne de 1,5 m, OP est mentionnée, j'obtiens 130 ms pour la version sync contre 2200 ms pour async. Avec votre approche, le temps mesuré pour la corde de 1,5 m est de 60 ms, pas mal.
Wiktor Zychla
4
Bonnes recherches là-bas, plus j'ai appris une poignée d'autres techniques de réglage pour notre code DAL.
Adam Houldsworth
Je viens de rentrer au bureau et j'ai essayé le code de mon exemple au lieu de ExecuteScalarAsync, mais j'ai quand même un temps d'exécution de 30 secondes avec une taille de paquet de 512 octets :(
hcd
6
Aha, ça a marché après tout :) Mais je dois ajouter le CommandBehavior.SequentialAccess à cette ligne: using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
hcd
@hcd Mon mauvais, je l'avais dans le texte mais pas dans l'exemple de code :)
Luaan