Straszna wydajność przy użyciu metod SqlCommand Async z dużymi danymi

95

Mam poważne problemy z wydajnością SQL podczas korzystania z wywołań asynchronicznych. Stworzyłem mały przypadek, aby zademonstrować problem.

Utworzyłem bazę danych na serwerze SQL Server 2016, który znajduje się w naszej sieci LAN (a więc nie w lokalnej bazie danych).

W tej bazie danych mam tabelę WorkingCopyz 2 kolumnami:

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]

W tej tabeli wstawiłem pojedynczy rekord ( id= 'PerfUnitTest', Valueto ciąg o rozmiarze 1,5 MB (plik ZIP większego zestawu danych JSON)).

Teraz, jeśli wykonam zapytanie w SSMS:

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

Natychmiast otrzymuję wynik i widzę w SQL Servre Profiler, że czas wykonania wyniósł około 20 milisekund. Wszystko normalne.

Podczas wykonywania zapytania z kodu .NET (4.6) przy użyciu zwykłego 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;

Czas wykonania tego również wynosi około 20-30 milisekund.

Ale zmieniając go na kod asynchroniczny:

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

Czas wykonania jest nagle 1800 ms ! Również w SQL Server Profiler widzę, że czas wykonania zapytania jest dłuższy niż sekunda. Chociaż wykonane zapytanie zgłoszone przez profilera jest dokładnie takie samo, jak wersja inna niż Async.

Ale jest gorzej. Jeśli bawię się rozmiarem pakietu w ciągu połączenia, otrzymuję następujące wyniki:

Rozmiar pakietu 32768: [TIMING]: ExecuteScalarAsync w SqlValueStore -> czas, który upłynął: 450 ms

Rozmiar pakietu 4096: [TIMING]: ExecuteScalarAsync w SqlValueStore -> czas, który upłynął: 3667 ms

Rozmiar pakietu 512: [TIMING]: ExecuteScalarAsync w SqlValueStore -> czas, który upłynął: 30776 ms

30 000 ms !! To ponad 1000 razy wolniej niż wersja nie-asynchroniczna. SQL Server Profiler zgłasza, że ​​wykonanie zapytania zajęło ponad 10 sekund. To nawet nie wyjaśnia, dokąd upłynęło pozostałe 20 sekund!

Potem wróciłem do wersji synchronizowanej i bawiłem się rozmiarem pakietu i chociaż wpłynęło to trochę na czas wykonywania, nigdzie nie było tak dramatycznego, jak w przypadku wersji asynchronicznej.

Na marginesie, jeśli wstawi tylko mały ciąg (<100 bajtów) do wartości, wykonanie zapytania asynchronicznego będzie tak samo szybkie, jak wersja synchronizacji (wynik w 1 lub 2 ms).

Jestem tym naprawdę zdumiony, zwłaszcza że używam wbudowanego SqlConnection, a nawet ORM. Również podczas poszukiwań nie znalazłem nic, co mogłoby wyjaśnić to zachowanie. Jakieś pomysły?

hcd
źródło
5
@hcd 1,5 MB ????? I pytasz, dlaczego pobieranie, które jest wolniejsze wraz ze zmniejszaniem się rozmiarów pakietów? Zwłaszcza, gdy używasz niewłaściwego zapytania dla obiektów BLOB?
Panagiotis Kanavos
3
@PanagiotisKanavos To tylko gra w imieniu OP. Właściwe pytanie brzmi, dlaczego asynchronizacja jest o wiele wolniejsza w porównaniu z synchronizacją z tym samym rozmiarem pakietu.
Fildor
2
Zaznacz opcję Modyfikowanie danych o dużej wartości (maks.) W ADO.NET, aby sprawdzić poprawny sposób pobierania obiektów CLOB i BLOB. Zamiast próbować odczytywać je jako jedną dużą wartość, użyj GetSqlCharslub GetSqlBinarypobierz je strumieniowo. Rozważ również przechowywanie ich jako danych FILESTREAM - nie ma powodu, aby zapisywać 1,5 MB danych na stronie danych tabeli
Panagiotis Kanavos
8
@PanagiotisKanavos To nie jest poprawne. OP zapisuje synchronizację: 20-30 ms i asynchronicznie ze wszystkim innym tak samo 1800 ms. Efekt zmiany rozmiaru pakietu jest całkowicie jasny i oczekiwany.
Fildor
5
@hcd wygląda na to, że możesz usunąć część dotyczącą twoich prób zmiany rozmiarów pakietów, ponieważ wydaje się to nie mieć związku z problemem i powoduje zamieszanie wśród niektórych komentujących.
Kuba Wyrostek

Odpowiedzi:

141

W systemie bez znacznego obciążenia wywołanie asynchroniczne ma nieco większy narzut. Chociaż sama operacja we / wy jest asynchroniczna niezależnie od tego, blokowanie może być szybsze niż przełączanie zadań w puli wątków.

Ile narzutów? Spójrzmy na twoje numery czasowe. 30 ms dla połączenia blokującego, 450 ms dla połączenia asynchronicznego. Rozmiar pakietu 32 kiB oznacza, że ​​potrzebujesz około pięćdziesięciu indywidualnych operacji we / wy. Oznacza to, że każdy pakiet ma około 8 ms narzutu, co bardzo dobrze odpowiada Twoim pomiarom przy różnych rozmiarach pakietów. To nie brzmi jak obciążenie tylko z powodu asynchroniczności, mimo że wersje asynchroniczne muszą wykonać dużo więcej pracy niż synchroniczne. Wygląda na to, że wersja synchroniczna to (uproszczona) 1 żądanie -> 50 odpowiedzi, podczas gdy wersja asynchroniczna kończy się na 1 żądaniu -> 1 odpowiedzi -> 1 żądaniu -> 1 odpowiedzi -> ..., ponosząc koszty w kółko jeszcze raz.

Idąc głębiej. ExecuteReaderdziała równie dobrze ExecuteReaderAsync. Po następnej operacji Readnastępuje GetFieldValue- i dzieje się tam interesująca rzecz. Jeśli którykolwiek z nich jest asynchroniczny, cała operacja przebiega powoli. Więc z pewnością dzieje się coś zupełnie innego, gdy zaczniesz robić rzeczy naprawdę asynchroniczne - a Readbędzie szybkie, a następnie asynchronizacja GetFieldValueAsyncbędzie wolna, lub możesz zacząć od wolnego ReadAsync, a potem oba GetFieldValuei GetFieldValueAsyncsą szybkie. Pierwszy asynchroniczny odczyt ze strumienia jest powolny, a powolność zależy całkowicie od rozmiaru całego wiersza. Jeśli dodam więcej wierszy tego samego rozmiaru, odczytanie każdego wiersza zajmuje tyle samo czasu, co gdybym miał tylko jeden wiersz, więc jest oczywiste, że dane nadal transmitowane wiersz po wierszu - po prostu wydaje się preferować, aby przeczytać cały rząd od razu po uruchomieniu dowolnego asynchronicznego odczytu. Jeśli odczytam pierwszy wiersz asynchronicznie, a drugi synchronicznie - odczytany drugi wiersz będzie znowu szybki.

Widzimy więc, że problemem jest duży rozmiar pojedynczego wiersza i / lub kolumny. Nie ma znaczenia, ile danych masz w sumie - asynchroniczne odczytywanie miliona małych wierszy jest tak samo szybkie, jak synchroniczne. Ale dodaj tylko jedno pole, które jest zbyt duże, aby zmieścić się w jednym pakiecie, a w tajemniczy sposób ponosisz koszty asynchronicznego odczytu tych danych - tak jakby każdy pakiet wymagał oddzielnego pakietu żądania, a serwer nie mógł po prostu wysłać wszystkich danych na adres pewnego razu. Użycie CommandBehavior.SequentialAccesspoprawia wydajność zgodnie z oczekiwaniami, ale nadal istnieje ogromna luka między synchronizacją i asynchronizacją.

Najlepsze wykonanie, jakie uzyskałem, to wykonanie całej sprawy poprawnie. Oznacza to używanie CommandBehavior.SequentialAccess, a także jawne przesyłanie strumieniowe danych:

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

Dzięki temu różnica między synchronizacją i asynchronizacją staje się trudna do zmierzenia, a zmiana rozmiaru pakietu nie powoduje już tak absurdalnego narzutu, jak poprzednio.

Jeśli chcesz mieć dobrą wydajność w przypadkach skrajnych, upewnij się, że korzystasz z najlepszych dostępnych narzędzi - w tym przypadku przesyłaj strumieniowo dane z dużych kolumn zamiast polegać na pomocnikach, takich jak ExecuteScalarlub GetFieldValue.

Luaan
źródło
3
Świetna odpowiedź. Powielono scenariusz PO. W przypadku tego 1,5-metrowego łańcucha OP, o którym mowa, otrzymuję 130 ms dla wersji synchronizacji w porównaniu z 2200 ms dla asynchronicznego. Przy twoim podejściu zmierzony czas dla struny 1,5 m wynosi 60 ms, nieźle.
Wiktor Zychla
4
Dobre badania, a także poznałem kilka innych technik dostrajania naszego kodu DAL.
Adam Houldsworth
Właśnie wróciłem do biura i wypróbowałem kod na moim przykładzie zamiast ExecuteScalarAsync, ale nadal mam 30 sekundowy czas wykonywania przy rozmiarze pakietu 512 bajtów :(
hcd
6
Aha, mimo wszystko zadziałało :) Ale muszę dodać CommandBehavior.SequentialAccess do tej linii: using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
hcd
@hcd Mój błąd, miałem to w tekście, ale nie w przykładowym kodzie :)
Luaan