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:

  1. Najpierw pobierz tylko ID z paginacją (w bazie danych!)
  2. 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 FETCH do jednorazowego pobrania wszystkich potrzebnych danych
  • Przy paginacji zastosować podejście dwuetapowe: najpierw ID, potem JOIN FETCH
  • Pamiętać, że zmiana z LAZY na EAGER nie rozwiązuje problemu N+1