web-dev-qa-db-fra.com

Performance horrible en utilisant les méthodes SqlCommand Async avec des données volumineuses

Je rencontre des problèmes majeurs de performances SQL lorsque j'utilise des appels asynchrones. J'ai créé un petit étui pour démontrer le problème.

J'ai créé une base de données sur un serveur SQL Server 2016 qui réside dans notre réseau local (donc pas une base de données locale).

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 cette table, j'ai inséré un seul enregistrement (id = 'PerfUnitTest', Value est une chaîne de 1,5 Mo (un zip d'un plus grand ensemble de données JSON)).

Maintenant, si j'exécute la requête dans SSMS:

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

J'ai immédiatement le résultat et je vois dans SQL Servre Profiler que le temps d'exécution était d'environ 20 millisecondes. Tout à fait 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 est également d'environ 20-30 millisecondes.

Mais lorsque vous le changez en code asynchrone:

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

Le temps d'exécution est soudainement 1800 ms! Également dans SQL Server Profiler, je constate 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 de paquet dans la chaîne de connexion, j'obtiens les résultats suivants:

Taille de paquet 32768: [TIMING]: ExecuteScalarAsync dans SqlValueStore -> temps écoulé: 450 ms

Taille de paquet 4096: [TIMING]: ExecuteScalarAsync dans SqlValueStore -> temps écoulé: 3667 ms

Taille de paquet 512: [TIMING]: ExecuteScalarAsync dans SqlValueStore -> temps écoulé: 30776 ms

000 ms !! C'est plus de 1000 fois plus lent que la version non asynchrone. Et SQL Server Profiler indique 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 secondes restantes!

Ensuite, je suis revenu à la version de synchronisation et j’ai également joué avec la taille de paquet. Bien que cela ait eu un impact significatif sur le temps d’exécution, le résultat n’était pas aussi spectaculaire que celui de la version async.

Sidenote, si elle ne met qu'une petite chaîne (<100 octets) dans la valeur, l'exécution de la requête asynchrone est aussi rapide que la version de synchronisation (résultat de 1 ou 2 ms).

Cela me déconcerte beaucoup, surtout que j'utilise le SqlConnection intégré, même pas un ORM. Aussi, lors de mes recherches, je n'ai rien trouvé qui puisse expliquer ce comportement. Des idées?

81
hcd

Sur un système sans charge importante, un appel asynchrone entraîne une surcharge légèrement plus importante. Même si l'opération d'E/S elle-même est 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 numéros de chronométrage. 30 ms pour un appel bloquant, 450 ms pour un appel asynchrone. La taille de paquet de 32 Ko signifie que vous avez besoin d’une cinquantaine d’opérations d’entrée/sortie individuelles. Cela signifie que nous avons environ 8 ms de temps système sur chaque paquet, ce qui correspond assez bien à vos mesures pour différentes tailles de paquet. Cela ne semble pas être une surcharge d'être asynchrone, même si les versions asynchrones doivent faire beaucoup plus de travail que la version synchrone. On dirait que la version synchrone est (simplifiée) 1 demande -> 50 réponses, tandis que la version asynchrone finit par être 1 demande -> 1 réponse -> 1 demande -> 1 réponse -> ..., en payant le coût à plusieurs reprises encore.

Aller plus loin. ExecuteReader fonctionne aussi bien que ExecuteReaderAsync. La prochaine opération est Read suivie par un GetFieldValue - et une chose intéressante s’y passe. Si l'un des deux est asynchrone, l'opération est lente. Donc, il se passe certainement quelque chose très différent une fois que vous commencez à rendre les choses vraiment asynchrones - un Read sera rapide, puis l'async GetFieldValueAsync sera lent, ou vous pouvez Commencez par le lent ReadAsync, puis les deux GetFieldValue et GetFieldValueAsync 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 plusieurs 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 is sont toujours diffusées ligne par ligne. semble préférer lire toute la ligne à la fois une fois que vous commencez toute lecture asynchrone. Si je lis la première ligne de manière asynchrone et la deuxième de manière synchrone, la deuxième ligne en cours de lecture sera à nouveau rapide.

Nous pouvons donc voir que le problème est la taille importante d’une ligne et/ou d’une colonne. Peu importe le nombre total de données dont vous disposez - la lecture d'un million de petites lignes de manière asynchrone est aussi rapide que synchrone. Cependant, ajoutez un seul champ trop volumineux pour contenir un seul paquet et vous lisez mystérieusement un coût si vous lisez ces données de manière asynchrone. une fois que. En utilisant CommandBehavior.SequentialAccess améliore les performances comme prévu, mais le fossé entre sync et async existe toujours.

La meilleure performance que j'ai obtenue a été lorsque je fais tout le travail correctement. Cela signifie utiliser CommandBehavior.SequentialAccess, ainsi que la diffusion explicite des données:

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 synchro et asynchrone devient difficile à mesurer, et changer la taille du paquet n'entraîne plus la surcharge ridicule, comme auparavant.

Si vous souhaitez obtenir de bonnes performances dans les cas Edge, veillez à utiliser les meilleurs outils disponibles. Dans ce cas, diffusez des données de colonne volumineuses plutôt que de vous fier à des assistants tels que ExecuteScalar ou GetFieldValue.

127
Luaan