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

alexander-java
10.03.2026 15:22Есть такая книга "настройка (оптимизация, tuning) sql " . Это типичная проблема при работе с индексами, неявное преобразование.

amironov
10.03.2026 15:22Как C#‑строки и Dapper тихо убивают ваши индексы сервера SQL
SQL Server лучше не переводить, так как речь идеть про конкретный продукт, а не про все SQL СУБД.

stranger777 Автор
10.03.2026 15:22Поправил, спасибо. Пока уточнял, нашёл глобальное решение, если везде уместны ANSI‑строки:
Dapper.SqlMapper.AddTypeMap(typeof(string), System.Data.DbType.AnsiString);Проблема старая.
aamonster
Для меня выглядит как баг SQL Server (и workaround для этого бага). Не вижу никакой причины для него перебирать все записи, а не учесть при поиске тип столбца (если надо – выполнив дополнительную проверку для найденных кандидатов).
Tzimie
Нет, не баг. Тип приводится от простого к более сложному, то есть varchar приводится к nvarchar, а не наоборот.
aamonster
Прекрасно. Пусть приводится – но не при поиске по индексу, а при возврате результата. Т.е. пришёл нам nvarchar – приводим его к varchar (с потерей данных), ищем по нему (используя индекс), найденные приводим к nvarchar и сравниваем с образцом. Опционально можно оптимизировать: если при приведении в varchar и обратно обратно образец меняется – сразу "not found", даже не проверяя индекс. По-моему, очевидное решение, и делать его должны разработчики SQL Server.
Но при его отсутствии, понятно, приходится костылить (и, главное, вообще помнить о таких gotcha).
Stol-Stul
Неужели там разработчики идиоты и мазохисты сидят?
aamonster
Возможно, legacy и сложно менять. Или жалоб не было (хотя это и маловероятно).
amironov
Неявное преобразование -- зло в любом языке. По-хорошему в таких случаях должно как минимум предупреждение кидаться.