Kategoria: Java

  • Spring Boot vs Quarkus – czy stare wygrywa z szybkim?

    „Czemu jeszcze nie przepisaliśmy tego na Quarkusa?”

    To pytanie padło na spotkaniu technologicznym, które miało być spokojnym podsumowaniem roadmapy. Zamiast tego rozpoczęło jedną z bardziej emocjonalnych dyskusji od czasu, gdy ktoś zaproponował wprowadzenie GraphQL.

    Zacznijmy od początku.

    Spring Boot – działa od 2014

    Jeśli napisałeś w Javie więcej niż Hello World i nie używałeś nigdy Springa, to nie wiem, gdzie się uchowałeś. Ale nawet wtedy debugowałeś pewnie chociaż stack trace wygenerowany przez Spring Boota.

    Ten framework nie próbuje być sexy. Nie próbuje być lean. Chcesz jedną małą rzecz, on wciąga do Twojej aplikacji setki kilobajtów kodu. Ale działa. I to w 99% przypadków wystarczy. Miliony blogpostów, tysiące przykładów na StackOverflow, setki rozszerzeń – to wszystko sprawia, że Spring jest jak stary wygodny fotel: trochę wytarty, trochę trzeszczy, ale wiesz, gdzie co jest.

    Quarkus – działa w 0.04 sekundy

    A potem pojawia się on – Quarkus. Młody, szybki, zgrabny. Wchodzi, robi swoje, znika. Czas startu w trybie natywnym? 0.04s. Spring może o tym tylko pomarzyć. RAM? Żre mniej niż przeglądarka z otwartym StackOverflow. Integracja z GraalVM? Wbudowana.

    Brzmi jak marzenie. Prawda?

    Ok, ale jak to wygląda w kodzie?

    Weźmy przykład z życia wzięty: REST-owy endpoint zwracający zamówienia z bazy. Brzmi jak CRUD? Bo to CRUD.

    Spring Boot

    @RestController
    @RequestMapping("/orders")
    public class OrderController {
      private final OrderRepository repository;
    
      public OrderController(OrderRepository repository) {
        this.repository = repository;
      }
    
      @GetMapping
      public List<Order> getAll() {
        return repository.findAll();
      }
    }
    public interface OrderRepository extends JpaRepository {}

    Odpalasz, działa. H2 w pamięci, logi śmigają, jak coś nie działa, to marudzi już przy starcie, więc nie przeoczysz tego.

    Czas uruchomienia? ~1.5s.

    Quarkus

    @Path("/orders")
    public class OrderResource {
    
        @Inject
        EntityManager em;
    
        @GET
        @Produces(MediaType.APPLICATION_JSON)
        public List<Order> getAll() {
            return em.createQuery("from Order", Order.class).getResultList();
        }
    }
    

    Zero Spring Data. Zero magii. Wszystko jawnie. ORM działa, ale jesteś bliżej metalu. Trochę czujesz, że trochę wróciłeś do czasów Hibernate 3 i pisania SQL-a na kartce.

    Czas uruchomienia? ~0.9s w JVM, ~0.04s w natywie. No robi wrażenie.

    A co z testami?

    W Springu wrzucasz @SpringBootTest i modlisz się, żeby test wstał przed kawą. Ale masz wszystko: kontekst, wstrzykiwanie, nawet MockMvc.

    W Quarkusie masz @QuarkusTest. W teorii działa jak SpringBootTest. W praktyce… działa, ale czasem trzeba przeklikać dokumentację. I nie wszystko działa tak samo.

    Środowiska enterprise

    Jeśli pracujesz w dużej organizacji, gdzie proces release’u ma więcej kroków niż Twój test end-to-end, a każda nowa technologia wymaga pięciu podpisów, to Spring Boot jest bezpiecznym wyborem. Większość aplikacji w korporacjach i tak już działa na Springu. Migracja do Quarkusa w takim miejscu najczęściej kończy się na Proof of Concept – jeśli w ogóle ktoś pozwoli go napisać.

    Spring Boot daje coś, co w enterprise ma ogromną wartość: przewidywalność.
    Każdy nowy developer, konsultant, audytor czy architekt wie, czego się spodziewać. Spring Data, konfiguracja YAML, klasyczny podział warstw – standard, który skraca onboarding i minimalizuje ryzyko.

    Framework dobrze integruje się z narzędziami klasy enterprise: monitoringiem (Actuator + Prometheus/Grafana), systemami logowania, brokerami wiadomości (Kafka, RabbitMQ), systemami autoryzacji (OAuth2, Keycloak), a także korporacyjnymi politykami bezpieczeństwa (czyt. braku internetu na build machine).

    Czas startu aplikacji? W tym środowisku to nie problem – ważniejsze, że działa w sposób powtarzalny. Restart mikroserwisu trwa 2 sekundy dłużej? Nikt tego nawet nie zauważy, bo i tak jest za reverse proxy i load balancerem.

    Startupy

    W startupie liczy się czas. Czas wejścia na rynek, czas testowania hipotezy, czas dostarczenia działającego MVP. Framework ma pomóc, a nie przeszkadzać. I tu sprawa robi się ciekawsza.

    Jeśli zespół zna Springa – to naturalny wybór. Dużo robi się samo: migracje schematów, walidacje, serializacja, dokumentacja (Springdoc/OpenAPI). Mało konfiguracji, dużo gotowych rozwiązań. Dobrze nadaje się do szybkiego prototypowania, o ile ktoś nie zacznie komplikować na siłę (patrz: custom autokonfiguracja w trzecim tygodniu sprintu).

    Ale jeśli zespół ma doświadczenie z cloud-native, zna Dockera, Kubernetes, GraalVM – Quarkus staje się realną alternatywą. Mniejsze zużycie pamięci, krótszy czas uruchamiania, natywne buildy do deployowania na FaaS (np. AWS Lambda) – wszystko to może dać oszczędności w środowisku, gdzie skaluje się przez mnożenie kontenerów.

    Oczywiście: brakuje „magii Springa”, więc część rzeczy trzeba napisać samemu. Ale startupy często i tak piszą własne rozwiązania – tylko szybciej. Tu Quarkus dobrze się wpasowuje: mniej domyślności, więcej jawności. Nie zawsze lepiej, ale bardziej świadomie.

    Mikroserwisy

    Jeśli myślisz o mikroserwisach jako o 30 kopiach tej samej aplikacji z innym endpointem – to Spring Boot wystarczy. Ale jeśli naprawdę projektujesz system rozproszony, z osobnymi cyklami życia serwisów, wysoką skalowalnością i optymalizacją zasobów – tu Quarkus zaczyna być realną opcją.

    Czas startu i zużycie pamięci w Quarkusie to nie tylko ciekawostka z benchmarku – to konkretna oszczędność przy dużej liczbie instancji. W środowisku serverless (FaaS, autoscaling, KNative) różnice robią się zauważalne. Aplikacja, która startuje w 40 ms zamiast 1.5 s i zużywa 70 MB zamiast 200 MB? Na poziomie kilkuset kontenerów dziennie to są wymierne liczby.

    Z drugiej strony: mikroserwisy to nie tylko runtime. To też: testy kontraktowe, logowanie skorelowane po traceId, health checki, obsługa błędów, retry, circuit breakers. Spring Boot ma to wszystko gotowe w Spring Cloud. W Quarkusie musisz to poskładać ręcznie – można, ale trzeba wiedzieć jak.

    W skrócie:

    • Spring Boot to dobry wybór do mikroserwisów klasy „REST + baza + monitoring”, szybko wdrażanych, łatwo rozwijanych.
    • Quarkus warto rozważyć przy większej skali, automatycznym skalowaniu, FaaS lub bardzo wysokich wymaganiach dotyczących czasu reakcji i zużycia zasobów.

    Co warto wiedzieć porównując oba rozwiązania?

    KryteriumSpring BootQuarkus
    Czas uruchamianiaDość szybkiBłyskawiczny (natywny tryb robi robotę)
    Dojrzałość ekosystemuAbsolutna dominacjaJeszcze rośnie, ale ambitnie
    Wsparcie w communityOd „jak to zrobić” po „czemu nie działa”Trochę dziki zachód
    Wersja Hello WorldJedna adnotacja i leciTrochę więcej dłubania
    Sytuacje awaryjneStackOverflow cię nie zawiedzieCzasem trzeba pogrzebać głębiej
    Próg wejściaNiski (albo przynajmniej znajomy)Trochę wyższy
    Praca w zespoleKażdy to znaKtoś musi „nauczyć resztę”

    A co z realnymi projektami?

    Nie widziałem jeszcze dużego polskiego software house’u, który produkcyjnie przeszedł w 100% na Quarkusa. Startupy? Tak. MVP, mikroserwisy, funkcje w chmurze – idealne środowisko. Ale w bankowości, telco czy e-comie? Spring króluje. Bo „działa” jest nadal ważniejsze niż „działa szybko”.

    Co więc wybrać?

    • Jeśli masz do utrzymania system z legacy i 10 letnią historią – Spring Boot.
    • Jeśli tworzysz mikroserwis na serverless z AWS Lambda – Quarkus.
    • Jeśli chcesz szybko dowieźć coś w zespole, który zna Springa – nie kombinuj.
    • Jeśli lubisz eksperymentować i nie boisz się, że trzeba będzie coś napisać samemu – spróbuj Quarkusa.

    Czy warto przechodzić z Spring Boot na Quarkusa?

    W większości projektów – nie. Quarkus nie jest „zamiennikiem” Springa. To framework dla innych przypadków użycia. Tam, gdzie dominują serwisy krótkotrwałe, działające w chmurze i uruchamiane setki razy dziennie – różnica w czasie startu i zużyciu pamięci może mieć znaczenie biznesowe. W projektach korporacyjnych, z rozbudowaną infrastrukturą CI/CD, monitoringiem i długim czasem życia procesów – przewaga Springa nadal jest wyraźna.

  • Piramida testów

    Gdy młody programista zaczyna swoją przygodę z testowaniem, często napotyka się po raz pierwszy na pojęcie piramidy testów. Stało się ono bardzo popularne kilka lat temu, wraz z tym, jak testy automatyczne „weszły pod strzechy” i stały się częścią codziennej rzeczywistości programistów.

    Warto sobie zdawać sprawę, że to podejście nie zawsze musi być optymalne i istnieją odstępstwa od tego modelu. Piramida testów jest przydatnym uproszczeniem i jeśli dopiero zaczynasz rozumieć, o co chodzi w testowaniu, to naprawdę, NAPRAWDĘ, polecam, abyś zrozumiał to podejście i stosował je w praktyce. W wielu przypadkach jest ono dobre, a w najgorszym razie nie jest dramatycznie złe.

    Zacznijmy od wyjaśnienia, jak rozumiemy poszczególne poziomy testów.

    Testy jednostkowe

    Pierwszym rodzajem testów są testy jednostkowe. Dla niektórych to synonim zautomatyzowanych testów, jednak warto wiedzieć, że to jedynie jeden z ich rodzajów. Ich cechą jest to, że testujemy w nich jedynie… cóż, jednostkę. Zawsze tu pojawia się dyskusja – czym jest jednostka (unit)? Co programista, to opinia, ja nie zamierzam Ci narzucać żadnej.

    Jedynie zaznaczę, że dla mnie unit nie musi być koniecznie jedną klasą. Jeśli mamy zespół kilku klas, które ściśle ze sobą współpracują i nie mają sensu w oderwaniu od siebie, nie widzę sensu, by testować każdą z nich osobno. Takie testy bardziej zaciemniają sytuację i „betonują” kod – sprawiają, że jakikolwiek refactoring jest bolesny, bo trzeba zrobić zmiany w wielu testach, mimo że sama funkcjonalność się nie zmienia.

    Co jednak z pewnością charakteryzuje test jednostkowy to szybkość wykonywania. Aby test wykonywał się szybko, musimy maksymalnie go uprościć. Nie ma mowy o łączeniu się do bazy danych, stawiania serwera HTTP, tworzeniu kontekstu springowego. Testujemy tylko klasę lub kilka klas, w izolacji od całego świata. Dzięki temu, wykonanie takiego testu nie powinno zająć więcej niż kilka milisekund.

    Testy integracyjne

    Drugim poziomem są testy integracyjne. Tu też znajdą się różne definicje, jednak wyznacznikiem jest wyjście poza sam kod. Jeśli testujemy nie tylko nasz kod, ale też framework, którego używamy (np. Spring), jeśli łączymy się z bazą danych lub wysyłamy coś na topic Kafki, to prawdopodobnie mamy już do czynienia z testem integracyjnym.

    Dobrym przykładem mógłby być test repozytorium Spring Data. Mogłoby się wydawać, że przedmiotem testu jest tylko klasa, tak więc test jest jednostkowy, jednak sam kod repozytorium nie ma większego sensu, jeśli nie zostanie odpowiednio zinterpretowany przez Springa. Twój test nie sprawdza tak naprawdę Twojego kodu, tylko czy dobrze zintegrowałeś się z frameworkiem, jakim jest Spring Data. 

    Innym przykładem ze świata Springa mógłby być test kontrolera. Znów, klasa kontrolera sama w sobie nie robi wiele ciekawych rzeczy. Cała magia dzieje się, gdy Spring Web analizuje adnotacje, którymi oznaczyłeś klasę i na tej podstawie tworzy endpointy HTTP.

    Bez testów integracyjnych trudno wyobrazić sobie dziś dobrą aplikację. Jakie są ich wady? Z pewnością szybkość. W przypadku testów springowych test odpala tak naprawdę cały kontekst, co jest kosztowne. Czas wykonania przeciętnego testu integracyjnego może wynosić nawet kilkaset milisekund.

    Testy E2E

    Na górze piramidy zobaczymy testy E2E (end-to-end, obejmujące całą aplikację). W czasach, gdy tworzono pojęcie piramidy testów, tutaj zwykle pojawiały się już testy manualne. Oddelegowana osoba ręcznie przechodziła różne ścieżki w aplikacji w celu wyłapania błędów. Jeśli nawet takie testy były zautomatyzowane, to często wciąż zajmowała się nim osoba spoza zespołu programistów. Nie trzeba chyba mówić, że takie podejście było bardzo kosztowne, ponieważ ludzie zawsze będą drożsi w utrzymaniu, niż kawałek kodu.

    Jednak technologia poszła do przodu i dziś pisanie niezawodnych testów E2E nie jest aż takim wyzwaniem. Często zajmuje się tym sam programista. Wciąż jednak są one dość drogie w utrzymaniu, ponieważ są bardzo wolne. Wymagają często uruchomienia całego środowiska, a niekiedy łączą się z zewnętrznymi systemami, które zawsze będą miały narzut czasowy związany z przesyłem danych.

    Są też mniej stabilne- zmiana każdej niewielkiej części kodu może skutkować w zmianie wyniku testu. Z tego wynika jeszcze jeden problem. Ponieważ test obejmuje dużą ilość kodu, trudniejsze jest dostrzeżenie przyczyny, jeśli wykonanie go się nie powiedzie. Test jednostkowy zwróci negatywny wynik jeśli dana klasa jest źle zaimplementowana. Jeśli nie powiedzie się test E2E – musimy dopiero rozpocząć poszukiwanie źródła błędu.

    Mimo to, testy E2E są niezastąpione do upewnienia się, że całość aplikacji działa tak, jak sobie to wyobraziliśmy. Pełnią więc często rolę testów akceptacyjnych.

    Piramida testów

    Teraz, gdy już wiemy, co kryje się pod poszczególnymi kategoriami testów, możemy wyjaśnić, co kryje pod swoją nazwą piramida testów. Kilkukrotnie wspominałem o szybkości działania i koszcie utrzymania poszczególnych testów. Najszybsze są testy jednostkowe, najwolniejsze są testy E2E. To wymusza już na nas pewną strategię testowania. Jeśli napiszemy mnóstwo testów integracyjnych i E2E, taki zestaw testów będzie się wykonywał godzinami (uwierz mi, widziałem takie systemy). Takim ograniczeniom nie podlegają testy jednostkowe. Setki lub tysiące takich testów to wciąż kwestia kilku sekund. Dzięki temu programista może bez problemu puszczać takie testy co kilka minut bez negatywnego wpływu na swoją efektywność (i stan psychiczny).

    Nazwa piramidy bierze się od liczby testów, które chcemy mieć na poszczególnych poziomach. Oto, jak można taką strategię testów zwizualizować.

    Staramy się więc, aby zdecydowaną większość naszych testów stanowiły testy jednostkowe. Są one tanie w pisaniu i utrzymaniu, są szybkie, więc możemy puszczać ich setki lub tysiące naraz, utrzymując krótki czas wykonania oraz produkujemy je niejako „przy okazji” developmentu (przynajmniej tak powinno być przy użyciu podejścia TDD).

    To były podstawy wiedzy o strategii testowania. Jeśli chcesz dowiedzieć się więcej, przeczytaj też mój kolejny artykuł:

    Piramida testów na głowie

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