Przejdź do treści
Entity Framework Core Performance: 5 błędów, które spowalnia 90% aplikacji
· 5 min czytania

Entity Framework Core Performance: 5 błędów, które spowalnia 90% aplikacji

EF Core Performance C# .NET Bazy danych

AI tu, AI tam, a na produkcji od lat to samo. Gdy aplikacja zwalnia, CPU i RAM szorują po suficie, a wentylatory na serwerach wyją z wyczerpania - winowajcą są zazwyczaj te same, klasyczne problemy na styku kodu i bazy danych.

Na przestrzeni lat nie raz słyszałem: „ORM-y są wolne, zmieńmy framework, piszmy czystego SQL-a”. Faktem jest, że każdy ORM to wrapper, który dodaje narzut. Jednak to, jak duży jest ten narzut, zależy głównie od jego użycia. Domyślne zachowania EF Core przy skali stają się pułapką, jeśli nie rozumiesz, co dzieje się pod maską.

Z drugiej strony, ORM oferuje ogrom benefitów, które dramatycznie przyspieszają development - pod warunkiem, że umiesz go okiełznać.

Przy analizach wydajności aplikacji opartych o EF Core widać ten sam wzorzec błędów powtarzany w kółko. Oto pierwsza piątka.

1. Problem N+1

Klasyk. Jedno zapytanie po autorów, a potem 100 dodatkowych o książki każdego z nich w pętli. Zamiast jednego JOIN-a, masz setki round-tripów do bazy, które zabijają czas odpowiedzi.

Problem:

var authors = context.Authors.ToList();
foreach (var author in authors)
{
// Każda iteracja = nowe zapytanie SQL do bazy!
var books = context.Books
.Where(b => b.AuthorId == author.Id)
.ToList();
}
// Wynik: 1 zapytanie po autorów + N zapytań po książki

Rozwiązanie:

var authors = context.Authors
.Include(a => a.Books) // Eager loading - jeden JOIN
.ToList();
// Wynik: 1 zapytanie z JOIN, wszystko załadowane od razu

Użyj .Include() aby załadować powiązane encje w jednym zapytaniu. To eliminuje N+1 u źródła.

2. Change Tracking przy odczycie

EF domyślnie śledzi każdą encję „na wszelki wypadek”. Pobierasz 1000 produktów tylko do wyświetlenia? Marnujesz zasoby CPU i RAM na śledzenie zmian, których nigdy nie zapiszesz.

Problem:

// EF śledzi każdy z 1000 obiektów - alokuje pamięć na snapshoty
var products = context.Products.ToList();
// Te dane idą tylko do widoku - nigdy nie wywołasz SaveChanges()

Rozwiązanie:

var products = context.Products
.AsNoTracking() // Wyłączamy śledzenie zmian
.ToList();
// ~30% szybciej, ~40% mniej RAM

Zasada jest prosta: jeśli nie zamierzasz modyfikować pobranych danych - zawsze dodawaj .AsNoTracking(). Na endpointach read-only to obowiązkowe.

3. SELECT * zamiast projekcji

Pobierasz 50 kolumn z tabeli Users, a używasz tylko Id i Name. Marnujesz czas procesora, przepustowość sieci i pamięć na dane, które od razu lądują w koszu - zarówno po stronie serwera bazodanowego, jak i samej aplikacji.

Problem:

// Pobiera WSZYSTKIE kolumny z tabeli Users
var users = context.Users.ToList();
// A potem w widoku używasz tylko:
// user.Id, user.Name

Rozwiązanie:

var users = context.Users
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name
})
.ToList();
// SQL: SELECT Id, Name FROM Users - nic więcej

.Select() generuje SQL z dokładnie tymi kolumnami, których potrzebujesz. Mniej danych = szybsza sieć, mniej RAM, szybsza serializacja.

4. Eksplozja kartezjańska (Cartesian Explosion)

Wiele .Include() do kolekcji generuje gigantyczny JOIN pełen duplikatów. Jeden blog ze 100 postami i 10 tagami? Baza może zwrócić tysiące wierszy pełnych powtarzających się danych, które EF musi potem „odfiltrować”.

Problem:

var blogs = context.Blogs
.Include(b => b.Posts) // 100 postów
.Include(b => b.Tags) // 10 tagów
.ToList();
// SQL generuje JOIN dający 100 × 10 = 1000 wierszy dla jednego bloga!

Rozwiązanie:

var blogs = context.Blogs
.Include(b => b.Posts)
.Include(b => b.Tags)
.AsSplitQuery() // Dzieli na osobne zapytania zamiast jednego gigantycznego JOIN
.ToList();
// 3 proste zapytania zamiast jednego monstrum

.AsSplitQuery() zamienia jeden ogromny JOIN na kilka mniejszych, niezależnych zapytań. Mniej duplikatów, mniej transferu, szybszy wynik.

5. Brak indeksów na kolumnach w WHERE

Szukasz użytkownika po Email bez założonego indeksu? Przy 1 mln wierszy to oznacza Full Table Scan. Zapytanie z 10ms zamienia się w 2000ms, blokując zasoby bazy dla innych procesów.

Problem:

// To zapytanie wymusi Full Table Scan jeśli nie ma indeksu na Email
var user = context.Users
.FirstOrDefault(u => u.Email == "jan@example.com");

Rozwiązanie - dodaj indeks w konfiguracji modelu:

public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasIndex(u => u.Email); // Indeks na Email
}
}

Lub bezpośrednio w OnModelCreating:

modelBuilder.Entity<User>()
.HasIndex(u => u.Email);

Milisekundy zamiast sekund. Każda kolumna, po której filtrujesz regularnie, powinna mieć indeks.

Dlaczego to ma znaczenie?

Na lokalnej bazie z setką rekordów wszystko działa błyskawicznie. Problem ujawnia się dopiero na produkcji, gdy czasy wykonania rosną wykładniczo wraz z przyrostem danych.

Złote zasady - ściągawka

TechnikaEfekt
.Include() dla relacjiEliminujesz N+1
.AsNoTracking() dla read-only~30% szybciej, ~40% mniej RAM
.Select() - tylko potrzebne polaOszczędzasz sieć i pamięć
.AsSplitQuery() przy wielu kolekcjachUnikasz eksplozji danych
.HasIndex() na kolumnach filtrowaniaMilisekundy zamiast sekund

Jak wykrywać te problemy?

Włącz logowanie SQL w EF Core, aby widzieć, co naprawdę leci do bazy:

optionsBuilder
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging(); // Tylko w DEV!

Gdy zobaczysz dziesiątki niemal identycznych zapytań w logach - masz N+1. Gdy zobaczysz SELECT * - brakuje projekcji. Logi to Twoja pierwsza linia obrony.

W Part 2 pokażę 5 zaawansowanych problemów, które niszczą skalowalność: blokowanie wątków, Contains z dużą listą, bulk updates, memory leaks z DbContext i client-side evaluation.

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