Ef Core

Продуктивність EF Core — Основи (Частина 1)

Чому EF Core може бути катастрофічно повільним і як це виявити. N+1 проблема — найдорожчий антипатерн у ORM. Еволюція рішення від Eager Loading до Split Query. Проєкція як фундаментальна техніка. Logging generated SQL і EF Core Diagnostics.

Продуктивність EF Core: Основи

Від «працює» до «працює швидко»

Є два стани застосунку: той що працює і той що працює добре. Перший стан досягається підключенням EF Core до бази і написанням LINQ-запитів. Другий — розумінням що відбувається «під капотом» і свідомим контролем над кожним SQL що генерується.

EF Core — потужна абстракція над SQL. Як і кожна абстракція — вона «протікає» у певних сценаріях. Розробник бачить зручний LINQ. Database server виконує SQL що може відрізнятись від очікуваного у десятки разів за ефективністю.

Найгірший сценарій: застосунок відмінно працює з 1000 записами на staging, але падає з timeout на production де 10 мільйонів записів. Це не баг EF Core — це незнання того, який SQL він генерує.

Ключове правило продуктивності у EF Core: ніколи не дозволяйте запитам виконуватись без розуміння що відбувається на рівні SQL. SQL logging — не luxury, а обов'язковий інструмент розробки.


Logging Generated SQL: перший крок до розуміння

Перш ніж говорити про антипатерни — потрібно їх бачити. EF Core логує всі SQL запити, але за замовчуванням — у Debug рівень. Для активної розробки — підняти до Information або вивести у консоль.

Налаштування логування

// Program.cs: логувати EF Core SQL у консоль
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .LogTo(
               Console.WriteLine,            // де писати
               LogLevel.Information,         // мінімальний рівень
               DbContextLoggerOptions.DefaultWithLocalTime  // формат
           )
           .EnableSensitiveDataLogging()     // показувати значення параметрів
           .EnableDetailedErrors();           // детальні повідомлення про помилки
});
// Або через стандартний ILogger:
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .UseLoggerFactory(LoggerFactory.Create(b =>
               b.AddConsole()
                .AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Information)
           ))
);

Приклад виводу логів

Виглядає так (і це те що треба постійно читати під час розробки):

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@__categoryId_0='2'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[Id], [p].[Name], [p].[Price], [p].[CategoryId]
      FROM [Products] AS [p]
      WHERE [p].[CategoryId] = @__categoryId_0
      AND [p].[IsActive] = CAST(1 AS bit)
      ORDER BY [p].[Price]

Зверніть на час виконання (3ms) і параметри (@__categoryId_0='2'). Якщо бачите 500ms — щось не так. Якщо бачите цей запит 100 разів — N+1.

EF Core Diagnostics: глибший рівень

DiagnosticListener — внутрішня шина подій .NET що EF Core використовує для публікації детальної діагностики. Підписавшись на неї — можна отримувати подію перед і після кожного запиту:

// Кастомний DiagnosticListener для EF Core
public class EfCoreDiagnosticObserver : IObserver<DiagnosticListener>
{
    public void OnNext(DiagnosticListener listener)
    {
        if (listener.Name == DbLoggerCategory.Name)   // "Microsoft.EntityFrameworkCore"
        {
            listener.Subscribe(new EfCoreCommandObserver());
        }
    }

    public void OnError(Exception error) { }
    public void OnCompleted() { }
}

public class EfCoreCommandObserver : IObserver<KeyValuePair<string, object?>>
{
    public void OnNext(KeyValuePair<string, object?> value)
    {
        // Подія: SQL команда виконана
        if (value.Key == RelationalEventId.CommandExecuted.Name)
        {
            var data = (CommandExecutedEventData)value.Value!;
            Console.WriteLine($"SQL ({data.Duration.TotalMilliseconds:F1}ms): {data.Command.CommandText}");
        }

        // Подія: повільний запит
        if (value.Key == "Microsoft.EntityFrameworkCore.Database.Command.CommandError")
        {
            Console.Error.WriteLine("Query FAILED!");
        }
    }

    public void OnError(Exception error) { }
    public void OnCompleted() { }
}

// Реєстрація:
DiagnosticListener.AllListeners.Subscribe(new EfCoreDiagnosticObserver());

Slow Query Detection: автоматичне виявлення повільних запитів

// Middleware або фільтр для виявлення повільних запитів
public class SlowQueryLoggingInterceptor : DbCommandInterceptor
{
    private readonly ILogger<SlowQueryLoggingInterceptor> _logger;
    private readonly TimeSpan _threshold = TimeSpan.FromMilliseconds(500);

    public SlowQueryLoggingInterceptor(ILogger<SlowQueryLoggingInterceptor> logger)
        => _logger = logger;

    public override DbDataReader ReaderExecuted(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result)
    {
        if (eventData.Duration >= _threshold)
        {
            _logger.LogWarning(
                "Slow query detected ({Duration}ms):\n{SQL}",
                eventData.Duration.TotalMilliseconds,
                command.CommandText);
        }
        return result;
    }

    public override ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken ct = default)
    {
        if (eventData.Duration >= _threshold)
        {
            _logger.LogWarning(
                "Slow query detected ({Duration}ms):\n{SQL}",
                eventData.Duration.TotalMilliseconds,
                command.CommandText);
        }
        return ValueTask.FromResult(result);
    }
}

// Реєстрація:
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
    options.UseSqlServer(connectionString)
           .AddInterceptors(sp.GetRequiredService<SlowQueryLoggingInterceptor>()));

builder.Services.AddSingleton<SlowQueryLoggingInterceptor>();

N+1 Problem: найдорожчий антипатерн ORM

N+1 — найпоширеніша і найруйнівніша проблема продуктивності у будь-якому ORM, включаючи EF Core. Її назва описує математику: замість 1 SQL запиту виконується 1 + N запитів де N — кількість батьківських записів.

Класична демонстрація

Маємо задачу: вивести список замовлень з іменем клієнта для кожного.

// ❌ N+1 АНТИПАТЕРН:
var orders = await context.Orders.ToListAsync();  // ЗАПИТ 1: SELECT всі Orders

foreach (var order in orders)
{
    // Для кожного Order — окремий запит до Customers!
    Console.WriteLine($"{order.Id}: {order.Customer.Name}");
    //                               ↑ lazy load або auto-include
    // ЗАПИТ 2: SELECT Customer WHERE Id = 1
    // ЗАПИТ 3: SELECT Customer WHERE Id = 2
    // ЗАПИТ 4: SELECT Customer WHERE Id = 3
    // ... до ЗАПИТУ N+1
}

// При 1000 замовленнях: 1001 SQL запит!
// При 100,000 замовленнях: 100,001 SQL запит!

З увімкненим SQL logging ви побачите:

-- Запит 1:
SELECT [o].[Id], [o].[CustomerId], [o].[TotalAmount], [o].[PlacedAt]
FROM [Orders] AS [o]

-- Запит 2:
SELECT [c].[Id], [c].[Name], [c].[Email]
FROM [Customers] AS [c]
WHERE [c].[Id] = 1

-- Запит 3:
SELECT [c].[Id], [c].[Name], [c].[Email]
FROM [Customers] AS [c]
WHERE [c].[Id] = 2

-- ... ще 998 запитів ...

Чому N+1 так легко допустити

EF Core пізніх версій не підтримує Lazy Loading за замовчуванням (на відміну від EF 6). Але розробники можуть ненавмисно його увімкнути, або мають справу з API що передає об'єкти де навігаційні властивості «вантажаться» пізніше.

Другий, менш очевидний сценарій:

// ❌ Прихований N+1 через LINQ у пам'яті:
var categories = await context.Categories.ToListAsync();  // 1 запит

// LINQ на рівні C# (не SQL!) → lazy evaluation → N запитів!
var summaries = categories.Select(c => new
{
    c.Name,
    // ProductCount викликає навантаження Products для кожної категорії!
    ProductCount = context.Products.Count(p => p.CategoryId == c.Id)
}).ToList();

// SQL що виконується: 1 + N запитів (N = кількість категорій)

Еволюція вирішення N+1: від Eager Loading до Split Query

Крок 1: Eager Loading через Include

Найпростіше рішення — явне завантаження пов'язаних даних через Include:

// ✅ Eager Loading: 1 SQL з JOIN
var orders = await context.Orders
    .Include(o => o.Customer)     // LEFT JOIN Customers
    .ToListAsync();

// SQL:
// SELECT o.Id, o.CustomerId, o.TotalAmount,
//        c.Id, c.Name, c.Email
// FROM Orders o
// LEFT JOIN Customers c ON c.Id = o.CustomerId

Один запит — результат. Але Include має власні проблеми.

Проблема Include: Cartesian Explosion

При включенні кількох колекцій — рядки множаться:

// ❌ Cartesian Explosion:
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.LineItems)      // колекція!
    .Include(o => o.LineItems)
        .ThenInclude(li => li.Product)
    .ToListAsync();

// SQL: ORDER JOIN LINE_ITEMS × JOIN PRODUCTS
// Order з 100 позиціями × 3 JOIN = 300 рядків замість 1!
// Order з 1000 таблиць = надсилає гігабайти даних

При Order з 50 OrderLineItems і 50 Products — JOIN поверне 50 × 50 = 2500 рядків замість 50. EF Core де-дублює їх у пам'яті, але дані вже передані мережею.

-- Приблизний SQL при подвійному Include колекцій:
SELECT o.*, c.*, li.*, p.*
FROM Orders o
LEFT JOIN Customers c ON c.Id = o.CustomerId
LEFT JOIN OrderLineItems li ON li.OrderId = o.Id    -- множить рядки!
LEFT JOIN Products p ON p.Id = li.ProductId         -- ще раз множить!
-- З 1 замовленням з 10 позиціями → 10 рядків
-- З 100 замовленнями × 10 позицій → 1000 рядків

Крок 2: AsSplitQuery — рішення Cartesian Explosion

EF Core 5+ вводить AsSplitQuery() — стратегія що виконує окремий SQL для кожного Include:

// ✅ Split Query: окремий SELECT для кожної колекції
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.LineItems)
        .ThenInclude(li => li.Product)
    .AsSplitQuery()           // ← виконати окремі запити
    .ToListAsync();

// SQL що виконується (3 окремих запити):
// Запит 1:
// SELECT o.*, c.*
// FROM Orders o LEFT JOIN Customers c ON c.Id = o.CustomerId

// Запит 2:
// SELECT li.*
// FROM OrderLineItems li
// WHERE li.OrderId IN (1, 2, 3, ...)  ← ID з першого запиту

// Запит 3:
// SELECT p.*
// FROM Products p
// WHERE p.Id IN (...)

QuerySplittingBehavior: глобальне налаштування

Замість AsSplitQuery() на кожному запиті — налаштувати глобально:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sqlOptions =>
        sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)));

// Тепер всі Include автоматично використовують Split Query
// Для конкретного запиту можна перевизначити:
var result = await context.Orders
    .Include(o => o.Customer)
    .AsSingleQuery()    // ← для цього конкретного запиту — назад до JOIN
    .ToListAsync();

Порівняльна таблиця стратегій

СтратегіяSQLПроблемаРекомендовано
N+11 + NТисячі запитів❌ Ніколи
SingleQuery (один JOIN)1Cartesian Explosion⚠️ Тільки для 1 рівня
SplitQuery2-NОкремі транзакції✅ Для множинних Include
Projection (Select)1✅ Завжди краще

Projection: Select тільки те що потрібно

Projection — це найефективніша техніка оптимізації EF Core запитів. Замість завантаження повного entity з усіма стовпцями — вибирати лише потрібні поля у анонімний об'єкт або DTO.

Чому projection краще за Include

// ❌ Include: завантажує ВСІ поля Customer (включаючи хеш пароля, адресу тощо)
var orders = await context.Orders
    .Include(o => o.Customer)     // SELECT *.* with JOIN
    .ToListAsync();

// ✅ Projection: лише те що реально потрібне
var orderDtos = await context.Orders
    .Select(o => new OrderListDto
    {
        Id           = o.Id,
        TotalAmount  = o.TotalAmount,
        CustomerName = o.Customer.Name,   // лише Name, без решти полів Customer!
        PlacedAt     = o.PlacedAt,
        Status       = o.Status
    })
    .ToListAsync();

// SQL що генерується (мінімально необхідний):
// SELECT o.Id, o.TotalAmount, c.Name, o.PlacedAt, o.Status
// FROM Orders o
// LEFT JOIN Customers c ON c.Id = o.CustomerId
// (Без зайвих полів Customer!)

Вкладені об'єкти у projection

var orders = await context.Orders
    .Select(o => new OrderDetailDto
    {
        Id     = o.Id,
        Status = o.Status,
        Customer = new CustomerSummaryDto
        {
            Name  = o.Customer.Name,
            Email = o.Customer.Email
        },
        // Агрегація LineItems без завантаження кожного:
        ItemCount   = o.LineItems.Count(),
        TotalAmount = o.LineItems.Sum(li => li.Quantity * li.UnitPrice),
        // Вибірка лише перших 3 позицій:
        PreviewItems = o.LineItems
            .OrderBy(li => li.Id)
            .Take(3)
            .Select(li => new LineItemPreviewDto
            {
                ProductName = li.Product.Name,
                Quantity    = li.Quantity
            })
            .ToList()
    })
    .ToListAsync();

// EF Core транслює ALL це в один оптимальний SQL з потрібними JOIN і GROUP BY!

Projection і AsNoTracking

Важлива деталь: Select automatic implies AsNoTracking — результат projection містить анонімні об'єкти або DTO, не EF entity. Change Tracker їх не відстежує. Але якщо ви проєктуєте у entity (власний тип), трекінг все одно відбувається.

// Без трекінгу (efficient):
var dtos = await context.Products
    .Select(p => new ProductDto { Id = p.Id, Name = p.Name })
    .ToListAsync();   // Change Tracker порожній!

// З трекінгом (неефективно для read-only):
var products = await context.Products
    .Select(p => new Product { Id = p.Id, Name = p.Name })  // ← entity type!
    .ToListAsync();   // Change Tracker відстежує!  ← небажано

AsNoTracking: відключення Change Tracker

За замовчуванням EF Core відстежує всі завантажені entity — зберігає оригінальні значення, перевіряє зміни при SaveChanges. Це займає пам'ять і CPU.

Для read-only запитів (API відповіді, звіти, графіки) — Change Tracker марнує ресурси.

Профіт AsNoTracking на числах

// Benchmark результати (наближені, залежать від сценарію):
// 1000 Products з Include(Category):
//
// WithTracking:    ~45ms,  ~12 MB RAM
// AsNoTracking:   ~28ms,   ~4 MB RAM
// Projection:      ~15ms,   ~2 MB RAM
//
// При 10,000 entity різниця ще суттєвіша.

// ✅ Read-only запити: завжди AsNoTracking або Projection
var products = await context.Products
    .AsNoTracking()
    .Include(p => p.Category)
    .Where(p => p.IsActive)
    .ToListAsync();

AsNoTrackingWithIdentityResolution

Є нюанс: AsNoTracking НЕ де-дублює об'єкти. Якщо 5 Products мають однаковий Category — у результаті буде 5 різних об'єктів Category (не один загальний):

// AsNoTracking: 5 Products → 5 різних Category об'єктів у пам'яті (навіть якщо CategoryId однаковий)
var products = await context.Products
    .AsNoTracking()
    .Include(p => p.Category)
    .ToListAsync();

// products[0].Category != products[1].Category (різні об'єкти, але однаковий вміст)

// AsNoTrackingWithIdentityResolution: де-дублює по Identity
var products = await context.Products
    .AsNoTrackingWithIdentityResolution()
    .Include(p => p.Category)
    .ToListAsync();

// products[0].Category == products[1].Category (той самий об'єкт якщо CategoryId однаковий)

Глобальний NoTracking для всього DbContext

// Налаштувати весь DbContext у read-only режим:
builder.Services.AddDbContext<ReadDbContext>(options =>
    options.UseSqlServer(replicaConnectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

// Тоді для операцій запису — окремий WriteDbContext (article 25 CQRS pattern)

Count vs Any: перевірка існування

Класична помилка: використання Count() > 0 замість Any() для перевірки існування.

// ❌ COUNT(*) завантажує кількість ВСІХ записів
bool hasOrders = await context.Orders
    .Where(o => o.CustomerId == customerId)
    .Count() > 0;
// SQL: SELECT COUNT(*) FROM Orders WHERE CustomerId = @customerId
// БД сканує ВСІ записи і рахує їх!

// ✅ EXISTS: зупиняється на першому знайденому записі
bool hasOrders = await context.Orders
    .Where(o => o.CustomerId == customerId)
    .AnyAsync();
// SQL: SELECT CASE WHEN EXISTS (
//          SELECT 1 FROM Orders WHERE CustomerId = @customerId
//      ) THEN 1 ELSE 0 END
// БД зупиняється одразу після знаходження першого запису!

// ❌ Також погано: FirstOrDefault() != null
var hasProduct = await context.Products
    .Where(p => p.Name == "Laptop")
    .FirstOrDefaultAsync() != null;
// SQL: SELECT TOP 1 * FROM Products WHERE Name = 'Laptop'
// Завантажує всі поля entity (хоча потрібно лише перевірити існування)

// ✅ Any()
var hasProduct = await context.Products
    .AnyAsync(p => p.Name == "Laptop");
// SQL: EXISTS (SELECT 1 FROM Products WHERE Name = 'Laptop')

Count коли справді потрібна кількість

// ✅ Count коли потрібна точна кількість:
int totalOrders = await context.Orders
    .Where(o => o.CustomerId == customerId)
    .CountAsync();

// ✅ LongCount для великих таблиць:
long totalProducts = await context.Products.LongCountAsync();

// ✅ Any у Where (NOT EXISTS):
var customersWithNoOrders = await context.Customers
    .Where(c => !context.Orders.Any(o => o.CustomerId == c.Id))
    .ToListAsync();
// SQL: WHERE NOT EXISTS (SELECT 1 FROM Orders WHERE CustomerId = c.Id)

Pagination: Offset vs Keyset

Pagination — невід'ємна частина будь-якого API що повертає списки. Але стандартний Offset підхід має фундаментальний недолік що проявляється лише на великих таблицях.

Offset Pagination (Skip/Take): стандартний підхід

// Класична сторінкова навігація: сторінка N розміром PageSize
int pageSize   = 20;
int pageNumber = 50;  // п'ятдесята сторінка

var orders = await context.Orders
    .OrderBy(o => o.PlacedAt)
    .Skip((pageNumber - 1) * pageSize)   // OFFSET 980
    .Take(pageSize)                       // FETCH NEXT 20 ROWS
    .ToListAsync();

// SQL Server:
// SELECT ...
// FROM Orders
// ORDER BY PlacedAt
// OFFSET 980 ROWS FETCH NEXT 20 ROWS ONLY;

// PostgreSQL:
// SELECT ... FROM orders ORDER BY placed_at LIMIT 20 OFFSET 980;

Проблема Offset на великих таблицях: БД не може «прокрутити» до рядка 980 без читання попередніх 979. При сторінці 50,000 (OFFSET 999,980) — БД читає і відкидає майже мільйон записів перед поверненням 20 потрібних. Час зростає лінійно з номером сторінки.

Keyset Pagination (Cursor-based): правильний підхід для великих наборів

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

// Перша сторінка:
var firstPage = await context.Orders
    .OrderBy(o => o.PlacedAt)
    .ThenBy(o => o.Id)          // tiebreaker (для унікальності)
    .Take(pageSize)
    .ToListAsync();

var lastItem = firstPage.Last();

// Наступна сторінка (після останнього елементу попередньої):
var nextPage = await context.Orders
    .Where(o => o.PlacedAt > lastItem.PlacedAt ||   // після по даті
               (o.PlacedAt == lastItem.PlacedAt &&
                o.Id > lastItem.Id))                  // або той самий час, але старший Id
    .OrderBy(o => o.PlacedAt)
    .ThenBy(o => o.Id)
    .Take(pageSize)
    .ToListAsync();

// SQL:
// SELECT TOP 20 *
// FROM Orders
// WHERE PlacedAt > '2024-06-15' OR (PlacedAt = '2024-06-15' AND Id > 12345)
// ORDER BY PlacedAt, Id

// БД використовує Index на (PlacedAt, Id) → O(log n) замість O(n)!

Generic Keyset Pagination

// Реалізація через курсор (base64-encoded JSON стан):
public class PagedResult<T>
{
    public IReadOnlyList<T> Items   { get; init; } = Array.Empty<T>();
    public string?   NextCursor     { get; init; }  // null = остання сторінка
    public bool      HasNextPage    => NextCursor is not null;
}

public async Task<PagedResult<OrderDto>> GetOrdersPageAsync(
    string? cursor, int pageSize = 20)
{
    var query = context.Orders
        .OrderBy(o => o.PlacedAt)
        .ThenBy(o => o.Id);

    // Декодувати курсор
    if (cursor is not null)
    {
        var state = DecodeCursor(cursor);  // { PlacedAt, Id }
        query = query.Where(o =>
            o.PlacedAt > state.PlacedAt ||
            (o.PlacedAt == state.PlacedAt && o.Id > state.Id));
    }

    var items = await query
        .Take(pageSize + 1)   // +1 щоб знати чи є наступна сторінка
        .Select(o => new OrderDto
        {
            Id          = o.Id,
            PlacedAt    = o.PlacedAt,
            TotalAmount = o.TotalAmount
        })
        .ToListAsync();

    string? nextCursor = null;
    if (items.Count > pageSize)
    {
        items.RemoveAt(pageSize);  // видалити +1 елемент
        var lastItem = items.Last();
        nextCursor = EncodeCursor(lastItem.PlacedAt, lastItem.Id);
    }

    return new PagedResult<OrderDto> { Items = items, NextCursor = nextCursor };
}

private static string EncodeCursor(DateTime placedAt, int id)
    => Convert.ToBase64String(
        JsonSerializer.SerializeToUtf8Bytes(new { PlacedAt = placedAt, Id = id }));

private static (DateTime PlacedAt, int Id) DecodeCursor(string cursor)
{
    var json  = Convert.FromBase64String(cursor);
    var state = JsonSerializer.Deserialize<JsonElement>(json);
    return (state.GetProperty("PlacedAt").GetDateTime(),
            state.GetProperty("Id").GetInt32());
}

Порівняння Offset vs Keyset

Offset PaginationKeyset Pagination
Перша сторінка✅ Просто✅ Просто
Сторінка 1,000+❌ Повільно✅ Завжди швидко
Перехід до довільної сторінки✅ Skip(N)❌ Неможливо
Консиситентність❌ Нові записи зсувають сторінки✅ Стабільний курсор
Загальна кількість✅ COUNT(*)✅ COUNT(*) (але дорого)
ВикористанняАдмін, звіти, малі набориAPI, infinite scroll, великі набори

Buffering vs Streaming: ToList vs AsAsyncEnumerable

Стандартний ToListAsync() — буферизує всі результати у пам'яті перед поверненням. Для великих наборів це може призвести до OutOfMemoryException.

AsAsyncEnumerable() — streaming підхід: отримуємо і обробляємо по одному рядку без завантаження всього у пам'ять.

// ❌ ToListAsync: завантажує ALL records у пам'ять
var allProducts = await context.Products
    .Where(p => p.IsActive)
    .ToListAsync();            // 1M рядків → 1M об'єктів у RAM!

foreach (var product in allProducts)
{
    await ProcessProductAsync(product);
}

// ✅ AsAsyncEnumerable: streaming (один рядок у RAM у будь-який момент)
await foreach (var product in context.Products
    .Where(p => p.IsActive)
    .AsAsyncEnumerable())
{
    await ProcessProductAsync(product);  // оброблено і звільнено з RAM
}
// SQL: один запит, але результати читаються потоково

Використання AsAsyncEnumerable

// Batch processing великих наборів через Chunking:
public async Task ProcessAllProductsAsync()
{
    const int batchSize = 1000;
    var batch = new List<Product>(batchSize);

    await foreach (var product in context.Products.AsAsyncEnumerable())
    {
        batch.Add(product);

        if (batch.Count >= batchSize)
        {
            await ProcessBatchAsync(batch);
            batch.Clear();   // Звільнити пам'ять
        }
    }

    // Останній неповний батч:
    if (batch.Count > 0)
        await ProcessBatchAsync(batch);
}

// Трансформація і передача у Stream (HTTP Response Streaming):
[HttpGet("products/export")]
public async IAsyncEnumerable<ProductExportDto> ExportProducts()
{
    await foreach (var product in context.Products
        .Where(p => p.IsActive)
        .Select(p => new ProductExportDto { ... })
        .AsAsyncEnumerable())
    {
        yield return product;  // передає клієнту по одному, без буфера!
    }
}
AsAsyncEnumerable() тримає з'єднання з БД відкритим протягом всього часу ітерації. Якщо обробка кожного елементу займає 100ms, а у базі 100K рядків — з'єднання відкрите 10,000 секунд! Використовуйте лише для швидкої streaming-обробки або export сценаріїв. Для складної обробки — краще Offset pagination з батчами.

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

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

Завдання 1.1: Виявлення N+1

  1. Увімкніть SQL logging: LogTo(Console.WriteLine, LogLevel.Information)
  2. Напишіть наступний код і порахуйте кількість SQL запитів:
    var categories = await context.Categories.ToListAsync();
    var result = categories.Select(c => new {
        c.Name,
        Products = context.Products.Where(p => p.CategoryId == c.Id).ToList()
    }).ToList();
    
  3. Виправте через Include або Projection
  4. Порівняйте кількість запитів і час виконання

Завдання 1.2: Projection vs Include

Для запиту «список замовлень з ім'ям клієнта і кількістю позицій»:

  1. Версія A: Include(o => o.Customer).Include(o => o.LineItems) → виміряйте час і RAM
  2. Версія B: Select(o => new { o.Id, CustomerName = o.Customer.Name, ItemCount = o.LineItems.Count() }) → виміряйте
  3. Порівняйте згенерований SQL (кількість стовпців, JOIN, GROUP BY)

Завдання 1.3: Count vs Any бенчмарк

Для таблиці з 100K записів:

  1. Count() > 0 → виміряйте час (Console.Stopwatch)
  2. Any() → виміряйте час
  3. Подивіться EXPLAIN ANALYZE (PostgreSQL) або Estimated Execution Plan (SQL Server)
  4. В якому випадку різниця найбільша: мало або багато записів?

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

Завдання 2.1: SplitQuery дослідження

  1. Завантажте Orders → Include(Customer) → Include(LineItems → Product) — Single Query
  2. Подивіться кількість рядків у результаті (Cartesian!)
  3. Те саме з AsSplitQuery() — порахуйте кількість SQL запитів
  4. Виміряйте час для обох при 1000 Orders × 10 LineItems кожне

Завдання 2.2: Keyset Pagination

Реалізуйте endpoint /api/orders?cursor=...&pageSize=20:

  1. Перша сторінка: cursor відсутній
  2. Наступна: cursor = base64(lastItem.PlacedAt + lastItem.Id)
  3. Перевірте: EXPLAIN показує Index Seek (не Scan) для Keyset
  4. Порівняйте performance при cursor=page1 і cursor=page1000 — чи є різниця?

Завдання 2.3: SlowQueryLoggingInterceptor

Реалізуйте та зареєструйте SlowQueryLoggingInterceptor:

  1. Threshold: 200ms для unit тестів, 500ms для production
  2. Логувати: SQL текст, параметри, час виконання, stack trace (!!)
  3. Відправляти метрику у IMetrics (можна через простий counter)
  4. Написати тест що перевіряє: повільний запит → logger отримує Warning

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

Завдання 3.1: Performance Audit Middleware

Реалізуйте ASP.NET Core Middleware що:

  1. Перед кожним HTTP запитом — встановлює counter запитів до БД (через DiagnosticListener)
  2. Після — логує: URL, HTTP метод, кількість DB запитів, загальний час DB, загальний час HTTP
  3. Якщо DB запитів > 10 → LogWarning "Potential N+1 detected"
  4. Якщо загальний DB час > 1000ms → LogWarning "Slow request"

Формат логу: GET /api/orders → 200 OK | DB: 3 queries, 45ms | Total: 120ms


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

  • SQL Logging: обов'язковий інструмент. LogTo(), EnableSensitiveDataLogging(). SlowQueryLoggingInterceptor для автоматичного виявлення
  • N+1: 1 запит для батьківської + N для child. Виявляється через логи. Вирішення: Include, Projection, або AsSplitQuery
  • SplitQuery: вирішує Cartesian Explosion при кількох Include колекцій — окремий SQL для кожної
  • Projection: Select лише потрібних полів → менше даних через мережу, менше пам'яті, без трекінгу
  • AsNoTracking: ~30-40% швидше для read-only запитів. AsNoTrackingWithIdentityResolution для де-дублювання
  • Count vs Any: Any() використовує EXISTS — зупиняється на першому знайденому. Count() сканує все
  • Pagination: Offset (Skip/Take) — простий, але сповільнюється лінійно. Keyset (WHERE Id > lastId) — завжди O(log n) з індексом
  • Streaming: AsAsyncEnumerable() для великих export без завантаження у RAM. Але тримає з'єднання відкритим

У другій частині — Compiled Queries, DbContext Pooling, ExecuteUpdate / ExecuteDelete, MaxBatchSize та Memory Management у Change Tracker.