1. Koszt czytania i wybór LAZY/EAGER
Gdy rysujemy związki między encjami, bardzo łatwo wpaść w pułapkę: myśleć, że praca jest skończona, gdy kod się kompiluje i testowy rekord pomyślnie się zapisuje. Ale w prawdziwej aplikacji encje są częściej czytane niż tworzone, i właśnie czytanie zazwyczaj staje się najdroższym miejscem. Przy czym drogo — to nie filozoficznie, a dosłownie: w czasie zapytań, w ilości danych, w obciążeniu bazy, w pamięci JVM i w „przypadkowych niespodzianksach" w logach.
Żeby nie zamieniać rozmowy w abstrakcję, wprowadźmy prosty model „kosztu czytania". Gdy kod robi productRepository.findById(id), koszt składa się z kilku części. Po pierwsze, to liczba zapytań SQL, bo każde zapytanie to nie tylko praca bazy, ale i sieciowy „ping-pong". Po drugie, rozmiar wyniku: ile wierszy i kolumn faktycznie wyciągnięto. Po trzecie, pośrednie efekty — na przykład jeśli przez joiny ta sama encja „rozmnożyła się" po wierszach wyniku (i Hibernate potem składa wszystko z powrotem).
FetchType — to w istocie twój kontrakt z ORM: „gdy czytam tę encję, zazwyczaj od razu potrzebuję powiązane dane" lub „nie ciągaj nic zbędnego — załaduj, jeśli naprawdę sięgnę po związek". Dobra wiadomość: kontrolujesz to całkowicie na poziomie mappingu. Zła wiadomość: jeśli nie będziesz kontrolować — JPA i tak dokona wyboru za ciebie, i nie ma gwarancji, że będzie „jak chciałeś".
2. Co oznacza LAZY i EAGER
Słowa LAZY i EAGER wyglądają jak dwa przełączniki „leniwy / energiczny". Wydaje się, że chodzi po prostu o „szybkość". W rzeczywistości chodzi o moment płatności. EAGER — to „zapłać od razu", LAZY — to „zapłać, gdy do kasy dojdziesz". I co najważniejsze: nie tyle o to, ile będzie zapytań, ile o to, kiedy i dlaczego się pojawią.
FetchType.EAGER oznacza: gdy Hibernate ładuje encję, musi też załadować ten związek. Ale sam decyduje, jak: przez JOIN w tym samym SQL lub osobnym zapytaniem zaraz potem. Technicznie może ci to wyglądać jak „magia", bo nie pisałeś drugiego zapytania ręcznie, a ono nagle pojawiło się w logach.
FetchType.LAZY oznacza: gdy Hibernate ładuje encję, nie musi od razu ładować powiązanego obiektu. Może zwrócić ci „zastępnik" (proxy/wrapper), który wygląda jak normalna referencja, ale faktyczne ładowanie zrobi dopiero przy dostępie do danych związku. Jeśli nigdy nie sięgniesz do product.getCategory() — nigdy nie zapłacisz za odczyt kategorii. A jeśli sięgniesz — zapłacisz wtedy, gdy sięgnąłeś.
Można zapamiętać bardzo praktyczną formułę:
| Wybór | Jak zachowuje się aplikacja | Co dzieje się z kosztem |
|---|---|---|
| EAGER | „Przyniosę związek od razu, nawet jeśli nie pytasz" | koszt jest ponoszony z góry, czasem niepotrzebnie |
| LAZY | „Przyniosę związek, gdy naprawdę do niego sięgniesz" | koszt jest odkładany i czasem w ogóle nie jest ponoszony |
Żeby ta formuła nie pozostała teorią, zobaczmy minimalny fragment Product z jawnym leniwym ładowaniem:
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
// Jawnie ustalamy strategię: związek nie jest ładowany, dopóki nie sięgniemy do niego
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
Na tym poziomie ważne jest zrozumienie nie szczegółów implementacji proxy, a sensu: Product można odczytać „tanim odczytem", a kategorię — „na żądanie". To właśnie jest model ekonomiczny.
3. Fetch domyślny w JPA
Teraz będzie moment, gdy wiele osób mówi: „A ja w ogóle nic nie wybierałem, u mnie wszystko i tak działa". Tak, działa — bo JPA i Hibernate podjęły decyzję za ciebie. Ale ta decyzja nie zawsze odpowiada scenariuszom aplikacyjnym, szczególnie w projekcie edukacyjnym, gdzie chcemy przewidywalności i kontroli.
JPA ma domyślne wartości dla typu fetch, i warto je znać jak tabliczkę mnożenia (nie dlatego że to ładne, a dlatego że to oszczędza godziny debugowania):
| Adnotacja związku | Fetch domyślny |
|---|---|
| @ManyToOne | EAGER |
| @OneToOne | EAGER |
| @OneToMany | LAZY |
| @ManyToMany | LAZY |
Zwróć uwagę na najbardziej „podstępny" punkt: @ManyToOne domyślnie jest eager. A @ManyToOne — to dosłownie „najczęstszy związek w aplikacjach biznesowych". W naszym mini-shop to Product -> Category, OrderItem -> Product, OrderItem -> CustomerOrder. Jeśli zostawisz domyślne, każde czytanie OrderItem potencjalnie będzie ciągnąć za sobą jeszcze i Product, nawet jeśli chciałeś tylko zobaczyć quantity i unitPrice.
Dlatego reguła dnia jest prosta i pragmatyczna: dla związków to-one (szczególnie @ManyToOne) nie zostawiamy typu fetch „jak wyjdzie". Ustawiamy go jawnie, żeby model był przewidywalny przy czytaniu.
4. Jak EAGER podraża czytanie
Gdyby EAGER zawsze oznaczał „jeden JOIN i gotowe", życie byłoby prostsze. Ale w rzeczywistości EAGER oznacza: „ORM jest zobowiązany przynieść związek przed zwróceniem wyniku", a jak dokładnie — zależy od dostawcy, ustawień i konkretnego zapytania. Oznacza to, że EAGER może okazać się drogi nawet tam, gdzie się tego nie spodziewasz.
Scenariusz 1: metoda prosi tylko o nazwę, a płacisz za pół sklepu
Wyobraź sobie całkowicie niewinną metodę w CatalogService: potrzebujemy tylko name produktu.
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
public String loadProductName(ProductRepository productRepository, Long id) {
// Jeśli Product ma związki EAGER, mogą zostać załadowane już na etapie findById(...)
Product product = productRepository.findById(id).orElseThrow();
// Czytamy tylko proste pole encji, ale "koszt czytania" mógł wzrosnąć przez domyślne eager
return product.getName();
}
}
Jeśli związki w Product są oznaczone jako EAGER, to przy findById(id) Hibernate postara się przynieść i Category, i ProductDetails, i cokolwiek tam jeszcze wisi. Nawet jeśli ta metoda w ogóle nie dotyka kategorii, szczegółów i tagów. Koszt czytania wzrósł, a sens biznesowy nie zmienił się ani o milimetr.
Scenariusz 2: EAGER nie oznacza jednego zapytania
Powiedzmy, że Product.category jest eager. Hibernate może zrobić JOIN:
-- przybliżony wzorzec, nie dosłowny wynik
-- Ważne: EAGER może być realizowany jednym JOIN, ale to nie jest gwarancja "zawsze tak"
select p.*, c.*
from product p
join category c on c.id = p.category_id
where p.id = ?;
A może zrobić dwa zapytania (najpierw produkt, potem kategoria):
-- Ważne: EAGER może zamienić się w kilka zapytań (zależy od dostawcy/ustawień/sytuacji)
select * from product where id = ?;
select * from category where id = ?;
I tu zaczyna się subtelna rzecz: mogłeś myśleć „no okej, jeden join, nie straszne". A faktycznie dostałeś dwa zapytania. Albo odwrotnie: spodziewałeś się dwóch zapytań, a dostałeś join, który wyciągnął więcej kolumn niż potrzeba.
Wniosek jest prosty: EAGER — to obietnica wyniku, ale nie forma wykonania. Dlatego rozumować trzeba jak inżynier, a nie jak szaman: „czy naprawdę potrzebuję tego zawsze przy czytaniu encji?" Jeśli nie — EAGER bardzo łatwo staje się „podatkiem na każde zapytanie".
5. Jak LAZY odkłada koszt
LAZY często jest postrzegany jako „opcja do optymalizacji". Ale w rzeczywistości to po prostu uczciwa dyscyplina: nie ciągnąć danych, dopóki nie są potrzebne. Przypomina sytuację, gdy nie niesiesz ze sklepu wszystkiego naraz, a wzywasz dostawę tylko dla ciężkich rzeczy. Jeśli ciężkich rzeczy nie potrzeba — dostawy nie będzie.
Zobaczmy prostą ilustrację na Product -> Category.
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
// LAZY: do wywołania product.getCategory() Hibernate nie musi ładować Category
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false) // kolumna FK w tabeli product
private Category category;
Teraz czytanie produktu może być tańsze. Jeśli kod robi tak:
Product product = productRepository.findById(id).orElseThrow();
// Tu używamy tylko prostego pola encji, bez sięgania do związku category
System.out.println(product.getName()); // drukujemy tylko nazwę
baza najprawdopodobniej otrzyma tylko zapytanie do product. Kategoria nie jest potrzebna — więc nie jest ładowana.
Ale gdy tylko robisz:
Product product = productRepository.findById(id).orElseThrow();
// WAŻNE: wywołanie getCategory() może uruchomić dodatkowe zapytanie SQL (ładowanie LAZY)
String categoryName = product.getCategory().getName();
System.out.println(categoryName); // np.: "Laptops"
Hibernate rozumie: „aha, gospodarz naprawdę sięgnął do kategorii" — i robi dodatkowy SQL, żeby załadować Category. To normalne. To nie „zbędne zapytanie", to zapytanie wynikające z faktycznej potrzeby.
Bardzo ważny inżynierski detal (bez wchodzenia w duże przyszłe tematy): żeby LAZY mógł załadować dane, Hibernate musi mieć realną możliwość pójścia do bazy w momencie sięgnięcia do związku. Jeśli wyciągnąłeś encję, „oderwałeś" ją od pracy z bazą, a potem nagle chciałeś dobadać dane związku — ORM fizycznie nie jest w stanie tego zrobić. Dlatego LAZY — to zawsze nie tylko ekonomia, ale i dyscyplina „gdzie i kiedy czytamy dane".
Żeby wizualnie utrwalić, można wyobrazić sobie to jako sekwencję:
sequenceDiagram
participant Code as Kod
participant ORM as Hibernate
participant DB as PostgreSQL
Code->>ORM: "findById(Product, 10)"
ORM->>DB: "SELECT product ..."
Note over ORM: "category na razie nie jest załadowana (LAZY)"
Code->>ORM: "product.getCategory().getName()"
ORM->>DB: "SELECT category ..."
ORM-->>Code: "Laptops"
Sens diagramu nie leży w SQL, a w momencie płatności: przed wywołaniem getCategory() kategoria była „możliwością", a nie „kosztem".
6. Bazowe ustawienia fetch dla mini-shop
Teraz nie próbujemy „optymalizować wszystkiego na świecie". Nasz cel jest skromniejszy i bardziej użyteczny: sprawić, że model jest przewidywalny. To znaczy taki, gdzie czytanie Product nie zamienia się w loterię „a co tam jeszcze przyciągnie?" W projekcie edukacyjnym jest to szczególnie ważne: inaczej będziesz debugować nie ideę JPA, a konsekwencje przypadkowych domyślnych wartości.
Dla Product w naszym mini-shop rozsądny start wygląda tak: Category czytamy leniwie (bo nie zawsze potrzebna), ProductDetails czytamy leniwie (szczegóły nie zawsze potrzebne), tags (jeśli dodałeś Tag) — też leniwie, bo tagi zazwyczaj nie są potrzebne w każdym scenariuszu. Dla StockItem ważne jest, żeby nie pomylić fetch i samej formy związku: dla prostego modelu mini-shop stany wygodnie jest trzymać jednokierunkowo ze strony StockItem na Product, i wtedy w Product w ogóle nie ma pola stockItem. Jeśli później jednak dodasz odwrotną referencję do Product, rozsądny start dla niej — LAZY, żeby katalog nie płacił za stany przy każdym odczycie.
Przykład „oszczędnego" fragmentu mappingu dla Product:
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
// Kategoria jest często potrzebna tylko w konkretnych scenariuszach, więc zaczynamy od LAZY
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
// Szczegóły produktu zazwyczaj są "cięższe" i nie zawsze potrzebne, więc też LAZY
@OneToOne(fetch = FetchType.LAZY, mappedBy = "product")
private ProductDetails details;
Dla @ManyToOne taki LAZY — to zazwyczaj niezawodny start. Dla @OneToOne warto być nieco uważniejszym: szczególnie po stronie mappedBy realne zachowanie mocniej zależy od Hibernate i konfiguracji, więc tu lepiej patrzeć nie tylko na adnotację, ale i na logi SQL. Ogólna zasada jest wciąż ta sama: zaczynamy od minimalnie niezbędnego czytania, a nie od nawyczki ciągania związku z góry.
I osobno — ważna uwaga o OrderItem. W poprzednich dniach zrobiliśmy związek OrderItem -> Product przez @ManyToOne. Jeśli nie ustawisz fetch jawnie, domyślnie będzie eager. A znaczy, czytanie jednej pozycji zamówienia może zacząć ciągnąć za sobą Product. W prawdziwym życiu to często zaskakująco drogie.
Mini-fragment właściwego startu dla OrderItem:
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
// Pozycja zamówienia może być czytana osobno od samego zamówienia, więc LAZY — bezpieczny start
@ManyToOne(fetch = FetchType.LAZY)
private CustomerOrder order;
// Produkt dla pozycji zamówienia też nie zawsze jest potrzebny "od razu" (np. czytamy tylko cenę/ilość)
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
Zauważ: tu nie ma „magii optymalizacji". To po prostu uczciwa reguła: pozycje zamówienia istnieją jako wiersze z własną ceną i ilością, i nie zawsze potrzebujemy pełnego produktu i pełnego zamówienia przy każdym czytaniu OrderItem. Wybieramy LAZY jako dyscyplinę minimalnie niezbędnego czytania.
7. Typowe błędy przy wyborze FetchType.LAZY / FetchType.EAGER
Gdy zaczynasz zarządzać typami fetch, bardzo łatwo „przekręcić pokrętło" w dowolną stronę: albo postawić EAGER wszędzie i dostać ciężkie czytanie, albo postawić LAZY wszędzie i dziwić się, że dane nagle nie ładują się w nieoczekiwanym momencie. To normalne błędy wzrostu, ale lepiej natknąć się na nie w projekcie edukacyjnym niż w piątek wieczorem na produkcji (piątek wieczorem i tak jest trudny, nie trzeba tam dodawać Hibernate).
Błąd nr 1: zostawić @ManyToOne domyślny i nie zauważyć, że jest eager.
To jeden z najczęstszych problemów u nowicjuszy: model „niby działa", ale czytanie staje się ciężkie i nieprzewidywalne. Leczy się prosto: prawie zawsze ustawiaj fetch = FetchType.LAZY na @ManyToOne, jeśli nie masz bardzo jasnego powodu, żeby trzymać eager.
Błąd nr 2: stawiać EAGER, bo „tak wygodniej, wszystko od razu dostępne".
Wygląda to jak troska o przyszłe ja, ale często zamienia się w podatek na każde zapytanie. Wygoda tu jest złudna: oszczędzasz jedną linię kodu dziś i płacisz wydajnością i złożonością czytania jutro. W modelu edukacyjnym lepiej zaczynać od minimalnie niezbędnego czytania i nie wstydzić się „dobierać" dane tylko tam, gdzie są naprawdę potrzebne.
Błąd nr 3: uważanie, że EAGER gwarantuje jedno zapytanie z JOIN.
EAGER gwarantuje „załadowane", ale nie gwarantuje „jednym SQL". Hibernate może zrobić join, a może zrobić dodatkowe zapytania. Dlatego właściwy sposób myślenia: EAGER — to nie „jednym zapytaniem", a „zapłacić przed zwróceniem encji".
Błąd nr 4: włączanie EAGER na kolekcjach (@OneToMany, @ManyToMany) „na wszelki wypadek".
Kolekcje to potencjalnie dużo danych. Dziś produkt ma dwa tagi, jutro dwadzieścia. Dziś zamówienie ma dwie pozycje, jutro dwieście. Jeśli ciągniesz kolekcje eager, bardzo szybko dostajesz czytanie „wszystkiego i od razu", które jest niespodziewanie ciężkie. Nawet w projekcie edukacyjnym prowadzi to do rozrastania zapytań i dziwnych logów.
Błąd nr 5: nieświadome „wyzwalanie" leniwego ładowania przez toString() lub logowanie.
Już mówiliśmy, że toString() w encji powinno być ostrożne. Jeśli wewnątrz toString() sięgniesz do getCategory().getName(), to zwykłe logowanie log.info("Product = {}", product) może nagle pójść do bazy. I będziesz długo tłumaczyć sobie, dlaczego logowanie „robi zapytania".
GO TO FULL VERSION