← Wszystkie wpisy

EF Core N+1: kod, który po cichu wysyła 51 zapytań

Pętla po klientach wygląda niewinnie, a lazy loading wysyła osobne zapytanie dla każdego z nich. Prawdziwy log SQL, pomiary i dwie poprawki.

Artur Kot 9 min czytania

Ten raport wypisuje każdego klienta sklepu, liczbę jego zamówień i ich łączną wartość:

foreach (var customer in db.Customers.ToList())
{
    var total = customer.Orders.Sum(o => o.Total);
    lines.Add($"{customer.Name}: {customer.Orders.Count} zamówienia, razem {total} zł");
}

W bazie jest 50 klientów, każdy ma trzy zamówienia. Raport się kompiluje, nie rzuca wyjątku, a pierwsza linia wyniku wygląda dokładnie tak, jak powinna:

Klient 1: 3 zamówienia, razem 36.0 zł

Test, który porównuje te linie z oczekiwanymi, też przejdzie. Problem nie dotyczy wyniku.

Dotyczy tego, ile razy program zapytał bazę, żeby go policzyć. Odpowiedź brzmi: 51 razy. To jest problem EF Core N+1, i jest o tyle podstępny, że w żadnym miejscu kodu nie widać pętli wysyłającej zapytania.

Co EF Core wysłał do bazy

Model jest zwyczajny. Customer ma kolekcję Orders, a kontekst włącza leniwe ładowanie (lazy loading) przez pakiet Microsoft.EntityFrameworkCore.Proxies:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public virtual List<Order> Orders { get; set; } = new();
}

protected override void OnConfiguring(DbContextOptionsBuilder options)
{
    options.UseSqlite("Data Source=shop.db").UseLazyLoadingProxies();
}

Słowo virtual przy Orders i wywołanie UseLazyLoadingProxies() sprawiają, że EF Core tworzy w locie klasę pochodną od Customer. Jej właściwość Orders przy pierwszym odczycie sama wysyła zapytanie do bazy.

Żeby zobaczyć, co faktycznie trafia do SQLite, dołożyłem do kontekstu interceptor DbCommandInterceptor, który liczy wykonane polecenia, i włączyłem log LogTo dla zdarzenia RelationalEventId.CommandExecuted. Pierwsze trzy polecenia po uruchomieniu raportu:

-- zapytanie 1
SELECT "c"."Id", "c"."Name"
FROM "Customers" AS "c"

-- zapytanie 2
SELECT "o"."Id", "o"."CustomerId", "o"."Total"
FROM "Orders" AS "o"
WHERE "o"."CustomerId" = @p

-- zapytanie 3
SELECT "o"."Id", "o"."CustomerId", "o"."Total"
FROM "Orders" AS "o"
WHERE "o"."CustomerId" = @p

Licznik po zakończeniu raportu pokazał Zapytań do bazy: 51.

Jedno zapytanie pobiera listę klientów. To jest „1” w nazwie. Potem pętla dochodzi do customer.Orders i proxy, zamiast oddać pustą listę, wysyła osobny SELECT po zamówienia tego jednego klienta. Pięćdziesiąt razy, bo klientów jest pięćdziesięciu. To jest „N”.

W samym raporcie nie ma żadnego wywołania, które wyglądałoby jak zapytanie. customer.Orders.Sum(...) wygląda jak operacja na liście w pamięci, i od momentu, w którym proxy załaduje kolekcję, rzeczywiście nią jest. Zapytanie ukrywa się w odczycie właściwości.

Liczba zapytań rośnie razem z danymi, a baza deweloperska zwykle ma ich mało. Uruchomiłem ten sam raport na trzech rozmiarach bazy, za każdym razem z jednym przebiegiem na rozgrzewkę i dwudziestoma pomiarami. W tabeli jest mediana:

KlientówZapytańCzas bez poprawkiCzas z Include
562,0 ms0,9 ms
505116–19 msokoło 8 ms
500501170–200 ms20–32 ms

Środowisko: SQLite w pliku na tym samym komputerze, EF Core 10, .NET SDK 10.0.401. Zakres oznacza medianę z kilku serii, które różniły się między sobą bardziej, niż bym chciał; wiersz dla pięciu klientów to jedna seria. Rozrzut nie zmienia jednak obrazu, bo różnica między kolumnami jest wielokrotnie większa niż różnica między seriami.

Przy pięciu klientach różnica wynosi ułamek milisekundy i nikt jej nie poczuje, klikając po aplikacji. Pięciuset klientów zmienia sytuację: raport z poprawką jest wtedy od sześciu do ośmiu razy szybszy.

Kod się nie zmienił, zmieniła się tylko liczba wierszy w tabeli Customers, i dlatego EF Core N+1 tak często wychodzi dopiero po wdrożeniu, kiedy pierwszy większy klient zaimportuje swoje dane.

A to jest SQLite, który działa w tym samym procesie co aplikacja. Każde zapytanie omija sieć. Na serwerze bazy danych każde z tych dodatkowych zapytań to osobna podróż przez sieć, więc tabela powyżej pokazuje najłagodniejszy możliwy przypadek. Nie mierzyłem tego na zdalnym PostgreSQL ani SQL Server, więc nie podam liczb, ale kierunek jest jednoznaczny: im dalej baza, tym drożej kosztuje każde z N zapytań.

Include: jedno zapytanie zamiast 51

Najkrótsza poprawka to powiedzieć EF Core z góry, że zamówienia będą potrzebne:

foreach (var customer in db.Customers.Include(c => c.Orders).ToList())
{
    var total = customer.Orders.Sum(o => o.Total);
    lines.Add($"{customer.Name}: {customer.Orders.Count} zamówienia, razem {total} zł");
}

Zmieniła się jedna linia. Log pokazuje teraz jedno polecenie:

SELECT "c"."Id", "c"."Name", "o"."Id", "o"."CustomerId", "o"."Total"
FROM "Customers" AS "c"
LEFT JOIN "Orders" AS "o" ON "c"."Id" = "o"."CustomerId"
ORDER BY "c"."Id"

Licznik: Zapytań do bazy: 1. Wynik raportu jest identyczny co do znaku.

LEFT JOIN zwraca 150 wierszy, po jednym na zamówienie, a dane klienta powtarzają się w każdym z nich. EF Core składa je z powrotem w 50 obiektów Customer z wypełnionymi kolekcjami. Proxy dalej istnieją, ale kolekcja jest już załadowana, więc odczyt Orders niczego do bazy nie wysyła.

Jedna kolekcja oznacza tanie powtarzanie danych klienta. Kiedy dołączasz kilka kolekcji naraz, liczba wierszy JOIN-a mnoży się, i wtedy pomaga AsSplitQuery(). Dopisane po Include daje u mnie dokładnie dwa zapytania: jedno po klientów, drugie po wszystkie ich zamówienia przez INNER JOIN. Dwa zapytania to nie jest N+1. Liczba dalej nie zależy od tego, ilu klientów jest w bazie, a to jest właściwa miara. Dla 500 klientów wersja z AsSplitQuery() miała medianę od 16 do 19 ms, w tym samym przedziale co zwykłe Include.

Projekcja: tylko dane, których raport potrzebuje

Raport nie potrzebuje obiektów Order. Potrzebuje dwóch liczb na klienta. Można o nie poprosić wprost:

var rows = db.Customers
    .Select(c => new { c.Name, Count = c.Orders.Count, Total = c.Orders.Sum(o => o.Total) })
    .ToList();

EF Core tłumaczy tę projekcję na jedno zapytanie, w którym liczenie i sumowanie robi baza:

SELECT "c"."Name", (
    SELECT COUNT(*)
    FROM "Orders" AS "o"
    WHERE "c"."Id" = "o"."CustomerId") AS "Count", (
    SELECT COALESCE(ef_sum("o0"."Total"), '0.0')
    FROM "Orders" AS "o0"
    WHERE "c"."Id" = "o0"."CustomerId") AS "Total"
FROM "Customers" AS "c"

ef_sum to funkcja, którą dostawca SQLite rejestruje na potrzeby sumowania typu decimal, bo sam SQLite nie ma typu dziesiętnego.

Wynik: jedno zapytanie, 50 wierszy zamiast 150, żadnych encji do śledzenia. Mediana wyszła poniżej 1 ms we wszystkich seriach dla 50 klientów i 2,3 ms dla 500. To ta sama liczba zapytań co przy Include, a czas mniejszy mniej więcej dziesięciokrotnie. W wersji z Include większość czasu nie idzie na bazę, tylko na utworzenie 200 śledzonych encji razem z ich proxy. Projekcja nie tworzy żadnej.

Jest też wariant pośredni: Include z dopisanym AsNoTracking(). Encje dalej powstają, ale kontekst ich nie śledzi. W moich pomiarach mediana spadła mniej więcej o połowę względem zwykłego Include: z około 8 do nieco ponad 3 ms dla 50 klientów i z 32 do 16 ms dla 500 w tej samej serii. Projekcja i tak została najszybsza, bo nie tworzy w ogóle obiektów Order.

Wybór między nimi zależy od tego, co dalej dzieje się z danymi. Jeśli kod modyfikuje zamówienia i zapisuje je przez SaveChanges, potrzebujesz śledzonych encji i Include jest właściwe. Jeśli tylko je czytasz, żeby coś wyświetlić, projekcja wygrywa.

Ten sam problem w endpoincie API

Pętla z foreach jest łatwa do zauważenia w przeglądzie kodu. Trudniej, kiedy pętli w ogóle nie piszesz. Endpoint, który zwraca listę encji, oddaje ją do serializatora JSON, a serializator przechodzi po każdej właściwości każdego obiektu, także po Orders.

Sprawdziłem to na tym samym kontekście z proxy: pobrałem klientów bez Include i przekazałem listę do JsonSerializer.Serialize. Z domyślnymi opcjami serializacja kończy się wyjątkiem:

System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Orders.Customer.Orders.Customer.Orders...

Order ma właściwość Customer, a Customer ma Orders, więc serializator krąży między nimi, a proxy po drodze doładowuje kolejne kolekcje. Do momentu wyjątku licznik pokazał dwa zapytania.

Typowa reakcja na ten wyjątek to ustawienie ReferenceHandler.IgnoreCycles. Wyjątek znika, odpowiedź zaczyna się od [{"Id":1,"Name":"Klient 1","Orders":[{"Id":1,"Total":11.0,..., a licznik pokazuje 51 zapytań. Ten sam N+1 co w raporcie, tylko pętla jest teraz w kodzie System.Text.Json, którego nie czytasz.

Dlatego endpoint nie powinien zwracać encji, tylko obiekty utworzone projekcją Select, opisaną wyżej. Taki obiekt nie ma właściwości nawigacyjnych, więc serializator nie ma czego doładowywać.

Wyłączenie lazy loading to nie poprawka

Kuszące jest usunięcie UseLazyLoadingProxies() i uznanie sprawy za zamkniętą. Sprawdziłem, co wtedy robi oryginalna pętla bez Include. Jedno zapytanie, zero błędów i taki wynik:

Klient 1: 0 zamówienia, razem 0 zł

Bez leniwego ładowania niezaładowana kolekcja jest pusta, a raport liczy zera. Zamiast wolnego kodu dostajesz szybki kod, który podaje złe liczby. Gorsza wymiana. Problem N+1 nie znika razem z proxy. Zmienia się w inny błąd. Rozwiązaniem jest jawne powiedzenie, jakich danych potrzebujesz: przez Include albo przez projekcję.

Jak złapać N+1, zanim zrobi to produkcja

Licznik zapytań, którego użyłem do tego wpisu, ma kilka linii i nadaje się też do testu. Klasa dziedziczy po DbCommandInterceptor, nadpisuje ReaderExecuted i zwiększa pole Count. Test rejestruje ją przez AddInterceptors, uruchamia raport i sprawdza Assert.Equal(1, counter.Count).

Taki test pilnuje rzeczy, której pierwszy test jednostkowy porównujący wynik nigdy by nie zauważył. Raport z 51 zapytaniami daje poprawny wynik, więc test wyniku przechodzi. Test liczby zapytań pada.

Drugi sposób nie wymaga pisania kodu. Włącz LogTo w trybie deweloperskim i przejrzyj log, kiedy strona albo endpoint ładuje się podejrzanie długo. Seria identycznych zapytań różniących się tylko parametrem @p to znak rozpoznawczy N+1. Domyślnie EF Core nie wypisuje wartości parametrów, więc każda linia wygląda tak samo:

Executed DbCommand (0ms) [Parameters=[@p='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']

Po dopisaniu EnableSensitiveDataLogging() do opcji kontekstu w tym samym logu pojawiają się konkretne wartości: @p='1', @p='2' i tak dalej, aż do @p='50' w ostatnim zapytaniu. N z nazwy problemu da się wtedy policzyć wprost z logu. Tej opcji używaj tylko lokalnie, bo do logów trafiają wtedy dane z zapytań, a w prawdziwej aplikacji są wśród nich dane klientów.

Debugger tego nie pokaże, bo każdy krok pętli wygląda poprawnie. Ten problem widać dopiero w logu, co dobrze uzupełnia wpis o tym, kiedy debugger wygrywa z Console.WriteLine.

Każdy odczyt właściwości nawigacyjnej w pętli to potencjalne zapytanie. W pętli czytaj tylko to, co załadowałeś wcześniej.

EF Core, ładowanie powiązanych danych i projekcje są częścią kursu ASP.NET Core, a rozdziały o nich znajdziesz w programie kursu.

Tematy: EF Core.NETBłędy i komunikaty

Pobieranie ruszyło

DevJourney_2.1.1_x64-setup.exe · 2.1.1

Jeśli system ostrzeże i jak sprawdzić plik

Jeśli Windows ostrzeże

Komunikat „System Windows ochronił Twój komputer” nie oznacza wykrycia wirusa. SmartScreen ufa certyfikatowi, którym podpisany jest instalator, a ten jeszcze zbiera reputację poprzez licznik pobrań.

W oknie ostrzeżenia: Więcej informacji → Uruchom mimo to.

Sprawdź to sam

Zobacz wynik skanowania w VirusTotal ↗

Albo w PowerShellu, w katalogu z pobranym plikiem:

Get-FileHash .\DevJourney_2.1.1_x64-setup.exe

Powinno wypisać:

2e441de05e02ae32caad96bdca86f9ca0ff59e01cbc8b289a1f6f783e4f7bc46