Ужасная производительность при использовании методов SqlCommand Async с большими данными

95

При использовании асинхронных вызовов у меня возникают серьезные проблемы с производительностью 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. Также при поиске я не нашел ничего, что могло бы объяснить такое поведение. Любые идеи?

ЖКД
источник
5
@hcd 1.5 МБ ????? И вы спрашиваете, почему извлечение становится медленнее с уменьшением размера пакета? Особенно, когда вы используете неправильный запрос для больших двоичных объектов?
Панайотис Канавос
3
@PanagiotisKanavos Это просто игра от имени OP. Фактический вопрос в том, почему асинхронный режим работает намного медленнее по сравнению с синхронизацией с тем же размером пакета.
Филдор
2
Установите флажок «Изменение данных большого размера (макс.) В ADO.NET», чтобы узнать, как правильно получать объекты CLOB и BLOB. Вместо того, чтобы пытаться читать их как одно большое значение, используйте GetSqlCharsили GetSqlBinaryизвлекайте их в потоковом режиме. Также подумайте о том, чтобы сохранить их как данные FILESTREAM - нет причин сохранять 1,5
МБ
8
@PanagiotisKanavos Это неверно. OP записывает синхронизацию: 20-30 мс и асинхронность со всем остальным - 1800 мс. Эффект от изменения размера пакета очевиден и ожидаем.
Филдор
5
@hcd кажется, что вы могли бы удалить часть о своих попытках изменить размеры пакетов, поскольку это не имеет отношения к проблеме и вызывает путаницу среди некоторых комментаторов.
Куба Выростек

Ответы:

141

В системе без значительной нагрузки асинхронный вызов имеет немного большие накладные расходы. Хотя сама операция ввода-вывода является асинхронной, блокировка может быть быстрее, чем переключение задач пула потоков.

Сколько накладных расходов? Давайте посмотрим на ваши временные показатели. 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.

Луаан
источник
3
Отличный ответ. Воспроизведен сценарий ОП. Для этой 1,5-метровой строки, о которой упоминается OP, я получаю 130 мс для версии синхронизации против 2200 мс для асинхронной. С вашим подходом измеренное время для 1,5-метровой струны составляет 60 мсек, неплохо.
Wiktor Zychla
4
Хорошие исследования, плюс я изучил несколько других техник настройки нашего кода DAL.
Адам Хоулдсворт
Только что вернулся в офис и попробовал код из моего примера вместо ExecuteScalarAsync, но у меня все еще было время выполнения 30 секунд с размером пакета 512 байт :(
hcd
6
Ага, это все-таки сработало :) Но я должен добавить CommandBehavior.SequentialAccess к этой строке: using (var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess))
hcd
@hcd Моя проблема, у меня это было в тексте, но не в образце кода :)
Luaan