Prestazioni orribili utilizzando metodi SqlCommand Async con dati di grandi dimensioni


95

Sto riscontrando gravi problemi di prestazioni SQL durante l'utilizzo di chiamate asincrone. Ho creato un piccolo case per dimostrare il problema.

Ho creato un database su un SQL Server 2016 che risiede nella nostra LAN (quindi non un database locale).

In quel database, ho una tabella WorkingCopycon 2 colonne:

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]

In quella tabella ho inserito un singolo record (id = 'PerfUnitTest', Valueè una stringa da 1,5 MB (uno zip di un set di dati JSON più grande)).

Ora, se eseguo la query in SSMS:

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

Ottengo immediatamente il risultato e vedo in SQL Servre Profiler che il tempo di esecuzione è stato di circa 20 millisecondi. Tutto normale.

Quando si esegue la query dal codice .NET (4.6) utilizzando un semplice 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;

Anche il tempo di esecuzione per questo è di circa 20-30 millisecondi.

Ma quando lo si modifica in codice asincrono:

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

Il tempo di esecuzione è improvvisamente 1800 ms ! Anche in SQL Server Profiler, vedo che la durata dell'esecuzione della query è superiore a un secondo. Sebbene la query eseguita riportata dal profiler sia esattamente la stessa della versione non Async.

Ma c'è di peggio. Se gioco con la dimensione del pacchetto nella stringa di connessione, ottengo i seguenti risultati:

Dimensione pacchetto 32768: [TIMING]: ExecuteScalarAsync in SqlValueStore -> tempo trascorso: 450 ms

Packet Size 4096: [TIMING]: ExecuteScalarAsync in SqlValueStore -> tempo trascorso: 3667 ms

Dimensione pacchetto 512: [TIMING]: ExecuteScalarAsync in SqlValueStore -> tempo trascorso: 30776 ms

30.000 ms !! È oltre 1000 volte più lento rispetto alla versione non asincrona. Inoltre, SQL Server Profiler segnala che l'esecuzione della query ha richiesto più di 10 secondi. Questo non spiega nemmeno dove sono finiti gli altri 20 secondi!

Poi sono tornato alla versione di sincronizzazione e ho anche giocato con la dimensione del pacchetto, e sebbene abbia influito un po 'sul tempo di esecuzione, non è stato così drammatico come con la versione asincrona.

Come nota a margine, se inserisce solo una piccola stringa (<100 byte) nel valore, l'esecuzione della query asincrona è altrettanto veloce della versione di sincronizzazione (risultato in 1 o 2 ms).

Sono davvero sconcertato da questo, soprattutto perché sto usando il built-in SqlConnection, nemmeno un ORM. Inoltre, durante la ricerca in giro, non ho trovato nulla che potesse spiegare questo comportamento. Qualche idea?


5
@hcd 1,5 MB ????? E chiedi perché il recupero diventa più lento con la diminuzione delle dimensioni dei pacchetti? Soprattutto quando usi la query sbagliata per i BLOB?
Panagiotis Kanavos

3
@PanagiotisKanavos Stavo solo giocando per conto di OP. La vera domanda è perché l'asincronia è molto più lenta rispetto alla sincronizzazione con la stessa dimensione del pacchetto.
Fildor

2
Controllare Modifica dati di grandi dimensioni (max) in ADO.NET per il modo corretto di recuperare CLOB e BLOB. Invece di cercare di leggerli come un unico grande valore, usali GetSqlCharso GetSqlBinaryrecuperali in streaming. Considera anche di archiviarli come dati FILESTREAM: non c'è motivo di salvare 1,5 MB di dati nella pagina dati di una tabella
Panagiotis Kanavos

8
@PanagiotisKanavos Non è corretto. OP scrive la sincronizzazione: 20-30 ms e async con tutto il resto stesso 1800 ms. L'effetto della modifica della dimensione del pacchetto è del tutto chiaro e previsto.
Fildor

5
@hcd sembra che tu possa rimuovere la parte relativa ai tuoi tentativi di modificare le dimensioni dei pacchetti poiché sembra irrilevante per il problema e causa confusione tra alcuni commentatori.
Kuba Wyrostek

Risposte:


140

In un sistema senza un carico significativo, una chiamata asincrona ha un overhead leggermente maggiore. Sebbene l'operazione di I / O stessa sia asincrona indipendentemente, il blocco può essere più veloce del cambio di attività del pool di thread.

Quanto overhead? Diamo un'occhiata ai tuoi numeri di tempo. 30 ms per una chiamata di blocco, 450 ms per una chiamata asincrona. La dimensione del pacchetto di 32 kiB significa che hai bisogno di una cinquantina di operazioni di I / O individuali. Ciò significa che abbiamo circa 8 ms di overhead su ogni pacchetto, che corrisponde abbastanza bene alle tue misurazioni su diverse dimensioni di pacchetto. Non sembra un sovraccarico solo per essere asincrono, anche se le versioni asincrone devono fare molto più lavoro rispetto a quelle sincrone. Sembra che la versione sincrona sia (semplificata) 1 richiesta -> 50 risposte, mentre la versione asincrona finisce per essere 1 richiesta -> 1 risposta -> 1 richiesta -> 1 risposta -> ..., pagando il costo più e più volte ancora.

Andando più in profondità. ExecuteReaderfunziona altrettanto bene ExecuteReaderAsync. La prossima operazione è Readseguita da un GetFieldValue- e lì accade una cosa interessante. Se uno dei due è asincrono, l'intera operazione è lenta. Quindi c'è sicuramente qualcosa di molto diverso che accade una volta che inizi a rendere le cose veramente asincrone: a Readsarà veloce, e poi l'asincrono GetFieldValueAsyncsarà lento, oppure puoi iniziare con il lento ReadAsync, e poi entrambi GetFieldValuee GetFieldValueAsyncsono veloci. La prima lettura asincrona dal flusso è lenta e la lentezza dipende interamente dalle dimensioni dell'intera riga. Se aggiungo più righe della stessa dimensione, la lettura di ogni riga richiede la stessa quantità di tempo come se avessi solo una riga, quindi è ovvio che i dati sonoancora in fase di streaming riga per riga - sembra proprio preferire a leggere l'intera riga in una sola volta, una volta che si avvia qualsiasi lettura asincrona. Se leggo la prima riga in modo asincrono e la seconda in modo sincrono, la seconda riga da leggere sarà di nuovo veloce.

Quindi possiamo vedere che il problema è una grande dimensione di una singola riga e / o colonna. Non importa quanti dati hai in totale: leggere un milione di piccole righe in modo asincrono è altrettanto veloce che in modo sincrono. Ma aggiungi solo un singolo campo che è troppo grande per stare in un singolo pacchetto e misteriosamente incorrerai in un costo per la lettura asincrona di quei dati, come se ogni pacchetto avesse bisogno di un pacchetto di richiesta separato e il server non potesse semplicemente inviare tutti i dati a una volta. L'utilizzo CommandBehavior.SequentialAccessmigliora le prestazioni come previsto, ma l'enorme divario tra sincronizzazione e asincronia esiste ancora.

La migliore prestazione che ho ottenuto è stata quando ho fatto tutto correttamente. Ciò significa utilizzare CommandBehavior.SequentialAccess, oltre a trasmettere i dati in modo esplicito:

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

Con questo, la differenza tra sincronizzazione e asincrono diventa difficile da misurare e la modifica della dimensione del pacchetto non comporta più il ridicolo sovraccarico come prima.

Se desideri prestazioni ottimali nei casi limite, assicurati di utilizzare i migliori strumenti disponibili: in questo caso, esegui lo streaming di dati di colonne di grandi dimensioni anziché fare affidamento su helper come ExecuteScalaro GetFieldValue.


3
Bella risposta. Riprodotto lo scenario del PO. Per questa stringa da 1,5 m che OP sta menzionando, ottengo 130 ms per la versione di sincronizzazione contro 2200 ms per asincrona. Con il tuo approccio, il tempo misurato per la corda da 1,5 m è di 60 ms, non male.
Wiktor Zychla

4
Buone indagini lì, in più ho imparato una manciata di altre tecniche di ottimizzazione per il nostro codice DAL.
Adam Houldsworth,

Sono appena tornato in ufficio e ho provato il codice nel mio esempio invece di ExecuteScalarAsync, ma ho ancora un tempo di esecuzione di 30 secondi con una dimensione del pacchetto di 512 byte :(
hcd

6
Aha, dopotutto ha funzionato :) Ma devo aggiungere CommandBehavior.SequentialAccess a questa riga: using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
hcd

@hcd Colpa mia, ce l'avevo nel testo ma non nel codice di esempio :)
Luaan
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.