Rendimiento horrible utilizando métodos SqlCommand Async con datos grandes

95

Tengo problemas importantes de rendimiento de SQL cuando uso llamadas asíncronas. He creado un pequeño caso para demostrar el problema.

He creado una base de datos en un SQL Server 2016 que reside en nuestra LAN (por lo que no es un localDB).

En esa base de datos, tengo una tabla WorkingCopycon 2 columnas:

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]

En esa tabla, he insertado un solo registro ( id= 'PerfUnitTest', Valuees una cadena de 1.5mb (un archivo zip de un conjunto de datos JSON más grande)).

Ahora, si ejecuto la consulta en SSMS:

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

Inmediatamente obtengo el resultado, y veo en SQL Servre Profiler que el tiempo de ejecución fue de alrededor de 20 milisegundos. Todo normal.

Al ejecutar la consulta desde el código .NET (4.6) usando 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;

El tiempo de ejecución para esto también es de alrededor de 20-30 milisegundos.

Pero al cambiarlo a código asincrónico:

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

¡El tiempo de ejecución es de repente 1800 ms ! También en SQL Server Profiler, veo que la duración de la ejecución de la consulta es más de un segundo. Aunque la consulta ejecutada informada por el generador de perfiles es exactamente la misma que la versión no Async.

Pero empeora. Si juego con el tamaño del paquete en la cadena de conexión, obtengo los siguientes resultados:

Tamaño de paquete 32768: [TIEMPO]: ExecuteScalarAsync en SqlValueStore -> tiempo transcurrido: 450 ms

Tamaño de paquete 4096: [TIEMPO]: ExecuteScalarAsync en SqlValueStore -> tiempo transcurrido: 3667 ms

Tamaño de paquete 512: [TIEMPO]: ExecuteScalarAsync en SqlValueStore -> tiempo transcurrido: 30776 ms

30.000 ms !! Eso es 1000 veces más lento que la versión no asíncrona. Y SQL Server Profiler informa que la ejecución de la consulta tomó más de 10 segundos. ¡Eso ni siquiera explica a dónde se han ido los otros 20 segundos!

Luego volví a la versión sincronizada y también jugué con el tamaño del paquete, y aunque afectó un poco el tiempo de ejecución, no fue tan dramático como con la versión asincrónica.

Como nota al margen, si coloca solo una pequeña cadena (<100bytes) en el valor, la ejecución de la consulta asíncrona es tan rápida como la versión de sincronización (el resultado es 1 o 2 ms).

Estoy realmente desconcertado por esto, especialmente porque estoy usando el integrado SqlConnection, ni siquiera un ORM. Además, al buscar, no encontré nada que pudiera explicar este comportamiento. ¿Algunas ideas?

hcd
fuente
5
@hcd 1,5 MB ????? ¿Y pregunta por qué la recuperación se vuelve más lenta al disminuir el tamaño de los paquetes? ¿Especialmente cuando utiliza la consulta incorrecta para BLOB?
Panagiotis Kanavos
3
@PanagiotisKanavos Eso solo estaba jugando en nombre de OP. La pregunta real es por qué la sincronización es mucho más lenta en comparación con la sincronización con el mismo tamaño de paquete.
Fildor
2
Consulte Modificar datos de gran valor (máx.) En ADO.NET para conocer la forma correcta de recuperar CLOB y BLOB.En lugar de intentar leerlos como un gran valor, utilícelos GetSqlCharso GetSqlBinaryrecupérelos en forma de transmisión. También considere almacenarlos como datos de FILESTREAM; no hay razón para guardar 1.5 MB de datos en la página de datos de una tabla
Panagiotis Kanavos
8
@PanagiotisKanavos Eso no es correcto. OP escribe sincronización: 20-30 ms y asincrónica con todo lo demás igual 1800 ms. El efecto de cambiar el tamaño del paquete es totalmente claro y esperado.
Fildor
5
@hcd parece que podría eliminar la parte sobre sus intentos de alterar el tamaño de los paquetes, ya que parece irrelevante para el problema y causa confusión entre algunos comentaristas.
Kuba Wyrostek

Respuestas:

140

En un sistema sin una carga significativa, una llamada asíncrona tiene una sobrecarga ligeramente mayor. Si bien la operación de E / S en sí es asíncrona independientemente, el bloqueo puede ser más rápido que la conmutación de tareas de grupo de subprocesos.

Cuanta sobrecarga? Veamos sus números de sincronización. 30 ms para una llamada de bloqueo, 450 ms para una llamada asincrónica. El tamaño de paquete de 32 kiB significa que necesita unas cincuenta operaciones de E / S individuales. Eso significa que tenemos aproximadamente 8 ms de sobrecarga en cada paquete, lo que se corresponde bastante bien con sus medidas en diferentes tamaños de paquetes. Eso no suena como una sobrecarga solo por ser asíncrona, aunque las versiones asincrónicas necesitan hacer mucho más trabajo que las síncronas. Parece que la versión sincrónica es (simplificada) 1 solicitud -> 50 respuestas, mientras que la versión asincrónica termina siendo 1 solicitud -> 1 respuesta -> 1 solicitud -> 1 respuesta -> ..., pagando el costo una y otra vez de nuevo.

Profundizando. ExecuteReaderfunciona tan bien como ExecuteReaderAsync. A la siguiente operación le Readsigue un GetFieldValue... y allí sucede algo interesante. Si cualquiera de los dos es asíncrono, toda la operación es lenta. Entonces, ciertamente, algo muy diferente sucede una vez que comienzas a hacer que las cosas sean realmente asincrónicas: unaRead será rápido y luego el asincrónico GetFieldValueAsyncserá lento, o puedes comenzar con el lento ReadAsync, y luego ambos GetFieldValuey GetFieldValueAsyncson rápidos. La primera lectura asincrónica de la secuencia es lenta, y la lentitud depende completamente del tamaño de toda la fila. Si agrego más filas del mismo tamaño, leer cada fila toma la misma cantidad de tiempo que si solo tuviera una, por lo que es obvio que los datos sontodavía se transmite fila por fila; simplemente parece preferir leer toda la fila a la vez una vez que comienza cualquier lectura asincrónica. Si leo la primera fila de forma asincrónica y la segunda sincrónicamente, la segunda fila que se lee será rápida de nuevo.

Entonces podemos ver que el problema es un gran tamaño de una fila y / o columna individual. No importa cuántos datos tenga en total: leer un millón de filas pequeñas de forma asincrónica es tan rápido como sincrónico. Pero agregue un solo campo que sea demasiado grande para caber en un solo paquete, y misteriosamente incurrirá en un costo al leer de forma asincrónica esos datos, como si cada paquete necesitara un paquete de solicitud separado, y el servidor no pudiera simplemente enviar todos los datos a una vez. UtilizandoCommandBehavior.SequentialAccess mejora el rendimiento como se esperaba, pero la brecha masiva entre sincronización y asincrónica aún existe.

El mejor rendimiento que obtuve fue cuando lo hice todo correctamente. Eso significa usar CommandBehavior.SequentialAccess, además de transmitir los datos explícitamente:

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

Con esto, la diferencia entre sincronización y asíncrona se vuelve difícil de medir, y cambiar el tamaño del paquete ya no genera la ridícula sobrecarga como antes.

Si desea un buen rendimiento en casos extremos, asegúrese de utilizar las mejores herramientas disponibles; en este caso, transmita datos de columnas grandes en lugar de depender de ayudantes como ExecuteScalaro GetFieldValue.

Luaan
fuente
3
Gran respuesta. Reprodujo el escenario del OP. Para esta cadena de 1,5 m que OP está mencionando, obtengo 130 ms para la versión de sincronización frente a 2200 ms para la asincrónica. Con su enfoque, el tiempo medido para la cuerda de 1,5 m es de 60 ms, nada mal.
Wiktor Zychla
4
Buenas investigaciones allí, además de que aprendí un puñado de otras técnicas de ajuste para nuestro código DAL.
Adam Houldsworth
Acabo de regresar a la oficina y probé el código en mi ejemplo en lugar del ExecuteScalarAsync, pero todavía tengo 30 segundos de tiempo de ejecución con un tamaño de paquete de 512 bytes :(
hcd
6
Ajá, funcionó después de todo :) Pero tengo que agregar CommandBehavior.SequentialAccess a esta línea: using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
hcd
@hcd Mi mal, lo tenía en el texto pero no en el código de muestra :)
Luaan