Start Debugging

Скомпилированные запросы 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 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

Скомпилированные запросы — это скальпель для этапа трансляции. Они окупаются только тогда, когда одна и та же форма запроса выполняется достаточно часто, чтобы стоимость трансляции за вызов была измеримой долей запроса.

// .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, когда:

Ограничения резкие, и их стоит запомнить: 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, когда:

Цена — всё то, что 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 на трансляцию на этом пути, и именно поэтому им место на профилированной горячей конечной точке одной строки и нигде больше.

Подвох, который выбирает за вас

Некоторые ограничения перекрывают предпочтения.

Аргументированная рекомендация, переформулированная

По умолчанию используйте чистый 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.

< Назад