Недавно я посвятил время проблеме производительности на продакшене. Приложение работало в горячке — загрузка процессора в среднем превышала 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 запрещены в России].

Дальнейшее чтение


Если вы новичок в Dapper, ознакомьтесь со вводной статьёй о том, что такое Dapper и почему вы должны рассмотреть его для ваших .NET‑проектов. А если строите постраничные запросы, вам может понравиться моя статья об оконных функциях COUNT(*) OVER() для расчёта пагинации за один запрос.