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 WorkingCopy
con 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', Value
es 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?
GetSqlChars
oGetSqlBinary
recupé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 tablaRespuestas:
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.
ExecuteReader
funciona tan bien comoExecuteReaderAsync
. A la siguiente operación leRead
sigue unGetFieldValue
... 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ónicoGetFieldValueAsync
será lento, o puedes comenzar con el lentoReadAsync
, y luego ambosGetFieldValue
yGetFieldValueAsync
son 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. Utilizando
CommandBehavior.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: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
ExecuteScalar
oGetFieldValue
.fuente
using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))