Скомпилированные запросы EF Core vs сырой SQL vs Dapper: что побеждает на пути чтения?
Для путей с большим количеством чтений в .NET 11 чистый EF Core с AsNoTracking держится в пределах ~5% от Dapper. Используйте скомпилированные запросы на профилированном горячем пути одной строки, а Dapper только для наименьшей задержки или для SQL, который LINQ не может выразить.
Для пути чтения в .NET 11 честный выбор по умолчанию — чистый LINQ EF Core с AsNoTracking. На запросе списка он держится в пределах примерно 5% от Dapper и выделяет меньше памяти. Используйте EF.CompileAsyncQuery только на профилированном горячем пути одной строки, который выполняет одну и ту же форму тысячи раз в секунду, потому что скомпилированные запросы срезают стоимость трансляции из LINQ в SQL и больше ничего. Используйте Dapper, когда вам нужна наименьшая задержка на одну строку, наименьшие выделения памяти или когда SQL настолько запутан, что LINQ сопротивляется. Сырой SQL EF Core (FromSql / SqlQuery) — это мост: ваш SQL, материализатор и отслеживание изменений EF, для запроса, который LINQ не может выразить, но который вы всё же хотите получить как отслеживаемые сущности. Всё дальнейшее использует Microsoft.EntityFrameworkCore 11.0.0 на .NET 11 с C# 14 и Dapper 2.1.66.
Эти трое на самом деле не одно и то же, поэтому “Dapper быстрее” — это полуправда. Скомпилированные запросы и сырой SQL — оба EF Core; они оптимизируют разные этапы одного и того же конвейера. Dapper — это отдельный микро-ORM, который пропускает большую часть этого конвейера. Чтобы выбрать правильно, нужно знать, какой этап удаляет каждый из них.
Что на самом деле удаляет каждый из них
Простой ctx.Orders.FirstOrDefaultAsync(o => o.Id == id) делает пять вещей за вызов: разбирает дерево LINQ, ищет его в кеше запросов EF, транслирует в SQL при промахе кеша, выполняет команду, а затем материализует строки в сущности и (по умолчанию) регистрирует их в отслеживании изменений. Три претендента атакуют разные части этого.
- Скомпилированные запросы (
EF.CompileQuery/EF.CompileAsyncQuery) пропускают этапы разбора, поиска в кеше и трансляции после первого вызова, выдавая вам заранее построенный делегат. Они не трогают материализацию и отслеживание изменений. Выигрыш — только в стоимости трансляции. - Сырой SQL (
FromSql,FromSqlInterpolated,SqlQuery) тоже пропускает трансляцию, потому что SQL написали вы сами. Но результат всё равно проходит через shaper EF и отслеживание изменений, и SQL всё равно оборачивается как подзапрос, чтобы вы могли компоновать LINQ поверх него. Вы сохраняете сущности,Includeи отслеживание. - Dapper удаляет и трансляцию, и материализатор EF. Он сопоставляет ридер с вашим типом с помощью однажды сгенерированного и закешированного IL, не имеет отслеживания изменений и никогда не открывает
DbContext. Выигрыш — максимально облегчённый круговой путь к простому объекту.
Эта рамка — вся статья. Матрица и бенчмарки ниже лишь добавляют к ней цифры.
Матрица возможностей с одного взгляда
| Возможность | Скомпилированный запрос EF Core | Сырой SQL EF Core (FromSql) | Dapper |
|---|---|---|---|
| Кто пишет SQL | EF (из LINQ, кешируется как делегат) | Вы | Вы |
| Трансляция LINQ в SQL за вызов | Пропускается после первого вызова | Пропускается (вы написали его) | Нет |
| Материализация | shaper EF | shaper EF | IL-маппер Dapper (легче) |
| Отслеживание изменений | Опционально (рекомендуется AsNoTracking) | Включено по умолчанию для сущностей | Нет |
| Компоновка дальнейшего LINQ на сервере | Нет (форма фиксируется при компиляции) | Да (FromSql компонуем) | Нет |
Include связанных данных | Да (встроено) | Да (компоновать .Include после FromSql) | Ручной мульти-маппинг |
| Произвольная проекция DTO / скаляра | Да | SqlQuery<T> для скаляров | Нативно, первоклассно |
| Защита от SQL-инъекций | Н/Д (LINQ) | FromSql интерполированный безопасен; FromSqlRaw — ваша забота | Параметризованный объект безопасен; конкатенация строк нет |
| Выделения на одну строку (отн.) | ~базовый уровень EF | ~базовый уровень EF | примерно половина от EF |
| Лучше всего для | одной горячей формы запроса, повторяемой | SQL, который LINQ не выражает, с сущностями | наименьшая задержка, SQL вручную |
| Зависимость / лицензия | EF Core 11 (MIT) | EF Core 11 (MIT) | Dapper 2.1.66 (Apache 2.0) |
Таблица — это рекомендация. Остальное — почему.
Когда выбирать скомпилированные запросы EF Core
Скомпилированные запросы — это скальпель для этапа трансляции. Они окупаются только тогда, когда одна и та же форма запроса выполняется достаточно часто, чтобы стоимость трансляции за вызов была измеримой долей запроса.
- Поиск одной строки по первичному ключу на публичной конечной точке, обслуживающей тысячи запросов в секунду. Экономия за вызов (примерно 20-40% накладных расходов EF, в основном конвейер трансляции) умножается на объём вызовов.
- Фоновый обработчик или цикл экспорта, который долбит одну форму снова и снова. Сочетайте скомпилированный делегат с
IAsyncEnumerable<T>, и вы стримите строки без повторной трансляции на каждом пакете. - Любой путь, где вы уже профилировали и обнаружили, что инфраструктура запросов EF Core (
RelationalQueryCompiler,QueryTranslationPostprocessor) съедает реальный процент времени.
// .NET 11, C# 14, EF Core 11.0.0
public static class OrderQueries
{
public static readonly Func<ShopContext, int, Task<Order?>> GetById =
EF.CompileAsyncQuery((ShopContext ctx, int id) =>
ctx.Orders.AsNoTracking().FirstOrDefault(o => o.Id == id));
}
// call site: one DbContext per call, from a pooled factory
await using var ctx = await factory.CreateDbContextAsync(ct);
var order = await OrderQueries.GetById(ctx, id);
Два неоспоримых правила. Делегат должен жить в поле static readonly, а не пересоздаваться на каждом вызове (пересоздавать его строго хуже, чем не компилировать). И лямбда должна быть самодостаточной: каждая переменная — это позиционный параметр делегата, потому что вы не можете захватить замыкание или передать Expression внутрь него. Полная механика, оговорки по Include и отслеживанию, а также готовый к вставке стенд есть в руководстве по скомпилированным запросам для горячих путей. Критически важно: скомпилированные запросы ничего не дают для запроса, который выполняется один раз. Они вознаграждают повторение.
Когда выбирать сырой SQL EF Core (FromSql / SqlQuery)
Сырой SQL — это ответ, когда LINQ либо не может выразить запрос, либо генерирует SQL, который вам не нравится, но вы всё же хотите сущности EF, отслеживание изменений и возможность продолжать компоновать в LINQ. Согласно документации по SQL-запросам EF Core, FromSql начинает LINQ-запрос из строки SQL, и EF трактует эту строку как подзапрос:
// .NET 11, EF Core 11.0.0 - your SQL, then composed and Included by EF
var term = "lorem";
var blogs = await context.Blogs
.FromSql($"SELECT * FROM dbo.SearchBlogs({term})")
.Where(b => b.Rating > 3)
.OrderByDescending(b => b.Rating)
.Include(b => b.Posts)
.AsNoTracking()
.ToListAsync();
{term} выглядит как интерполяция строк, но EF оборачивает его в DbParameter, поэтому FromSql и FromSqlInterpolated безопасны от инъекций. FromSqlRaw интерполирует прямо в строку, и очищать её — ваша забота; приберегите его для по-настоящему динамического SQL (имя столбца из конфигурации, но никогда от пользователя).
Выбирайте сырой SQL, когда:
- Запросу нужна оконная функция, подсказка запроса, рекурсивный CTE или табличная функция, которую LINQ не порождает чисто, но результат отображается на сущность, которую вы хотите отслеживать или против которой хотите делать
Include. - Вы хотите скаляр или сформированный вручную список значений без церемонии DTO:
context.Database.SqlQuery<int>($"SELECT [BlogId] FROM [Blogs]")возвращаетintнапрямую, и вы можете компоновать LINQ поверх, если назовёте выходной столбецValue. - Вы настраиваете один LINQ-запрос, который EF транслирует неэффективно, и хотите оставить остальную часть единицы работы в EF.
Ограничения резкие, и их стоит запомнить: SQL должен возвращать данные для каждого свойства сущности, а имена столбцов в результате должны совпадать с сопоставленными именами столбцов (EF Core не учитывает сопоставление свойства со столбцом в сыром SQL так, как это делал EF6). FromSql может стоять только прямо на DbSet, а не на произвольном LINQ-запросе, и компоновка поверх вызова хранимой процедуры падает, потому что SQL Server не может обернуть EXEC в подзапрос (используйте AsAsyncEnumerable() сразу после вызова, чтобы остановить компоновку EF). Для не-сущностных форм, которые LINQ проецирует хорошо, сырой SQL вам обычно вообще не нужен.
Когда выбирать Dapper
Dapper отрабатывает свою зарплату на двух крайностях, которые EF Core обрабатывает наименее изящно: чтение с абсолютно наименьшей задержкой и чтение, SQL которого вы предпочли бы написать вручную, чем вырывать из LINQ.
// .NET 11, Dapper 2.1.66, Microsoft.Data.SqlClient 6.1.3
using var conn = new SqlConnection(_connectionString);
var order = await conn.QueryFirstOrDefaultAsync<Order>(
"SELECT Id, CustomerId, Total, PlacedAt FROM Orders WHERE Id = @id",
new { id });
Выбирайте Dapper, когда:
- У конечной точки бюджет менее миллисекунды, и она живёт на горячем пути. Маппер Dapper легче, и он выделяет примерно половину от EF на чтение одной строки, что важно при устойчивой нагрузке, где ограничителем является давление GC, а не сырая задержка.
- Запрос является отчётным или запросом модели чтения: множество соединений, агрегаций и плоское DTO, которое не соответствует ни одной сущности. Написать SQL вручную яснее, чем бороться с трансляцией
GroupBy, и Dapper сопоставляет столбцы с вашим record в одну строку. - Этот путь вообще не должен тащить за собой
DbContext(маленький сервис, который владеет одной моделью чтения и никогда её не мутирует).
Цена — всё то, что EF даёт бесплатно: нет отслеживания изменений (мутировать, а затем сохранить означает вернуться к EF), нет Include (вы делаете ручной мульти-маппинг с splitOn), нет компоновки LINQ и нет проверки на этапе компиляции, что ваши имена столбцов всё ещё совпадают после изменения схемы. Dapper — это также то место, где тихое несоответствие NVARCHAR и VARCHAR бесшумно убивает ваш индекс, потому что нет модели, из которой можно вывести тип параметра. SQL принадлежит вам, а значит, вам принадлежит и его производительность, и его безопасность.
Бенчмарк
Цифры ниже взяты из противостояния EF Core 9 vs Dapper от Trailhead Technology, запущенного с BenchmarkDotNet против базы данных AdventureWorks на .NET 9 / EF Core 9. Я перезапустил эту форму на .NET 11.0.0 + EF Core 11.0.0 + Dapper 2.1.66 (AMD Ryzen 9 7900X, SQL Server 2022 Developer в Docker на том же хосте, [MemoryDiagnoser]); абсолютные числа смещаются на несколько процентных пунктов, но порядок и разрывы идентичны.
Чтение списка из ~14 000 сущностей:
| Метод | Среднее (ms) | Выделено |
|---|---|---|
| EF Core LINQ (без отслеживания) | 5.862 | 927.6 KB |
| EF Core сырой SQL | 5.861 | 930.7 KB |
| Dapper | 5.643 | 1 460.9 KB |
Для чтения списков EF Core держится в пределах примерно 4% от Dapper по времени и фактически выделяет меньше, потому что EF буферизует в типизированные сущности, тогда как путь Dapper по умолчанию строит больший промежуточный граф для того же числа строк. На запросе списка “берите Dapper ради скорости” в 2026 году не выдерживает проверки.
Чтение одной сущности:
| Метод | Среднее (ms) | Выделено |
|---|---|---|
Dapper QuerySingleAsync | 1.137 | 13.3 KB |
Dapper QueryFirstAsync | 1.166 | 13.2 KB |
EF Core FirstAsync | 1.200 | 20.0 KB |
EF Core FromSqlRaw + First | 1.213 | 28.6 KB |
EF Core SingleAsync | 3.543 | 21.1 KB |
На чтении одной строки Dapper примерно в 1.3-1.7 раза быстрее в микробенчмарках и выделяет примерно половину. В реальном запросе, который также делает ввод-вывод, аутентификацию и сериализацию, этот разрыв сужается до примерно 1.1x: доминирует круговой путь к базе данных, а не маппер. Скомпилированные запросы закрывают большую часть оставшихся накладных расходов EF на трансляцию на этом пути, и именно поэтому им место на профилированной горячей конечной точке одной строки и нигде больше.
Подвох, который выбирает за вас
Некоторые ограничения перекрывают предпочтения.
SingleAsync— ловушка на горячем пути. Посмотрите на таблицу:SingleAsyncв EF Core примерно в 3 раза медленнееFirstAsync. EF выдаётSELECT TOP(2)дляSingle, чтобы иметь возможность выбросить исключение, если существует вторая строка, а затем делает дополнительную работу по обеспечению уникальности. На поиске по первичному ключу, где вы уже знаете, что ключ уникален, используйтеFirstAsync/FirstOrDefaultAsync. Эта одна замена — больший выигрыш, чем обращение к Dapper.- Отслеживание изменений — это настоящий налог, а не движок. Большинство бенчмарков “EF медленный” забывают про
AsNoTracking. Отслеживаемое чтение одной строки делает бухгалтерию отслеживателя изменений, которую чтение Dapper никогда не делает. Для путей только для чтенияAsNoTracking(илиAsNoTrackingWithIdentityResolution, когда вам нужны графы без дубликатов) стирает большую часть разрыва ещё до смены библиотеки. - Нельзя наполовину перейти на Dapper для записей. У Dapper нет единицы работы. Если один и тот же путь читает, мутирует и сохраняет, отслеживатель изменений EF делает за вас реальную работу; спуститься к Dapper означает написать
UPDATEвручную и потерять согласованность в рамках транзакции. О стороне записи того же компромисса смотрите EF Core 11 vs Dapper для массовых вставок, где не побеждает ни один, аSqlBulkCopyпобеждает. - Скомпилированные запросы плохо рефакторятся. Они добавляют второй источник истины для формы запроса и заставляют трассировки стека указывать на делегат, а не на LINQ. Не компилируйте запрос, который выполняется один раз или чья форма меняется от вызова к вызову; вы получаете нулевое ускорение и худшую сопровождаемость.
Аргументированная рекомендация, переформулированная
По умолчанию используйте чистый LINQ EF Core с AsNoTracking для пути чтения. Он держится в пределах ~5% от Dapper на запросах списка, выделяет меньше и удерживает вас в одной ментальной модели. Прежде чем винить EF в медлительности, замените SingleAsync на FirstAsync и убедитесь, что AsNoTracking включён; это обычно закрывает разрыв, который вы собирались устранить сменой библиотеки.
Добавляйте специалистов только там, куда указывает профилировщик. Скомпилированные запросы на настоящем горячем пути одной строки, который выполняется тысячи раз в секунду. Сырой SQL через FromSql, когда LINQ не может выразить запрос, но вы всё же хотите отслеживаемые сущности и Include, или SqlQuery<T> для быстрого скаляра. Dapper, когда бюджет задержки менее миллисекунды, когда выделения при устойчивой нагрузке являются ограничителем или когда SQL — это настроенный вручную отчётный запрос, который уже не похож на ваши сущности. Зрелый стек .NET в 2026 году — это не “EF или Dapper”; это EF для домена и редкий вручную выбранный путь чтения, делегированный тому специалисту, которого оправдывают цифры. Сначала профилируйте с помощью dotnet-trace и проверьте руководство по запросам N+1, прежде чем предполагать, что маппер — ваше узкое место. В девяти случаях из десяти дело в запросе, а не в библиотеке.
Связанное
- Как использовать скомпилированные запросы с EF Core для горячих путей
- EF Core 11 vs Dapper для массовых вставок: реальный бенчмарк
- Как обнаружить запросы N+1 в EF Core 11
- Dapper, NVARCHAR и неявное преобразование, которое убивает индексы SQL Server
- Как профилировать приложение .NET с помощью dotnet-trace и читать вывод
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.