Недавно я посвятил время проблеме производительности на продакшене. Приложение работало в горячке — загрузка процессора в среднем превышала 50% и периодически подскакивала до 90%. Мы сделали диагностический снапшот и начали работать с топом запросов по процессорному времени.
Нарушитель номер один? Простой запрос Dapper. Незамысловатое условие WHERE по проиндексированному столбцу. Должно было быть молниеносным, но в среднем потребляло тысячи миллисекунд процессора при сотнях тысяч выполнений в день.
Несоответствие типов на пару символов, совсем невидимое в C#‑коде. Я очень долго глазел на запрос, прежде чем понял происходящее.
Так что же на самом деле происходит?
Вот паттерн, который найдётся в практически каждом .NET-проекте с Dapper:
const string sql = "SELECT * FROM Products WHERE ProductCode = @productCode"; var result = await connection.QueryFirstOrDefaultAsync<Product>(sql, new { productCode });
Чисто. Просто. И если в вашей БД ProductCode — столбец типа varchar, то этот код тихо угробит производительность вашего запроса.
Когда вы передаёте C#‑строку через анонимный объект, Dapper отображает её как nvarchar(4000). Это — отображение по умолчанию для System.String в ADO.NET — и, по‑честному, с точки зрения «безопасного значения по умолчанию» оно имеет смысл. Но если ваш столбец имеет тип varchar, перед сравнением SQL Server конвертирует в nvarchar каждое отдельное значение в столбце. Это называется CONVERT_IMPLICIT и оно означает, что сервер не сможет работать с индексом. Полное сканирование. Каждый раз.
Прячется оно в планах выполнения запросов:
CONVERT_IMPLICIT(nvarchar(255), [Sales].[ProductCode], 0)
Так сервер рассказывает вам: «У меня был прекрасный индекс, но вы заставили меня конвертировать каждую строку, чтобы сравнить её с вашим Unicode‑параметром, поэтому я не смог воспользоваться индексом».
Насколько это плохо на самом деле?
Математика здесь жестока. Скажем, у вас есть таблица на миллион строк и некластеризованный индекс по ProductCode. При правильных типах параметров SQL Server ищет по индексу: сразу переходит к совпадающей строке. Это несколько логических чтений. Микросекунды.
При неявном преобразовании SQL Server сканирует индекс: читает каждую строку индекса, преобразует каждое значение, а затем сравнивает их. Вместо нескольких логических чтений вы получаете десятки тысяч. Умножьте их на количество выполнений запроса за день — и у вас серьёзная проблема с процессором.
В нашем случае единственный запрос был в ответе за значительную долю общего потребления процессорного времени сервера БД. Не потому, что запрос сложный. Не потому, что таблица плохо индексирована. А из‑за простого несоответствия типов в параметре.
О параметрах сортировки. Степень серьёзности зависит от параметров сортировки вашей БД. При наиболее распространённом значении по умолчанию (
SQL_Latin1_General_CP1_CI_AS) вы получите худший случай — полное сканирование индекса.Некоторые параметры сортировки Windows (например,
Latin1_General_CI_AS) всё‑таки могут позволять искать по индексу, но оверхед на неявное преобразование остаётся. В любом случае, сопоставление типов параметра — правильное решение.
Исправление
Исправление ошеломляюще простое. Явно укажите Dapper, что параметр имеет тип varchar, а не nvarchar. Для этого пропишите DynamicParameters и DbType.AnsiString:
const string sql = "SELECT * FROM Products WHERE ProductCode = @productCode"; var parameters = new DynamicParameters(); parameters.Add("productCode", productCode, DbType.AnsiString, size: 100); var result = await connection.QueryFirstOrDefaultAsync<Product>(sql, parameters);
И всё. DbType.AnsiString указывает ADO.NET отправлять параметр типа varchar. DbType.String (значение по умолчанию для типа C# string) отправляет nvarchar.
Параметр size должен соответствовать определению вашего столбца. Если столбец имеет тип varchar(255), пропишите size: 255. Это поможет SQL Server точно сопоставить тип параметра с типом столбца и эффективно использовать кэш планов запросов.
Если же предпочтёте оставить объекты анонимными, в Dapper есть альтернатива покороче — это DbString:
var result = await connection.QueryFirstOrDefaultAsync<Product>(sql, new { productCode = new DbString { Value = productCode, IsAnsi = true, Length = 100 } });
Оба подхода дают один результат — параметр типа varchar вместо nvarchar.
До и после
Разница — мгновенная и огромная:
Метрика | До (nvarchar) | После (varchar) |
|---|---|---|
Тип сканирования | Сканирование индекса | Поиск по индексу |
Логические чтения | Десятки тысяч | Единицы |
Процессорное время | Миллисекунды | Микросекунды |
Никаких изменений схемы. Никаких новых индексов. Никаких переписываний запросов. Просто укажите Dapper правильный тип параметра.
Как это найти в вашем приложении
Если вы подозреваете, что у вас та же проблема, вот несколько способов её обнаружить:
Первый. Проверьте query_store на неявные преобразования:
SELECT TOP 20 qsqt.query_sql_text, qsrs.avg_cpu_time, qsrs.count_executions FROM sys.query_store_runtime_stats qsrs JOIN sys.query_store_plan qsp ON qsrs.plan_id = qsp.plan_id JOIN sys.query_store_query qsq ON qsp.query_id = qsq.query_id JOIN sys.query_store_query_text qsqt ON qsq.query_text_id = qsqt.query_text_id WHERE qsqt.query_sql_text LIKE '%@%nvarchar(4000)%' ORDER BY qsrs.avg_cpu_time * qsrs.count_executions DESC;
Второй. В планах выполнения посмотрите CONVERT_IMPLICIT:
Если видите предупреждения CONVERT_IMPLICIT в фактическом плане выполнения запроса, который фильтрует по varchar, то вы нашли проблему.
Третий. Поищите в коде C#:
Нужны вызовы Dapper, передающие строковые параметры в запросы к varchar через анонимные объекты:
// Проблему вызывает этот паттерн await connection.QueryAsync<T>(sql, new { someVarcharColumn });
Простое правило
Если столбец имеет тип varchar, пропишите DbType.AnsiString. Если nvarchar, то подходит значение по умолчанию — DbType.String. Сопоставляйте тип параметра с типом столбца и size с размером столбца.
Защитите исправление комментариями
Кое‑что я настоятельно рекомендую: прокомментируйте, почему вы пишете DynamicParameters вместо анонимных объектов. Потому что гарантирую — без комментария какой‑нибудь добросовестный разработчик «упростит» код обратно до new { productCode } во время рефакторинга и снова внесёт проблему. (Спросите, откуда я это знаю).
var parameters = new DynamicParameters(); // DbType.AnsiString обязателен: Products.ProductCode имеет тип varchar(100). Без него Dapper отправляет // nvarchar(4000), что вызывает CONVERT_IMPLICIT для каждой строки и делает поиск по индексу невозможным. parameters.Add("productCode", productCode, DbType.AnsiString, size: 100);
Суть в многословии. Это лежачий полицейский, который предотвращает случайное отмену критически важного исправления производительности.
Проведите аудит запросов
Это один из тех багов, которые едва заметны. Код выглядит правильно. Запрос возвращает правильные результаты. В логах ошибок нет. Всё работает — просто медленно, и вы не знаете, почему, пока не углубитесь в планы выполнения или в данные хранилища запросов.
Если вы работаете с Dapper и SQL Server, а ваши столбцы имеют тип varchar, проверьте параметры. Серьёзно, сделайте это сегодня. Каждый анонимный объект, передающий строку в varchar, — это потенциально полное сканирование таблицы, скрытое на виду.
Сталкивались ли вы с этой проблемой раньше? Мне было бы интересно услышать ваши истории — напишите мне в X, Bluesky или LinkedIn [X, LinkedIn запрещены в России].
Дальнейшее чтение
Перечисление DbType — документация Microsoft по типам данных ADO.NET, включая различие
StringиAnsiString.Руководство по архитектуре обработки запросов — углублённое погружение в обработку запросов в SQL Server, включая неявные преобразования и кэширование планов.
Конвертирование типов данных (компонент Database Engine) — справочник Microsoft по явным и неявным преобразованиям, включая правила приоритета типов данных, вызывающие эту проблему.
Если вы новичок в Dapper, ознакомьтесь со вводной статьёй о том, что такое Dapper и почему вы должны рассмотреть его для ваших .NET‑проектов. А если строите постраничные запросы, вам может понравиться моя статья об оконных функциях COUNT(*) OVER() для расчёта пагинации за один запрос.
