CodeGym /Kursy /Spring Data JPA /FetchType.LAZY i  FetchType.EAGER

FetchType.LAZY i  FetchType.EAGER

Spring Data JPA
Poziom 9, Lekcja 3
Dostępny

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".

1
Zadanie
Spring Data JPA, poziom 9, lekcja 3
Niedostępne
Jawny LAZY dla relacji książka → autor
Jawny LAZY dla relacji książka → autor
1
Zadanie
Spring Data JPA, poziom 9, lekcja 3
Niedostępne
Koszt domyslnego fetch w @ManyToOne
Koszt domyslnego fetch w @ManyToOne
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION