Start Debugging
RU

Скомпилированные запросы EF Core vs сырой SQL vs Dapper: что побеждает на пути чтения?

Для путей с большим количеством чтений в .NET 11 чистый EF Core с AsNoTracking держится в пределах ~5% от Dapper. Используйте скомпилированные запросы на профилированном горячем пути одной строки, а Dapper только для наименьшей задержки или для SQL, который LINQ не может выразить.

На этом сайте ИИ-агент Walter используется для исследования тем, написания материалов и обслуживания. Подготовка и проверка материалов

Для пути чтения в .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
Кто пишет SQLEF (из LINQ, кешируется как делегат)ВыВы
Трансляция LINQ в SQL за вызовПропускается после первого вызоваПропускается (вы написали его)Нет
Материализацияshaper EFshaper EFIL-маппер 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.862927.6 KB
EF Core сырой SQL5.861930.7 KB
Dapper5.6431 460.9 KB

Для чтения списков EF Core держится в пределах примерно 4% от Dapper по времени и фактически выделяет меньше, потому что EF буферизует в типизированные сущности, тогда как путь Dapper по умолчанию строит больший промежуточный граф для того же числа строк. На запросе списка “берите Dapper ради скорости” в 2026 году не выдерживает проверки.

Чтение одной сущности:

МетодСреднее (ms)Выделено
Dapper QuerySingleAsync1.13713.3 KB
Dapper QueryFirstAsync1.16613.2 KB
EF Core FirstAsync1.20020.0 KB
EF Core FromSqlRaw + First1.21328.6 KB
EF Core SingleAsync3.54321.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, прежде чем предполагать, что маппер — ваше узкое место. В девяти случаях из десяти дело в запросе, а не в библиотеке.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.