Przejdź do treści
Entity Framework Core Performance Part 2: 5 zaawansowanych problemów skalowalności
· 5 min czytania

Entity Framework Core Performance Part 2: 5 zaawansowanych problemów skalowalności

EF Core Performance C# .NET Skalowalność

W pierwszej części pokazałem 5 błędów, które najczęściej spowalniają aplikacje z EF Core. Dziś druga piątka - problemy, które ujawniają się dopiero przy większym ruchu i skali.

EF Core potrafi rozpieścić prostotą, ale pod spodem generuje SQL, który na produkcji potrafi zaskoczyć.

1. Blokowanie wątków - brak await przy I/O

Brak await przy operacjach bazodanowych zabija skalowalność, nie CPU. Wątek czeka bezczynnie na odpowiedź z bazy, zamiast obsługiwać inne requesty.

Problem:

// Synchroniczne wywołanie - blokuje wątek z ThreadPool
public List<Product> GetProducts()
{
return context.Products.ToList(); // Blokuje wątek!
}

Rozwiązanie:

// Asynchroniczne wywołanie - wątek wraca do puli podczas oczekiwania na bazę
public async Task<List<Product>> GetProductsAsync()
{
return await context.Products.ToListAsync();
}

To nie jest kwestia szybkości jednego zapytania. To kwestia tego, ile requestów Twój serwer może obsłużyć jednocześnie. Przy 100 równoległych zapytaniach, synchroniczny kod wyczerpie pulę wątków i zacznie kolejkować - async nie.

Zasada: każda operacja I/O (baza, HTTP, plik) powinna być async/await.

2. Contains z dużą listą ID-ków

Przekazanie dużej listy do .Where(x => ids.Contains(x.Id)) generuje SQL z gigantyczną klauzulą IN(...). SQL Server ma limity na liczbę parametrów, a optymalizator planu wykonania traci skuteczność.

Problem:

var ids = GetThousandsOfIds(); // np. 5000 elementów
var products = context.Products
.Where(p => ids.Contains(p.Id)) // SQL: WHERE Id IN (@p0, @p1, ... @p4999)
.ToList();
// SQL Server: limit parametrów, wolny plan wykonania

Rozwiązanie - dzielenie na partie (batching):

var ids = GetThousandsOfIds();
var batchSize = 500;
var results = new List<Product>();
for (int i = 0; i < ids.Count; i += batchSize)
{
var batch = ids.Skip(i).Take(batchSize).ToList();
var batchResults = await context.Products
.Where(p => batch.Contains(p.Id))
.ToListAsync();
results.AddRange(batchResults);
}

Alternatywa - użyj tabeli tymczasowej lub OPENJSON (SQL Server):

Przy naprawdę dużych zestawach ID rozważ wstawienie ich do tabeli temp i JOIN zamiast IN.

3. Bulk Updates - pobieranie do RAM zamiast ExecuteUpdate

Przed EF 7 jedynym sposobem na masową aktualizację było pobranie encji, modyfikacja w pamięci i SaveChanges(). To generowało tonę UPDATE-ów - po jednym na obiekt.

Problem:

// Pobieramy WSZYSTKIE produkty z kategorii do RAM
var products = await context.Products
.Where(p => p.CategoryId == 5)
.ToListAsync();
foreach (var product in products)
{
product.IsActive = false; // Modyfikacja w pamięci
}
await context.SaveChangesAsync();
// EF generuje osobny UPDATE dla KAŻDEGO produktu!

Rozwiązanie (EF 7+):

// Jeden SQL UPDATE - bez pobierania czegokolwiek do pamięci
await context.Products
.Where(p => p.CategoryId == 5)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsActive, false));
// SQL: UPDATE Products SET IsActive = 0 WHERE CategoryId = 5

ExecuteUpdateAsync i ExecuteDeleteAsync (EF 7+) wysyłają jedno polecenie SQL bezpośrednio do bazy. Zero round-tripów, zero alokacji pamięci na encje. Przy tysiącach rekordów różnica jest dramatyczna.

4. Memory Leaks - DbContext jako Singleton

DbContext jako Singleton to najkrótsza droga do wycieków pamięci. Context cache’uje każdą encję, którą kiedykolwiek załadował. W trybie Singleton nigdy się nie zwolni.

Problem:

// NIGDY tak nie rób!
services.AddSingleton<AppDbContext>();
// DbContext żyje tak długo jak aplikacja,
// akumulując KAŻDĄ załadowaną encję w pamięci

Rozwiązanie:

ServiceLifetime.Scoped
// Scoped = nowy DbContext na każdy HTTP request
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));

DbContext powinien być Scoped - tworzony na początku requestu HTTP i usuwany na końcu. To domyślne zachowanie AddDbContext, więc po prostu nie zmieniaj lifetime.

Jeśli potrzebujesz DbContextu poza HTTP pipeline (np. w background service) - użyj IDbContextFactory<T>:

services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// W background service:
public class MyWorker : BackgroundService
{
private readonly IDbContextFactory<AppDbContext> _factory;
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var context = await _factory.CreateDbContextAsync(ct);
// Użyj i wyrzuć - czysto
}
}

5. Client-side Evaluation

Gdy EF nie potrafi przetłumaczyć wyrażenia LINQ na SQL, wykonuje je po stronie klienta - pobiera dane z bazy do pamięci i dopiero wtedy filtruje w C#. Przy milionach rekordów to katastrofa.

Problem:

var users = context.Users
.Where(u => MyCustomHelper.IsValid(u.Email)) // EF nie umie tego na SQL!
.ToList();
// EF pobiera WSZYSTKICH użytkowników do RAM
// i filtruje w C# - przy 1M rekordów to zabójcze

Rozwiązanie:

// Używaj tylko wyrażeń, które EF potrafi przetłumaczyć na SQL
var users = context.Users
.Where(u => u.Email != null && u.Email.Contains("@company.com"))
.ToList();
// SQL: WHERE Email IS NOT NULL AND Email LIKE '%@company.com%'

Jak wykryć client-side evaluation?

EF Core 3+ domyślnie rzuca wyjątek przy próbie client-side evaluation (w przeciwieństwie do EF Core 2.x, który robił to cicho). Ale warto dodatkowo włączyć ostrzeżenia:

optionsBuilder
.UseSqlServer(connectionString)
.ConfigureWarnings(w =>
w.Throw(RelationalEventId.MultipleCollectionIncludeWarning));

Podsumowanie złotych zasad

ProblemRozwiązanie
Synchroniczne I/Oasync/await + ToListAsync()
Contains z dużą listąBatching po 500 lub tabela tymczasowa
Masowe UPDATE/DELETEExecuteUpdateAsync / ExecuteDeleteAsync (EF 7+)
DbContext SingletonScoped (domyślne) lub IDbContextFactory
Client-side evaluationUżywaj wyrażeń tłumaczalnych na SQL

Kiedy EF Core to za mało?

EF Core to świetny ORM dla 90% przypadków. Ale są scenariusze, gdzie warto sięgnąć po alternatywy:

  • Dapper - dla krytycznych ścieżek, gdzie liczy się każda milisekunda
  • Raw SQL via FromSqlRaw - dla złożonych zapytań, które trudno wyrazić w LINQ
  • Stored Procedures - dla operacji batch lub logiki, która musi być blisko danych

Klucz to wiedzieć, kiedy EF Core jest wystarczający, a kiedy potrzebujesz czegoś lekkiego. Nie rezygnuj z ORM-a „bo jest wolny” - najpierw sprawdź, czy nie robisz jednego z tych 10 błędów.

Udostępnij X / Twitter LinkedIn
🤖

Chcesz opanować GitHub Copilot od podstaw?

Kurs GitHub Copilot - 5 poziomów, 15 modułów, od instalacji do własnych agentów. Pisany przez człowieka, weryfikowany z oficjalną dokumentacją VS Code.

Sprawdź kurs