CodeGym /Kursy /Spring Data JPA /join fetch: jedno zapytanie zamiast N + 1

join fetch: jedno zapytanie zamiast N + 1

Spring Data JPA
Poziom 22, Lekcja 1
Dostępny

1. join fetch po N+1

Gdy właśnie zobaczyłeś N+1 w logach SQL, pojawia się duża pokusa, żeby zacząć „dokręcać śruby" w mapowaniu: gdzieś postawić EAGER, gdzieś zrobić asocjacje jednokierunkowe, a gdzieś w ogóle usunąć kolekcję i trzymać tylko productId. To emocjonalnie zrozumiałe, ale zwykle prowadzi do nowej serii problemów: zbędne dane, ciężkie odczyty, dziwne grafy obiektów i nagłe spowolnienie w innych miejscach. join fetch jest dobry tym, że to nie zmiana modelu, lecz punktowa konfiguracja odczytu pod konkretny use-case.

Wyobraź sobie typowy scenariusz z naszego projektu mini-shop. Potrzebna jest karta zamówienia: numer, status, suma i lista pozycji, a wewnątrz pozycji — towar (choćby sku i name). W modelu encji wygląda to jak CustomerOrder -> items -> product. Jeśli czytamy zamówienie zwykłym findByOrderNumber(...), a potem w serwisie lub gdzieś „na zewnątrz" zaczynamy chodzić po order.getItems() i item.getProduct(), Hibernate uczciwie wykonuje pracę: „Okej, chcesz asocjację — masz zapytanie". I tak wiele razy.

Idea join fetch jest prosta: przeczytać bazową encję i potrzebne asocjacje od razu, w jednym SQL, przez JOIN. Wtedy nie „leczymy" lazy loading, lecz zarządzamy nim: zostawiamy asocjacje jako LAZY, ale dla konkretnego odczytu mówimy ORM: „W tym use-case załaduj asocjację od razu".

2. join vs join fetch

W JPQL (i w HQL) słowo join brzmi znajomo, bo wygląda jak SQL. Ale jest ważny niuans: w JPQL join może być użyty po prostu dla logiki zapytania (warunki, filtrowanie), a join fetch — to już polecenie dla ORM: „nie tylko łącz tabele, ale inicjalizuj asocjację w wyniku odczytu". Wygląda to jak mały dodatek fetch, ale znaczenie jest bardzo „dorosłe": zmieniasz plan ładowania.

Utrwalmy różnicę w kompaktowej tabelce. Nie czyni z ciebie DBA, ale pomaga mózgowi przestać mylić „join jako warunek" z „join jako ładowanie".

Co piszemy w JPQL Po co Co dostaje Hibernate Co dostajesz w obiektach Java
join p.category c wyrazić warunek/filtr/sortowanie join w SQL może być, ale asocjacja nie musi stać się załadowana p.getCategory() może pozostać lazy-proxy i doczytać się później
join fetch p.category załadować asocjację w tym samym odczycie join w SQL + materializacja grafu p.getCategory() jest już gotowy wewnątrz wyniku

Ważne, żeby nie pomylić: join fetch — to nie „optymalizacja dla optymalizacji", lecz narzędzie kontroli. Sprawdza się dokładnie wtedy, gdy możesz uczciwie powiedzieć: „W tym read-use-case ta asocjacja jest potrzebna od razu". W pozostałych przypadkach zamienia się w „załadujemy wszystko, bo może się przydać", a to już droga do zbędnych danych i ciężkich zapytań.

3. join fetch dla ManyToOne: Product + Category

Karta towaru — jeden z najbardziej przyjaznych scenariuszy dla join fetch. Mamy Product, ma on Category (ManyToOne). W typowym read-use-case kategorii potrzeba przynajmniej nazwy lub kodu, bo UI/raport chce pokazać „Towary w kategorii X". I jeśli dokładnie wiemy, że kategoria jest potrzebna, prościej załadować ją razem z towarem.

Poniżej staranna metoda repozytorium. Zwróć uwagę: nie zmieniamy FetchType na EAGER. Zostawiamy mapowanie jak jest (prawdopodobnie LAZY), ale dla konkretnej metody dodajemy fetch join.

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Karta towaru: od razu ładujemy category, żeby nie dostać N+1 przy odwołaniu do p.getCategory()
    // Ważne: to nie zmienia mapowania na EAGER, to punktowa strategia ładowania pod konkretny use-case
    @Query("""
           select p
           from Product p
           join fetch p.category
           where p.id = :id
           """)
    Optional<Product> findCardById(Long id);
}

Teraz warstwa serwisowa może uczciwie powiedzieć: „Ładuję kartę towaru" — i zwrócić encję, która wewnątrz transakcji ma już przygotowaną kategorię.

package com.example.shopdatajpa.catalog.service;

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogReadService {

    private final ProductRepository productRepository;

    public CatalogReadService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // readOnly=true: jawnie pokazujemy, że to odczyt, a nie zmiana danych
    // W ramach transakcji encja i asocjacje załadowane fetch join będą dostępne bez dodatkowych SELECT
    @Transactional(readOnly = true)
    public Optional<Product> loadProductCard(Long id) {
        // Metoda repozytorium skonfigurowana dla karty: category załaduje się jednym SQL
        return productRepository.findCardById(id);
    }
}

Jeśli włączone są logi SQL (robiliśmy to wcześniej), zobaczysz mniej więcej jedno zapytanie z join. Mniej więcej tak (dokładny SQL zależy od nazw tabel/kolumn i dialektu):

-- Jedno zapytanie zamiast łańcucha SELECT-ów po lazy-asocjacjach
select
  p.*, c.*
from product p
join category c on c.id = p.category_id
-- Warunek dla konkretnej karty towaru
where p.id = ?

Psychologicznie to bardzo przyjemny moment: przestajemy zgadywać „ile zapytań poleci", a zaczynamy projektować odczyt tak, żeby zapytanie było jedno i zrozumiałe. Dla karty jednego towaru join fetch zwykle działa przewidywalnie i bez niespodzianek.

4. Kolekcja join fetch w karcie zamówienia

Na kolekcjach join fetch staje się jak potężne narzędzie, którym trzeba posługiwać się z szacunkiem. Nadal rozwiązuje N+1, ale cena błędu jest wyższa: łatwo dostać duplikaty, rozdęty wynik i bardzo dziwne zachowanie przy paginacji. Dlatego używamy collection fetch join tam, gdzie to naprawdę „karta", czyli odczyt jednej encji z powiązanymi danymi, a nie „lista tysięcy zamówień".

W naszym projekcie karta zamówienia zazwyczaj oznacza: samo zamówienie (CustomerOrder), jego pozycje (OrderItem) i towary w pozycjach (Product). Jeśli czytać to naiwnie, pojawia się N+1 na items, a potem jeszcze N+1 na item.product. W efekcie jedno zamówienie zamienia się w „serie zapytań", szczególnie jeśli pozycja po pozycji wymaga doczytania towaru.

JPQL z fetch join wygląda tu tak:

package com.example.shopdatajpa.ordering.repository;

import com.example.shopdatajpa.ordering.entity.CustomerOrder;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {

    // Karta zamówienia: wyciągamy zamówienie + pozycje + towary w pozycjach jednym zapytaniem
    // left join fetch: zamówienie zwrócimy nawet jeśli items są puste (dostaniemy pustą kolekcję)
    // distinct: chronimy się przed logicznymi duplikatami encji korzenia z powodu JOIN po kolekcji
    @Query("""
           select distinct o
           from CustomerOrder o
           left join fetch o.items i
           left join fetch i.product
           where o.orderNumber = :orderNumber
           """)
    Optional<CustomerOrder> findCardByOrderNumber(String orderNumber);
}

Tutaj od razu widać kilka ważnych rzeczy.

Po pierwsze, left join fetch, a nie join fetch. To świadomy „bezpieczniejszy" wariant: nawet jeśli zamówienie z jakiegoś powodu nie ma pozycji (w idealnym świecie tak nie jest, ale dane testowe żyją czasem swoim życiem), nadal będziemy mogli załadować zamówienie. Dla karty zwykle chcemy „dostać zamówienie i pustą listę", a nie „nie dostać niczego".

Po drugie, robimy distinct. To nie próba „upiększenia zapytania", lecz ochrona przed logicznymi duplikatami, które pojawiają się przy ładowaniu kolekcji.

Skąd biorą się duplikaty przy fetch-ładowaniu kolekcji

Gdy robisz join tabeli zamówień i tabeli pozycji zamówień, baza danych zwraca wiersze postaci „zamówienie + pozycja". Jeśli jedno zamówienie ma 3 pozycje, w wyniku będą 3 wiersze i dane zamówienia powtórzą się 3 razy. Na poziomie SQL to normalne: model relacyjny tak właśnie działa.

Problem zaczyna się na poziomie obiektów Java: chcemy dostać jedno CustomerOrder, wewnątrz którego kolekcja items z trzech elementów. A SQL zwraca trzy wiersze. Hibernate potrafi to złożyć z powrotem w graf obiektowy, ale przy tym w wyniku zapytania JPQL mogą pojawić się „logiczne duplikaty" encji korzenia, jeśli zwracasz listę.

Jeśli zwracasz Optional<CustomerOrder> po orderNumber, duplikaty korzenia nie są tak groźne: Hibernate i tak złoży jedno CustomerOrder w persistence context, a pozycje doda do kolekcji. Ale distinct jest tu nadal przydatny, bo pomaga usunąć zbędne „powtarzające się" wystawienie korzenia na poziomie wyniku JPQL i sprawia, że zachowanie jest bardziej przewidywalne.

Ważne jest też rozumienie, że distinct w JPQL — to nie zawsze „SQL DISTINCT na wprost". Hibernate może stosować distinct zarówno na poziomie SQL, jak i jako „de-duplikacja" na poziomie obiektów. Dla nas, jako studentów, główna praktyczna zasada jest prosta: przy fetch join kolekcji distinct często jest potrzebny, żeby nie dostać „dziwnego wyniku", szczególnie jeśli zwracasz List<CustomerOrder>.

Karta vs lista: gdzie collection fetch join jest niebezpieczny

W karcie zamówienia czytamy jedno zamówienie. Nawet jeśli ma 20 pozycji, to nadal akceptowalny wolumen. Tak, SQL będzie szerszy, tak, wierszy będzie 20, ale jest oczekiwany, i to dokładnie to, czego potrzebuje use-case. Zapłaciliśmy jedną „czytelną cenę" zamiast N+1.

A jeśli spróbujesz zrobić „listę zamówień" z fetch join na items dla każdego — bardzo szybko dostaniesz lawinę danych: nie N+1 zapytań, lecz jedno zapytanie zwracające ogromny arkusz wierszy. Czasem to jest nawet gorsze niż N+1, bo zaczynasz ładować masę pozycji, których użytkownik na ekranie listy w ogóle nie potrzebuje.

5. Pageable vs collection join fetch

Paginacja — to kontrakt: „daj mi 20 zamówień na stronę". Baza danych robi to przez LIMIT/OFFSET (lub podobną mechanikę) i wszystko pięknie… dopóki nie zamieniasz „20 zamówień" w „20 zamówień × pozycje każdego zamówienia". Z punktu widzenia SQL paginujesz już po wierszach wyniku join, a nie po zamówieniach korzenia. I to łamie oczekiwania: możesz dostać mniej zamówień niż prosiłeś, albo dostać zamówienia z niekompletną kolekcją, albo Hibernate będzie zmuszony „doczytywać" dane w pamięci.

Najbardziej niebezpieczny moment: taki kod wygląda kusząco prosto i „poprawnie".

package com.example.shopdatajpa.ordering.repository;

import com.example.shopdatajpa.ordering.entity.CustomerOrder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {

    // Uwaga: to typowy przykład "wygląda ładnie, ale łamie oczekiwania paginacji"
    // Przyczyna: JOIN po kolekcji mnoży wiersze, a LIMIT/OFFSET stosuje się już do wyniku join
    @Query("""
           select distinct o
           from CustomerOrder o
           left join fetch o.items
           """)
    Page<CustomerOrder> findAllWithItems(Pageable pageable);
}

Problem nie w tym, że zapytanie „jest złe jako tekst". Problem w tym, że próbujesz jednym zapytaniem rozwiązać dwa zadania, które słabo ze sobą współgrają: „stronicowo wybrać korzenie" i „w tym samym momencie wyciągnąć kolekcję każdego korzenia". Na poziomie JPA jest to prawie zawsze zabronione/niestabilne albo działa „jakoś", ale nie tak, jak myślisz.

Jak rozpoznać to w praktyce: uruchamiasz metodę i nagle widzisz ostrzeżenia w logach, dziwny rozmiar strony, nieoczekiwaną liczbę obiektów w wyniku albo gigantyczne obciążenie pamięci. I to jest moment, gdzie właściwa reakcja — nie „pokręcić adnotację", lecz przeformułować use-case: możliwe, że na liście zamówień nie potrzebujesz pozycji, lecz potrzebujesz tylko summary.

Ale o alternatywnych narzędziach porozmawiamy później; w ramach tej lektury wystarczy ustalić granicę: collection join fetch — to z reguły narzędzie dla kart, a nie dla list stronicowanych.

6. Sprawdzamy, że join fetch usunął N+1

Po dodaniu join fetch u nowicjuszy zdarza się bardzo ludzki błąd: „Kod stał się ładniejszy, znaczy problem rozwiązany". Niestety Hibernate nie czyta piękna kodu — czyta mapowanie i zapytania. Dlatego obowiązkowy rytuał dojrzałego dewelopera — spojrzeć na logi SQL przed i po.

Wyobraź sobie, że wcześniej robiłeś tak:

package com.example.shopdatajpa.ordering.service;

import com.example.shopdatajpa.ordering.entity.CustomerOrder;
import com.example.shopdatajpa.ordering.repository.CustomerOrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadService {

    private final CustomerOrderRepository orderRepository;

    public OrderReadService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public int countItems(String orderNumber) {
        // Naiwny odczyt: pobieramy zamówienie bez doczytywania kolekcji items
        CustomerOrder order = orderRepository.findByOrderNumber(orderNumber).orElseThrow();

        // Jeśli items = LAZY, to wywołanie size() może wyzwolić dodatkowy SELECT (klasyczny N+1)
        return order.getItems().size();
    }
}

Jeśli items — LAZY, to order.getItems().size() wewnątrz transakcji zazwyczaj zadziała, ale wywoła dodatkowy SQL. Jeśli potem pójdziesz do item.getProduct(), dostaniesz jeszcze więcej zapytań. Po zastąpieniu na findCardByOrderNumber(...) w logach powinno stać się wyraźnie ciszej. Typowy „zdrowy" wzorzec logów: jedno select ... join ... join ... where order_number = ?, i to wszystko. Jeśli nadal widzisz osobne select-y po order_item lub product po głównym zapytaniu — znaczy nie załadowałeś potrzebnej asocjacji, lub załadowałeś nie tę, albo czytasz już inny obiekt/innym sposobem.

Tutaj sprawdza się prosta zasada: jeśli use-case nazywa się „karta zamówienia", powinieneś umieć pokazać dokładnie jedno (maksymalnie dwa) zapytania SQL i wyjaśnić, dlaczego właśnie tyle. Jeśli nie możesz tego wyjaśnić, prawdopodobnie nadal żyjesz w świecie „jakoś samo".

7. Typowe błędy przy używaniu join fetch

Błąd nr 1: zamienianie join fetch w przycisk „załaduj w ogóle wszystko od razu".
Bardzo łatwo zacząć dodawać join fetch wszędzie, bo po N+1 chce się „nigdy więcej nie widzieć zbędnego SELECT". Problem w tym, że leczysz strach, a nie use-case. W efekcie zapytania zaczynają ciągnąć masę asocjacji, których nie potrzeba, dane się rozdymają, a aplikacja zwalnia z innego powodu — bo czyta za dużo.

Błąd nr 2: mylenie join z join fetch i oczekiwanie, że asocjacja „sama stanie się załadowana".
Zwykły join w JPQL pomaga wyrazić warunki, ale nie gwarantuje, że asocjacja będzie zainicjalizowana w obiekcie. Nowicjusze piszą join, widzą JOIN w SQL, a potem dziwią się, dlaczego nadal były dodatkowe SELECT przy odwołaniu do asocjacji. Jeśli twoim celem jest załadowanie asocjacji, słowo fetch musi być w zapytaniu jawnie.

Błąd nr 3: zapominanie o distinct przy kolekcji i dostawanie „dziwnego wyniku".
Gdy fetch-ładujesz kolekcję, encja korzenia pojawia się w wierszach wyniku wielokrotnie. To normalna natura JOIN. Ale na poziomie wyniku JPQL możesz dostać logiczne duplikaty. distinct często sprawia, że zachowanie jest stabilniejsze. I tak, to ten rzadki przypadek, gdy „zbędne słowo" w zapytaniu realnie ratuje układ nerwowy.

Błąd nr 4: używanie collection join fetch razem z Pageable, a potem tłumaczenie bugów „dziwnościami Hibernate".
Z punktu widzenia dewelopera wygląda to jak „po prostu chcę stronę zamówień, i żeby były tam pozycje". Z punktu widzenia ORM i SQL — prosisz o dwie słabo kompatybilne rzeczy jednym ruchem. Jeśli widzisz, że lista i paginacja zachowują się podejrzanie, pierwszym podejrzanym jest fetch join kolekcji.

Błąd nr 5: robienie fetch join na wszystkim z rzędu, a potem dziwienie się, że zapytanie stało się „grube" i wolne.
Fetch join eliminuje N+1, ale nie obala podstawowej fizyki: jedno zapytanie może być ciężkie, jeśli ciągnie zbyt wiele kolumn i wierszy. ORM nie anuluje ograniczeń sieci, pamięci i dysku. Dlatego dobry join fetch — to zawsze wąskie zapytanie pod konkretną kartę, a nie uniwersalny „wyeksportuj bazę danych".

1
Zadanie
Spring Data JPA, poziom 22, lekcja 1
Niedostępne
Karta produktu przez `join fetch` dla powiązania `Product -> Category`
Karta produktu przez `join fetch` dla powiązania `Product -> Category`
1
Zadanie
Spring Data JPA, poziom 22, lekcja 1
Niedostępne
Karta zamowienia przez kolekcje `join fetch`
Karta zamowienia przez kolekcje `join fetch`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION