Как использовать скомпилированные запросы EF Core на горячих путях
Практическое руководство по скомпилированным запросам в EF Core 11: когда EF.CompileAsyncQuery действительно даёт выигрыш, шаблон со статическим полем, подводные камни Include и отслеживания, и как замерить производительность до и после, чтобы убедиться, что дополнительная церемония оправдана.
Короткий ответ: объявите запрос один раз как static readonly поле через EF.CompileAsyncQuery, сохраните полученный делегат и вызывайте его со свежим DbContext и параметрами на каждый вызов. На горячей конечной точке чтения, которая выполняет один и тот же запрос тысячи раз в секунду, это позволяет пропустить шаг трансляции LINQ в SQL и сокращает накладные расходы на вызов на 20-40% в EF Core 11. Вне горячих путей это не стоит шаблонного кода, потому что кеш запросов EF Core уже мемоизирует трансляцию для повторяющихся структурно одинаковых запросов.
Этот пост покрывает точную механику EF.CompileQuery и EF.CompileAsyncQuery в EF Core 11.0.x на .NET 11, шаблон со статическим полем, который делает экономию реальной, что скомпилированные запросы не умеют (нет цепочек Include во время выполнения, нет композиции на стороне клиента, нет возврата IQueryable) и тестовый стенд BenchmarkDotNet, который можно вставить в свой репозиторий, чтобы убедиться в выигрыше на собственной схеме. Всё ниже использует Microsoft.EntityFrameworkCore 11.0.0 против SQL Server, но те же API работают одинаково на PostgreSQL и SQLite.
Что на самом деле означает “скомпилированный запрос” в EF Core 11
Когда вы пишете ctx.Orders.Where(o => o.CustomerId == id).ToListAsync(), EF Core делает примерно пять вещей при каждом вызове:
- Разбирает дерево выражения LINQ.
- Ищет его во внутреннем кеше запросов (ключ кеша это структурная форма дерева плюс типы параметров).
- При промахе кеша транслирует дерево в SQL и строит делегат-формирователь.
- Открывает соединение, отправляет SQL со связанными параметрами.
- Материализует строки результата обратно в сущности.
Шаг 2 быстрый, но не бесплатный. Поиск в кеше обходит дерево выражения, чтобы вычислить хеш-ключ. На маленьком запросе это микросекунды. На горячей конечной точке, обслуживающей 5000 запросов в секунду, эти микросекунды накапливаются. EF.CompileAsyncQuery позволяет полностью пропустить шаги 1-3 на каждом вызове после первого. Вы передаёте EF дерево выражения один раз при запуске, он создаёт делегат Func, и с этого момента каждый вызов идёт прямо к шагу 4. Стоимость одного вызова падает до “построить параметр, запустить формирователь, вернуть строки”.
Официальные рекомендации находятся в документации по продвинутой производительности EF Core. Главное число из собственных бенчмарков команды это примерно 30% сокращение накладных расходов на запрос, причём большая часть выигрыша приходится на маленькие, часто выполняемые запросы, где трансляция составляет значительную долю общего времени.
Шаблон со статическим полем
Самый частый способ неправильно использовать EF.CompileAsyncQuery это вызывать его внутри метода, который выполняет запрос. Это пересоздаёт делегат при каждом вызове, что строго хуже, чем вообще не компилировать. Работающий шаблон это поместить его в статическое поле:
// .NET 11, C# 14, EF Core 11.0.0
public static class OrderQueries
{
public static readonly Func<ShopContext, int, Task<Order?>> GetOrderById =
EF.CompileAsyncQuery(
(ShopContext ctx, int id) =>
ctx.Orders
.AsNoTracking()
.FirstOrDefault(o => o.Id == id));
public static readonly Func<ShopContext, int, IAsyncEnumerable<Order>> GetOrdersByCustomer =
EF.CompileAsyncQuery(
(ShopContext ctx, int customerId) =>
ctx.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt));
}
Обратите внимание на две вещи. Во-первых, список параметров позиционный, и типы зашиты: int id это часть сигнатуры делегата. Вы не можете передать туда произвольное Expression<Func<Order, bool>> позже, потому что это свело бы на нет весь смысл. Во-вторых, делегат вызывается с экземпляром DbContext на каждый вызов:
public sealed class OrderService(IDbContextFactory<ShopContext> factory)
{
public async Task<Order?> Get(int id)
{
await using var ctx = await factory.CreateDbContextAsync();
return await OrderQueries.GetOrderById(ctx, id);
}
}
Шаблон фабрики здесь важен. Скомпилированные запросы потокобезопасны между контекстами, но сам DbContext нет. Если вы делите один контекст между потоками и выполняете скомпилированные запросы конкурентно, вы получите те же состояния гонки, что и при любом другом конкурентном использовании EF Core. Используйте пулированную фабрику DbContext для экземпляра на каждый вызов. Если этого не сделать, стоимость выделения и настройки нового контекста на каждый вызов перевесит всё, что вы сэкономили компиляцией запроса.
Два варианта и когда какой выигрывает
EF Core 11 поставляет два статических метода в EF:
EF.CompileQueryвозвращает синхронныйFunc<,...>. Тип результата это либоT,IEnumerable<T>, либоIQueryable<T>в зависимости от лямбды.EF.CompileAsyncQueryвозвращает либоTask<T>для одиночных терминальных операторов (First,FirstOrDefault,Single,Count,Anyи т. д.), либоIAsyncEnumerable<T>для потоковых запросов.
Для серверных нагрузок асинхронный вариант почти всегда то, что вам нужно. Синхронный вариант блокирует вызывающий поток на время кругового обхода базы данных, что нормально в консольном приложении или десктопном клиенте, но истощит пул потоков в ASP.NET Core под нагрузкой. Единственное исключение это стартовая миграция или CLI-инструмент, где вы действительно хотите блокировать.
Тонкий момент: EF.CompileAsyncQuery не принимает параметр CancellationToken напрямую. Токен захватывается окружающей асинхронной механикой. Если вам нужно отменить долго работающий скомпилированный запрос, шаблон из руководства по отмене долго работающих задач по-прежнему применим: зарегистрируйте CancellationToken в области запроса и позвольте DbCommand соблюдать его через соединение. Скомпилированные запросы пропускают токен через тот же путь DbCommand.ExecuteReaderAsync, что и нескомпилированный запрос.
Воспроизведение, показывающее выигрыш
Постройте минимальную модель:
// .NET 11, EF Core 11.0.0
public sealed class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
public DateTime PlacedAt { get; set; }
}
public sealed class ShopContext(DbContextOptions<ShopContext> options)
: DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
}
Теперь напишите две реализации одного и того же поиска, одну скомпилированную и одну нет:
// .NET 11, EF Core 11.0.0
public static class Bench
{
public static readonly Func<ShopContext, int, Task<Order?>> Compiled =
EF.CompileAsyncQuery(
(ShopContext ctx, int id) =>
ctx.Orders
.AsNoTracking()
.FirstOrDefault(o => o.Id == id));
public static Task<Order?> NotCompiled(ShopContext ctx, int id) =>
ctx.Orders
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == id);
}
Подключите оба к BenchmarkDotNet 0.14 с SQL Server на основе Testcontainers, тот же стенд, что и в руководстве по интеграционным тестам с Testcontainers:
// .NET 11, BenchmarkDotNet 0.14.0, Testcontainers 4.11
[MemoryDiagnoser]
public class CompiledQueryBench
{
private IDbContextFactory<ShopContext> _factory = null!;
[GlobalSetup]
public async Task Setup()
{
// Initialise the container, run migrations, seed N rows.
// Resolve the IDbContextFactory<ShopContext> from your service provider.
}
[Benchmark(Baseline = true)]
public async Task<Order?> NotCompiled()
{
await using var ctx = await _factory.CreateDbContextAsync();
return await Bench.NotCompiled(ctx, 42);
}
[Benchmark]
public async Task<Order?> Compiled()
{
await using var ctx = await _factory.CreateDbContextAsync();
return await Bench.Compiled(ctx, 42);
}
}
На ноутбуке 2024 года против локального контейнера SQL Server 2025 скомпилированная версия оказывается примерно на 25% быстрее на прогретых запусках, с меньшим профилем выделений, потому что конвейер трансляции LINQ не запускается. Точное число сильно зависит от количества строк и формы колонок, но на одиночном поиске по первичному ключу можно ожидать заметный выигрыш.
Интересный результат это что происходит на запросе, который выполнился ровно один раз: выигрыша нет. Скомпилированная версия делает ту же работу по трансляции в первый раз, когда вы вызываете делегат. Если ваш горячий путь это “разная форма на каждый вызов”, скомпилированные запросы это не тот инструмент. Они вознаграждают повторение.
Чего скомпилированные запросы не умеют
Скомпилированные запросы это статический анализ фиксированного дерева выражения. Это означает, что несколько распространённых шаблонов LINQ оказываются вне досягаемости:
- Никакого условного
Include. Нельзя сделатьquery.Include(o => o.Customer).If(includeLines, q => q.Include(o => o.Lines))внутри лямбды. Форма зафиксирована во время компиляции. - Никакого возврата
IQueryableдля дальнейшей композиции. Если вы возвращаетеIAsyncEnumerable<Order>, по нему можно делатьawait foreach, но нельзя вызывать.Where(...)на результате с расчётом, что фильтр выполнится на стороне сервера. Он выполнится на стороне клиента, что сводит выигрыш на нет. - Никакого захвата состояния замыканием. Лямбда, передаваемая в
EF.CompileAsyncQuery, должна быть самодостаточной. Захват локальной переменной или поля сервиса из окружающей области приводит к исключению во время выполнения: “An expression tree may not contain a closure-captured variable in a compiled query.” Решение это добавить значение как параметр в сигнатуру делегата. - Никаких
SkipиTakeсо значениями типаExpression. Они должны быть параметрамиintу делегата. EF Core 8 добавил поддержку постраничной выборки на параметрах, EF Core 11 её сохраняет, но передатьExpression<Func<int>>нельзя. - Никаких методов с вычислением на стороне клиента. Если ваш
WhereвызываетMyHelper.Format(x), EF не сможет это транслировать. В нескомпилированном запросе вы получили бы предупреждение во время выполнения. В скомпилированном запросе вы получите жёсткое исключение во время компиляции, что на самом деле лучший режим отказа.
Эти ограничения это та цена, которую вы платите за ускорение. Если вашему реальному запросу нужна ветвящаяся форма, напишите обычный LINQ-запрос и позвольте кешу запросов EF Core сделать свою работу. Кеш хорош. Просто он не бесплатный.
Отслеживание, AsNoTracking и почему это важно здесь
Почти каждый пример в этом посте использует AsNoTracking(). Это не декорация. Скомпилированные запросы на отслеживаемых сущностях по-прежнему проходят через трекер изменений при материализации, что добавляет обратно часть накладных расходов, которые вы только что убрали. Для горячих путей только на чтение AsNoTracking это нужное по умолчанию.
Если вам действительно нужно отслеживание (пользователь собирается изменить сущность и вызвать SaveChangesAsync), математика меняется. Работа трекера изменений доминирует в стоимости вызова, и доля, которую вы выигрываете от скомпилированных запросов, меньше. В этом случае выигрыш скорее 5-10%, что редко стоит шаблонного кода.
Есть следствие в руководстве по обнаружению N+1: если вы компилируете запрос, использующий Include для навигационного свойства, декартово раздувание запекается в скомпилированный SQL. Вы не можете оппортунистически применить AsSplitQuery позже. Решайте один раз и выбирайте форму, подходящую месту вызова.
Прогрев и первый вызов
Работа по компиляции откладывается до первого вызова делегата, а не до присваивания статическому полю. Если у вашего сервиса строгая цель по задержке P99 на холодных стартах, первый запрос, попавший на путь со скомпилированным запросом, оплатит стоимость трансляции поверх обычных накладных расходов первого запроса.
Самое чистое решение это прогреть как модель EF Core, так и скомпилированные запросы во время запуска приложения, та же идея, что описана в руководстве по прогреву модели EF Core:
// .NET 11, ASP.NET Core 11
var app = builder.Build();
await using (var scope = app.Services.CreateAsyncScope())
{
var factory = scope.ServiceProvider
.GetRequiredService<IDbContextFactory<ShopContext>>();
await using var ctx = await factory.CreateDbContextAsync();
// Touch the model
_ = ctx.Model;
// Trigger compilation by invoking each hot-path delegate once
_ = await OrderQueries.GetOrderById(ctx, 0);
}
await app.RunAsync();
Запрос с Id == 0 возвращает null, но он принуждает к трансляции. После этого блока ваш первый реальный запрос попадает в базу данных с уже закешированным в делегате SQL.
Когда вообще пропустить скомпилированные запросы
Есть искушение скомпилировать каждый запрос в кодовой базе. Сопротивляйтесь ему. Собственные рекомендации команды EF Core говорят использовать скомпилированные запросы “экономно, только в ситуациях, где действительно нужны микрооптимизации.” Причины:
- Внутренний кеш запросов уже мемоизирует трансляции для повторяющихся структурно одинаковых запросов. Для большинства нагрузок частота попаданий в кеш после прогрева больше 99%.
- Скомпилированные запросы добавляют второй источник истины для формы запроса (статическое поле плюс место вызова), что делает рефакторинг болезненнее.
- Трассировки стека становятся менее полезными: исключение в скомпилированном запросе указывает на место вызова делегата, а не на исходное выражение LINQ.
Честное правило принятия решения: сначала профилируйте. Запустите конечную точку под реалистичной нагрузкой с dotnet-trace и посмотрите, сколько времени уходит на инфраструктуру запросов EF Core. Если это однозначные проценты от общего времени запроса, оставьте как есть. Если вы видите 20%+ в RelationalQueryCompiler, QueryTranslationPostprocessor или QueryCompilationContext, это кандидат на скомпилированный запрос.
Два шаблона, которые хорошо сочетаются
Скомпилированный запрос наиболее полезен в плотных циклах или фоновых обработчиках, которые молотят одну и ту же форму:
// .NET 11, EF Core 11.0.0 - a streaming export
public static readonly Func<ShopContext, DateTime, IAsyncEnumerable<Order>> OrdersSince =
EF.CompileAsyncQuery(
(ShopContext ctx, DateTime since) =>
ctx.Orders
.AsNoTracking()
.Where(o => o.PlacedAt >= since)
.OrderBy(o => o.PlacedAt));
await foreach (var order in OrdersSince(ctx, cutoff).WithCancellation(ct))
{
await writer.WriteRowAsync(order, ct);
}
Соедините это с IAsyncEnumerable<T> в EF Core 11, и вы получите потоковый экспорт, который не буферизует набор результатов, не выделяет список и переиспользует скомпилированный SQL на каждом батче. Для задания ночного экспорта по миллионам строк такая комбинация измеримо снижает и задержку, и давление на память.
Другой шаблон это конечная точка поиска с высокой кардинальностью: одиночная выборка по первичному ключу на публичном API, где частота запросов измеряется тысячами в секунду. Там экономия на каждом вызове умножается на объём вызовов, и скомпилированный запрос на FirstOrDefault в паре с кешированием ответов даёт самое близкое к “бесплатному” чтению, что есть в EF Core.
Для всего остального пишите запрос на простом LINQ, опирайтесь на кеш запросов и возвращайтесь к этому только когда профилировщик скажет, что узкое место в шаге трансляции. Скомпилированные запросы это скальпель, а не кувалда.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.