Schreckliche Leistung mit SqlCommand Async-Methoden mit großen Datenmengen

95

Ich habe große SQL-Leistungsprobleme bei der Verwendung von asynchronen Aufrufen. Ich habe einen kleinen Fall erstellt, um das Problem zu demonstrieren.

Ich habe eine Datenbank auf einem SQL Server 2016 erstellt, der sich in unserem LAN befindet (also keine lokale Datenbank).

In dieser Datenbank habe ich eine Tabelle WorkingCopymit 2 Spalten:

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 diese Tabelle habe ich einen einzelnen Datensatz eingefügt ( id= 'PerfUnitTest', Valueist eine 1,5-MB-Zeichenfolge (eine Zip-Datei eines größeren JSON-Datasets)).

Wenn ich nun die Abfrage in SSMS ausführe:

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

Ich erhalte sofort das Ergebnis und sehe im SQL Servre Profiler, dass die Ausführungszeit etwa 20 Millisekunden betrug. Alles normal.

Wenn Sie die Abfrage aus .NET (4.6) -Code mit einem einfachen Code ausführen 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;

Die Ausführungszeit hierfür beträgt ebenfalls ca. 20-30 Millisekunden.

Aber wenn Sie es in asynchronen Code ändern:

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

Die Ausführungszeit beträgt plötzlich 1800 ms ! Auch in SQL Server Profiler sehe ich, dass die Ausführungsdauer der Abfrage mehr als eine Sekunde beträgt. Obwohl die vom Profiler gemeldete ausgeführte Abfrage genau mit der nicht-asynchronen Version identisch ist.

Aber es wird schlimmer. Wenn ich mit der Paketgröße in der Verbindungszeichenfolge herumspiele, erhalte ich die folgenden Ergebnisse:

Paketgröße 32768: [TIMING]: ExecuteScalarAsync im SqlValueStore -> verstrichene Zeit: 450 ms

Paketgröße 4096: [TIMING]: ExecuteScalarAsync im SqlValueStore -> verstrichene Zeit: 3667 ms

Paketgröße 512: [TIMING]: ExecuteScalarAsync im SqlValueStore -> verstrichene Zeit: 30776 ms

30.000 ms !! Das ist über 1000x langsamer als die nicht asynchrone Version. Und SQL Server Profiler meldet, dass die Ausführung der Abfrage mehr als 10 Sekunden gedauert hat. Das erklärt nicht einmal, wohin die anderen 20 Sekunden gehen!

Dann habe ich wieder zur Synchronisierungsversion gewechselt und auch mit der Paketgröße herumgespielt, und obwohl dies die Ausführungszeit ein wenig beeinflusst hat, war es nirgends so dramatisch wie bei der asynchronen Version.

Wenn nur eine kleine Zeichenfolge (<100 Byte) in den Wert eingefügt wird, ist die Ausführung der asynchronen Abfrage genauso schnell wie die Synchronisierungsversion (Ergebnis 1 oder 2 ms).

Das verwirrt mich wirklich, zumal ich das eingebaute SqlConnectionORM verwende, nicht einmal ein ORM. Auch beim Durchsuchen habe ich nichts gefunden, was dieses Verhalten erklären könnte. Irgendwelche Ideen?

hcd
quelle
4
@hcd 1,5 MB ????? Und Sie fragen sich, warum das Abrufen mit abnehmender Paketgröße langsamer wird? Vor allem, wenn Sie die falsche Abfrage für BLOBs verwenden?
Panagiotis Kanavos
3
@PanagiotisKanavos Das spielte nur im Auftrag von OP herum. Die eigentliche Frage ist, warum Async im Vergleich zur Synchronisierung mit derselben Paketgröße so viel langsamer ist .
Fildor
2
Überprüfen Sie das Ändern von Daten mit großem Wert (maximal) in ADO.NET, um festzustellen, wie CLOBs und BLOBs korrekt abgerufen werden. Anstatt zu versuchen, sie als einen großen Wert zu lesen, verwenden Sie sie GetSqlCharsoder GetSqlBinaryrufen Sie sie auf Streaming-Weise ab. Erwägen Sie auch, sie als FILESTREAM-Daten zu speichern - es gibt keinen Grund, 1,5 MB Daten auf der Datenseite einer Tabelle zu speichern
Panagiotis Kanavos
8
@PanagiotisKanavos Das stimmt nicht. OP schreibt Synchronisation: 20-30 ms und asynchron mit allem anderen gleich 1800 ms. Der Effekt der Änderung der Paketgröße ist völlig klar und wird erwartet.
Fildor
5
@hcd Es scheint, dass Sie den Teil über Ihre Versuche, die Paketgröße zu ändern, entfernen könnten, da er für das Problem irrelevant erscheint und bei einigen Kommentatoren Verwirrung stiftet.
Kuba Wyrostek

Antworten:

139

Auf einem System ohne nennenswerte Belastung hat ein asynchroner Aufruf einen etwas größeren Overhead. Während die E / A-Operation selbst unabhängig davon asynchron ist, kann das Blockieren schneller sein als das Wechseln von Thread-Pool-Aufgaben.

Wie viel Overhead? Schauen wir uns Ihre Timing-Zahlen an. 30 ms für einen blockierenden Anruf, 450 ms für einen asynchronen Anruf. Eine Paketgröße von 32 kiB bedeutet, dass Sie etwa fünfzig einzelne E / A-Vorgänge benötigen. Das heißt, wir haben ungefähr 8 ms Overhead für jedes Paket, was ziemlich gut mit Ihren Messungen über verschiedene Paketgrößen übereinstimmt. Das klingt nicht nach Overhead, nur weil es asynchron ist, obwohl die asynchronen Versionen viel mehr Arbeit leisten müssen als die synchronen. Es hört sich so an, als wäre die synchrone Version (vereinfacht) 1 Anfrage -> 50 Antworten, während die asynchrone Version 1 Anfrage -> 1 Antwort -> 1 Anfrage -> 1 Antwort -> ... ist und die Kosten immer wieder bezahlt nochmal.

Tiefer gehen. ExecuteReaderfunktioniert genauso gut wie ExecuteReaderAsync. Auf die nächste Operation Readfolgt ein GetFieldValue- und dort passiert etwas Interessantes. Wenn einer der beiden asynchron ist, ist der gesamte Vorgang langsam. Es passiert also sicherlich etwas ganz anderes, wenn Sie anfangen, die Dinge wirklich asynchron zu machen - a Readwird schnell sein, und dann wird die Asynchronität GetFieldValueAsynclangsam sein, oder Sie können mit der langsamen beginnen ReadAsyncund dann beides GetFieldValueund GetFieldValueAsyncsind schnell. Das erste asynchrone Lesen aus dem Stream ist langsam und die Langsamkeit hängt vollständig von der Größe der gesamten Zeile ab. Wenn ich mehr Zeilen derselben Größe hinzufüge, dauert das Lesen jeder Zeile genauso lange, als hätte ich nur eine Zeile. Es ist also offensichtlich, dass es sich um Daten handeltWird immer noch Zeile für Zeile gestreamt - es scheint nur vorzuziehen, die gesamte Zeile auf einmal zu lesen, sobald Sie einen asynchronen Lesevorgang starten . Wenn ich die erste Zeile asynchron und die zweite synchron lese, ist die zweite Zeile, die gelesen wird, wieder schnell.

Wir können also sehen, dass das Problem eine große Größe einer einzelnen Zeile und / oder Spalte ist. Es spielt keine Rolle, wie viele Daten Sie insgesamt haben - das asynchrone Lesen einer Million kleiner Zeilen ist genauso schnell wie synchron. Fügen Sie jedoch nur ein einzelnes Feld hinzu, das zu groß ist, um in ein einzelnes Paket zu passen, und Sie verursachen auf mysteriöse Weise Kosten beim asynchronen Lesen dieser Daten - als ob jedes Paket ein separates Anforderungspaket benötigt und der Server nicht einfach alle Daten an senden kann Einmal. Die Verwendung CommandBehavior.SequentialAccessverbessert zwar die Leistung wie erwartet, aber die massive Lücke zwischen Synchronisierung und Asynchronisierung besteht weiterhin.

Die beste Leistung, die ich bekommen habe, war, wenn ich das Ganze richtig gemacht habe. Das bedeutet CommandBehavior.SequentialAccess, die Daten explizit zu verwenden und zu streamen:

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

Dadurch wird der Unterschied zwischen Synchronisierung und Asynchronisierung schwer zu messen, und das Ändern der Paketgröße verursacht nicht mehr den lächerlichen Overhead wie zuvor.

Wenn Sie in Randfällen eine gute Leistung erzielen möchten, stellen Sie sicher, dass Sie die besten verfügbaren Tools verwenden. In diesem Fall streamen Sie große Spaltendaten, anstatt sich auf Helfer wie ExecuteScalaroder zu verlassen GetFieldValue.

Luaan
quelle
3
Gute Antwort. Reproduziert das OP-Szenario. Für diese 1,5 m lange Zeichenfolge, die OP erwähnt, erhalte ich 130 ms für die Synchronisierungsversion gegenüber 2200 ms für die asynchrone Version. Bei Ihrem Ansatz beträgt die gemessene Zeit für die 1,5 m lange Saite 60 ms, nicht schlecht.
Wiktor Zychla
4
Gute Untersuchungen dort und ich habe eine Handvoll anderer Tuning-Techniken für unseren DAL-Code gelernt.
Adam Houldsworth
Ich bin gerade ins Büro zurückgekehrt und habe den Code in meinem Beispiel anstelle von ExecuteScalarAsync ausprobiert, aber ich habe immer noch 30 Sekunden Ausführungszeit mit einer Paketgröße von 512 Byte :(
hcd
6
Aha, es hat doch funktioniert :) Aber ich muss das CommandBehavior.SequentialAccess zu dieser Zeile hinzufügen: using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
hcd
@hcd Mein schlechtes, ich hatte es im Text, aber nicht im Beispielcode :)
Luaan