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 WorkingCopy
mit 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', Value
ist 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 SqlConnection
ORM verwende, nicht einmal ein ORM. Auch beim Durchsuchen habe ich nichts gefunden, was dieses Verhalten erklären könnte. Irgendwelche Ideen?
GetSqlChars
oderGetSqlBinary
rufen 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 speichernAntworten:
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.
ExecuteReader
funktioniert genauso gut wieExecuteReaderAsync
. Auf die nächste OperationRead
folgt einGetFieldValue
- 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 - aRead
wird schnell sein, und dann wird die AsynchronitätGetFieldValueAsync
langsam sein, oder Sie können mit der langsamen beginnenReadAsync
und dann beidesGetFieldValue
undGetFieldValueAsync
sind 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.SequentialAccess
verbessert 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: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
ExecuteScalar
oder zu verlassenGetFieldValue
.quelle
using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))