Ef Core

10.11. Продуктивність та оптимізація

10.11. Продуктивність та оптимізація

Вступ: EF Core швидкий, але...

EF Core за замовчуванням працює «досить швидко» для більшості додатків. Але невдало написаний LINQ-запит може перетворити додаток на черепаху. N+1 problem, завантаження зайвих стовпців, відсутність індексів, надмірний Change Tracking — усе це знижує продуктивність.

У цій статті — конкретні техніки оптимізації з прикладами «до/після».


N+1 Problem: Діагностика та вирішення

Проблема

// ❌ N+1: 1 запит на авторів + N запитів на книги
var authors = context.Authors.ToList();        // SQL: SELECT * FROM Authors
foreach (var author in authors)
    Console.WriteLine($"{author.Name}: {author.Books.Count} книг");
    // Кожне звернення до Books → SELECT * FROM Books WHERE AuthorId = @Id

100 авторів = 101 SQL-запитів.

Рішення

// ✅ Eager Loading — 1 запит з JOIN
var authors = context.Authors
    .Include(a => a.Books)
    .ToList();
// SQL: SELECT a.*, b.* FROM Authors a LEFT JOIN Books b ON a.Id = b.AuthorId

// ✅ Projection — навіть краще, тільки потрібні дані
var stats = context.Authors
    .Select(a => new { a.Name, BookCount = a.Books.Count })
    .ToList();
// SQL: SELECT a.Name, (SELECT COUNT(*) FROM Books WHERE AuthorId = a.Id) FROM Authors a

AsNoTracking

// ❌ З відстеженням (за замовчуванням) — повільніше, більше пам'яті
var tracked = context.Books.ToList();

// ✅ Без відстеження — швидше для read-only
var fast = context.Books
    .AsNoTracking()
    .ToList();

// ✅ AsNoTrackingWithIdentityResolution — без tracking, але з Identity Map
// (уникає дублікатів при Include)
var optimized = context.Authors
    .AsNoTrackingWithIdentityResolution()
    .Include(a => a.Books)
    .ToList();
МетодChange TrackerIdentity MapШвидкість
За замовчуваннямБазова
AsNoTracking()+30-50%
AsNoTrackingWithIdentityResolution()+20-40%

Split Queries vs Single Query

Проблема Cartesian Explosion

// Include з кількома колекціями → Cartesian product
var authors = context.Authors
    .Include(a => a.Books)
    .Include(a => a.Awards)
    .ToList();
// SQL: SELECT ... FROM Authors
//      LEFT JOIN Books ON ... LEFT JOIN Awards ON ...
// Якщо автор має 10 книг і 5 нагород = 50 рядків на одного автора!

Рішення: AsSplitQuery

var authors = context.Authors
    .Include(a => a.Books)
    .Include(a => a.Awards)
    .AsSplitQuery()    // ← 3 окремих запити замість 1 з JOIN
    .ToList();
// SQL 1: SELECT * FROM Authors
// SQL 2: SELECT * FROM Books WHERE AuthorId IN (...)
// SQL 3: SELECT * FROM Awards WHERE AuthorId IN (...)
СтратегіяЗапитівДаніКоли
Single (default)1Можливий Cartesian1-2 Include
SplitNБез дублювання3+ Include

Bulk Operations (EF Core 7+)

// ❌ Старий підхід: завантажити все → змінити → зберегти
var books = context.Books.Where(b => b.Year < 2000).ToList(); // SELECT
foreach (var book in books) book.IsAvailable = false;          // Зміна в пам'яті
context.SaveChanges();                                          // N UPDATE

// ✅ ExecuteUpdate — один UPDATE без завантаження
context.Books
    .Where(b => b.Year < 2000)
    .ExecuteUpdate(b => b.SetProperty(p => p.IsAvailable, false));
// SQL: UPDATE Books SET IsAvailable = 0 WHERE Year < 2000

// ✅ ExecuteDelete — один DELETE без завантаження
context.Books
    .Where(b => b.IsDeleted)
    .ExecuteDelete();
// SQL: DELETE FROM Books WHERE IsDeleted = 1

Логування та діагностика

SQL Logging

optionsBuilder
    .LogTo(Console.WriteLine, LogLevel.Information)
    .EnableSensitiveDataLogging()  // Значення параметрів
    .EnableDetailedErrors();        // Детальні помилки

Query Tags

var books = context.Books
    .TagWith("GetAvailableBooks - BookService.cs line 42")
    .Where(b => b.IsAvailable)
    .ToList();
// SQL: -- GetAvailableBooks - BookService.cs line 42
//      SELECT ... FROM Books WHERE IsAvailable = 1

Tags з'являються як SQL-коментарі — допомагають знайти «який C# код згенерував цей запит».


Connection Resiliency

optionsBuilder.UseSqlServer(connectionString, options =>
    options.EnableRetryOnFailure(
        maxRetryCount: 3,
        maxRetryDelay: TimeSpan.FromSeconds(5),
        errorNumbersToAdd: null));

EF Core автоматично повторить запити при transient errors (таймаут, мережева помилка).


Compiled Queries

// Компіляція Expression Tree один раз
private static readonly Func<LibraryContext, string, Book?> FindByIsbn =
    EF.CompileQuery((LibraryContext ctx, string isbn) =>
        ctx.Books.FirstOrDefault(b => b.Isbn == isbn));

private static readonly Func<LibraryContext, int, IEnumerable<Book>> FindByYear =
    EF.CompileQuery((LibraryContext ctx, int year) =>
        ctx.Books.Where(b => b.Year == year));

// Використання — без overhead парсингу Expression Tree
var book = FindByIsbn(context, "978-0132350884");
var books = FindByYear(context, 2024).ToList();

DbContext Pooling

// Замість AddDbContext
builder.Services.AddDbContextPool<LibraryContext>(
    options => options.UseSqlServer(connectionString),
    poolSize: 128);

Контексти повертаються в пул замість GC — менше алокацій, швидше створення.


Чек-ліст продуктивності

#ПеревіркаТехніка
1Є N+1?Include() або Select()
2Read-only запит?AsNoTracking()
3SELECT * коли потрібні 2 стовпці?Select(b => new { ... })
4Кілька Include?AsSplitQuery()
5Масове UPDATE/DELETE?ExecuteUpdate() / ExecuteDelete()
6Hot path?EF.CompileQuery()
7Transient errors?EnableRetryOnFailure()
8Високе навантаження?AddDbContextPool()

Практичні завдання

Завдання 1: N+1 Hunting

  1. Створіть модель Author → Books (1000 авторів, 5 книг кожен).
  2. Виведіть кількість книг кожного автора без Include.
  3. Порахуйте SQL-запити в логах.
  4. Виправте через Include, потім через Select — порівняйте запити.

Завдання 2: Benchmark

  1. 10 000 книг у базі.
  2. Виміряйте: ToList() vs AsNoTracking().ToList() vs Select(dto).ToList().
  3. Виміряйте: foreach + Save vs ExecuteUpdate для масового оновлення.
  4. Виміряйте: звичайний vs compiled query (100 000 ітерацій).

Завдання 3: Split Query

  1. Author → Books → Reviews (triple Include).
  2. Порівняйте Single vs Split query за кількістю рядків у результаті.

Резюме

AsNoTracking

+30-50% для read-only. Немає snapshot, немає Change Tracker overhead.

Select (Projection)

Тільки потрібні стовпці. Менше трафіку з БД, менше пам'яті.

ExecuteUpdate/Delete

Один SQL без завантаження. EF Core 7+. Обходить Change Tracker.

AsSplitQuery

Окремі запити замість Cartesian product. Для 3+ Include.
Copyright © 2026