Ef Core

Продвинуті Запити — Compiled Queries, Bulk та Оптимізація (Частина 1)

Compiled Queries для повторюваних запитів, асинхронне перерахування через IAsyncEnumerable, Keyset Pagination замість OFFSET, проєкція vs Include — порівняння підходів і інструменти діагностики продуктивності.

Продвинуті Запити: Compiled Queries, Bulk та Оптимізація

Чому «просто написав запит» — недостатньо

Коли застосунок виходить у production і починає отримувати реальне навантаження, виявляється що частина LINQ-запитів «просідає». Деякі виконуються добре на 1000 рядках і катастрофічно — на 1 мільйоні. Деякі виглядають оптимально у коді, але генерують неефективний SQL.

У EF Core є три основні причини повільних запитів:

Компіляція запитів: кожного разу коли виконується LINQ-запит, EF Core має перетворити Expression Tree у SQL. Це не безкоштовна операція — парсинг, трансляція, побудова SQL рядка. Для часто-виконуваних запитів (перевірка авторизації, пошук по Id, отримання конфігурації) — overhead компіляції помітний.

Матеріалізація великих наборів: завантаження мільйону рядків у List<T> і обробка в C# — не тільки повільно, але й вичерпує RAM. Для streaming великих наборів є кращі підходи.

Пагінація через OFFSET: стандартний Skip(n).Take(m) bei великих таблицях змушує БД сканувати і пропускати перші n рядків перед поверненням потрібних.

Ця стаття дає інструменти для вирішення кожної з цих проблем.


Compiled Queries: один раз компілюємо, виконуємо багато

Compiled Query — скомпільований LINQ-запит, що проходить трансляцію в SQL один раз під час першого виклику і кешується для подальших. Це усуває overhead компіляції при кожному виклику.

EF.CompileAsyncQuery: синтаксис

// Визначаємо Compiled Query як статичне поле
public class ProductRepository
{
    // Компільований запит: визначається один раз (static readonly)
    private static readonly Func<AppDbContext, int, Task<Product?>> GetByIdQuery =
        EF.CompileAsyncQuery(
            (AppDbContext ctx, int id) =>
                ctx.Products.FirstOrDefault(p => p.Id == id)
        );

    private static readonly Func<AppDbContext, decimal, IAsyncEnumerable<Product>> GetExpensiveQuery =
        EF.CompileAsyncQuery(
            (AppDbContext ctx, decimal minPrice) =>
                ctx.Products
                   .Where(p => p.Price >= minPrice && p.IsActive)
                   .OrderBy(p => p.Price)
        );

    private readonly AppDbContext _context;

    public ProductRepository(AppDbContext context) => _context = context;

    // Виклик: компіляція відбулась одноразово при першому виклику
    public async Task<Product?> GetByIdAsync(int id)
        => await GetByIdQuery(_context, id);

    public IAsyncEnumerable<Product> GetExpensiveProductsAsync(decimal minPrice)
        => GetExpensiveQuery(_context, minPrice);
}

Синхронний варіант

// CompileQuery (синхронний)
private static readonly Func<AppDbContext, int, Product?> GetByIdSync =
    EF.CompileQuery(
        (AppDbContext ctx, int id) =>
            ctx.Products.FirstOrDefault(p => p.Id == id)
    );

Декілька параметрів

// До 8 параметрів (включаючи DbContext)
private static readonly Func<AppDbContext, string, decimal, decimal, Task<List<Product>>>
    SearchProductsQuery =
        EF.CompileAsyncQuery(
            (AppDbContext ctx, string name, decimal minPrice, decimal maxPrice) =>
                ctx.Products
                   .Where(p => p.Name.Contains(name)
                             && p.Price >= minPrice
                             && p.Price <= maxPrice
                             && p.IsActive)
                   .OrderBy(p => p.Price)
                   .ToList() // ← Compiled Query повертає List для async
        );

Обмеження Compiled Queries

Compiled Query не можна параметризувати динамічно після компіляції:

// ❌ НЕМОЖЛИВО: умовна логіка у Compiled Query
var query = EF.CompileAsyncQuery((AppDbContext ctx, bool includeInactive) =>
    includeInactive
        ? ctx.Products.Where(p => true)
        : ctx.Products.Where(p => p.IsActive)); // ← Expression не може бути dynamic

// ✅ Два окремі Compiled Queries
private static readonly Func<AppDbContext, Task<List<Product>>> GetAllActive =
    EF.CompileAsyncQuery(ctx => ctx.Products.Where(p => p.IsActive).ToList());

private static readonly Func<AppDbContext, Task<List<Product>>> GetAll =
    EF.CompileAsyncQuery(ctx => ctx.Products.ToList());

Також не підтримуються:

  • Include у деяких складних сценаріях
  • Динамічний OrderBy через вираз
  • Запити з FromSqlRaw всередині

Коли Compiled Queries дійсно корисні

Compiled Queries найбільш виправдані для:

  1. Hot path запитів: перевірка токена авторизації, пошук користувача за email, завантаження налаштувань
  2. Запитів що виконуються > 100 разів на секунду — overhead компіляції стає помітним
  3. Запитів з незмінною структурою (фільтри фіксовані, змінюються лише значення параметрів)

Для рідко виконуваних і динамічних запитів — звичайний LINQ з кешуванням SQL у пулі EF Core достатній.


IAsyncEnumerable: streaming великих наборів даних

Стандартний ToListAsync() матеріалізує весь результат у RAM перед поверненням першого елемента. Для великих наборів (10K+ рядків) це:

  • Велике споживання пам'яті (усі рядки в RAM одночасно)
  • Затримка до першого результату (чекаємо поки всі завантажаться)
  • Неможливість обробки «потоком» (streaming)

IAsyncEnumerable<T> дозволяє обробляти результати один за одним, як тільки вони надходять від БД.

AsAsyncEnumerable та await foreach

// Звичайний: весь результат у RAM
var allProducts = await context.Products.ToListAsync();
// ↑ Чекає поки всі рядки прийдуть, тоді повертає

// Streaming через IAsyncEnumerable
await foreach (var product in context.Products.AsAsyncEnumerable())
{
    // Обробляємо кожен рядок по мірі надходження
    await ProcessProductAsync(product);
}
// ↑ Перший рядок обробляється до того як прийшов останній

Практичний сценарій: Export великого файлу

// ASP.NET Core endpoint зі streaming response
[HttpGet("export")]
public async IAsyncEnumerable<ProductExportDto> ExportProductsAsync(
    [EnumeratorCancellation] CancellationToken ct)
{
    var query = context.Products
        .Where(p => p.IsActive)
        .OrderBy(p => p.Id)
        .Select(p => new ProductExportDto
        {
            Id         = p.Id,
            Name       = p.Name,
            Price      = p.Price,
            CategoryId = p.CategoryId
        })
        .AsAsyncEnumerable();

    await foreach (var product in query.WithCancellation(ct))
    {
        yield return product; // Відправляємо клієнту по мірі читання з БД
    }
}
// Клієнт отримує перший рядок майже миттєво
// Сервер ніколи не тримає всі записи в RAM

Streaming для batch processing

public async Task ProcessAllOrdersAsync(CancellationToken ct)
{
    int processedCount = 0;
    const int batchSize = 100;
    var batch = new List<Order>(batchSize);

    await foreach (var order in context.Orders
        .Where(o => o.Status == "Pending")
        .AsAsyncEnumerable()
        .WithCancellation(ct))
    {
        batch.Add(order);

        if (batch.Count >= batchSize)
        {
            await ProcessBatchAsync(batch);
            batch.Clear();
            processedCount += batchSize;
            _logger.LogInformation("Processed {Count} orders", processedCount);
        }
    }

    // Обробити залишок
    if (batch.Count > 0)
        await ProcessBatchAsync(batch);
}
IAsyncEnumerable і DbContext: При await foreach DbContext залишається відкритим протягом усієї ітерації. Не закривайте DbContext до завершення перерахування. У ASP.NET Core — Scoped DbContext живе протягом HTTP запиту, тому все правильно. У background workers — слідкуйте за scope.

Keyset Pagination: масштабована пагінація

Стандартна пагінація через Skip().Take() (OFFSET) має фундаментальну проблему при великих таблицях:

-- OFFSET пагінація: сторінка 1000 (по 20 елементів)
SELECT * FROM Products ORDER BY CreatedAt, Id
OFFSET 19980 ROWS           -- БД читає і відкидає 19980 рядків!
FETCH NEXT 20 ROWS ONLY

База даних має прочитати 19980 рядків щоб відкинути їх, і лише потім повернути 20 потрібних. З ростом номера сторінки — час запиту росте лінійно.

Keyset Pagination: принцип

Замість «пропустити N рядків» — фільтруємо за значенням останнього елемента попередньої сторінки:

-- Перша сторінка: немає фільтра (або MaxDate/MaxId)
SELECT TOP 20 * FROM Products
WHERE IsActive = 1
ORDER BY CreatedAt DESC, Id DESC;
-- Запам'ятовуємо: останній item має CreatedAt='2024-03-15 10:30:00', Id=1542

-- Наступна сторінка: фільтр за останнім значенням
SELECT TOP 20 * FROM Products
WHERE IsActive = 1
  AND (CreatedAt < '2024-03-15 10:30:00'  -- або
       OR (CreatedAt = '2024-03-15 10:30:00' AND Id < 1542))
ORDER BY CreatedAt DESC, Id DESC;
-- БД бере перші 20 з індексу — без OFFSET!

Реалізація у EF Core

public record CursorPage<T>
{
    public List<T> Items { get; init; } = new();
    public bool HasMore { get; init; }
    public string? NextCursor { get; init; } // Base64-encoded cursor
}

public record ProductCursor(DateTime CreatedAt, int Id);

public class ProductRepository
{
    private readonly AppDbContext _context;

    public ProductRepository(AppDbContext context) => _context = context;

    public async Task<CursorPage<Product>> GetPageAsync(
        string? cursor = null,
        int pageSize = 20,
        CancellationToken ct = default)
    {
        // Розкодовуємо cursor з попередньої сторінки
        ProductCursor? decoded = null;
        if (cursor is not null)
        {
            var json = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
            decoded = JsonSerializer.Deserialize<ProductCursor>(json);
        }

        var query = context.Products
            .Where(p => p.IsActive);

        // Keyset фільтр: якщо cursor є — фільтруємо за ним
        if (decoded is not null)
        {
            query = query.Where(p =>
                p.CreatedAt < decoded.CreatedAt ||
                (p.CreatedAt == decoded.CreatedAt && p.Id < decoded.Id));
        }

        // Беремо на 1 більше щоб визначити HasMore
        var items = await query
            .OrderByDescending(p => p.CreatedAt)
            .ThenByDescending(p => p.Id)
            .Take(pageSize + 1)
            .AsNoTracking()
            .ToListAsync(ct);

        var hasMore = items.Count > pageSize;
        if (hasMore) items.RemoveAt(items.Count - 1); // прибираємо "зайвий"

        // Формуємо cursor для наступної сторінки
        string? nextCursor = null;
        if (hasMore && items.Count > 0)
        {
            var last = items[^1];
            var cursorObj = new ProductCursor(last.CreatedAt, last.Id);
            var json = JsonSerializer.Serialize(cursorObj);
            nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
        }

        return new CursorPage<Product>
        {
            Items      = items,
            HasMore    = hasMore,
            NextCursor = nextCursor
        };
    }
}
// API Controller
[HttpGet("products")]
public async Task<IActionResult> GetProducts(
    [FromQuery] string? cursor = null,
    [FromQuery] int pageSize = 20)
{
    var page = await _productRepository.GetPageAsync(cursor, pageSize);

    return Ok(new
    {
        Items      = page.Items,
        HasMore    = page.HasMore,
        NextCursor = page.NextCursor // клієнт передає у наступному запиті
    });
}

Обмеження Keyset Pagination

  • Неможливо перейти на сторінку N (тільки «наступна»/«попередня»)
  • Cursor залежить від ORDER BY: якщо сортування змінюється — cursor застаріває
  • Підходить для infinite scroll, API pagination, але не для «перейти на сторінку 50 з 200»

Проєкція vs Include: порівняльний аналіз

Два підходи до завантаження пов'язаних даних — Select проєкція і Include. Кожен має свої ситуації застосування.

Include: зберегти entity граф

// Include: повні entity об'єкти з навігаційними властивостями
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.LineItems)
        .ThenInclude(li => li.Product)
    .Where(o => o.Status == "Pending")
    .ToListAsync();

// Можна змінювати і зберігати:
orders[0].Status = "Processing";
await context.SaveChangesAsync(); // ← Change Tracker відстежує зміни

Select: оптимальна проєкція для читання

// Select: тільки потрібні поля, один запит, без overhead Change Tracker
var orderDtos = await context.Orders
    .Where(o => o.Status == "Pending")
    .Select(o => new OrderListDto
    {
        Id           = o.Id,
        OrderNumber  = o.OrderNumber,
        CustomerName = o.Customer.FullName,     // JOIN без Include
        ItemCount    = o.LineItems.Count(),      // COUNT у підзапиті
        TotalAmount  = o.TotalAmount,
        PlacedAt     = o.PlacedAt
    })
    .ToListAsync();

// Не зберігаємо — це DTO для відображення

Порівняльна таблиця

КритерійIncludeSelect (проєкція)
SQLSELECT * з JOINSELECT лише потрібне
Зміна і збереження✅ Через Change Tracker❌ DTO нельзя зберегти
Пам'ятьПовний entityТільки поля DTO
Навігаційні JOINsЯвний IncludeАвтоматично у Select
Cartesian RiskТак (Split Query)Ні
Read-only endpoint❌ Зайвий overhead✅ Ідеально
CRUD операції✅ Необхідний❌ Потрібен окремий запит

Правило: Якщо мета — відобразити дані (GET endpoint, звіт) → Select з AsNoTracking. Якщо мета — змінити дані (POST, PUT, PATCH) → Include для завантаження потрібного графа.


Практичні завдання (Частина 1)

Рівень 1 — Базовий

Завдання 1.1: Перший Compiled Query

Реалізуйте клас CustomerRepository з такими Compiled Queries:

  • GetByEmailAsync(string email)Task<Customer?>
  • GetActiveByCountryAsync(string countryCode)IAsyncEnumerable<Customer>
  • ExistsAsync(int id)Task<bool>

Порівняйте час першого і наступних N викликів з і без компіляції через Stopwatch.

Завдання 1.2: IAsyncEnumerable для CSV Export

Реалізуйте endpoint GET /api/products/export.csv:

  • Streaming через IAsyncEnumerable<Product> (не ToListAsync)
  • Записуйте CSV рядок-за-рядком через StreamWriter безпосередньо у Response.Body
  • Заголовок: Content-Type: text/csv; charset=utf-8

Перевірте: чи сервер не завантажив одразу всі рядки у RAM?

Завдання 1.3: Keyset vs OFFSET — benchmark

Для таблиці з 100K+ рядків BlogPost (CreatedAt, Id):

  1. Реалізуйте OFFSET пагінацію: .Skip(page * size).Take(size)
  2. Реалізуйте Keyset пагінацію: .Where(p => p.CreatedAt < lastDate || ...).Take(size)
  3. Виміряйте час виконання для сторінки 1, 100, 1000, 10000

Рівень 2 — Логіка

Завдання 2.1: Generic Compiled Query Cache

Реалізуйте CompiledQueryCache<TContext> що:

  • Зберігає Compiled Queries у ConcurrentDictionary<string, object> (ключ — ім'я що вказав розробник)
  • GetOrAdd<TResult>(string key, Expression<Func<TContext, Task<TResult>>> query) → Compiled Query
  • Логує при компіляції (перший виклик) і при hit з кешу

Завдання 2.2: Bi-directional Cursor

Розширте Keyset Pagination для підтримки direction: "forward" | "backward":

  • Forward: WHERE (CreatedAt < lastCreatedAt) OR (CreatedAt = lastCreatedAt AND Id < lastId)
  • Backward: зворотна умова, реверс результату

Реалізуйте GetPageAsync(cursor?, direction, size) і GetPreviousPageAsync(cursor, size).

Рівень 3 — Архітектура

Завдання 3.1: Query Pipeline

Реалізуйте IQueryPipeline<T, TResult> — middleware-based LINQ pipeline:

var result = await pipeline
    .StartWith(context.Products)
    .Apply(new ActiveOnlyFilter<Product>())
    .Apply(new PriceRangeFilter<Product>(min: 100, max: 50000))
    .Apply(new PaginationFilter<Product>(page: 2, size: 20))
    .ProjectTo<ProductDto>(p => new ProductDto { Id = p.Id, Name = p.Name })
    .AsNoTracking()
    .ExecuteAsync();

Кожен IQueryFilter<T> трансформує IQueryable<T>. ProjectTo<TDto> конвертує у SELECT-проєкцію. ExecuteAsync матеріалізує.


Підсумок частини 1

У першій частині розглянули ключові техніки оптимізації запитів:

  • Compiled Queries (EF.CompileAsyncQuery): усуваємо overhead трансляції LINQ→SQL для часто виконуваних запитів. Статичне поле, Func<DbContext, params..., Task<T>>.
  • IAsyncEnumerable<T> (AsAsyncEnumerable()): streaming великих наборів без завантаження в RAM. await foreach для обробки рядок-за-рядком.
  • Keyset Pagination: масштабована пагінація без OFFSET. Cursor кодує значення останнього елемента, наступний запит — WHERE field < lastValue. Без лінійної деградації при великих offset.
  • Проєкція vs Include: Select для read-only (тільки потрібні поля, без Change Tracker), Include для CRUD (повний граф для збереження).

У другій частині — Query Splitting стратегії, No-Tracking Bulk Queries, Batch CRUD через ExecuteUpdate/ExecuteDelete, EF Core Query Interceptors для автоматичної оптимізації та Query Plan Caching.