Problem N+1 w Hibernate - jak go rozpoznać i naprawić
Wstęp
Problem N+1 to jedna z pułapek czekających na programistów podczas beztroskiego korzystania z ORM-ów. Bez świadomości jego istnienia łatwo napisać kod, który wykona dziesiątki, a nawet setki niepotrzebnych zapytań do bazy danych, dramatycznie pogarszając wydajność aplikacji. W tym wpisie przybliżę problem N+1, pokażę, kiedy się pojawia i jak go unikać.
Kiedy pojawia się problem N+1
Klasyczny przykład problemu N+1 dotyczy relacji jeden-do-wielu, na przykład autor → książki. Wyobraźmy sobie, że mamy tabele Author oraz Book i chcemy pobrać wszystkich autorów wraz z ich książkami.
Przy naiwnym podejściu moglibyśmy wykonać następujące zapytania:
-- Pobierz wszystkich autorów (1 zapytanie)
SELECT * FROM Author;
-- Wynik: Jan, Anna, Piotr
-- Dla każdego autora pobierz jego książki (N zapytań)
SELECT * FROM Book WHERE author_id = 1; -- książki Jana
SELECT * FROM Book WHERE author_id = 2; -- książki Anny
SELECT * FROM Book WHERE author_id = 3; -- książki Piotra
Wykonaliśmy 4 zapytania: jedno na pobranie autorów plus osobne zapytanie dla każdego z trzech autorów. To właśnie jest problem N+1 – jedno zapytanie plus N dodatkowych zapytań, gdzie N to liczba pobranych rekordów.
Oczywiście w czystym SQL uniknęlibyśmy tego używając JOIN:
SELECT a.*, b.*
FROM Author a
LEFT JOIN Book b ON a.id = b.author_id;
Teraz jedno zapytanie zwraca wszystkie potrzebne dane.
Problem N+1 w Hibernate
Link do repozytorium z kodem źródłowym wykorzystanym w tym wpisie:
HibernatePlayground - blog/2025-12-20-n-plus-1
Gdy pracujemy z ORM-em jak Hibernate, problem N+1 pojawia się niepostrzeżenie. Rozważmy prosty kod:
var authors = session.createQuery("from Author", Author.class).list();
for (Author author : authors) {
System.out.println(author.getName() + " -> " + author.getBooks().size());
}
Dzieje się tak przez mechanizm lazy loading – Hibernate domyślnie nie ładuje relacji, dopóki nie są potrzebne. To oszczędza pamięć, ale jeśli następnie iterujemy po kolekcji i odwołujemy się do leniwych relacji, każde odwołanie powoduje dodatkowe zapytanie do bazy.
EAGER vs LAZY – czy to rozwiązuje problem?
Zmiana strategii ładowania na EAGER (np. @OneToMany(fetch = FetchType.EAGER)) nie rozwiązuje problemu N+1. Hibernate nadal wykona osobne zapytania dla każdego autora – jedyna różnica polega na tym, że zrobi to od razu przy pobieraniu autorów, a nie przy pierwszym odwołaniu do kolekcji. Problem N+1 pozostaje, tylko zapytania wykonują się wcześniej.
Widać to w logach Hibernate:
LAZY:
select a1_0.id,a1_0.name from Author a1_0
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Jan -> Book 1
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Anna -> Book 2
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Bob -> Book 3
EAGER:
select a1_0.id,a1_0.name from Author a1_0
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Jan -> Book 1
Anna -> Book 2
Bob -> Book 3
Jedyną różnicą jest kolejność, ale dalej – gdybyśmy mieli 100 autorów, wykonalibyśmy 101 zapytań zamiast jednego, niezależnie czy używamy LAZY czy EAGER.
Jak naprawić problem N+1
JOIN FETCH – podstawowe rozwiązanie
Najprostsze i najczęściej stosowane rozwiązanie to użycie JOIN FETCH w zapytaniu HQL:
var authors = session.createQuery(
"from Author a JOIN FETCH a.books",
Author.class
).list();
for (Author author : authors) {
System.out.println(author.getName() + " -> " + author.getBooks().size());
}
Teraz Hibernate wykona jedno zapytanie z JOIN-em, ładując autorów i ich książki jednocześnie:
select a1_0.id,b1_0.author_id,b1_0.id,b1_0.title,a1_0.name
from Author a1_0
join Book b1_0 on a1_0.id=b1_0.author_id
Jan -> 1
Anna -> 1
Bob -> 1
Potencjalna pułapka – paginacja z JOIN FETCH
Najczęściej, gdy pobieramy dane z bazy, chcemy zastosować paginację, czyli pobrać tylko kilka rekordów. Naiwne podejście może wyglądać tak:
var authors = session.createQuery(
"from Author a JOIN FETCH a.books ORDER BY a.id",
Author.class
)
.setFirstResult(0)
.setMaxResults(3)
.list();
Output:
=== DEMO 3: JOIN FETCH z paginacją (źle) ===
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
select a1_0.id,b1_0.author_id,b1_0.id,b1_0.title,a1_0.name
from Author a1_0
join Book b1_0 on a1_0.id=b1_0.author_id
order by a1_0.id
Pobranych autorów: 3
Jan -> 1
Anna -> 1
Bob -> 1
Na pozór wszystko działa dobrze, ale zwróć uwagę na warning HHH90003004 oraz brak LIMIT w zapytaniu SQL.
Co się faktycznie dzieje:
- Hibernate pobiera WSZYSTKIE wiersze z bazy (wszystkich autorów i wszystkie ich książki)
- Ładuje je do pamięci JVM
- Dopiero w pamięci aplikacji wybiera pierwsze 3 autorów
W przykładzie z małą ilością danych (3 autorów) to nie stanowi problemu, ale przy większej ilości danych byłaby to katastrofa.
Poprawna paginacja – dwa zapytania
Aby naprawić problem, najlepiej rozbić całość na dwa zapytania:
- Najpierw pobierz tylko ID z paginacją (w bazie danych!)
- Następnie użyj tych ID do
JOIN FETCH
// 1. Pobierz tylko ID z paginacją
List<Long> authorIds = session.createQuery(
"select a.id from Author a order by a.id",
Long.class
)
.setFirstResult(0)
.setMaxResults(3)
.list();
// 2. Dociągnij autorów z książkami
var authors = session.createQuery(
"select distinct a from Author a join fetch a.books where a.id in :ids",
Author.class
)
.setParameter("ids", authorIds)
.list();
System.out.println("Autorów: " + authors.size());
for (Author a : authors) {
System.out.println(a.getName() + " -> " + a.getBooks().size());
}
Output:
=== DEMO 4: pagination FIXED ===
select a1_0.id
from Author a1_0
order by a1_0.id
offset ? rows fetch first ? rows only
select distinct a1_0.id,b1_0.author_id,b1_0.id,b1_0.title,a1_0.name
from Author a1_0
join Book b1_0 on a1_0.id=b1_0.author_id
where a1_0.id in (?,?,?)
Autorów: 3
Author 1 -> 10
Author 2 -> 10
Author 3 -> 10
Teraz:
LIMIT(w H2:fetch first ? rows only) działa w bazie danych- Pobieramy tylko potrzebne dane (3 autorów + ich książki)
- Dwa wydajne zapytania zamiast pobierania wszystkiego do pamięci
Podsumowanie
Aby uniknąć problemu N+1 w Hibernate, należy:
- Rozpoznać sytuacje, w których iterujemy po kolekcji i odwołujemy się do relacji
- Użyć
JOIN FETCHdo jednorazowego pobrania wszystkich potrzebnych danych - Przy paginacji zastosować podejście dwuetapowe: najpierw ID, potem
JOIN FETCH - Pamiętać, że zmiana z
LAZYnaEAGERnie rozwiązuje problemu N+1