Start Debugging

Как использовать скомпилированные запросы 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 делает примерно пять вещей при каждом вызове:

  1. Разбирает дерево выражения LINQ.
  2. Ищет его во внутреннем кеше запросов (ключ кеша это структурная форма дерева плюс типы параметров).
  3. При промахе кеша транслирует дерево в SQL и строит делегат-формирователь.
  4. Открывает соединение, отправляет SQL со связанными параметрами.
  5. Материализует строки результата обратно в сущности.

Шаг 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:

Для серверных нагрузок асинхронный вариант почти всегда то, что вам нужно. Синхронный вариант блокирует вызывающий поток на время кругового обхода базы данных, что нормально в консольном приложении или десктопном клиенте, но истощит пул потоков в 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 оказываются вне досягаемости:

Эти ограничения это та цена, которую вы платите за ускорение. Если вашему реальному запросу нужна ветвящаяся форма, напишите обычный 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 говорят использовать скомпилированные запросы “экономно, только в ситуациях, где действительно нужны микрооптимизации.” Причины:

Честное правило принятия решения: сначала профилируйте. Запустите конечную точку под реалистичной нагрузкой с 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.

< Назад