Є два стани застосунку: той що працює і той що працює добре. Перший стан досягається підключенням 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, а обов'язковий інструмент розробки.
Перш ніж говорити про антипатерни — потрібно їх бачити. 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.
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());
// 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 — найпоширеніша і найруйнівніша проблема продуктивності у будь-якому 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 запитів ...
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 = кількість категорій)
Найпростіше рішення — явне завантаження пов'язаних даних через 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 має власні проблеми.
При включенні кількох колекцій — рядки множаться:
// ❌ 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 рядків
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 (...)
Замість 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+1 | 1 + N | Тисячі запитів | ❌ Ніколи |
| SingleQuery (один JOIN) | 1 | Cartesian Explosion | ⚠️ Тільки для 1 рівня |
| SplitQuery | 2-N | Окремі транзакції | ✅ Для множинних Include |
| Projection (Select) | 1 | — | ✅ Завжди краще |
Projection — це найефективніша техніка оптимізації EF Core запитів. Замість завантаження повного entity з усіма стовпцями — вибирати лише потрібні поля у анонімний об'єкт або DTO.
// ❌ 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!)
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!
Важлива деталь: 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 відстежує! ← небажано
За замовчуванням EF Core відстежує всі завантажені entity — зберігає оригінальні значення, перевіряє зміни при SaveChanges. Це займає пам'ять і CPU.
Для read-only запитів (API відповіді, звіти, графіки) — Change Tracker марнує ресурси.
// 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();
Є нюанс: 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 однаковий)
// Налаштувати весь DbContext у read-only режим:
builder.Services.AddDbContext<ReadDbContext>(options =>
options.UseSqlServer(replicaConnectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
// Тоді для операцій запису — окремий WriteDbContext (article 25 CQRS pattern)
Класична помилка: використання 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 коли потрібна точна кількість:
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 — невід'ємна частина будь-якого API що повертає списки. Але стандартний Offset підхід має фундаментальний недолік що проявляється лише на великих таблицях.
// Класична сторінкова навігація: сторінка 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 потрібних. Час зростає лінійно з номером сторінки.
Замість «пропустити 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)!
// Реалізація через курсор (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 Pagination | Keyset Pagination | |
|---|---|---|
| Перша сторінка | ✅ Просто | ✅ Просто |
| Сторінка 1,000+ | ❌ Повільно | ✅ Завжди швидко |
| Перехід до довільної сторінки | ✅ Skip(N) | ❌ Неможливо |
| Консиситентність | ❌ Нові записи зсувають сторінки | ✅ Стабільний курсор |
| Загальна кількість | ✅ COUNT(*) | ✅ COUNT(*) (але дорого) |
| Використання | Адмін, звіти, малі набори | API, infinite scroll, великі набори |
Стандартний 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: один запит, але результати читаються потоково
// 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 з батчами.LogTo(Console.WriteLine, LogLevel.Information)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();
Include або ProjectionДля запиту «список замовлень з ім'ям клієнта і кількістю позицій»:
Include(o => o.Customer).Include(o => o.LineItems) → виміряйте час і RAMSelect(o => new { o.Id, CustomerName = o.Customer.Name, ItemCount = o.LineItems.Count() }) → виміряйтеДля таблиці з 100K записів:
Count() > 0 → виміряйте час (Console.Stopwatch)Any() → виміряйте часAsSplitQuery() — порахуйте кількість SQL запитівРеалізуйте endpoint /api/orders?cursor=...&pageSize=20:
cursor відсутнійcursor = base64(lastItem.PlacedAt + lastItem.Id)Реалізуйте та зареєструйте SlowQueryLoggingInterceptor:
IMetrics (можна через простий counter)Реалізуйте ASP.NET Core Middleware що:
Формат логу: GET /api/orders → 200 OK | DB: 3 queries, 45ms | Total: 120ms
LogTo(), EnableSensitiveDataLogging(). SlowQueryLoggingInterceptor для автоматичного виявленняInclude, Projection, або AsSplitQuerySelect лише потрібних полів → менше даних через мережу, менше пам'яті, без трекінгуAsNoTrackingWithIdentityResolution для де-дублюванняAny() використовує EXISTS — зупиняється на першому знайденому. Count() сканує всеSkip/Take) — простий, але сповільнюється лінійно. Keyset (WHERE Id > lastId) — завжди O(log n) з індексомAsAsyncEnumerable() для великих export без завантаження у RAM. Але тримає з'єднання відкритимУ другій частині — Compiled Queries, DbContext Pooling, ExecuteUpdate / ExecuteDelete, MaxBatchSize та Memory Management у Change Tracker.
Управління Схемою та Database-First (Частина 2)
Partial Classes для безпечного розширення згенерованих entity — бізнес-логіка без ризику перезапису. Re-scaffolding workflow при зміні БД. Database schema comparison tools. Multi-database scenarios у одному застосунку.
Interceptors в EF Core (Частина 1)
EF Core Interceptors — механізм перехоплення операцій на найнижчому рівні ORM. DbCommandInterceptor для модифікації SQL, SaveChangesInterceptor для аудиту та domain events, IDbConnectionInterceptor для управління з'єднаннями. Архітектура pipeline interceptors.