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ę WorkingCopy
z 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', Value
to 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?
GetSqlChars
lubGetSqlBinary
pobierz je strumieniowo. Rozważ również przechowywanie ich jako danych FILESTREAM - nie ma powodu, aby zapisywać 1,5 MB danych na stronie danych tabeliOdpowiedzi:
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.
ExecuteReader
działa równie dobrzeExecuteReaderAsync
. Po następnej operacjiRead
następujeGetFieldValue
- 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 - aRead
będzie szybkie, a następnie asynchronizacjaGetFieldValueAsync
będzie wolna, lub możesz zacząć od wolnegoReadAsync
, a potem obaGetFieldValue
iGetFieldValueAsync
są 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 są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.SequentialAccess
poprawia 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
ExecuteScalar
lubGetFieldValue
.źródło
using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))