Коли застосунок виходить у 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 Query — скомпільований LINQ-запит, що проходить трансляцію в SQL один раз під час першого виклику і кешується для подальших. Це усуває overhead компіляції при кожному виклику.
// Визначаємо 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 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 найбільш виправдані для:
Для рідко виконуваних і динамічних запитів — звичайний LINQ з кешуванням SQL у пулі EF Core достатній.
Стандартний ToListAsync() матеріалізує весь результат у RAM перед поверненням першого елемента. Для великих наборів (10K+ рядків) це:
IAsyncEnumerable<T> дозволяє обробляти результати один за одним, як тільки вони надходять від БД.
// Звичайний: весь результат у RAM
var allProducts = await context.Products.ToListAsync();
// ↑ Чекає поки всі рядки прийдуть, тоді повертає
// Streaming через IAsyncEnumerable
await foreach (var product in context.Products.AsAsyncEnumerable())
{
// Обробляємо кожен рядок по мірі надходження
await ProcessProductAsync(product);
}
// ↑ Перший рядок обробляється до того як прийшов останній
// 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
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);
}
await foreach DbContext залишається відкритим протягом усієї ітерації. Не закривайте DbContext до завершення перерахування. У ASP.NET Core — Scoped DbContext живе протягом HTTP запиту, тому все правильно. У background workers — слідкуйте за scope.Стандартна пагінація через Skip().Take() (OFFSET) має фундаментальну проблему при великих таблицях:
-- OFFSET пагінація: сторінка 1000 (по 20 елементів)
SELECT * FROM Products ORDER BY CreatedAt, Id
OFFSET 19980 ROWS -- БД читає і відкидає 19980 рядків!
FETCH NEXT 20 ROWS ONLY
База даних має прочитати 19980 рядків щоб відкинути їх, і лише потім повернути 20 потрібних. З ростом номера сторінки — час запиту росте лінійно.
Замість «пропустити 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!
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 // клієнт передає у наступному запиті
});
}
Два підходи до завантаження пов'язаних даних — Select проєкція і Include. Кожен має свої ситуації застосування.
// 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: тільки потрібні поля, один запит, без 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 для відображення
| Критерій | Include | Select (проєкція) |
|---|---|---|
| SQL | SELECT * з JOIN | SELECT лише потрібне |
| Зміна і збереження | ✅ Через 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: Перший 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:
IAsyncEnumerable<Product> (не ToListAsync)StreamWriter безпосередньо у Response.BodyContent-Type: text/csv; charset=utf-8Перевірте: чи сервер не завантажив одразу всі рядки у RAM?
Завдання 1.3: Keyset vs OFFSET — benchmark
Для таблиці з 100K+ рядків BlogPost (CreatedAt, Id):
.Skip(page * size).Take(size).Where(p => p.CreatedAt < lastDate || ...).Take(size)Завдання 2.1: Generic Compiled Query Cache
Реалізуйте CompiledQueryCache<TContext> що:
ConcurrentDictionary<string, object> (ключ — ім'я що вказав розробник)GetOrAdd<TResult>(string key, Expression<Func<TContext, Task<TResult>>> query) → Compiled QueryЗавдання 2.2: Bi-directional Cursor
Розширте Keyset Pagination для підтримки direction: "forward" | "backward":
WHERE (CreatedAt < lastCreatedAt) OR (CreatedAt = lastCreatedAt AND Id < lastId)Реалізуйте GetPageAsync(cursor?, direction, size) і GetPreviousPageAsync(cursor, size).
Завдання 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 матеріалізує.
У першій частині розглянули ключові техніки оптимізації запитів:
EF.CompileAsyncQuery): усуваємо overhead трансляції LINQ→SQL для часто виконуваних запитів. Статичне поле, Func<DbContext, params..., Task<T>>.IAsyncEnumerable<T> (AsAsyncEnumerable()): streaming великих наборів без завантаження в RAM. await foreach для обробки рядок-за-рядком.WHERE field < lastValue. Без лінійної деградації при великих offset.Select для read-only (тільки потрібні поля, без Change Tracker), Include для CRUD (повний граф для збереження).У другій частині — Query Splitting стратегії, No-Tracking Bulk Queries, Batch CRUD через ExecuteUpdate/ExecuteDelete, EF Core Query Interceptors для автоматичної оптимізації та Query Plan Caching.
Raw SQL — Stored Procedures, DbFunction та Bulk Operations (Частина 2)
Stored Procedures у EF Core — виклик з і без результату, OUTPUT параметри. HasDbFunction для маппінгу SQL-функцій у LINQ. ExecuteUpdate та ExecuteDelete (EF Core 7+) для ефективних bulk операцій без завантаження в пам'ять.
Продвинуті Запити — Query Tags, Bulk та Interceptors (Частина 2)
Query Tags для діагностики SQL, Bulk Insert через BulkExtensions, No-Tracking Bulk Queries, Query Interceptors для автоматичної оптимізації, Plan Cache і EF Core Query Diagnostics.