Ucieczka z piekła N+1. Problem i rozwiązania

Prawdopodobnie każdy, kto choć raz miał okazję ubiegać się o pracę jako programista, usłyszał na rozmowie kwalifikacyjnej pytanie: „Czy wie Pan, czym jest problem N+1?

Problem N+1 to absolutna klasyka. Mimo tego widzę, że nadal mnóstwo programistów wie o nim niewiele. W najlepszym przypadku znają teorię, ale niekoniecznie zwracają na niego uwagę w codziennej pracy, a nawet, gdy go napotykają, ignorują go, zazwyczaj przez brak pomysłu, co z tym fantem zrobić. A rozwiązanie potrafi być naprawdę proste.

Dlaczego powinieneś zdawać sobie sprawę z problemu? Jak sobie z nim poradzić, nie wpadając jednocześnie w inne pułapki? Oczywiście, wszystko można znaleźć na StackOverflow. Niestety, odpowiedzi, jakie tam znajdziemy, są często pójściem po linii najmniejszego oporu i jeśli nie rozumiemy, jaka jest prawdziwa przyczyna problemu, bardzo łatwo zrobić sobie krzywdę. Stąd ten artykuł, który jest kompletnym naświetleniem m0żliwych rozwiązań i pozwoli Ci dobrać to odpowiednie w większości sytuacji, z jakimi się spotkasz.

Problem N+1 na przykładzie

W ramach wyjaśnień: Będę używać adnotacji JPA tam gdzie to możliwe (oraz niektórych adnotacji Hibernate, które nie mają odpowiednika w JPA). Będę też czasem zamiennie posługiwał się nazwami JPA oraz Hibernate w sytuacji, gdy drugie jest jedynie implementacją pierwszego i nie wprowadza nic, czego w JPA nie ma. Jaka jest relacja między JPA i Hibernate – o tym przeczytacie na przykład w tym temacie na Stack Overflow.

Na tym wszystkim używam Spring Data, który staje się coraz bardziej standardem w przemyśle i który ułatwia wykonywanie wielu rzeczy, a mam wrażenie że jest traktowany przez niektórych jako usprawiedliwienie swojego niezrozumienia tego, co dzieje się „pod maską”.

Nie będę wyjaśniał w szczegółach całego kodu, tylko tyle, ile jest potrzebne do zrozumienia problemu. Źródła znajdziesz na moim GitHubie. Każde z rozwiązań problemu posiada swój własny branch, do którego odnośnik znajdziesz na początku każdej części.

Wyobraźmy sobie aplikację przechowującą informacje o zamówieniach (kolejny absulutny klasyk, tym razem w dziedzinie przykładów kodu). Każde podsumowanie zamówienia (OrderSummary) składa się z jakiejś liczby pozycji (Item):

@Entity
public class OrderSummary {
    @Id
    Long id;

    Long userId;

    String name;

    @OneToMany()
    @JoinColumn(name = "SUMMARY_ID")
    List<Item> items = new ArrayList<>();

    public Long getUserId() {
        return userId;
    }

    public String getName() {
        return name;
    }

    public List<Item> getItems() {
        return items;
    }
}

@Entity
public class Item {
    @Id
    Long id;

    String name;
    BigDecimal price;

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }
}

Jako interfejs komunikacji z bazą danych wykorzystuję repozytorium stworzone za pomocą Spring Data.

@Component
public interface OrderSummaryRepository extends JpaRepository<OrderSummary, Long> {
    public List<OrderSummary> findByUserId(Long userId);
}

Bazę danych inicjalizujemy następującymi danymi

INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (1, 1, 'Zamowienie 1');
INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (2, 1, 'Zamowienie 2');
INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (3, 1, 'Zamowienie 3');
INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (4, 1, 'Zamowienie 4');
INSERT INTO ORDER_SUMMARY (ID, USER_ID, NAME) VALUES (5, 1, 'Zamowienie 5');

INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (0, 1, 'Zel do wlosow', 11.99);
INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (1, 2, 'Gumowa kaczuszka', 2.49);
INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (2, 2, 'Mapa Imperium Lechitow', 119.00);
INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (3, 2, 'Maska antysmogowa', 89.90);
INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (4, 3, 'Zlota szczeka dziadka', 999.00);
INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (5, 3, 'Tajemniczy przedmiot 1', 324.00);
INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (6, 4, 'Tajemniczy przedmiot 2', 654.00);
INSERT INTO ITEM (ID, SUMMARY_ID, NAME, PRICE) VALUES (7, 5, 'Tajemniczy przedmiot 3', 12.00);

Mając takie mapowanie encji w bazie danych, wyobraźmy sobie bardzo prostą i typową operację, jaką moglibyśmy chcieć na tych danych wykonać: Chcemy pobrać wszystkie zamówienia danego użytkownika i sprawdzić, ile w sumie jest w nich wszystkich przedmiotów. W tym celu piszę test:

@Autowired
private SummaryRepository summaryRepository;

@Test
void shouldExtractItemsFromOrders() {
    List<OrderSummary> summaries = summaryRepository.findByUserId(1L);

    List<Item> items = summaries.stream()
      .map(OrderSummary::getItems)
      .flatMap(Collection::stream)
      .collect(Collectors.toList());

    assertThat(items).hasSize(8);
}

Odpalamy nasz test:

Wspaniale, wszystko się zgadza, możemy odtrąbić sukces, iść do domu i następnego dnia na stand-upie pochwalić się wykonaniem świetnej roboty.

ZARAZ ZARAZ…

Spójrzmy na chwilę w logi. To powód, dla którego warto czasem włączyć logowanie operacji na bazie danych, jakie wykonuje Hibernate:

org.hibernate.SQL                        : select ordersumma0_.id as id1_1_, ordersumma0_.name as name2_1_, ordersumma0_.user_id as user_id3_1_ from order_summary ordersumma0_ where ordersumma0_.user_id=?

org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?

org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?

org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?

org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?

org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_0_, items0_.id as id1_0_0_, items0_.id as id1_0_1_, items0_.name as name2_0_1_, items0_.price as price3_0_1_ from item items0_ where items0_.summary_id=?

Dużo niełatwego do przeanalizowania tekstu (mimo że zostawiłem tylko te fragmenty, które przydadzą nam się w analizie, w logach jest oczywiście znacznie więcej), jednak warto się w niego wczytać. Możemy zauważyć, że zostało wykonanych aż sześć Selectów na bazie danych! Jakikolwiek student kierunku pokrewnego z informatyką potrafiłby skonstruować pojedyncze zapytanie, które osiągnęłoby ten sam efekt.

To właśnie problem N+1 w całej okazałości. Jeśli jeszcze się nie zorientowałeś, 6 zapytań, jakie zostały wykonane, to jedno dotyczące wszystkich zamówień i po jednym, aby uzyskać listę pozycji dla każdego z nich. 5 zamówień: 5+1 = 6 zapytań. 20 zamówień: 20+1 = 21 zapytań. I tak dalej. Stąd N+1.

I w czym tu problem?

Oczywiście, można dyskutować: sześć zapytań, co to za problem? Przeciętna aplikacja nawet nie odczuje tych sześciu zapytań. Ok, sześciu może nie. Gorzej, jeśli – co się często dzieje – operujemy raczej na tysiącach rekordów. Wyobraź sobie wysyłanie do bazy danych dziesiątek tysięcy zapytań, by wyświetlić prostą listę. Coś takiego może zabić wydajność każdego systemu.

Czemu więc Hibernate nie potrafi (lub nie chce) wczytać danych „mądrzej”, tylko potrzebuje aż 6 zapytań? Spójrzmy jeszcze raz na to, jak zdefiniowaliśmy nasze mapowanie pomiędzy Zamówieniami i Pozycjami:

@OneToMany()
@JoinColumn(name = "SUMMARY_ID")
List<Item> items = new ArrayList<>();

Warto sobie zdawać sprawę, że mapowanie OneToMany domyślnie ładuje się leniwie (Lazy Loading). Oznacza to, że Hibernate, wczytując listę zamówień, nie prosi bazy danych o informację o konkretnych pozycjach się na nie składających. Nie jest to głupie – ostatecznie tych danych może być bardzo, bardzo dużo, nawet miliony wierszy, a często tak naprawdę wcale nie potrzebujemy po nie sięgać.

Wyobraźmy sobie, że chcemy jedynie wyświetlić użytkownikowi nazwę zamówień, a listę pozycji – dopiero po wybraniu któregoś z nich. Wtedy Lazy Loading jest wybawieniem. Co prawda robimy dwa zapytania, zamiast jednego, jednak nie dostajemy mnóstwa danych o zamówieniach, które nas nie interesują. Baza danych nie musi ich wysyłać, Hibernate ich nie przetwarza, pamięć nie jest przez nie zaśmiecona. Czyli w wielu sytuacjach jest to naprawdę dobra strategia.

Odpowiedź na pytanie, czy Lazy Loading jest dobrym rozwiązaniem brzmi więc „To zależy…”. Tym, co odróżnia przeciętnego programistę od dobrego jest dokonywanie dobrych wyborów, gdy sytuacja nie jest oczywista i rozeznanie się w niej wymaga wiedzy i często intuicji. Tym razem, wygląda na to że Lazy Loading to nie jest coś, czego chcemy. Jeśli ładujemy zamówienia tylko po to, by po chwili przetierować przez wszystkie pozycje, to optymalne byłoby jedno zapytanie. Spróbujmy więc zmusić Hibernate’a, by zachował się w taki właśnie sposób.

Pierwsze co może Ci przyjść do głowy…

Skoro problem N+1 wynika z leniwego ładowania, to może po prostu wyłączmy Lazy Loading? JPA oferuje taką możliwość. Spróbujmy więc.

@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "SUMMARY_ID")
List<Item> items = new ArrayList<>();

Zobacz kod na moim GitHubie

Można by się spodziewać, że powyższy kod rozwiąże nasz problem. Jednak po spojrzeniu w logi zauważymy, że… są dokładnie takie same.

Wciąż generujemy N+1 zapytań: jedno dla zamówienia i po jednym dla każdej pozycji. Natomiast nastąpiła jedna różnica, którą może zobrazować następujący kod:

PersistenceUtil persistenceUtil = Persistence.getPersistenceUtil();

@Test
void shouldExtractItemsFromOrders() {
     List<OrderSummary> summaries = summaryRepository.findByUserId(1L);
 
    // Elementy kolekcji są już załadowane przez Hibernate'a
    assertThat(persistenceUtil.isLoaded(summaries.get(0).getItems()))
        .isEqualTo(true);

Hibernate nie czeka aż zechcemy przejrzeć pozycje zamówienia. Ładuje je natychmiast, kiedy zostaje wczytane samo zamówienie. Nie zmienia to faktu, że wciąż wykonywanych jest tych samych 6 zapytań do bazy danych. W tym momencie można powiedzieć, że mamy najgorsze z dwóch światów: nie mamy Lazy Loadingu i mamy problem N+1. Jak przegrywać, to po całości…

To Ty, kiedy twoja aplikacja nie ma lazy loadingu i wciąż ma problem N+1

Tutaj warto się zatrzymać i wyjaśnić jedną rzecz. Wielu użytkowników, którzy na co dzień posługują się EntityManagerem, spodziewałoby się tutaj jednego zapytania zrealizowanego za pomocą SQL-owego joina. Jest to domyślne zachowanie Hibernate’a dla FetchType.EAGER. Jednak zostanie to wzięte pod uwagę tylko, gdy używamy metody .find() z EntityManagera. Spring Data pod spodem powyższego kodu tworzy zapytanie równoważne (zapytanie w JPQL):

select os from OrderSummary os where os.userId = ?1

To jedna z zawiłości Hibernate’a, która nie jest oczywista nawet dla zaawansowanych użytkowników. W momencie, gdy pobieramy dane za pomocą zapytania (i tu wlicza się zarówno jawne napisanie JPQL-a, zastosowanie Criteria API, jak i kod wygenerowany stworzone przez Spring Data repozytorium), biblioteka generuje zapytanie takie, jak powyższe, co oznacza pobranie danych za pomocą selectów. Tak już jest, czy się nam to podoba czy nie.

Rozwiązanie 1: Ładowanie z użyciem joina

Zobacz kod na moim GitHubie

Poznajmy adnotację @Fetch:

import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;

...

@OneToMany()
@Fetch(FetchMode.JOIN)
@JoinColumn(name = "SUMMARY_ID")
List<Item> items = new ArrayList<>();

Hibernate oferuje nieco więcej niż JPA jeśli chodzi o ładowanie danych. Nie znajdziecie tej adnotacji w standardzie JPA, a daje to naprawdę duże możliwości. W powyższym przypadku, mówimy Hibernate’owi, że zamiast używać osobnych selectów do pobrania encji z tabeli Order i Item, powinien zrobić na nich joina, by wszystkie dane zdobyć jednym zapytaniem.

Mniej więcej coś takiego zrobilibyśmy, gdybyśmy chcieli załadować te wszystkie dane za pomocą zapytania ręcznie. Więc wydaje się, że to jest właśnie to, czego nam trzeba.

Odpalamy kod i… cóż, znowu rozczarowanie! Widzimy dobrze nam znane 6 selectów.

Sytuacja jest dokładnie ta sama jak w przypadku użycia FetchType.EAGER. Hibernate zwyczajnie ignoruje nasze zalecenia. Nie żegnajmy się jeszcze z adnotacją FetchMode, jeszcze zdążymy ją docenić. Póki co jednak widać, że jeśli chcemy wymusić na naszym ORM-ie wykonanie joina, musimy podejść inaczej…

Więc jak…?

Dochodzimy do pierwszego działania, które rzeczywiście zadziała (przepraszam za spoiler… tak, będzie happy end, już wiecie).

Wspominałem już, że problem wynika z tego, jakie zapytanie jest generowane przez Hibernate’a. Przypomnijmy je:

select os from OrderSummary os where os.userId = ?1

Tutaj odezwą się z poczuciem wyższości fani używania Hibernate’a w najbardziej podstawowej postaci, bez nakładek typu Spring Data. Tam rozwiązanie byłoby całkiem proste. JPA posiada dyrektywę join fetch, która nakazuje bibliotece zrobić dokładnie to, czego chcemy. W tym samym zapytaniu, w którym ładujemy zamówienie, prosimy od razu o dane o pozycjach. Zapytanie to wyglądałoby następująco:

select distinct os from OrderSummary os join fetch os.items where os.userId = ?1

Ale tutaj zwrot akcji: Spring Data pozwala nam działać na tym poziomie. Wystarczy w naszym repozytorium użyć adnotacji @Query, aby dostosować, jakiego zapytania ma użyć Hibernate!

@Query("select distinct s from OrderSummary s join fetch s.items where s.userId = ?1") List<OrderSummary> findByUserId(Long userId);

Po tej zmianie uruchamiamy nasz test, by zobaczyć wreszcie upragniony rezultat:

 org.hibernate.SQL                        :  select distinct ordersumma0_.id as id1_1_0_, items1_.id as id1_0_1_, ordersumma0_.name as name2_1_0_, ordersumma0_.user_id as user_id3_1_0_, items1_.name as name2_0_1_, items1_.price as price3_0_1_, items1_.summary_id as summary_4_0_0__, items1_.id as id1_0_0__ from order_summary ordersumma0_ inner join item items1_ on ordersumma0_.id=items1_.summary_id where ordersumma0_.user_id=?

Tak, to wszystko! Jedno zapytanie pobiera wszystkie dane. Możemy więc odtrąbić sukces i iść do domu, prawda?

Prawda…?

No, niekoniecznie. W tym przypadku ładowanie wszystkich danych do razu jest nam na rękę, to prawda. Jednak jak pisaliśmy wcześniej, lazy loading ma swoje oczywiste zalety. Do tego ładowanie danych niesie ze sobą problem iloczynu kartezjańskiego (nie będę go już w tym artykule omawiać, warto jednak zdawać sobie sprawę z jego istnienia). Uh, to dużo różnych czynników, jednak warto wiedzieć, że różne sytuacje wymagają różnych rozwiązań.

Kiedy join będzie naszym przyjacielem? Gdy wiemy, że zawsze będziemy potrzebować wszyskich danych z zagnieżdżonych kolekcji, więc sensownie jest je załadować od razu oraz gdy takich kolekcji nie jest więcej niż jedna (ze względu na wspomniany proble iloczynu kartezjańskiego).

Rozwiązanie 2: Ładowanie z użyciem drugiego zapytania

Zobacz kod na moim GitHubie

Dzięki użyciu dyrektywy fetch join problem N+1 stał się niewyraźnym już wspomnieniem. Z zadowoleniem obserwujemy naszą aplikację, która pobiera wszystkie dane dotyczące zamówienia i składających się na nie pozycji za jednym zamachem, jedynie raz odpytując bazę danych.

Nasza aplikacja jednak się rozrasta i zaczęliśmy używać naszej klasy OrderSummary w zupełnie nowym kontekście, gdzie nie potrzebujemy informacji znajdujących się w klasie Item. Zaczynamy dostrzegać niedociągnięcia naszego podejścia i nieco tęsknić za lazy loadingiem, który przecież również się przydaje. Pamiętamy o koszmarze, jakim był problem N+1, jednak ładowanie tylu niepotrzebnych danych… Gdyby dało się znaleźć jakiś kompromis i używać leniwego ładowania bez N+1 zapytań…

Jeśli znasz SQL-a, to bez problemu wyobrazisz sobie takie rozwiązanie. Możemy w pierwszym zapytaniu pobrać wyłącznie informacje o zamówieniu, a w drugim – o wszystkich pozycjach. Nie jest to zbyt trudne.

Okazuje się, że Hibernate oferuje nam taką możliwość. Wróćmy do adnotacji @Fetch .

@OneToMany()
@Fetch(FetchMode.SUBSELECT)
@JoinColumn(name = "SUMMARY_ID")
List<Item> items = new ArrayList<>();

Poprzednie podejście do jej użycia było nieudane i musieliśmy sięgać po inne rozwiązania. Jak będzie tym razem? Przekonajmy się!

 org.hibernate.SQL                        : select ordersumma0_.id as id1_1_, ordersumma0_.name as name2_1_, ordersumma0_.user_id as user_id3_1_ from order_summary ordersumma0_ where ordersumma0_.user_id=?

 org.hibernate.SQL                        : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id in (select ordersumma0_.id from order_summary ordersumma0_ where ordersumma0_.user_id=?) 

Działa! To dokładnie to, czego potrzebujemy w wielu przypadkach. Mamy leniwie ładowaną kolekcję, a przy tym nie mamy problemu N+1 ani problemu iloczynu kartezjańskiego. Win-win.

Kiedy subselect powinien być naszym wyborem? Prawdopodobnie będziemy zadowoleni z takiego rozwiązania, jeśli chcemy mieć lazy loading i nie chcemy zawsze ładować od razu wszystkich danych, a jednocześnie wiemy, że jeśli już ładujemy dane w zagnieżdżonej kolekcji, to będziemy zawsze używać ich wszystkich.

Rozwiązanie 3: Ładowanie danych porcjami

Zobacz kod na moim GitHubie

Hibernate oferuje jeszcze jedną ciekawą opcję, która jest już dużo bardziej sytuacyjna. Spójrzmy na poniższy kod:

@OneToMany()
@Fetch(FetchMode.SELECT)
@BatchSize(size = 20)
@JoinColumn(name = "SUMMARY_ID")
List<Item> items = new ArrayList<>();

Jeśli przy czytaniu poprzedniego punktu zastanawiałeś się, czy możemy poprosić Hibernate’a o załadowanie tylko części danych – tak, da się i oto właśnie sposób. Przy powyższych ustawieniach pozycje będą ładowane „paczkami”. Przy zapytaniu o pozycje z pierwszego zamówienia, zostaną od razu załadowane wszystkie pozycje z zamówień 1-20. Kiedy już przez nie przeiterujemy i odniesiemy się do zamówienia 21, Hibernate załaduje treść kolejnych 20 zamówień. I tak dalej.

Zobaczmy jak zadziała kod podobny do powyższego (jedynie zmniejszymy rozmiar „paczki danych” do 2, żeby łatwiej nam było zobaczyć działanie na naszym przykładzie).

@BatchSize(size = 2)
 org.hibernate.SQL                        : select ordersumma0_.id as id1_1_, ordersumma0_.name as name2_1_, ordersumma0_.user_id as user_id3_1_ from order_summary ordersumma0_ where ordersumma0_.user_id=?

org.hibernate.SQL : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id in (?, ?)
binding parameter [1] as [BIGINT] - [1]
binding parameter [2] as [BIGINT] - [2]

org.hibernate.SQL : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id in (?, ?)
binding parameter [1] as [BIGINT] - [3]
binding parameter [2] as [BIGINT] - [4]

org.hibernate.SQL : select items0_.summary_id as summary_4_0_1_, items0_.id as id1_0_1_, items0_.id as id1_0_0_, items0_.name as name2_0_0_, items0_.price as price3_0_0_ from item items0_ where items0_.summary_id=?
binding parameter [1] as [BIGINT] - [5]

Przykładem, kiedy takie podejście się sprawdzi, jest na przykład stronicowanie. Jeśli na jednej stronie jest 20 wyników, a następne pojawiają się dopiero po naciśnięciu przycisku „Następna strona”, @BatchSize sprawdzi się doskonale.

Zwróćmy jednak uwagę, że jeśli będziemy chcieli załadować wszystkie pozycje, wciąż mamy problem N+1, tylko na mniejszą skalę (można powiedzieć, że jest to teraz problem (N/pageSize)+1. Jest to więc rozwiązanie, które sprawdzi się jedynie w bardzo konkretnych sytuacjach.

Problem N+1 – Podsumowując

Teraz mamy w swoim arsenale cztery główne podejścia. Podsumujmy je:

Leniwe ładowanie z osobnymi zapytaniami (domyślne) – Lazy loading
– Problem N+1
Ładowanie danych paczkami – Lazy loading
– Wciąż problem N+1, choć mniej zapytań
Ładowanie za pomocą joina (wszystko w jednym zapytaniu) – Problem N+1 wyeliminowany – brak lazy loadingu
– problem iloczynu kartezjańskiego
Ładowanie za pomocą subselecta (dwa zapytania) – Problem N+1 wyeliminowany
– Nadal mamy lazy loading
– dodatkowe zapytanie w stosunku do joina

Każde z rozwiązań ma swoje plusy i minusy. Każde z nich będzie najlepsze w pewnych konkretnych sytuacjach i warto sobie zdawać sprawę, w jakich. Osobiście uznaję subselect za całkiem dobry kompromis w wielu sytuacjach, jego wadą jest to, że nie jest to feature JPA, a Hibernate’a, więc jeśli używamy innego ORM-a, to niekoniecznie mamy to do dyspozycji.

Co dalej?

Kiedy już znamy możliwe rozwiązania i wiemy, jak je zaimplementować, możemy pójść nieco dalej. Moglibyśmy chcieć stosować w ramach jednej aplikacji różne podejścia do ładowania danych w sposób dynamiczny. Na przykład, czasem wiemy, że będziemy potrzebowali wszystkich danych i wtedy użyjemy joina lub subselecta, a czasem jedynie ich części i to nie od razu. Wtedy podeście z osobnymi zapytaniami może się okazać najlepsze. JPA i Hibernate również oferują taką możliwość i w przyszłości z pewnością opiszę to w szczegółach.

Jeśli czujesz, że dynamiczne podejście to coś, czego potrzebujesz, proponuję zapoznać się na przykład z tym artykułem, który opisuje zastosowanie Entity Graphów, czyli kolejnego ciekawego feature’a JPA.