Ef Core
10.11. Продуктивність та оптимізація
10.11. Продуктивність та оптимізація
Вступ: EF Core швидкий, але...
EF Core за замовчуванням працює «досить швидко» для більшості додатків. Але невдало написаний LINQ-запит може перетворити додаток на черепаху. N+1 problem, завантаження зайвих стовпців, відсутність індексів, надмірний Change Tracking — усе це знижує продуктивність.
У цій статті — конкретні техніки оптимізації з прикладами «до/після».
Передумови: 10.5. LINQ to Entities, 10.6. Відношення 1:N.
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 Tracker | Identity 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 | Можливий Cartesian | 1-2 Include |
| Split | N | Без дублювання | 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() |
| 2 | Read-only запит? | AsNoTracking() |
| 3 | SELECT * коли потрібні 2 стовпці? | Select(b => new { ... }) |
| 4 | Кілька Include? | AsSplitQuery() |
| 5 | Масове UPDATE/DELETE? | ExecuteUpdate() / ExecuteDelete() |
| 6 | Hot path? | EF.CompileQuery() |
| 7 | Transient errors? | EnableRetryOnFailure() |
| 8 | Високе навантаження? | AddDbContextPool() |
Практичні завдання
Завдання 1: N+1 Hunting
- Створіть модель Author → Books (1000 авторів, 5 книг кожен).
- Виведіть кількість книг кожного автора без Include.
- Порахуйте SQL-запити в логах.
- Виправте через Include, потім через Select — порівняйте запити.
Завдання 2: Benchmark
- 10 000 книг у базі.
- Виміряйте:
ToList()vsAsNoTracking().ToList()vsSelect(dto).ToList(). - Виміряйте:
foreach + SavevsExecuteUpdateдля масового оновлення. - Виміряйте: звичайний vs compiled query (100 000 ітерацій).
Завдання 3: Split Query
- Author → Books → Reviews (triple Include).
- Порівняйте 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.