При использовании асинхронных вызовов у меня возникают серьезные проблемы с производительностью SQL. Я создал небольшой кейс, чтобы продемонстрировать проблему.
Я создал базу данных на SQL Server 2016, которая находится в нашей локальной сети (а не в localDB).
В этой базе данных у меня есть таблица WorkingCopy
с двумя столбцами:
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]
В эту таблицу я вставил одну запись ( id
= 'PerfUnitTest', Value
это строка 1,5 МБ (zip большего набора данных JSON)).
Теперь, если я выполню запрос в SSMS:
SELECT [Value]
FROM [Workingcopy]
WHERE id = 'perfunittest'
Я сразу получаю результат и вижу в SQL Servre Profiler, что время выполнения было около 20 миллисекунд. Все нормально.
При выполнении запроса из кода .NET (4.6) с использованием простого 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;
Время выполнения для этого также составляет около 20-30 миллисекунд.
Но при изменении его на асинхронный код:
string value = await command.ExecuteScalarAsync() as string;
Время выполнения внезапно составляет 1800 мс ! Также в SQL Server Profiler я вижу, что продолжительность выполнения запроса больше секунды. Хотя выполненный запрос, сообщаемый профилировщиком, точно такой же, как и неасинхронная версия.
Но становится еще хуже. Если я поиграю с размером пакета в строке подключения, я получу следующие результаты:
Размер пакета 32768: [ВРЕМЯ]: ExecuteScalarAsync в SqlValueStore -> прошедшее время: 450 мс
Размер пакета 4096: [ВРЕМЯ]: ExecuteScalarAsync в SqlValueStore -> прошедшее время: 3667 мс
Размер пакета 512: [ВРЕМЯ]: ExecuteScalarAsync в SqlValueStore -> прошедшее время: 30776 мс
30 000 мс !! Это более чем в 1000 раз медленнее, чем неасинхронная версия. И SQL Server Profiler сообщает, что выполнение запроса заняло более 10 секунд. Это даже не объясняет, куда ушли остальные 20 секунд!
Затем я вернулся к версии с синхронизацией и также поигрался с размером пакета, и хотя это немного повлияло на время выполнения, оно не было таким драматичным, как с асинхронной версией.
В качестве примечания, если в значение помещается только небольшая строка (<100 байт), выполнение асинхронного запроса выполняется так же быстро, как и версия синхронизации (результат составляет 1 или 2 мс).
Меня это действительно сбивает с толку, тем более что я использую встроенный SqlConnection
, даже не ORM. Также при поиске я не нашел ничего, что могло бы объяснить такое поведение. Любые идеи?
GetSqlChars
илиGetSqlBinary
извлекайте их в потоковом режиме. Также подумайте о том, чтобы сохранить их как данные FILESTREAM - нет причин сохранять 1,5Ответы:
В системе без значительной нагрузки асинхронный вызов имеет немного большие накладные расходы. Хотя сама операция ввода-вывода является асинхронной, блокировка может быть быстрее, чем переключение задач пула потоков.
Сколько накладных расходов? Давайте посмотрим на ваши временные показатели. 30 мс для блокирующего вызова, 450 мс для асинхронного вызова. Размер пакета 32 КБ означает, что вам потребуется около пятидесяти отдельных операций ввода-вывода. Это означает, что у нас есть примерно 8 мс накладных расходов на каждый пакет, что довольно хорошо соответствует вашим измерениям для разных размеров пакетов. Это не похоже на накладные расходы только из-за асинхронности, хотя асинхронные версии должны выполнять гораздо больше работы, чем синхронные. Похоже, что синхронная версия (упрощенная) 1 запрос -> 50 ответов, в то время как асинхронная версия заканчивается 1 запрос -> 1 ответ -> 1 запрос -> 1 ответ -> ..., оплачивая стоимость снова и снова. еще раз.
Идем глубже.
ExecuteReader
работает так же хорошо, как иExecuteReaderAsync
. Следующая операцияRead
сопровождаетсяGetFieldValue
- и там происходит интересная вещь. Если один из двух является асинхронным, вся операция будет медленной. Так что, конечно, что-то совсем другое происходит, когда вы начинаете делать вещи по-настоящему асинхронными - аRead
будет быстро, а затем асинхронныйGetFieldValueAsync
будет медленным, или вы можете начать с медленногоReadAsync
, а затем и то и другое,GetFieldValue
иGetFieldValueAsync
будут быстрыми. Первое асинхронное чтение из потока выполняется медленно, и медленность полностью зависит от размера всей строки. Если добавить несколько строк одного и того же размера, читая каждую строку занимает столько же времени , как если бы я только одну строку, так что очевидно , что данные естьпо-прежнему транслируется строка за строкой - просто кажется, что он предпочитает читать всю строку сразу, как только вы начинаете любое асинхронное чтение. Если я читаю первую строку асинхронно, а вторую синхронно - вторая читаемая строка снова будет быстрой.Итак, мы видим, что проблема в большом размере отдельной строки и / или столбца. Неважно, сколько всего у вас данных - асинхронное чтение миллиона маленьких строк происходит так же быстро, как и синхронно. Но добавьте только одно поле, которое слишком велико, чтобы поместиться в один пакет, и вы таинственным образом понесете затраты на асинхронное чтение этих данных - как если бы каждый пакет нуждался в отдельном пакете запроса, а сервер не мог просто отправить все данные в один раз. Использование
CommandBehavior.SequentialAccess
действительно улучшает производительность, как и ожидалось, но огромный разрыв между синхронизацией и асинхронностью все еще существует.Лучшая производительность, которую я получил, - это когда я все делал правильно. Это означает использование
CommandBehavior.SequentialAccess
, а также явную потоковую передачу данных:using (var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess)) { while (await reader.ReadAsync()) { var data = await reader.GetTextReader(0).ReadToEndAsync(); } }
При этом разницу между синхронизацией и асинхронностью становится трудно измерить, и изменение размера пакета больше не влечет за собой нелепые накладные расходы, как раньше.
Если вам нужна хорошая производительность в крайних случаях, обязательно используйте лучшие доступные инструменты - в этом случае передавайте данные больших столбцов, а не полагайтесь на помощников, таких как
ExecuteScalar
илиGetFieldValue
.источник
using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))