Animowane kompendium · Hibernate + Spring Data JPA

Hibernate i Spring Data JPA od środka

Encja, persistence context, flush, N+1, transakcje i blokady — pokazane na klikalnych symulacjach z podglądem generowanego SQL. Na końcu: dobre praktyki, galeria błędów, pytania na rozmowę i quiz. Przewijaj — diagramy ruszają same.

licz zapytania, nie linijki 🧠
przewiń ↓

Jak czytać to kompendium

ORM to nie magia — to automat, który można podejrzeć

Hibernate robi za ciebie bardzo dużo: tłumaczy obiekty na wiersze, pamięta, co zmieniłeś, i sam decyduje, kiedy wysłać SQL. Dlatego kod, który wygląda niewinnie, potrafi wysłać do bazy sto zapytań zamiast jednego. To kompendium nie jest listą adnotacji. Każdy rozdział pokazuje jeden mechanizm na klikalnej symulacji, a obok widzisz SQL, który ten mechanizm generuje, razem z licznikiem zapytań.

Kompendium ma poziomy. Rozdziały i bloki mają znaczek: L1 podstawy, L2 praktyk, L3 ekspert. Głębokość wybierasz przełącznikiem w lewym dolnym rogu; głębszy materiał chowa się, dopóki go nie zechcesz. Mapa poniżej przenosi cię do dowolnego rozdziału i odhacza to, co już przeczytałeś.

Na końcu są trzy rozdziały praktyczne: dobre praktyki, najczęstsze błędy i pytania na rozmowę rekrutacyjną, a po nich quiz i ściągawka.

🧠 Pomyśl

Endpoint zwraca 100 zamówień razem z nazwą klienta. Kod to jedna linijka: orderRepository.findAll(), a potem pętla, która czyta order.getCustomer().getName(). W logach widzisz 101 zapytań SELECT. Skąd się wzięło to jedno, a skąd pozostałe sto? I dlaczego zmiana relacji na FetchType.EAGER wcale tego nie naprawia? Odpowiedź czeka na samym końcu.

Fakty sprawdzone w październiku 2026 z dokumentacją Hibernate ORM 7.4, specyfikacją Jakarta Persistence 3.2, dokumentacją Spring Data JPA 4.1, Spring Framework i Spring Boot. Szczegóły zmieniają się między wersjami, więc przy aktualizacji zajrzyj do przewodników migracji Hibernate.

Część I

Fundamenty ORM

Kto naprawdę wysyła SQL, czym jest encja, w jakim stanie jest twój obiekt i dlaczego UPDATE dzieje się „sam”. Bez tego reszta kursu to magia.

Stos: JDBC → JPA → Hibernate → Spring Data

kto tu właściwie gada z bazą?

Piszesz orderRepo.findById(7) i dostajesz zamówienie. Jedna linijka, ale pod spodem pracuje kilka warstw. Każda ma inną robotę, a większość nieporozumień z tego kursu bierze się z ich mylenia.

JDBC to najniższe piętro: standardowe API Javy do wysyłania SQL i czytania wierszy. Żadnych obiektów, tylko PreparedStatement i ResultSet.

JPA (Jakarta Persistence) to specyfikacja: zestaw interfejsów (EntityManager, EntityManagerFactory) i adnotacji (@Entity, @Id…). Sama nie robi nic. Potrzebuje implementacji.

Hibernate to ta implementacja, czyli ORM (Object-Relational Mapper): tłumaczy obiekty na wiersze i z powrotem, generuje SQL i woła JDBC. Jego natywny interfejs Session rozszerza EntityManager, więc obie nazwy opisują ten sam obiekt.

Spring Data JPA nie jest ORM-em. Generuje za ciebie repozytoria: piszesz interfejs, a Spring w czasie działania podstawia proxy, które deleguje do klasy SimpleJpaRepository. Ona z kolei woła zwykły EntityManager. Starter spring-boot-starter-data-jpa przynosi wszystkie trzy elementy: Hibernate, Spring Data JPA i Spring ORM.

▶ Jedno wywołanie, sześć pięter kliknij ▶ i zobacz, na którym piętrze powstaje SQL
SQL wysłany do bazy
Kliknij ▶.
Uproszczenie: pomijamy pulę połączeń, interceptor transakcji i szczegóły proxy. Aliasy w SQL (o1_0) w stylu Hibernate 6+; lista kolumn skrócona. Komunikat błędu po „usunięciu Hibernate” zależy od wersji i konfiguracji, dlatego go nie cytujemy.
❓ Nie ma głupich pytań
P:
To mówię „Hibernate” czy „JPA”?
O:
JPA to umowa, Hibernate to wykonawca. Jeśli korzystasz tylko z EntityManager i adnotacji jakarta.persistence.*, piszesz „pod JPA”. Gdy sięgasz po Session albo adnotacje org.hibernate.annotations.*, wiążesz się z Hibernate.
P:
Czy Spring Data JPA trzyma jakiś własny cache encji?
O:
Nie. Cache pierwszego poziomu (persistence context) należy do Hibernate i żyje w EntityManager. Spring Data tylko go woła. Zobaczysz to w rozdziale o persistence context.
P:
Jak podejrzeć SQL, który wysyła Hibernate?
O:
Ustaw hibernate.show_sql=true albo włącz poziom DEBUG dla kategorii org.hibernate.SQL. Logger jest lepszy, bo trafia tam, gdzie reszta logów.
📌 W punktach
  • JDBC wysyła SQL. JPA to specyfikacja. Hibernate to implementacja JPA i ORM.
  • Spring Data JPA generuje repozytoria. SimpleJpaRepository deleguje do EntityManager (np. findById → find).
  • Session rozszerza EntityManager, SessionFactory rozszerza EntityManagerFactory.
  • Metody odczytu w SimpleJpaRepository działają w transakcji readOnly.

Encja i mapowanie

klasa Javy ↔ tabela, adnotacja po adnotacji

Encja to klasa, której obiekty mają trwałą tożsamość (id) i odpowiadają wierszom w tabeli. Oznaczasz ją @Entity, a jej pole z kluczem głównym @Id.

JPA stawia encji kilka warunków. Klasa nie może być final (Hibernate tworzy podklasy-proxy), musi mieć konstruktor bez argumentów (JPA: public lub protected), a encją nie może być enum, record ani interfejs. Dodatkowe konstruktory możesz mieć dowolne.

@Entity
@Table(name = "orders")                 // "order" to słowo kluczowe SQL, więc zmieniamy nazwę
public class Order {
    @Id @GeneratedValue
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(nullable = false, length = 100)
    private String customerEmail;

    private LocalDateTime createdAt;      // bez adnotacji też jest mapowane

    @Embedded
    private Address address;              // street + city lądują w tej samej tabeli

    protected Order() {}                  // dla Hibernate
}

Domyślnie każde pole jest mapowane na kolumnę. Tabela nazywa się jak encja (chyba że podasz @Table), a @Column bez atrybutów daje kolumnę nullable o długości 255. Spring Boot dodatkowo zamienia camelCase na snake_case małymi literami: customerEmail → customer_email.

@GeneratedValue bez strategii oznacza AUTO: Hibernate wybiera sekwencję, tabelę albo UUID zależnie od typu id i bazy. Strategiom i ich kosztom przyjrzysz się w rozdziale o zapisach wsadowych.

@Embeddable to „kawałek” encji bez własnej tożsamości, np. adres. Jego pola stają się kolumnami tabeli encji-właściciela. Może być zwykłą klasą albo rekordem Javy.

▶ Adnotacje → DDL przełączaj adnotacje, potem 💥

        
Wiersze w tabeli orders
idw bazieodczytane jako
SQL wysłany do bazy
Kliknij ▶.
DDL w stylu PostgreSQL, uproszczony: pomijamy sekwencję dla id i ograniczenia check, które Hibernate może dodać dla enumów; kolejność kolumn uproszczona. Trzy wiersze są ilustracyjne.
⚠️ Uwaga

Brak @Enumerated = ORDINAL. Enum zapisuje się jako liczba, czyli pozycja stałej w deklaracji. Dopisz stałą w środek albo zmień kolejność, a stare wiersze po cichu zaczną znaczyć coś innego. Baza nie zgłosi błędu, bo to nadal poprawne liczby.

Dokumentacja Hibernate mówi wprost: zwykle lepszy jest STRING. Kosztuje kilka bajtów więcej, ale przetrwa refaktoryzację enuma (poza zmianą nazwy stałej).

🤓 Głębiej

Jakarta Persistence 3.2 dodała @EnumeratedValue: oznaczasz nim pole enuma (np. String code), a do bazy trafia jego wartość zamiast ordinal() czy name(). Jeśli to pole jest typu String, a nie ma @Enumerated, mapowanie przyjmuje STRING.

✏️ Zaostrz ołówek

Encja Product ma pole private String name; bez adnotacji i jest w Spring Boot. Jak będzie się nazywać tabela, jaka kolumna powstanie dla name i czy przyjmie null? A co z private final String sku;?

Odpowiedź

Tabela product (nazwa encji, małe litery w Boot), kolumna name varchar(255), nullable (domyślne length=255, nullable=true). Pole final łamie wymaganie JPA: trwałe pola encji nie mogą być final.

Cykl życia encji

ten sam obiekt, cztery stany

Obiekt encji zawsze jest w jednym z czterech stanów względem persistence context (kontekstu trwałości). Na razie myśl o nim jak o notesie EntityManager-a: lista obiektów, których zmiany Hibernate pilnuje. Szczegóły w następnym-następnym rozdziale.

StanCo to znaczyJak tu trafia
new / transientzwykły obiekt, bez tożsamości w bazie, nikt go nie śledzinew Order()
managed (Hibernate: persistent)ma id, jest w persistence context; zmiany trafią do bazy przy flushpersist, find, zapytanie, wynik merge
detachedma id, ale kontekst już go nie śledzikoniec transakcji (kontekst per transakcja), detach, clear, close, serializacja
removedwciąż w kontekście, ale zaplanowany do usunięciaremove na obiekcie managed

persist i remove nie wysyłają SQL od razu. Planują INSERT lub DELETE na później, na flush (synchronizację z bazą, najczęściej tuż przed commitem). Metody update() nie ma wcale: zmiany w obiektach managed Hibernate wykrywa sam.

merge jest podchwytliwy. Nie „przyłącza” przekazanego obiektu. Kopiuje jego stan na egzemplarz managed (wczytany z bazy albo nowy) i zwraca tę kopię. Przekazany obiekt dalej jest detached.

▶ Maszyna stanów ▶ przechodzi pełny cykl; 💥 pokazuje pułapkę merge
SQL wysłany do bazy
Kliknij ▶.
Uproszczenie: id z sekwencji pomijamy, transakcje pokazujemy jako „tx 1”, „tx 2”. UPDATE zawiera wszystkie kolumny, bo tak domyślnie robi Hibernate (bez @DynamicUpdate). Aliasy i kolumny skrócone.
🔥 Rozmowa przy kominku
PersistDostaję nowy obiekt i po prostu go adoptuję. Ten sam egzemplarz, który mi dałeś, staje się managed.
MergeA ja nikogo nie adoptuję. Biorę stan z twojego obiektu, kopiuję go na egzemplarz z kontekstu i oddaję ci ten egzemplarz. Twój zostaje, gdzie był.
PersistZa to ty musisz najpierw zajrzeć do bazy, prawda?
MergeJeśli encji o tym id nie mam jeszcze w kontekście, to tak: robię SELECT. A ty z detached obiektem w ogóle sobie nie radzisz.
PersistBo nie muszę. Dla detached mogę rzucić EntityExistsException (albo błąd wyjdzie dopiero przy flush lub commicie). Jestem od nowych obiektów.
⚠️ Uwaga

remove na obiekcie detached kończy się IllegalArgumentException. Najpierw zrób go managed (find albo merge), dopiero potem usuń.

📌 W punktach
  • Cztery stany: new, managed, detached, removed.
  • persist/remove planują SQL na flush; nie ma update().
  • Po commicie (kontekst per transakcja) obiekty stają się detached.
  • merge zwraca inną instancję. Pracuj na wyniku: order = em.merge(order).

Persistence context: cache 1. poziomu i identity map

dwa razy find, jeden SELECT

Każdy EntityManager (czyli każda Session) ma własny persistence context. To mapa „typ + id → obiekt”: dla każdego wczytanego lub utrwalonego wiersza trzyma dokładnie jeden obiekt Javy. Hibernate nazywa ją też cache pierwszego poziomu.

Skutek pierwszy: drugie find(Order.class, 7) w tym samym kontekście nie idzie do bazy. Skutek drugi: dostajesz tę samą referencję, więc a == b daje true. Zmiana zrobiona w jednym miejscu kodu jest widoczna wszędzie w tej jednostce pracy.

Ile żyje kontekst? Zwykle tyle, co transakcja. W Springu wstrzyknięty EntityManager to współdzielone proxy, które deleguje do EntityManager-a bieżącej transakcji. Nowa transakcja to nowy, pusty kontekst. (Wyjątek: Open Session in View, który Spring Boot włącza domyślnie; patrz rozdział o OSIV.)

▶ Identity map w akcji ▶, potem 🔁 i 💥; patrz na licznik SQL
SQL wysłany do bazy
Kliknij ▶.
Adresy obiektów (@3f2a) są ilustracyjne. Pomijamy cache drugiego poziomu (wyłączony domyślnie, rozdz. 19). Aliasy i kolumny skrócone.
❓ Nie ma głupich pytań
P:
Skoro jest cache, to czemu zapytanie JPQL o to samo zamówienie znowu idzie do bazy?
O:
Bo kontekst umie odpowiadać tylko na pytanie „daj obiekt o tym id”. Zapytanie może mieć dowolne warunki, więc Hibernate musi je wykonać. Ale wiersze, które już ma w kontekście, zamienia na istniejące obiekty: dostaniesz tę samą referencję co z find.
P:
Mogę trzymać jeden EntityManager na całą aplikację i mieć wielki cache?
O:
Nie. Kontekst nie jest bezpieczny wątkowo, nie wolno go dzielić między współbieżne transakcje, a do tego trzyma twarde referencje do wszystkich swoich encji, więc GC ich nie sprzątnie.
🤓 Głębiej

Po rollbacku kontekst trzeba wyrzucić: stan jego encji może nie zgadzać się z bazą. W Springu dzieje się to samo, bo kontekst kończy się razem z transakcją.

✏️ Zaostrz ołówek

W jednej metodzie @Transactional wołasz repo.findById(7), potem repo.findById(7), potem repo.findById(8). Ile SELECT-ów zobaczysz? A ile, jeśli metoda nie jest transakcyjna i nie działa OSIV?

Odpowiedź

W transakcji: 2 (id 7 raz, id 8 raz). Bez transakcji każde wywołanie repozytorium dostaje własną, krótką transakcję i własny kontekst, więc: 3.

Dirty checking i flush

UPDATE, o który nie prosiłeś

Wczytujesz zamówienie, robisz order.setStatus(SHIPPED) i… tyle. Nie wołasz save(). Po commicie w bazie i tak jest SHIPPED. To dirty checking.

Jak to działa? Przy wczytaniu encji Hibernate odkłada snapshot, czyli kopię jej stanu. Przy flush porównuje bieżące pola ze snapshotem i dla każdej zmienionej encji generuje UPDATE.

Kiedy następuje flush? W trybie domyślnym (AUTO) w trzech sytuacjach:

TrybCo robiKiedy
AUTO (JPA i Hibernate)flush przed commitem i przed zapytaniem, na które wpływają zmianydomyślnie; prawie zawsze
COMMIT (JPA i Hibernate)flush tylko przed commitemoptymalizacja; zapytania mogą zwrócić nieaktualne dane
MANUAL (tylko Hibernate)nigdy automatycznietransakcje tylko do odczytu (tak ustawia go Spring dla readOnly)
ALWAYS (tylko Hibernate)flush przed każdym zapytaniemrzadko, np. natywny SQL w Hibernate bez JPA
▶ Oś czasu jednej transakcji klikaj ✏️, 🔎, ✅ w dowolnej kolejności
Order#7snapshotw pamięci
Oś czasu
    SQL wysłany do bazy
    Kliknij ▶.
    Order#7 jest już wczytany na starcie transakcji (ten SELECT pomijamy). commit pokazujemy w logu, ale nie liczymy jako zapytanie. Uproszczenie: w trybie COMMIT zapytanie zwraca stan sprzed zmian w tej transakcji.
    ⚠️ Uwaga

    save() na encji managed niczego nie dodaje. Wołanie repo.save(order) w pętli po każdej zmianie to szum: encja i tak trafi do bazy przy flush. Ale uwaga w drugą stronę: dirty checking działa tylko dla encji managed. Detached obiekt trzeba scalić (merge).

    Natywne zapytania SQL wołane przez EntityManager (a przy bootstrapie JPA, czyli zwykle w Springu, także przez rozpakowaną Session) wywołują flush automatycznie. Dopiero Hibernate uruchomiony natywnie (bez JPA) domyślnie nie flushuje przed natywnym SQL, bo nie wie, których tabel dotyczy zapytanie; wtedy pomaga addSynchronizedEntityClass albo ręczny flush().

    🤓 Głębiej: kolejność akcji przy flush

    Hibernate nie wysyła SQL w kolejności twoich wywołań. Akcje czekają w ActionQueue i wychodzą w stałej kolejności: usunięcia sierot (orphan removal) → INSERT → UPDATE → operacje na kolekcjach → DELETE. Dlatego remove(a); persist(b); daje najpierw INSERT, potem DELETE. Gdy b ma unikalną wartość zajętą przez a, możesz dostać naruszenie ograniczenia. Wtedy pomaga flush() między operacjami.

    Spring przy @Transactional(readOnly = true) ustawia Hibernate FlushMode.MANUAL, przez co Hibernate pomija dirty checking. Na dużych grafach obiektów to odczuwalna oszczędność.

    📌 W punktach
    • Dirty checking: snapshot przy wczytaniu, porównanie przy flush, UPDATE bez save().
    • AUTO: flush przed commitem i przed zapytaniem, na które wpływają zmiany.
    • readOnly = true w Springu → FlushMode.MANUAL, więc zmiany nie trafią do bazy.
    • Kolejność SQL wyznacza ActionQueue, nie kolejność wywołań.

    Relacje i strona właścicielska

    kto pisze klucz obcy?

    Zamówienie ma pozycje. W bazie to jeden klucz obcy: kolumna order_id w tabeli order_items. W Javie chcesz nawigować w obie strony, więc mapujesz relację dwukierunkową:

    @Entity @Table(name = "order_items")
    class OrderItem {
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "order_id")       // ta strona zapisuje FK
        private Order order;
    }
    
    @Entity @Table(name = "orders")
    class Order {
        @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
        private List<OrderItem> items = new ArrayList<>();
    
        public void addItem(OrderItem i)    { items.add(i);    i.setOrder(this); }
        public void removeItem(OrderItem i) { items.remove(i); i.setOrder(null); }
    }

    Relacja dwukierunkowa ma stronę właścicielską (owning side) i stronę odwrotną (inverse). O tym, co trafi do bazy, decyduje tylko strona właścicielska. Przy one-to-many / many-to-one jest nią zawsze strona „wiele”, czyli @ManyToOne. Strona odwrotna wskazuje właściciela przez mappedBy. Zmiany zrobione tylko po stronie odwrotnej nigdy nie trafiają do bazy.

    Stąd metody pomocnicze addItem/removeItem: ustawiają obie strony naraz, żeby obiekty w pamięci zgadzały się z tym, co pójdzie do bazy.

    Cascade przenosi operację z rodzica na dzieci: persist(order) z PERSIST utrwala też pozycje. Do wyboru masz PERSIST, MERGE, REMOVE, REFRESH, DETACH i ALL (wszystkie naraz); domyślnie nic się nie kaskaduje. orphanRemoval idzie dalej: dziecko usunięte z kolekcji zostaje skasowane z bazy przy flush.

    ▶ Order i OrderItem ▶ dodaje pozycję, ✂️ ją usuwa; przełącz orphanRemoval; 💥 ustaw tylko jedną stronę
    
            
    Tabela order_items
    idorder_idproduct
    SQL wysłany do bazy
    Kliknij ▶.
    Ids (7, 70) ilustracyjne, generowanie id pominięte. Zakładamy, że order_id przyjmuje NULL; z NOT NULL wariant 💥 skończyłby się błędem ograniczenia zamiast cichej sieroty. Kolumny skrócone.
    ⚠️ Uwaga

    CascadeType.REMOVE (albo ALL) na @ManyToOne lub @ManyToMany: usuwasz jedną pozycję, a kaskada próbuje usunąć całe zamówienie, które współdzielą inne pozycje. Specyfikacja mówi, że REMOVE powinno się stosować tylko do @OneToOne i @OneToMany; inne użycia nie są przenośne. Kaskada na many-to-many jest rzadko uzasadniona.

    🤓 Głębiej

    Domyślne fetch: @ManyToOne i @OneToOne to EAGER, @OneToMany i @ManyToMany to LAZY. Dlatego wyżej @ManyToOne ma jawne LAZY. Więcej w rozdziale o lazy loading. W @ManyToMany właścicielem może być dowolna strona; druga dostaje mappedBy. Bez @JoinColumn nazwa FK to „nazwa pola + _ + kolumna klucza” (tu: order_id).

    ✏️ Zaostrz ołówek

    Kolega pisze item.setOrder(order); itemRepo.save(item);, ale nie dodaje item do order.getItems(). Czy FK w bazie będzie poprawny? Co zobaczy kod, który w tej samej transakcji przejdzie po order.getItems()?

    Odpowiedź

    FK będzie poprawny, bo ustawiono stronę właścicielską. Ale kolekcja w pamięci nie zawiera nowej pozycji (jeśli była już wczytana), więc kod w tej transakcji jej nie zobaczy. Dlatego metody pomocnicze ustawiają obie strony.

    Część II

    Spring Data JPA

    Repozytoria bez implementacji, zapytania z nazw metod, save(), projekcje, paginacja, dynamiczne filtry i @Transactional. Wszystko to warstwa nad EntityManagerem z części I.

    Repozytoria i proxy

    interfejs bez ani jednej linijki implementacji, a jednak działa

    Piszesz interface OrderRepository extends JpaRepository<Order, Long> i… tyle. Nie ma klasy OrderRepositoryImpl. Mimo to wstrzykujesz orderRepo do serwisu i wołasz orderRepo.findById(42L). Kto to wykonuje?

    Spring Data tworzy dla Twojego interfejsu proxy: obiekt, który implementuje ten interfejs i przekierowuje każde wywołanie w odpowiednie miejsce. W domyślnym trybie startu (spring.data.jpa.repositories.bootstrap-mode=default) proxy powstaje zachłannie, przy starcie kontekstu, jak każdy inny bean.

    Gdzie trafia wywołanie? Metody CRUD (save, findById, delete…) wykonuje klasa SimpleJpaRepository, czyli domyślna implementacja JpaRepository. Ona z kolei woła EntityManager (persist, merge, find…), a ten należy do Hibernate'a. Metody, które dopisujesz sam (findByStatus), nie mają kodu w żadnej klasie: proxy buduje dla nich zapytanie z nazwy metody albo z adnotacji @Query (następny rozdział).

    Hierarchia interfejsów jest krótka. Repository to znacznik: niczego nie deklaruje, tylko mówi „typ encji i typ id”. CrudRepository dodaje save, findById, findAll, count, delete, existsById. ListCrudRepository ma te same metody, ale zwraca List zamiast Iterable. PagingAndSortingRepository dodaje findAll(Sort) i findAll(Pageable). Na końcu JpaRepository łączy to wszystko i dorzuca rzeczy specyficzne dla JPA: flush(), saveAndFlush(), deleteAllInBatch(), getReferenceById().

    I jeszcze transakcje. SimpleJpaRepository ma na poziomie klasy @Transactional(readOnly = true), więc odczyty CRUD biegną w transakcji tylko do odczytu. Metody zapisujące (save, delete…, flush) mają zwykłe @Transactional. Natomiast metody zadeklarowane przez Ciebie (findByStatus) domyślnie nie dostają żadnej konfiguracji transakcji.

    ▶ Dokąd idzie wywołanie repozytorium wybierz metodę albo kliknij ▶
    Repository
    CrudRepository / ListCrudRepository
    PagingAndSortingRepository / List…
    JpaRepository
    OrderRepository (Twój)
    Twój kod—
    Proxy repozytorium—
    Transakcja—
    Implementacja—
    EntityManager (Hibernate)—
    SQL wysłany do bazy
    Kliknij ▶.
    Uproszczenie: pomijamy interceptory poza transakcyjnym i wątek „znajdź EntityManager dla tej transakcji”. Encja Order mapuje tabelę orders; aliasy SQL w stylu Hibernate (o1_0), lista kolumn skrócona.
    ⚠️ Uwaga

    Chcesz dać findAll() limit czasu, więc w swoim interfejsie redeklarujesz @Override @Transactional(timeout = 10) List<Order> findAll();. Działa, ale dokumentacja Spring Data mówi wprost: metoda biegnie wtedy z timeoutem 10 s i bez flagi readOnly. Twoja adnotacja zastępuje całą konfigurację, a nie dopisuje jeden atrybut. Jeśli chcesz zachować readOnly, napisz @Transactional(readOnly = true, timeout = 10).

    Druga pułapka: Twoje metody zapytań (findByStatus) nie mają transakcji z automatu. Dokumentacja proponuje @Transactional(readOnly = true) na całym interfejsie repozytorium, a jeszcze lepiej granice transakcji w serwisie, który zaczyna jednostkę pracy (rozdział o @Transactional).

    📌 W punktach
    InterfejsCo dajeKiedy
    Repository<T, ID>Nic poza znacznikiem typu; same Twoje metodyChcesz wystawić tylko wybrane operacje (np. bez delete)
    CrudRepositorysave, findById, findAll, count, delete, existsById; zwraca IterableKod niezależny od technologii (ten sam interfejs w Mongo, JDBC…)
    ListCrudRepositoryTo samo, ale List zamiast IterableNie chcesz konwertować Iterable na listę
    PagingAndSortingRepositoryfindAll(Sort), findAll(Pageable); nie dziedziczy CRUD-aŁączysz z CrudRepository, gdy potrzebujesz stron
    JpaRepositoryWszystko powyżej (wersje List) + flush, saveAndFlush, deleteAllInBatch, getReferenceById, Query by ExampleDomyślny wybór w aplikacji na JPA
    • Proxy implementuje Twój interfejs; CRUD wykonuje SimpleJpaRepository, a ono woła EntityManager Hibernate'a.
    • Odczyty CRUD: readOnly = true; zapisy: zwykłe @Transactional; Twoje metody zapytań: brak transakcji z automatu.

    Zapytania: derived, @Query, natywne, @Modifying

    nazwa metody to też kod

    Dopisujesz do repozytorium List<Order> findByStatusAndCreatedAtAfterOrderByIdDesc(OrderStatus s, Instant t). Bez ciała, bez SQL. Spring Data parsuje nazwę: odcina przedrostek find…By (to „podmiot” zapytania), a resztę czyta jako warunek („predykat”): właściwości encji połączone And/Or, operatory takie jak After, Between, Like, i na końcu opcjonalne OrderBy…Asc/Desc.

    Każdy fragment nazwy musi trafić w właściwość encji (pole status, createdAt), nie w kolumnę tabeli. Z tego powstaje zapytanie JPQL, a z JPQL Hibernate robi SQL dla Twojej bazy. Zapytanie powstaje, gdy repozytorium jest tworzone (domyślnie przy starcie), więc literówka w nazwie wywali aplikację przy starcie, a nie na produkcji o 3 w nocy.

    Domyślna strategia wyszukiwania zapytań to CREATE_IF_NOT_FOUND: najpierw szuka zapytania zadeklarowanego (@Query albo @NamedQuery o nazwie Order.findBy…), a dopiero gdy go nie ma, buduje je z nazwy metody. @Query na metodzie wygrywa z @NamedQuery.

    ▶ Parser nazw metod wybierz nazwę, kliknij ▶
    Nazwa metody → tokeny
    podmiotwłaściwośćoperatorsortowanie
    JPQL
    —
    SQL wysłany do bazy
    Kliknij ▶.
    JPQL zgodny z tabelą słów kluczowych Spring Data JPA (?1, ?2 to parametry pozycyjne). SQL w stylu Hibernate, lista kolumn skrócona. Encja: Order(id, status, createdAt, total, customerEmail).

    Gdy nazwa robi się zbyt długa, piszesz zapytanie sam. @Query("select o from Order o where o.customerEmail = :email") to JPQL: operujesz na encjach i polach. Parametr nazwany wiążesz przez @Param("email"); jeśli kompilujesz z flagą -parameters, Spring odczyta nazwę parametru sam i @Param możesz pominąć.

    Potrzebujesz czystego SQL-a (funkcja bazy, CTE)? @NativeQuery("SELECT * FROM orders WHERE customer_email = ?1") to w praktyce @Query(nativeQuery = true) z paroma dodatkowymi atrybutami. Tu piszesz nazwy tabel i kolumn, nie encji.

    Zapytanie zmieniające dane (update, delete) w @Query oznaczasz @Modifying; bez tego Spring Data potraktuje je jak SELECT. @Modifying dotyczy tylko @Query: metody pochodne (deleteByStatus) go nie potrzebują.

    ⚠️ Uwaga

    Bulk update omija persistence context. Masz w kontekście załadowane zamówienie #7 ze statusem NEW. Wołasz @Modifying @Query("update Order o set o.status = 'CANCELLED' where o.createdAt < :t"). Baza ma już CANCELLED, a obiekt w pamięci dalej NEW; kolejne findById(7) w tej samej transakcji odda Ci tę starą kopię z kontekstu (rozdział o persistence context).

    Spring Data celowo nie czyści kontekstu sam, bo wyrzuciłoby to też niezapisane zmiany. Masz dwa przełączniki, oba domyślnie false: flushAutomatically = true robi flush przed zapytaniem, clearAutomatically = true czyści kontekst po nim. Gdy używasz clear, ustaw też flush, inaczej clear wyrzuci niezapisane zmiany.

    🔬 Głębiej: deleteBy… a @Modifying delete

    void deleteByStatus(OrderStatus s) i @Modifying @Query("delete from Order o where o.status = ?1") wyglądają tak samo, ale działają inaczej. Metoda pochodna najpierw wykonuje SELECT, ładuje wszystkie pasujące encje do pamięci, a potem usuwa je po kolei przez delete, więc odpalają się callbacki @PreRemove i kaskady. Wersja @Modifying wysyła jedno zapytanie DELETE i żadnych callbacków nie uruchamia. Przy tysiącach wierszy pierwsza jest wolna i pamięciożerna, druga szybka, ale „ślepa” na logikę encji.

    ✏️ Zaostrz ołówek

    Jaki warunek JPQL zbuduje Spring Data dla findByCustomerEmailContainingAndTotalGreaterThan(String s, BigDecimal min)? Co dostanie baza jako pierwszy parametr, gdy przekażesz "firma"?

    Odpowiedź

    … where o.customerEmail like ?1 and o.total > ?2. Containing owija parametr w znaki %, więc baza dostaje %firma%.

    save(): persist czy merge?

    jedna metoda, dwie bardzo różne ścieżki

    save() nie jest operacją JPA. To decyzja Spring Data: jeśli encja jest nowa, woła entityManager.persist(…); jeśli nie, woła entityManager.merge(…). Cała zagadka sprowadza się do tego, skąd Spring Data wie, że encja jest nowa.

    Najpierw Spring Data sprawdza, czy encja implementuje Persistable: jeśli tak, decyduje jej metoda isNew() i nic więcej nie jest brane pod uwagę. W przeciwnym razie działa domyślna reguła w dwóch krokach. Czy encja ma pole @Version typu obiektowego (Long, Integer)? Jeśli tak, encja jest nowa, gdy wersja to null. Jeśli pola wersji nie ma, Spring Data patrzy na @Id: null znaczy „nowa”. Dla id typu prymitywnego (long) nowa jest encja z id równym 0.

    Dlaczego wersja prymitywna (long version) się nie liczy? Bo w JPA 0 to pierwsza zapisana wersja encji, więc zero nie odróżnia nowej encji od zapisanej.

    Co robi merge? Kopiuje stan obiektu na egzemplarz zarządzany. Jeśli w persistence context nie ma encji o tym id, Hibernate najpierw ją ładuje z bazy (SELECT), a gdy wiersza nie ma, tworzy nową i robi INSERT. merge zwraca inny obiekt niż ten, który mu dałeś. Dlatego zawsze używaj wartości zwróconej przez save: order = repo.save(order);.

    ▶ Drzewko decyzji isNew wybierz wariant encji, kliknij ▶
    —
    implements Persistable?
    @Version typu obiektowego?
    @Id == null (lub 0 dla prymitywu)?
    persist()
    merge()
    SQL wysłany do bazy
    Kliknij ▶.
    Wariant A: @GeneratedValue(strategy = IDENTITY), więc INSERT idzie od razu (Hibernate musi poznać id). B–D: id typu UUID ustawiane w kodzie. Kontekst jest pusty przed save, wiersza jeszcze nie ma w bazie. SQL uproszczony.
    🤓 Głębiej: Persistable

    Ręcznie nadawane id bez @Version zawsze jest różne od null, więc domyślna reguła uzna każdą encję za „nie nową” i każdy save nowej encji zrobi SELECT + INSERT. Dokumentacja Spring Data podsuwa wzorzec: klasa bazowa z przejściową flagą, którą callbacki JPA przestawiają po zapisie i po załadowaniu.

    @MappedSuperclass
    public abstract class AbstractEntity<ID> implements Persistable<ID> {
      @Transient
      private boolean isNew = true;
    
      @Override
      public boolean isNew() { return isNew; }
    
      @PostPersist
      @PostLoad
      void markNotNew() { this.isNew = false; }
    }

    Prostsza alternatywa: dodaj @Version Long version. Nowa encja ma null, Spring Data wybierze persist, a przy okazji dostajesz optimistic locking (rozdział o blokowaniu optymistycznym).

    📌 W punktach
    • save = persist, gdy encja nowa; w przeciwnym razie merge.
    • „Nowa”: Persistable.isNew(), jeśli encja go implementuje; inaczej wersja obiektowa null → a bez wersji @Id null (prymityw: 0).
    • merge encji spoza kontekstu = SELECT po id, potem INSERT lub UPDATE. Ręczne id bez wersji płaci ten SELECT przy każdym nowym zapisie.
    • Używaj wyniku save: po merge zarządzany jest zwrócony obiekt, nie Twój.
    • Encji zarządzanej w transakcji nie musisz „zapisywać”: zmiany wyłapie dirty checking.

    Projekcje

    po co ciągnąć 30 kolumn, skoro ekran pokazuje dwie

    Lista zamówień pokazuje numer i status. Metoda List<Order> findByCustomerEmail(…) pobierze jednak wszystkie kolumny, zrobi z nich encje i wrzuci je do persistence context (ze snapshotami do dirty checkingu). Projekcja to typ wyniku, który mówi Spring Data: „potrzebuję tylko tych pól”.

    Projekcja interfejsowa zamknięta: interfejs, którego wszystkie gettery odpowiadają właściwościom encji, np. interface OrderSummary { Long getId(); OrderStatus getStatus(); }. Spring Data zna wszystkie potrzebne pola, więc może zawęzić SELECT. Wynik to proxy tego interfejsu, zbudowane (w JPA) zwykle z zapytania typu Tuple.

    Projekcja otwarta: getter z @Value("#{target.id + ' / ' + target.status}"), czyli wyrażeniem SpEL. Wyrażenie może sięgnąć do dowolnego pola, więc Spring Data nie może zoptymalizować zapytania: ładuje całą encję.

    Projekcja klasowa (DTO): zwykła klasa albo record OrderTotal(Long id, BigDecimal total). Pola do pobrania Spring Data ustala z nazw parametrów konstruktora; nie ma proxy i nie ma zagnieżdżonych projekcji. W JPA działa to przez constructor expression (select new com.example.OrderTotal(o.id, o.total) …).

    Projekcja dynamiczna: <T> List<T> findByCustomerEmail(String email, Class<T> type). Typ wyniku wybierasz przy wywołaniu: repo.findByCustomerEmail(e, OrderSummary.class).

    ▶ Projekcja → kolumny w SELECT wybierz typ wyniku
    —
    Kolumny tabeli orders
    idstatuscreated_attotalcustomer_email
    Persistence context: —
    SQL wysłany do bazy
    Kliknij ▶.
    Zapytanie pochodne findByCustomerEmail. Encja ma tu tylko 5 kolumn; w prawdziwych tabelach różnica bywa większa. SQL w stylu Hibernate, uproszczony.
    ❓ Nie ma głupich pytań
    P:
    Czy projekcja z zagnieżdżonym polem (getCustomer().getName()) też pobierze tylko jedną kolumnę?
    O:
    Nie. W zapytaniach pochodnych projekcja zawęża tylko właściwości najwyższego poziomu. Zagnieżdżona właściwość, która wymaga złączenia, wybiera cały powiązany obiekt.
    P:
    Czy obiekty z projekcji mogę zmienić i zapisać?
    O:
    Nie w ten sposób. Projekcja DTO to zwykły obiekt, nie encja: Hibernate go nie śledzi, więc jego zmiana nie trafi do bazy. Projekcje służą do odczytu.
    P:
    Mam DTO z dwoma konstruktorami. Działa?
    O:
    Nie bez podpowiedzi. Przy kilku konstruktorach wskaż ten właściwy adnotacją @PersistenceCreator.
    ✏️ Zaostrz ołówek

    Ekran listy pokazuje id, status i „Klient: email”. Kolega pisze @Value("#{'Klient: ' + target.customerEmail}") String getLabel(); w interfejsie OrderRow. Ile kolumn pobierze zapytanie i jak to poprawić?

    Odpowiedź

    Wszystkie, bo @Value robi z interfejsu projekcję otwartą i Spring Data nie może zawęzić SELECT-a. Poprawka: zamknięta projekcja z getId(), getStatus(), getCustomerEmail(), a napis „Klient: …” sklej w warstwie widoku (dokumentacja podsuwa też metodę default w interfejsie jako opcję dla prostych wyrażeń).

    Paginacja i sortowanie

    strona 50 kosztuje więcej niż strona 1

    Do metody repozytorium dodajesz parametr Pageable, a w wywołaniu podajesz PageRequest.of(1, 20, Sort.by("id")). Numeracja stron zaczyna się od zera, więc to druga strona po 20 wierszy, posortowana po id. Samo sortowanie bez stron? Parametr Sort.

    Typ wyniku decyduje, ile zapytań poleci. Page<Order> zna liczbę wszystkich elementów i stron, więc infrastruktura wysyła dodatkowe zapytanie COUNT. Slice<Order> wie tylko, czy jest następna porcja: pobiera rozmiar strony + 1 wiersz i bez COUNT sprawdza, czy ten „nadmiarowy” istnieje. List<Order> z Pageable też nie liczy.

    Wszystkie trzy opierają się na OFFSET: „pomiń 980 wierszy, daj 20”. Baza i tak musi przejść te 980 wierszy, żeby je pominąć. Alternatywą jest przewijanie keyset (Window<Order> + ScrollPosition.keyset()): zamiast offsetu zapytanie dostaje warunek where id > ostatnie_widziane_id i może skoczyć prosto w indeks.

    ▶ Page vs Slice vs Window (keyset) wybierz typ i stronę
    zwróconeprzeczytane i pominiętepoliczone przez COUNT
    Wiersze dotknięte przez bazę: 0
    SQL wysłany do bazy
    Kliknij ▶.
    Tabela 1000 zamówień, strona = 20 wierszy (liczby ilustracyjne). „Wiersze dotknięte” to model poglądowy, nie pomiar: realny koszt zależy od indeksów i planu zapytania. Składnia limitu (offset … fetch first … albo limit … offset …) zależy od dialektu bazy.
    ⚠️ Uwaga

    Głęboki OFFSET. Dokumentacja Spring Data mówi wprost: zapytania z offsetem stają się nieefektywne, gdy offset jest duży, bo baza i tak musi zmaterializować pełny wynik. Do tego Page dokłada COUNT, który przy dużej tabeli z filtrami bywa droższy niż samo pobranie strony.

    Keyset ma swoje warunki: stabilne sortowanie, indeks pasujący do pól sortowania, a te pola nie mogą być nullable (porównania z NULL dadzą dziwne wyniki). Spring Data sam dokleja klucz główny do sortowania, żeby pozycja była jednoznaczna. Za to nie skoczysz od razu na „stronę 37”: keyset idzie tylko do następnego okna.

    🔬 Głębiej: COUNT przy @Query i @NativeQuery

    Dla Page zapytanie COUNT jest domyślnie wyprowadzane z Twojego zapytania. Wbudowany mechanizm radzi sobie tylko z prostymi zapytaniami; przy złożonych potrzebujesz biblioteki JSqlParser na classpath albo jawnego countQuery, np. @NativeQuery(value = "…", countQuery = "SELECT count(*) FROM orders WHERE …"). Przewijanie (Window) działa z metodami pochodnymi, Query by Example i Querydsl; z zapytaniami pisanymi jako tekst (@Query) jeszcze nie.

    📌 W punktach
    • PageRequest.of(numer, rozmiar, sort), numer od 0. Brak sortowania/stron: Sort.unsorted(), Pageable.unpaged(), nie null.
    • Page = strona + COUNT. Slice = rozmiar + 1 wiersz, bez COUNT. List + Pageable = sama strona.
    • OFFSET rośnie → baza przechodzi coraz więcej wierszy. Do długich list i eksportów: Window + keyset.

    Specification i Query by Example

    filtry, których kombinacji nie znasz z góry

    Ekran wyszukiwania ma cztery opcjonalne filtry: status, data od, minimalna kwota, fragment e-maila. Metoda pochodna na każdą kombinację to 16 metod. @Query z warunkami (:status is null or o.status = :status) szybko staje się nieczytelne. Potrzebujesz zapytania składanego w czasie działania.

    Specification to predykat nad encją zbudowany Criteria API. Repozytorium rozszerzasz o JpaSpecificationExecutor<Order> i dostajesz findAll(spec), count(spec), findAll(spec, pageable). Od Spring Data JPA 4.0 masz też PredicateSpecification: prostszy interfejs (toPredicate(From, CriteriaBuilder)) niezależny od typu zapytania. Klasyczne Specification jest związane z SELECT-em; dla UPDATE i DELETE są UpdateSpecification i DeleteSpecification.

    static PredicateSpecification<Order> hasStatus(OrderStatus s) {
      return (from, cb) -> cb.equal(from.get(Order_.status), s);
    }
    static PredicateSpecification<Order> totalAbove(BigDecimal min) {
      return (from, cb) -> cb.greaterThan(from.get(Order_.total), min);
    }
    // składanie:
    orderRepo.findAll(hasStatus(PAID).and(totalAbove(new BigDecimal("100"))));

    Specyfikacje składasz przez and, or, not i allOf(…). Specyfikacja, która nic nie ogranicza (Specification.unrestricted(), zwraca predykat null), jest przy składaniu pomijana. To wygodne przy filtrach opcjonalnych, ale ma konsekwencję, którą zobaczysz w figurze.

    ▶ Builder filtrów zaznacz filtry; każda zmiana wysyła zapytanie
    Złożona specyfikacja
    —
    Pasujące wiersze: — z 1000
    SQL wysłany do bazy
    Kliknij ▶.
    Liczby wierszy ilustracyjne (tabela 1000 zamówień). SQL w stylu Hibernate, lista kolumn skrócona do o1_0.*; parametry jako ?.

    Query by Example (QBE) to lżejsza droga: wypełniasz „wzorcowy” obiekt i pytasz o podobne. Example.of(probe, matcher) i orderRepo.findAll(example) (JpaRepository dziedziczy QueryByExampleExecutor). Pola z wartością null są domyślnie pomijane, a pola prymitywne (int, double) zawsze biorą udział w warunku, chyba że je zignorujesz. ExampleMatcher zmienia te reguły: withIgnorePaths("total"), withStringMatcher(StringMatcher.CONTAINING), withIncludeNullValues().

    Ograniczenia QBE: tylko równość dla nie-tekstów (żadnego „kwota > 100”), brak grup typu a = ?1 or (b = ?2 and c = ?3), brak dopasowań na kolekcjach i mapach. Do prostych formularzy „szukaj po polach” wystarczy; do zakresów i OR-ów potrzebujesz Specification.

    🔥 Rozmowa przy kominku
    @QueryJa jestem jednym stringiem. Otwierasz plik i widzisz całe zapytanie, od select do order by.
    SpecificationDopóki ktoś nie doda piątego opcjonalnego filtra. Wtedy jesteś ścianą (:x is null or …).
    @QueryA Ty jesteś rozsmarowana po pięciu metodach i nikt nie wie, jaki SQL wyjdzie, póki nie włączy logów.
    SpecificationZa to każdy klocek testujesz osobno i składasz je w dowolnej kombinacji. Z metamodelem Order_ literówka w polu nie skompiluje się.
    @QueryUmowa: stały kształt zapytania, raporty, złączenia z fetch — moje. Formularz wyszukiwania z opcjonalnymi polami — Twój.
    📌 W punktach
    • JpaSpecificationExecutor + małe, nazwane specyfikacje składane and/or.
    • Specyfikacja bez ograniczenia (unrestricted(), predykat null) znika przy składaniu; wszystkie puste = brak WHERE = cała tabela. Pilnuj limitu lub stronicowania.
    • QBE: szybkie wyszukiwanie po równości/tekstach; bez zakresów, OR-grup i kolekcji.

    @Transactional w praktyce

    adnotacja działa tylko wtedy, gdy wywołanie przejdzie przez proxy

    Oznaczasz metodę serwisu @Transactional. Spring (w domyślnym trybie proxy) opakowuje bean w obiekt pośredniczący. Gdy ktoś z zewnątrz woła orderService.placeOrder(), najpierw trafia do proxy: ono otwiera transakcję, wywołuje Twoją metodę, a na końcu robi commit albo rollback. Sama adnotacja to tylko metadane; pracę wykonuje proxy.

    Stąd najważniejsza reguła: przechwytywane są tylko wywołania z zewnątrz, przez proxy. Jeśli metoda checkout() w tej samej klasie woła this.placeOrder(), wywołanie omija proxy i żadna transakcja nie powstaje, mimo adnotacji. To self-invocation.

    Domyślne ustawienia @Transactional: propagacja REQUIRED (dołącz do istniejącej transakcji albo zacznij nową), izolacja DEFAULT (taka jak domyślna w bazie), transakcja read-write, timeout taki jak w systemie transakcyjnym (albo brak).

    I reguła, która zaskakuje najczęściej: rollback powodują tylko RuntimeException i Error. Wyjątek sprawdzany (IOException, Twój PaymentDeclinedException extends Exception) nie cofa transakcji: Spring zrobi commit i dopiero potem przepuści wyjątek dalej. Zmieniasz to przez rollbackFor = Exception.class (albo noRollbackFor w drugą stronę).

    ▶ Wywołanie przez proxy i bez niego kliknij ▶, potem scenariusze 💥
    OrderController
    →
    Proxy
    TransactionInterceptor
    →
    OrderService
    placeOrder()
    →
    repozytoria
    save(order), save(payment)
    Transakcja: brak
    Baza po zakończeniu
    SQL wysłany do bazy
    Kliknij ▶.
    placeOrder() zapisuje zamówienie i płatność, a w scenariuszach z wyjątkiem rzuca go po obu zapisach. Id typu IDENTITY, więc INSERT idzie od razu przy save. Komentarze -- w logu to granice transakcji (JDBC), nie SQL. Uproszczenie: jedna baza, jeden menedżer transakcji.
    ⚠️ Uwaga: self-invocation i widoczność metod

    Self-invocation: this.placeOrder() z wnętrza tej samej klasy nie przechodzi przez proxy, więc @Transactional na placeOrder jest ignorowane. Rozwiązania: przenieś metodę transakcyjną do osobnego beana, oznacz @Transactional metodę zewnętrzną (tę, którą woła kontroler), albo użyj trybu AspectJ (kod klasy jest modyfikowany, więc proxy w ogóle nie ma). Z tego samego powodu nie licz na transakcję w @PostConstruct: proxy musi być w pełni zainicjalizowane.

    Widoczność: typowo @Transactional stawiasz na metodach public. Od Springa 6.0 (więc i w Springu 7) proxy oparte na klasach (CGLIB) obsługują domyślnie także metody protected i pakietowe. W proxy opartych na interfejsach metoda musi być public i zadeklarowana w interfejsie. Spring Boot domyślnie tworzy proxy klasowe (spring.aop.proxy-target-class=true). Metoda private nie zostanie przechwycona przez żadne proxy: podklasa nie może jej nadpisać.

    readOnly = true to wskazówka, nie zabezpieczenie: nie blokuje zapytań modyfikujących (choć niektóre bazy odrzucą INSERT/UPDATE w transakcji tylko do odczytu). Spring przekazuje ją sterownikowi JDBC, a przy Hibernate ustawia flush mode na MANUAL, więc Hibernate pomija dirty checking. Przy dużych grafach obiektów to odczuwalna oszczędność.

    REQUIRED vs REQUIRES_NEW. Przy REQUIRED metoda wewnętrzna dołącza do transakcji zewnętrznej: to jedna fizyczna transakcja. Jeśli metoda wewnętrzna oznaczy ją jako „tylko do wycofania” (rzuci wyjątek, który zewnętrzna złapie i zignoruje), zewnętrzna przy commicie dostanie UnexpectedRollbackException. REQUIRES_NEW zawsze otwiera osobną fizyczną transakcję z własnym połączeniem: wewnętrzna commituje lub wycofuje się niezależnie. Koszt: drugie połączenie z puli na czas trwania obu, co przy małej puli i wielu wątkach grozi zakleszczeniem. Więcej w rozdziale o propagacji.

    🤓 Głębiej: izolacja, timeout i globalna reguła rollbacku

    isolation, timeout i readOnly mają znaczenie tylko dla propagacji REQUIRED i REQUIRES_NEW, czyli tam, gdzie transakcja faktycznie startuje. Metoda dołączająca do istniejącej transakcji domyślnie przejmuje jej charakterystykę.

    Od Springa 6.2 możesz zmienić domyślną regułę rollbacku globalnie: @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) cofa transakcję przy każdym wyjątku, także sprawdzanym. Dokumentacja Springa zaleca to ustawienie, chyba że celowo polegasz na wyjątkach biznesowych z commitem w stylu EJB; szczególnie w Kotlinie, który nie wymusza obsługi wyjątków sprawdzanych.

    ✏️ Zaostrz ołówek
    @Service
    public class InvoiceService {
      public void issueAll(List<Long> ids) {
        for (Long id : ids) issue(id);
      }
      @Transactional
      public void issue(Long id) throws InvoiceException { … }
    }

    Kontroler woła invoiceService.issueAll(ids). issue rzuca InvoiceException extends Exception po zapisaniu faktury. Ile transakcji powstanie i co zostanie w bazie?

    Odpowiedź

    Żadna transakcja z @Transactional na issue: wywołanie idzie przez this, z pominięciem proxy. Każdy save repozytorium biegnie we własnej transakcji SimpleJpaRepository i od razu się commituje, więc zapisane faktury zostają. Gdyby nawet wywołanie szło przez proxy, InvoiceException jest wyjątkiem sprawdzanym, więc domyślnie byłby commit. Poprawka: @Transactional(rollbackFor = InvoiceException.class) i wywołanie z innego beana (albo transakcja na issueAll, jeśli faktury mają przejść „wszystkie albo żadna”).

    Część III

    Wydajność

    Tu dowiesz się, skąd biorą się „magiczne” dodatkowe SELECT-y, jak je policzyć i usunąć, jak wysłać 100 INSERT-ów w kilku podróżach do bazy, co naprawdę robi Open Session in View i kiedy cache drugiego poziomu pomaga, a kiedy kłamie.

    Lazy, eager i proxy

    nie wszystko naraz… ale kiedy?

    Twoje encje tworzą graf: zamówienie wskazuje klienta, klient ma adresy, adresy mają kraj… Gdyby Hibernate przy każdym find() ładował wszystko, do czego da się dojść po referencjach, ściągnąłbyś pół bazy. Musi więc gdzieś przeciąć graf.

    Do wyboru są dwa tryby. EAGER („chciwie”) znaczy: załaduj od razu, razem z właścicielem. LAZY („leniwie”) znaczy: załaduj później, przy pierwszym dostępie.

    Jeśli nic nie napiszesz, obowiązują domyślne ustawienia ze specyfikacji JPA. Łatwo je zapamiętać: „*ToOne” jest EAGER, „*ToMany” jest LAZY.

    AdnotacjaDomyślny fetchCo to znaczy w praktyce
    @ManyToOneEAGERklient zamówienia ładuje się od razu, nawet gdy go nie potrzebujesz
    @OneToOneEAGERjak wyżej
    @OneToManyLAZYpozycje zamówienia ładują się dopiero, gdy ich dotkniesz
    @ManyToManyLAZYjak wyżej
    @ElementCollectionLAZYkolekcja wartości (np. tagi) też czeka

    Jak Hibernate „czeka” z ładowaniem? Zamiast prawdziwego obiektu wkłada w pole proxy: obiekt, który udaje encję (albo kolekcję), ale nie ma jeszcze danych. Gdy wywołasz na nim metodę, Hibernate wysyła SELECT, wypełnia dane i dopiero wtedy przepuszcza wywołanie dalej.

    Jest jeden wyjątek, który warto znać: getId() na proxy nie idzie do bazy, bo identyfikator proxy zna od początku.

    ▶ Proxy w akcji kliknij ▶, potem 💥
    Sesja (persistence context): zamknięta
    Order #101
    —
    customer
    @ManyToOne(LAZY)
    items
    @OneToMany
    Customer
    —
    List<OrderItem>
    —
    // tu pojawi się wywoływany kod
    SQL wysłany do bazy
    Kliknij ▶.
    Uproszczenie: jedna encja, jedna sesja, aliasy w SQL skrócone w stylu Hibernate 6+/7. Szare, przerywane ramki to proxy lub niezainicjowana kolekcja; zielone to obiekty z danymi.

    Proxy działa tylko wtedy, gdy encja jest podpięta do otwartego persistence context (czyli sesji). Po zamknięciu sesji proxy nie ma już skąd wziąć danych i rzuca znienawidzony LazyInitializationException.

    Rekomendacja z dokumentacji Hibernate brzmi tak: wszystkie asocjacje ustaw na fetch = LAZY. Dla @ManyToOne i @OneToOne musisz to napisać jawnie, bo domyślnie są EAGER. A potrzebne dane pobieraj z góry, na początku jednostki pracy, przez join fetch albo EntityGraph (zobaczysz je w następnym rozdziale).

    @Entity @Table(name = "orders")
    class Order {
      @Id Long id;
      String status;
    
      @ManyToOne(fetch = FetchType.LAZY)   // jawnie! domyślnie byłoby EAGER
      Customer customer;
    
      @OneToMany(mappedBy = "order")        // LAZY już domyślnie
      List<OrderItem> items = new ArrayList<>();
    }
    ❓ Nie ma głupich pytań
    P:
    Skoro LAZY sprawia kłopoty, czemu nie ustawić wszędzie EAGER?
    O:
    Bo wtedy każdy find() ciągnie za sobą cały osiągalny graf. Dokumentacja Hibernate nazywa to wprost „okropnym pomysłem”. Co gorsza, EAGER wcale nie usuwa dodatkowych zapytań (zobaczysz to w N+1).
    P:
    Jak sprawdzić, czy proxy albo kolekcja są już załadowane?
    O:
    W JPA: emf.getPersistenceUnitUtil().isLoaded(x). W Hibernate prościej: Hibernate.isInitialized(x). Wymuszone załadowanie: Hibernate.initialize(x), ale to kolejna podróż do bazy.
    P:
    Czy LAZY to gwarancja?
    O:
    Nie. Według specyfikacji JPA EAGER to wymóg, a LAZY tylko podpowiedź (hint), którą dostawca może zignorować. Hibernate leniwe ładowanie asocjacji wspiera, więc w praktyce podpowiedź działa.
    ⚠️ Uwaga

    W sieci znajdziesz „lekarstwo” na LazyInitializationException: hibernate.enable_lazy_load_no_trans=true. Domyślnie jest false, a dokumentacja oznacza je jako niebezpieczne: każde leniwe ładowanie otwiera wtedy tymczasowy persistence context poza transakcją, co łamie izolację transakcji. Właściwe rozwiązanie to pobrać dane, zanim sesja się zamknie.

    🔬 Głębiej: proxy a dziedziczenie

    Przy asocjacji polimorficznej (np. @ManyToOne Payment payment, gdzie Payment ma podklasy) Hibernate w chwili tworzenia proxy nie zna konkretnej klasy. Dlatego instanceof i rzutowanie na proxy nie działają poprawnie. Adnotacja @ConcreteProxy to rozwiązuje, ale kosztem dodatkowych złączeń, więc dokumentacja odradza ją poza szczególnymi przypadkami.

    Druga przydatna sztuczka: em.getReference(Customer.class, 7L) daje proxy bez SELECT-a. Wystarczy, żeby ustawić klucz obcy: order.setCustomer(em.getReference(Customer.class, 7L)).

    📌 W punktach
    • Domyślnie: *ToOne to EAGER, *ToMany to LAZY.
    • Proxy to „pusty” obiekt, który przy pierwszym wywołaniu metody (poza getId()) wysyła SELECT.
    • Po zamknięciu sesji proxy rzuca LazyInitializationException.
    • Dobra praktyka: wszystko LAZY, a potrzebne dane pobierasz z góry w zapytaniu.

    Problem N+1

    jedna linijka kodu, jedenaście zapytań

    Dokumentacja Hibernate nazywa N+1 „bez wątpienia najczęstszą przyczyną” wolnego dostępu do danych w Javie. Mechanizm jest prosty: 1 zapytanie pobiera listę N wierszy, a potem N kolejnych zapytań dociąga powiązaną encję dla każdego wiersza z osobna.

    List<Order> orders = orderRepository.findByStatus("NEW");   // 1 zapytanie
    for (Order o : orders) {
      print(o.getId() + " " + o.getCustomer().getName());       // + 1 zapytanie na każde zamówienie
    }

    To nie jest błąd Hibernate. Ten sam problem ma ręcznie pisany kod JDBC w DAO. Tylko Ty wiesz z góry, jakich danych potrzebuje dany kawałek kodu, więc tylko Ty możesz go rozwiązać. Hibernate daje do tego narzędzia. Zobacz je w akcji:

    ▶ Lista zamówień i licznik SQL wybierz strategię, kliknij ▶
    
            
    SQL wysłany do bazy
    Kliknij ▶.
    10 zamówień o statusie NEW, każde z innym klientem (dane ilustracyjne). SQL w stylu Hibernate 7 na PostgreSQL, aliasy skrócone; przy batch fetching PostgreSQL dostaje tablicę kluczy (= any (?)), na innych bazach zobaczysz in (?, ?, …). Podział kluczy na paczki jest uproszczony.

    Masz cztery narzędzia. Hibernate wymienia trzy strategie (outer join, batch, subselect) i dodaje: prawie zawsze używaj złączenia.

    OpcjaCo robiKiedy
    @Query("select o from Order o join fetch o.customer where o.status = :s")jedno zapytanie z JOIN; klient przychodzi razem z zamówieniemdomyślny wybór, gdy wiesz, czego potrzebujesz
    @EntityGraph(attributePaths = "customer") na metodzie repozytoriumSpring Data buduje z tego EntityGraph JPA; Hibernate dokłada left joinchcesz zostać przy metodzie pochodnej (findByStatus) bez pisania JPQL
    @BatchSize(size = 5) na klasie Customer albo globalnie hibernate.default_batch_fetch_sizeleniwe ładowanie zostaje, ale jeden SELECT inicjuje do 5 proxy naraz (where id in …)siatka bezpieczeństwa; gdy join dałby iloczyn kartezjański
    @Fetch(FetchMode.SUBSELECT) / hibernate.use_subselect_fetchdla kolekcji: drugie zapytanie powtarza pierwsze jako podzapytanie (where … in (select …))druga kolekcja, której nie możesz dołączyć joinem
    interface OrderRepository extends JpaRepository<Order, Long> {
      @Query("select o from Order o join fetch o.customer where o.status = :status")
      List<Order> findWithCustomerByStatus(String status);
    
      @EntityGraph(attributePaths = "customer")
      List<Order> findByStatus(String status);
    }

    Batch i subselect fetching są domyślnie wyłączone. W Spring Boot włączysz batch globalnie przez spring.jpa.properties.hibernate.default_batch_fetch_size=16 (liczba przykładowa). Dokumentacja jest tu szczera: batch fetching łagodzi N+1, ale go nie rozwiązuje. Rozwiązaniem jest pobranie asocjacji złączeniem.

    ⚠️ Uwaga

    EAGER nie naprawia N+1. Zapytanie JPQL (a takie generuje findByStatus) nie dołącza samo z siebie asocjacji EAGER. Hibernate, żeby dotrzymać obietnicy „od razu”, wysyła osobny SELECT dla każdej pominiętej asocjacji, zanim odda Ci listę. Zapytania są te same, przychodzą tylko wcześniej, i to nawet tam, gdzie klienta w ogóle nie czytasz. Kliknij 💥 w figurze.

    Jak wykryć N+1? Włącz log SQL (spring.jpa.show-sql=true, domyślnie false, albo logger org.hibernate.SQL na DEBUG) i policz zapytania na jeden request. W testach integracyjnych warto to liczenie zautomatyzować (opinia: to najtańszy strażnik wydajności).

    ✏️ Zaostrz ołówek

    Masz 50 zamówień, każde z innym klientem i z listą pozycji (items, LAZY). Pętla czyta o.getCustomer().getName() i o.getItems().size(). Ile zapytań poleci: (a) bez poprawek, (b) z join fetch o.customer, (c) bez joinów, z hibernate.default_batch_fetch_size=25?

    Odpowiedź

    (a) 1 + 50 + 50 = 101. (b) 1 + 50 = 51: klientów już masz, ale kolekcje nadal ładują się po jednej. (c) 1 + 2 + 2 = 5: dwie paczki klientów i dwie paczki kolekcji po 25. Liczby zakładają, że każdy klient występuje raz. Najlepiej byłoby dołączyć klienta joinem, a pozycje pobrać drugim zapytaniem (patrz następny rozdział).

    JOIN FETCH: pułapki

    jedno zapytanie, trzy niespodzianki

    Złączenie do relacji *ToOne jest bezpieczne: jedno zamówienie to jeden wiersz. Złączenie do kolekcji mnoży wiersze. Zamówienie z trzema pozycjami wraca z bazy jako trzy wiersze.

    Niespodzianka 1: duplikaty. Kiedyś trzeba było pisać select distinct. Od Hibernate 6 duplikaty wynikające z join fetch są usuwane automatycznie w pamięci, a dokumentacja wprost mówi, żeby nie używać do tego distinct.

    Niespodzianka 2: paginacja. LIMIT 2 liczy wiersze, a nie zamówienia. Gdyby baza ucięła wynik po dwóch wierszach, dostałbyś jedno zamówienie z obciętą listą pozycji. Przed Hibernate 7.4 limity źle współpracowały z kolekcjami w join fetch. W 7.4 jest to naprawione na bazach, które wspierają limit i offset w podzapytaniu. Na bazie bez tej możliwości (dokumentacja podaje Sybase ASE) Hibernate pobiera wszystkie pasujące wiersze i stronicuje w pamięci.

    Niespodzianka 3: dwie listy. Dwie kolekcje typu List (w żargonie Hibernate: bag, czyli kolekcja bez kolejności, mogąca mieć duplikaty) w jednym join fetch kończą się MultipleBagFetchException. Zamiana na Set usuwa wyjątek, ale nie problem: dwie kolekcje dołączone równolegle dają iloczyn kartezjański.

    ▶ Wiersze wyniku JOIN FETCH kliknij przyciski po kolei
    Wiersze z bazy (0)
    Obiekty w Javie
    SQL wysłany do bazy
    Kliknij ▶.
    Dane ilustracyjne: 3 zamówienia, 6 pozycji, 4 płatności. SQL pokazuje schemat zapytania; dokładna postać podzapytania z limitem zależy od dialektu i wersji Hibernate.
    UstawienieCo robiKiedy
    hibernate.query.fail_on_pagination_over_collection_fetchdomyślnie false; true zamienia paginację w pamięci na wyjątekzawsze warto włączyć: wolisz błąd w teście niż cichy odczyt całej tabeli
    Set zamiast Listznika MultipleBagFetchException, zostaje iloczyn kartezjańskigdy kolekcja i tak nie ma duplikatów; nie jako „naprawa” dwóch joinów
    drugie zapytanie / @Fetch(SUBSELECT)pierwsza kolekcja przez join, druga osobnym zapytaniempotrzebujesz dwóch kolekcji naraz
    🤓 Głębiej

    Rozwiązanie dwoma zapytaniami działa dzięki persistence context. Pierwsze zapytanie ładuje zamówienia z pozycjami. Drugie (select o from Order o join fetch o.payments where o in :orders) zwraca te same instancje zamówień i uzupełnia w nich płatności. Dwa małe wyniki (6 + 4 wiersze) zamiast jednego iloczynu (9 wierszy, a przy prawdziwych danych często tysiące).

    Nie filtruj po kolekcji pobieranej joinem. join fetch o.items i where i.price > 100 da zamówienia z niekompletną listą pozycji, a Hibernate potraktuje ją jak pełną. Dokumentacja radzi nie nadawać nawet aliasu złączeniu typu fetch, chyba że do zagnieżdżonego join fetch. Do filtrowania użyj zwykłego join albo exists.

    Paginacja w Spring Data: Page<Order> z @Query zawierającym join fetch to dokładnie przypadek z przycisku 📄. Sprawdź, którą wersję Hibernate masz w projekcie, zanim zaufasz poprawce z 7.4.

    📌 W punktach
    • join fetch do *ToOne: bezpieczny. Do kolekcji: najwyżej jednej naraz.
    • Duplikaty z join fetch Hibernate 6+ usuwa sam; nie dodawaj distinct.
    • Paginacja + kolekcja: w 7.4 limit trafia do podzapytania, jeśli baza to umie; inaczej stronicowanie w pamięci. Włącz fail_on_pagination_over_collection_fetch.
    • Dwie listy w jednym joinie: MultipleBagFetchException. Lekarstwo to dwa zapytania, nie Set.

    Zapisy wsadowe i generatory ID

    100 INSERT-ów w kilku podróżach

    Każda podróż do bazy (round trip) kosztuje czas sieci. Gdy zapisujesz 100 encji, możesz wysłać 100 osobnych INSERT-ów albo kilka paczek JDBC (batch) po kilkadziesiąt. Hibernate potrafi to robić sam, wystarczy jedna właściwość:

    # application.properties (Spring Boot)
    spring.jpa.properties.hibernate.jdbc.batch_size=25
    spring.jpa.properties.hibernate.order_inserts=true
    spring.jpa.properties.hibernate.order_updates=true
    OpcjaDomyślnie / co robiKiedy
    hibernate.jdbc.batch_size0 (wyłączone); każda wartość dodatnia włącza batching w sesji stanowejdużo INSERT/UPDATE/DELETE do jednej tabeli w jednej transakcji; dokumentacja proponuje 10–50
    hibernate.order_insertsfalse; sortuje INSERT-y wg typu encji i klucza, z poszanowaniem kluczy obcychzapisujesz na przemian różne encje (np. zamówienie, pozycje, zamówienie…)
    hibernate.order_updatesfalse; to samo dla UPDATE-ów i zmian kolekcjijak wyżej, przy masowych zmianach

    Jest jeden haczyk i siedzi w @Id. Generator IDENTITY (kolumna auto-increment) zakłada, że identyfikator powstaje dopiero przy INSERT. Hibernate musi więc wykonać każdy INSERT osobno, żeby poznać id, i po cichu wyłącza batch insert dla takiej encji.

    SEQUENCE działa inaczej: Hibernate pobiera z sekwencji cały blok identyfikatorów (allocationSize, w JPA domyślnie 50) i przydziela je w pamięci. Do bazy wraca dopiero, gdy blok się wyczerpie. Tym zajmuje się optymalizator, domyślnie pooled. Bez jawnej nazwy sekwencji Hibernate przyjmie nazwę tabeli z sufiksem _seq.

    ▶ saveAll(100 produktów) wybierz generator, kliknij ▶
    ma id zapisana
    Round tripy do bazy
    Kliknij ▶.
    Licznik liczy podróże do bazy: jedna paczka JDBC to jedna linia. Liczba wywołań sekwencji (2 bloki po 50) jest ilustracyjna; dokładna liczba zależy od optymalizatora i wartości startowej sekwencji. SQL w stylu PostgreSQL.
    ⚠️ Uwaga

    GenerationType.IDENTITY plus batch_size=50 wygląda w konfiguracji dobrze, a daje zero batchowania INSERT-ów. Jeśli tworzysz dużo nowych encji jednego typu, wybierz SEQUENCE (albo UUID generowane w Javie). Jeśli wstawiasz rzadko, IDENTITY nie zaszkodzi, bo batch i tak by niewiele dał.

    UUID (GenerationType.UUID, albo samo @GeneratedValue UUID id, bo AUTO wybiera wtedy UUID po typie pola) powstaje w kodzie Javy, więc nie wymaga żadnej podróży do bazy. Masowe zmiany wielu wierszy naraz najlepiej zrobić jednym update/delete w HQL zamiast ładować i zmieniać encje po kolei. A żeby sprawdzić, czy batching w ogóle działa, włącz logowanie TRACE dla kategorii org.hibernate.orm.jdbc.batch.

    📌 W punktach
    • hibernate.jdbc.batch_size domyślnie 0. Ustaw 10–50.
    • order_inserts/order_updates (domyślnie false) pomagają, gdy zapisujesz różne encje na przemian.
    • IDENTITY wyłącza batch insert. SEQUENCE z allocationSize (domyślnie 50) i optymalizatorem pooled rzadko pyta bazę o id.
    • Masowe zmiany najlepiej robić jednym zapytaniem HQL update/delete.

    Open Session in View

    wygodne, dopóki nie zajrzysz w log

    Uruchamiasz aplikację webową na Spring Boot i w logu widzisz ostrzeżenie:

    WARN … spring.jpa.open-in-view is enabled by default. Therefore, database queries may be
    performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

    Za tym stoi wzorzec Open EntityManager in View (w świecie Hibernate: Open Session in View, OSIV). W aplikacji webowej Spring Boot domyślnie rejestruje OpenEntityManagerInViewInterceptor (spring.jpa.open-in-view=true). Interceptor wiąże EntityManager z wątkiem na cały czas obsługi requestu, a nie tylko na czas transakcji w serwisie.

    Efekt: LazyInitializationException „znika”. Kontroler, szablon albo Jackson serializujący encję do JSON-a mogą dotknąć leniwej asocjacji, a ta po cichu wyśle SELECT. To dokładnie te zapytania „podczas renderowania widoku”, przed którymi ostrzega log.

    ▶ Oś czasu jednego requestu porównaj trzy scenariusze
    filtr / interceptorkontroler → serwismetoda @Transactionalserializacja JSON
    EntityManager
    transakcja
    SQL
    SQL wysłany do bazy
    Kliknij ▶.
    Schemat: endpoint zwraca 3 zamówienia z nazwą klienta (dane ilustracyjne). Długości odcinków są umowne. Fioletowy pasek to życie EntityManagera, zielony to transakcja serwisu.

    Co w tym złego? Po pierwsze, N+1 przenosi się do warstwy, w której nikt go nie szuka: do serializacji odpowiedzi. Po drugie, te zapytania lecą już po zakończeniu transakcji z serwisu. Po trzecie (opinia, ale powszechna): EntityManager żyjący przez cały request może dłużej przytrzymywać zasoby, w tym połączenie z puli, co pod obciążeniem zjada pulę połączeń.

    Typowa decyzja w nowych projektach to spring.jpa.open-in-view=false, a w serwisie pobieranie wszystkiego, czego potrzebuje odpowiedź: join fetch, @EntityGraph albo od razu projekcja do DTO.

    🔥 Rozmowa przy kominku
    OSIVZe mną nic nie wybucha. Encja trafia do widoku, a wszystko, czego dotkniesz, samo się doładuje.
    DTO„Samo się doładuje”, czyli po jednym SELECT-cie na wiersz, w trakcie pisania JSON-a, poza transakcją. Ja wiem z góry, które pola pójdą do klienta, i pobieram je jednym zapytaniem.
    OSIVAle przy Tobie trzeba pisać dodatkową klasę i zapytanie.
    DTOZa to zmiana encji nie zmieni po cichu API, a licznik zapytań przestaje być loterią. Wolę jawny kontrakt niż wygodną niespodziankę.
    ✏️ Zaostrz ołówek

    Wyłączyłeś OSIV. Endpoint GET /orders zwraca List<Order> prosto z @Transactional serwisu i nagle dostajesz LazyInitializationException przy serializacji pola customer. Podaj dwa poprawne rozwiązania i jedno „rozwiązanie”, którego nie powinieneś użyć.

    Odpowiedź

    Poprawne: (1) w serwisie pobierz zamówienia z klientem (join fetch / @EntityGraph(attributePaths = "customer")), (2) zwracaj DTO zbudowane w transakcji lub projekcję. Złe: włączenie z powrotem OSIV „bo działało” albo hibernate.enable_lazy_load_no_trans=true. Oba ukrywają N+1 poza transakcją.

    Cache drugiego poziomu i query cache

    pamięć dzielona między sesjami, z ceną

    Persistence context to cache pierwszego poziomu: żyje tyle, co jedna sesja. Cache drugiego poziomu (L2) jest dzielony między sesjami, więc druga sesja może dostać encję bez SQL-a.

    Brzmi jak darmowa wydajność, ale nie jest darmowa. Zmiany w cache i w bazie nie są atomowe (nie ma między nimi dwufazowego commitu), więc L2 osłabia gwarancje ACID. Dlatego w Hibernate caching jest domyślnie wyłączony, a każdą encję musisz oznaczyć jawnie.

    @Entity
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)   // org.hibernate.annotations.Cache
    class Customer { … }
    
    # application.properties
    spring.jpa.properties.hibernate.cache.region.factory_class=jcache
    # + implementacja JCache na classpath (np. EHCache albo Caffeine)

    Hibernate nie ma własnej implementacji cache, więc potrzebny jest zewnętrzny dostawca: najczęściej JCache (EHCache, Caffeine) albo Infinispan. Cache dzieli się na regiony, po jednym na hierarchię encji i na rolę kolekcji. Domyślna nazwa regionu to nazwa klasy albo roli kolekcji. Polityki wygasania i rozmiaru konfigurujesz u dostawcy, nie w Hibernate. @Cache stawiasz na klasie bazowej hierarchii dziedziczenia; na podklasie to błąd.

    ▶ Dwie sesje, jeden klient kliknij ▶, przełącz @Cache, potem 💥
    Sesja A
    PC: —
    Sesja B
    PC: —
    Cache L2 · region Customer
    pusty
    Baza
    customer #7: „Ala”
    SQL wysłany do bazy
    Kliknij ▶.
    Uproszczenie: jeden węzeł aplikacji, strategia READ_WRITE, bez wygasania wpisów. PC = persistence context (cache 1. poziomu). Dane ilustracyjne.
    Strategia (usage)Co robiKiedy
    READ_ONLYdane niezmienne; aktualizacja rzuca wyjątek; najprostsza i najszybszadane słownikowe: kraje, waluty
    NONSTRICT_READ_WRITEbez blokad; unieważnia wpis przed i po transakcji zmieniającejzmiany rzadkie, a równoległe zmiany tego samego wiersza skrajnie mało prawdopodobne
    READ_WRITE„miękkie blokady” (soft lock) na czas commitu; inni w tym czasie czytają z bazyrównoległe zmiany możliwe, ale nieczęste
    TRANSACTIONALwymaga w pełni transakcyjnego dostawcy (JTA/XA)częste równoległe zapisy; rzadko spotykane

    L2 obsługuje tylko wyszukiwanie po id: find() i rozwiązywanie asocjacji po kluczu. Zapytanie HQL/JPQL i tak idzie do bazy. Na to jest osobny query cache: hibernate.cache.use_query_cache=true plus setCacheable(true) na zapytaniu. Wyniki lądują w regionie default-query-results-region. Ważność sprawdza się przez logiczne znaczniki czasu ostatniej zmiany każdej tabeli, trzymane w regionie default-update-timestamps-region; ten region nie powinien nigdy wygasać. Dokumentacja Hibernate pisze, że cache’owanie wyników zapytań „rzadko” się opłaca.

    ⚠️ Uwaga · kiedy NIE

    L2 nigdy nie wie o zmianach zrobionych poza Hibernate: w czystym JDBC, w innej aplikacji, w skrypcie SQL. Jeśli ktoś inny pisze do tej tabeli, dane w cache będą nieaktualne, dopóki wpis nie wygaśnie albo go nie unieważnisz. Nie cache’uj danych często zmienianych ani takich, przy których nieaktualny odczyt jest niedopuszczalny (salda, stany magazynowe). Dobry kandydat to dane słownikowe: małe, rzadko zmieniane, często czytane.

    🤓 Głębiej

    JPA @Cacheable a Hibernate @Cache. Domyślny jakarta.persistence.sharedCache.mode to ENABLE_SELECTIVE: cache’owane jest tylko to, co jawnie oznaczysz. Dokumentacja Hibernate nazywa JPA-owe @Cacheable „prawie bezużytecznym”, bo nie da się nim wybrać strategii współbieżności ani oznaczyć kolekcji. @Cache robi jedno i drugie.

    Asocjacje do danych z cache. Jeśli Publisher prawie zawsze jest w L2, to @ManyToOne Publisher mapowane jako EAGER zwykle zostanie dołączone joinem, czyli i tak trafi do bazy. Reguła z dokumentacji: dla takich danych użyj EAGER razem z @Fetch(FetchMode.SELECT), żeby Hibernate miał szansę wziąć encję z cache. Pozostałe asocjacje zostaw LAZY.

    Sterowanie w kodzie: em.setCacheRetrieveMode(CacheRetrieveMode.BYPASS) czyta prosto z bazy, a hibernate.cache.use_second_level_cache=false wyłącza L2 globalnie (przydatne przy profilowaniu).

    📌 W punktach
    • L1 = persistence context jednej sesji. L2 = cache dzielony między sesjami, domyślnie wyłączony.
    • Wymaga @Cache(usage = …) na encji i zewnętrznego dostawcy (JCache: EHCache/Caffeine, Infinispan).
    • Działa dla find() po id i asocjacji; dla zapytań jest osobny query cache, który rzadko się opłaca.
    • Nie widzi zmian spoza Hibernate. Najlepiej sprawdza się przy danych słownikowych.
    Część IV

    Współbieżność

    Dwie osoby edytują to samo zamówienie w tej samej chwili. Kto wygrywa? Tu zobaczysz, jak nie zgubić cudzej zmiany: kolumną version (blokowanie optymistyczne) albo blokadą wiersza w bazie (blokowanie pesymistyczne), i kiedy które ma sens.

    Optimistic locking: @Version

    zakładam, że nikt mi nie przeszkodzi… ale sprawdzam

    Ala otwiera zamówienie #101, żeby oznaczyć je jako opłacone. W tej samej sekundzie Bartek otwiera to samo zamówienie, żeby poprawić literówkę w e-mailu klienta. Oboje czytają ten sam wiersz, oboje coś zmieniają, oboje zapisują.

    Domyślnie Hibernate w UPDATE ustawia wszystkie kolumny encji poza identyfikatorem, nie tylko te zmienione. Bartek zapisuje więc także stary status NEW, który przeczytał przed zmianą Ali. Zmiana Ali znika bez śladu. To jest lost update, czyli zgubiona aktualizacja: nikt nie dostał błędu, a dane są złe.

    Lekarstwo to jedno pole z adnotacją @Version. Hibernate nadaje mu wartość przy persist (dla liczby: 0) i zwiększa je przy każdym zapisie encji. Każdy UPDATE i DELETE dostaje w WHERE warunek „i wersja jest nadal taka, jaką przeczytałem”.

    @Entity
    @Table(name = "orders")
    public class Order {
        @Id @GeneratedValue
        private Long id;
    
        @Version
        private Long version;      // Hibernate sam ustawia i podbija; Ty tylko czytasz
    
        @Enumerated(EnumType.STRING)
        private OrderStatus status;
        private String customerEmail;
        // ...
    }

    Jeśli UPDATE … WHERE id=? AND version=? zmieni zero wierszy, Hibernate wie, że ktoś inny zapisał encję w międzyczasie. Rzuca OptimisticLockException i transakcja idzie do wycofania. Nazwa „optymistyczne” bierze się stąd, że nic nie jest blokowane: zakładasz, że konfliktu nie będzie, i tylko sprawdzasz to przy zapisie.

    ▶ Ala i Bartek edytują zamówienie #101 kliknij ▶, potem 💥 i porównaj
    👩 Ala · transakcja A
    —
    czeka
    👨 Bartek · transakcja B
    —
    czeka
    🗄️ Baza · tabela orders
    —
    SQL wysłany do bazy
    Kliknij ▶.
    Uproszczenie: obie transakcje pokazane krok po kroku, aliasy w SQL skrócone, wartości parametrów w komentarzach --. Dane ilustracyjne.

    W Springu nie złapiesz gołego OptimisticLockException. Warstwa repozytoriów tłumaczy wyjątki dostawcy na hierarchię Springa, więc dostajesz ObjectOptimisticLockingFailureException (przy JPA czasem jej podklasę JpaOptimisticLockingFailureException). Obie dziedziczą po OptimisticLockingFailureException i ten typ warto łapać.

    Co zrobić z konfliktem? Masz dwie uczciwe odpowiedzi. Albo pokazujesz użytkownikowi „ktoś zmienił to zamówienie, odśwież i spróbuj ponownie”, albo ponawiasz całą transakcję: wczytujesz świeży stan, nakładasz zmianę jeszcze raz i zapisujesz. Ponawiać trzeba od nowa, z nową transakcją i nowym persistence context, bo po wyjątku stary kontekst jest „zatruty”.

    @Service
    class OrderEmailFixer {                       // osobny bean: ponawia z zewnątrz
        private final OrderService service;       // tu jest @Transactional fixEmail(...)
        OrderEmailFixer(OrderService service) { this.service = service; }
    
        @Retryable(includes = OptimisticLockingFailureException.class, maxRetries = 3)
        public void fixEmail(long orderId, String email) {
            service.fixEmail(orderId, email);     // każda próba = nowa transakcja
        }
    }   // @Retryable (Spring Framework 7) działa po włączeniu @EnableResilientMethods
    ⚠️ Uwaga

    Ponowienie musi objąć całą transakcję. Gdy @Retryable siedzi wewnątrz metody, która już ma otwartą transakcję, każda próba pracuje na tym samym, zepsutym persistence context. Dlatego w przykładzie ponawianie siedzi w osobnym beanie, który woła metodę transakcyjną: każda próba otwiera nową transakcję. Jeśli chcesz obu adnotacji na jednej metodzie, sprawdź w teście, które proxy jest zewnętrzne.

    Wersji nie ustawiaj ręcznie w kodzie biznesowym. Jeśli przyjmujesz encję z formularza lub API (encja odłączona, merge), przenieś wersję z DTO: wtedy merge wykryje, że klient edytował nieaktualny stan, i rzuci wyjątek.

    🔬 Głębiej: typy wersji i jawne tryby optymistyczne

    Specyfikacja JPA dopuszcza dla @Version typy int, Integer, short, Short, long, Long, LocalDateTime, Instant i java.sql.Timestamp. Liczba jest pewniejsza od znacznika czasu: dwa zapisy w tej samej chwili mogą dostać ten sam czas.

    LockModeType.OPTIMISTIC każe sprawdzić wersję na końcu transakcji także dla encji, której nie zmieniałeś, osobnym SELECT-em. OPTIMISTIC_FORCE_INCREMENT dodatkowo podbija wersję przy końcu transakcji. Przydaje się, gdy zmieniasz pozycje zamówienia (OrderItem), a chcesz, żeby wersja samego Order też drgnęła. W JPA READ to synonim OPTIMISTIC, a WRITE to synonim OPTIMISTIC_FORCE_INCREMENT.

    ✏️ Zaostrz ołówek

    Zamówienie ma @Version Long version = 7. Dwie transakcje czytają je naraz. A zapisuje pierwsza. Jakie WHERE wyśle B i ile wierszy zmieni? Jaki wyjątek zobaczy kod w serwisie Springa?

    Odpowiedź

    B wyśle … WHERE id=? AND version=? z wartością 7. Po zapisie A w bazie jest już 8, więc UPDATE zmieni 0 wierszy. Hibernate zgłosi konflikt wersji, a Spring przetłumaczy go na ObjectOptimisticLockingFailureException (podtyp OptimisticLockingFailureException).

    Pessimistic locking

    zamykam drzwi, zanim zacznę

    Optymizm ma swoją cenę: przegrany konflikt to wyjątek i ponowienie. Jeśli konflikty są częste, a powtarzanie pracy drogie (na przykład dwa procesy wysyłki biorą to samo zamówienie), wolisz, żeby druga transakcja po prostu poczekała.

    Do tego służy blokada pesymistyczna. Hibernate prosi bazę o zablokowanie wiersza już przy odczycie, zwykle przez SELECT … FOR UPDATE. Blokada trwa do końca transakcji (commit albo rollback). Każda inna transakcja, która chce ten sam wiersz zablokować, czeka.

    public interface OrderRepository extends JpaRepository<Order, Long> {
    
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        @Query("select o from Order o where o.id = :id")
        Optional<Order> findForShipping(Long id);
    }

    @Lock ze Spring Data działa na metodach zapytań: tych z @Query i tych wyprowadzanych z nazwy. Możesz też przedeklarować metodę CRUD (np. findById) w interfejsie i dodać jej @Lock. Bez otwartej transakcji blokada nie ma sensu, bo zniknęłaby od razu po zapytaniu, więc wołaj takie metody w @Transactional.

    LockModeTypeCo robi (Hibernate)Kiedy
    PESSIMISTIC_WRITEblokada „for update”: inni nie zablokują ani nie zmienią wierszaczytasz, żeby zaraz zmienić
    PESSIMISTIC_READblokada „for share” (jeśli baza ją ma): inni czytają, nikt nie zmieniapotrzebujesz stabilnego odczytu
    PESSIMISTIC_FORCE_INCREMENTblokada + natychmiastowe podbicie versionłączysz oba podejścia
    ▶ Dwa procesy wysyłki biorą zamówienie #101 ▶ czekanie · ⏱ timeout · 💥 deadlock
    📦 Wysyłka-1 · T1
    —
    czeka
    📦 Wysyłka-2 · T2
    —
    czeka
    🗄️ Baza · tabela orders
    —
    —
    SQL wysłany do bazy
    Kliknij ▶.
    Uproszczenie: składnia blokady jak w PostgreSQL (for update); inne bazy i dialekty generują inną. Czasy ilustracyjne, aliasy skrócone.

    Timeout. Domyślnie czekanie na blokadę trwa tyle, ile pozwala baza. Limit ustawisz wskazówką jakarta.persistence.lock.timeout w milisekundach, w Spring Data przez @QueryHints. Dialekt tłumaczy ją na składnię bazy, na przykład w Oracle na FOR UPDATE wait 2. Gdy sterownik tego nie obsługuje, Hibernate wskazówkę ignoruje.

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
    @Query("select o from Order o where o.id = :id")
    Optional<Order> findForShipping(Long id);

    Gdy blokady nie da się zdobyć, JPA rozróżnia dwa przypadki. Jeśli baza wycofała tylko jedno polecenie, leci LockTimeoutException i transakcja nie jest oznaczana do wycofania. Jeśli baza wycofała całą transakcję, leci PessimisticLockException i transakcja jest oznaczona do wycofania. Spring tłumaczy je odpowiednio na CannotAcquireLockException i PessimisticLockingFailureException. Dokumentacja Springa radzi łapać ogólne PessimisticLockingFailureException, nie konkretne podklasy.

    ⚠️ Uwaga: deadlock

    Dwie transakcje blokują wiersze w odwrotnej kolejności: T1 trzyma #101 i chce #102, T2 trzyma #102 i chce #101. Żadna nie ustąpi. Baza to wykrywa i wycofuje jedną z nich. Najprostsza profilaktyka: blokuj zawsze w tej samej kolejności (np. rosnąco po id) i trzymaj transakcje krótko. Nigdy nie trzymaj blokady przez czas, w którym użytkownik myśli nad formularzem.

    🔬 Głębiej: NOWAIT, SKIP LOCKED i blokady w natywnym API

    W natywnym API Hibernate (Session) blokadę można połączyć z obiektem Timeout. Interfejs Timeouts ma specjalne wartości (np. Timeouts.NO_WAIT), które na bazach z obsługą tej składni dają for update nowait albo for update skip locked. „Skip locked” pomija wiersze już zablokowane: klasyczny wzorzec kolejki zadań, w której kilku workerów bierze kolejne wolne rekordy.

    Specyfikacja JPA zastrzega, że dostawca lub baza mogą zablokować więcej wierszy niż wybrałeś, a przy zapisie encji z @Version zablokowanej pesymistycznie wersja i tak jest podbijana. Obie strategie dobrze ze sobą współpracują.

    🔥 Rozmowa przy kominku
    OptymistaJa nic nie blokuję. Ludzie czytają i piszą równolegle, a ja tylko sprawdzam numer wersji przy zapisie. W systemach dla wielu użytkowników jestem królem, sama dokumentacja Hibernate tak mówi.
    PesymistaA jak przegrasz, to wyjątek, rollback i wszystko od nowa. Przy gorącym wierszu, który dziesięć procesów chce zmienić naraz, ja po prostu ustawiam ich w kolejce.
    OptymistaDopóki ktoś nie zostawi otwartej transakcji na kwadrans. Albo nie zablokuje dwóch wierszy w złej kolejności. Wtedy masz deadlocki i timeouty.
    PesymistaDlatego biorę krótkie transakcje i stałą kolejność. Ty bierz się za formularze i długie „myślenie” użytkownika, ja za krótkie, gorące sekcje w backendzie.
    📌 W punktach
    • @Lock(PESSIMISTIC_WRITE) → SELECT … FOR UPDATE (składnia zależy od dialektu); blokada do końca transakcji.
    • Timeout: wskazówka jakarta.persistence.lock.timeout w milisekundach; nieobsługiwana → ignorowana.
    • Wyjątki w Springu: CannotAcquireLockException, PessimisticLockingFailureException; łap ten drugi.
    • Deadlock: blokuj w stałej kolejności i krótko. Domyślnie wybieraj @Version, blokadę pesymistyczną zostaw dla gorących, krótkich sekcji.
    Część V

    Ekspert

    Tematy, które wychodzą dopiero w dużym systemie: hierarchie encji, pułapki equals/hashCode i Lomboka, propagacja transakcji, bytecode enhancement z przetwarzaniem wsadowym oraz audyt zmian. Każdy z nich to miejsce, w którym „działa na moim laptopie” rozjeżdża się z produkcją.

    Dziedziczenie encji

    jedna klasa bazowa, trzy kształty tabel

    Zamówienie można opłacić kartą albo przelewem. W Javie naturalnie robisz klasę bazową Payment (kwota, zamówienie) i dwie podklasy: CardPayment (ostatnie 4 cyfry karty) i BankTransfer (IBAN). Baza relacyjna nie zna dziedziczenia, więc ktoś musi zdecydować, jak tę hierarchię rozłożyć na tabele.

    Najpierw rozróżnij dwie rzeczy. @MappedSuperclass to tylko wspólne pola do skopiowania w podklasy (typowo id, version, daty audytu). Taka klasa nie jest encją: nie ma własnej tabeli i nie zapytasz o nią polimorficznie. @Entity na klasie bazowej plus @Inheritance to prawdziwa hierarchia encji: możesz napisać select p from Payment p i dostać mieszankę kart i przelewów.

    @Entity                                   // brak @Inheritance = SINGLE_TABLE
    @DiscriminatorColumn(name = "dtype")      // domyślnie i tak "DTYPE", typ STRING
    public abstract class Payment {
        @Id @GeneratedValue Long id;
        BigDecimal amount;
        @ManyToOne(fetch = FetchType.LAZY) Order order;
    }
    @Entity class CardPayment extends Payment { String cardLast4; }
    @Entity class BankTransfer extends Payment { String iban; }
    StrategiaTabeleKiedy
    SINGLE_TABLE (domyślna)jedna tabela na całą hierarchię + kolumna dyskryminatorapodklasy mają mało własnych pól; najszybsze zapytania
    JOINEDtabela na klasę, każda tylko z własnymi polami; złączenia po idzależy Ci na ograniczeniach i normalizacji
    TABLE_PER_CLASStabela na klasę konkretną, ze wszystkimi polami odziedziczonymirzadko; zapytania polimorficzne przez UNION
    ▶ Ta sama hierarchia, różne tabele wybierz strategię i kliknij ▶
    SQL wysłany do bazy
    Wybierz strategię i kliknij ▶.
    SQL uproszczony (aliasy, kolejność kolumn); dokładna postać zależy od wersji Hibernate i dialektu. W TABLE_PER_CLASS klasa Payment jest abstrakcyjna.
    ⚠️ Uwaga: SINGLE_TABLE i NOT NULL

    W jednej tabeli wiersz karty ma pustą kolumnę iban, a wiersz przelewu pustą card_last4. Dlatego kolumny podklas nie mogą mieć NOT NULL. Hibernate, generując schemat, pilnuje niepustości pól podklas ograniczeniem CHECK, a nie NOT NULL. Jeśli schemat piszesz sam (Flyway, Liquibase), nie dodawaj not null do kolumn podklas, bo pierwszy zapis innej podklasy wyleci na ograniczeniu.

    🔬 Głębiej: dyskryminator, JOINED i klucze obce

    Dla SINGLE_TABLE dyskryminator jest zawsze potrzebny; domyślnie kolumna nazywa się DTYPE, a Hibernate wpisuje do niej nazwę encji (np. CardPayment). Zmienisz ją przez @DiscriminatorValue. W JOINED dyskryminator jest opcjonalny; jeśli go dodasz, SQL zapytań polimorficznych będzie trochę prostszy.

    W TABLE_PER_CLASS asocjacja wskazująca klasę bazową (np. @ManyToOne Payment) nie może mieć klucza obcego w bazie, bo wiersz może leżeć w jednej z kilku tabel. Specyfikacja JPA traktuje tę strategię jako opcjonalną, więc aplikacja z nią nie jest przenośna między dostawcami. Hibernate ją obsługuje.

    📌 W punktach
    • @MappedSuperclass = wspólne pola, bez tabeli i bez zapytań polimorficznych.
    • SINGLE_TABLE (domyślna): jedna tabela, jeden SELECT, kolumny podklas bez NOT NULL.
    • JOINED: znormalizowane tabele i ograniczenia, za to złączenia w każdym zapytaniu polimorficznym.
    • TABLE_PER_CLASS: UNION i brak kluczy obcych do klasy bazowej. Zwykle lepiej wybrać coś innego.

    equals/hashCode encji i Lombok

    obiekt zniknął z HashSet, choć nikt go nie usunął

    Wewnątrz jednej sesji Hibernate gwarantuje, że ten sam wiersz to ta sama instancja: dwa find(Order.class, 101L) zwrócą ten sam obiekt. Domyślne equals z Object (porównanie referencji) wystarcza. Kłopot zaczyna się, gdy mieszasz obiekty z różnych sesji albo wkładasz nowe encje do HashSet.

    HashSet trzyma obiekt w „kubełku” wyznaczonym przez hashCode() w chwili wstawienia. Kontrakt Set mówi: hashCode obiektu nie może się zmienić, dopóki obiekt jest w zbiorze. A jeśli hashCode liczysz z generowanego id, to zmienia się on dokładnie przy persist: przed nim id jest null, po nim liczbą.

    ▶ HashSet przed i po persist ▶ klucz naturalny · 💥 hashCode z id
    Set<Product> basket = new HashSet<>();
    —
    SQL wysłany do bazy
    Kliknij ▶.
    Model uproszczony: 4 kubełki, numer kubełka = hashCode mod 4 (prawdziwy HashSet miesza bity inaczej). Wartości hashCode ilustracyjne. Generator id: sekwencja, więc id pojawia się już przy persist().

    Dokumentacja Hibernate daje kilka zasad. Nie wkładaj do hashCode pól zmiennych. Generowane id da się użyć, ale musisz uważać, by nie wkładać encji do zbiorów przed nadaniem id. Najbezpieczniej oprzeć equals/hashCode na kluczu naturalnym: niezmiennym polu, które w świecie biznesu jednoznacznie wskazuje rzecz (SKU produktu, ISBN książki). Hibernate oznacza go adnotacją @NaturalId i sam generuje na tych kolumnach ograniczenie UNIQUE.

    @Entity
    public class Product {
        @Id @GeneratedValue Long id;
        @NaturalId String sku;               // domyślnie niezmienny (mutable = false)
        String name;
    
        @Override public boolean equals(Object o) {
            return o instanceof Product p           // instanceof, nie getClass(): o może być proxy
                && sku.equals(p.getSku());          // przez getter, nie pole: proxy ma puste pola
        }
        @Override public int hashCode() { return sku.hashCode(); }
    }
    ⚠️ Uwaga: Lombok na encjach

    @Data to skrót, który generuje między innymi @EqualsAndHashCode i @ToString. Oba domyślnie biorą wszystkie pola (equals/hashCode: wszystkie niestatyczne i nie-transient). Na encji oznacza to trzy kłopoty:

    • hashCode z id i pól zmiennych: dokładnie błąd z figury powyżej;
    • dotknięcie leniwych asocjacji: toString() w logu albo equals() w zbiorze może dociągnąć kolekcję z bazy, a po zamknięciu sesji rzucić LazyInitializationException;
    • przy asocjacji dwukierunkowej Order.toString() woła OrderItem.toString(), które woła Order.toString()… aż do StackOverflowError.

    Bezpieczniej: @Getter/@Setter zamiast @Data, equals/hashCode napisane ręcznie (albo @EqualsAndHashCode(onlyExplicitlyIncluded = true) z @EqualsAndHashCode.Include na kluczu naturalnym), a asocjacje wyłączone z toString przez @ToString.Exclude.

    🔬 Głębiej: a jeśli nie ma klucza naturalnego?

    Dokumentacja Hibernate sugeruje, że brak klucza naturalnego bywa sygnałem do przemyślenia modelu. Gdy go naprawdę nie ma, popularny w społeczności wzorzec (opinia, nie zalecenie z dokumentacji) to equals po id z warunkiem id != null i stały hashCode dla całej klasy, np. return getClass().hashCode();. Stały hashCode nie zmienia się przy persist, więc zbiór działa; kosztem jest to, że wszystkie elementy trafiają do jednego kubełka, co przy dużych zbiorach spowalnia wyszukiwanie. Uwaga: przy proxy getClass() zwraca klasę proxy, więc w praktyce sięga się po klasę „odpakowaną”.

    Inna droga: generuj id po stronie aplikacji (np. UUID nadawany w konstruktorze). Wtedy id istnieje od początku i nie zmienia się przy persist.

    ✏️ Zaostrz ołówek

    Encja OrderItem ma Lombokowe @Data, a Order trzyma Set<OrderItem> items. Dodajesz nową pozycję do zbioru, potem zapisujesz zamówienie, a potem wołasz items.remove(item). Co zwróci remove i dlaczego?

    Odpowiedź

    Najpewniej false. @Data liczy hashCode ze wszystkich pól, także z id, które przy zapisie zmieniło się z null na liczbę. Obiekt leży w kubełku dla starego hashCode, a remove szuka w kubełku dla nowego. Do tego hashCode z pola order mógłby rekurencyjnie wołać hashCode zamówienia, a ten zbioru pozycji.

    Propagacja i izolacja

    metoda transakcyjna woła metodę transakcyjną. I co teraz?

    OrderService.placeOrder() ma @Transactional i w środku woła AuditLog.record(), który też ma @Transactional. Czy to jedna transakcja, czy dwie? Odpowiada atrybut propagation metody wewnętrznej. Domyślnie to REQUIRED.

    Spring rozróżnia transakcję fizyczną (prawdziwa transakcja na połączeniu z bazą) i logiczną (zakres jednej metody z @Transactional). Przy REQUIRED kilka zakresów logicznych dzieli jedną fizyczną: każdy może ją oznaczyć jako „tylko do wycofania” (rollback-only), ale commit jest jeden.

    PropagationGdy transakcja już jestGdy jej nie ma
    REQUIRED (domyślna)dołączatworzy nową
    REQUIRES_NEWzawiesza ją i tworzy nową, niezależnątworzy nową
    NESTEDtransakcja zagnieżdżona (savepoint JDBC)jak REQUIRED
    SUPPORTSdołączadziała bez transakcji
    MANDATORYdołączawyjątek
    NOT_SUPPORTEDzawiesza ją, działa bez transakcjidziała bez transakcji
    NEVERwyjątekdziała bez transakcji
    ▶ placeOrder() woła record() wybierz propagację; 💥 = record() rzuca wyjątek, a placeOrder() go łapie
    placeOrder() · @Transactional (REQUIRED)
    record() · REQUIRED
    —
    —
    🔌 połączenie #1: —
    🔌 połączenie #2: —
    SQL wysłany do bazy
    Wybierz propagację i kliknij ▶.
    Założenia: Spring Boot z JpaTransactionManager (ustawienia domyślne), record() zapisuje przez repozytorium Spring Data. Linie begin/commit to uproszczony zapis tego, co dzieje się na połączeniu JDBC.
    ⚠️ Uwaga: NESTED z JPA

    NESTED działa przez savepointy JDBC. JpaTransactionManager ma flagę nestedTransactionAllowed domyślnie ustawioną na false, więc bez zmiany konfiguracji dostaniesz NestedTransactionNotSupportedException. Po włączeniu flagi savepoint cofnie zmiany na połączeniu JDBC, ale nie stan persistence context: JPA nie zna transakcji zagnieżdżonych. Encje w pamięci mogą więc „pamiętać” zmiany, które baza już wycofała.

    Izolacja (isolation) mówi, jakie anomalie współbieżności baza dopuszcza: READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE. Domyślne DEFAULT znaczy „to, co ustawia baza”, a to zależy od produktu. HibernateJpaDialect ustawia poziom izolacji na połączeniu JDBC, więc z Hibernate własny poziom per transakcja działa. Pamiętaj, że izolacja, timeout i readOnly liczą się tylko tam, gdzie transakcja fizyczna startuje.

    readOnly = true z Hibernate: HibernateJpaDialect przestawia sesję na FlushMode.MANUAL (brak automatycznego flush, więc i dirty checkingu przy commicie) i ustawia sesję w tryb tylko do odczytu, a połączeniu JDBC przekazuje flagę read-only.

    🔥 Rozmowa przy kominku
    REQUIREDJestem domyślny, bo zwykle chcesz, żeby wszystko w jednym przypadku użycia zapisało się albo nic.
    REQUIRES_NEWA ja jestem od rzeczy, które mają przetrwać porażkę głównej operacji: log audytu, licznik prób. Moja cena: drugie połączenie z puli na czas trwania obu transakcji.
    REQUIREDI zakleszczenie puli, gdy wszystkie wątki trzymają po jednym połączeniu i czekają na drugie. Spring mówi wprost: pula musi być większa od liczby równoległych wątków co najmniej o jeden.
    📌 W punktach
    • Wewnętrzny REQUIRED rzucił wyjątek, zewnętrzny go złapał → przy commicie UnexpectedRollbackException, bo transakcja była już rollback-only.
    • REQUIRES_NEW = osobna fizyczna transakcja i osobne połączenie; commit/rollback niezależne.
    • NESTED = savepoint; z JpaTransactionManager domyślnie wyłączony i nie cofa stanu encji w pamięci.
    • Izolacja DEFAULT = poziom bazy; readOnly w Hibernate → FlushMode.MANUAL.

    Bytecode enhancement, StatelessSession i przetwarzanie wsadowe

    10 000 encji i pamięć, która nie rośnie

    Persistence context to wygoda: pamięta każdą encję, którą dotknąłeś, i przy flush porównuje jej stan z migawką. Przy 20 encjach na request to nic nie kosztuje. Przy nocnym imporcie 100 000 wierszy ta sama wygoda zjada pamięć: każda zapisana encja zostaje w kontekście do końca transakcji, aż do OutOfMemoryError.

    Klasyczne lekarstwo to flush i clear co N encji: wysyłasz zaległe INSERT-y i opróżniasz kontekst. Dobierz N do hibernate.jdbc.batch_size (więcej o batchach w rozdziale o zapisach wsadowych).

    @Transactional
    public void importOrders(List<OrderDto> rows) {
        for (int i = 0; i < rows.size(); i++) {
            em.persist(toOrder(rows.get(i)));
            if (i > 0 && i % 50 == 0) {   // 50 = hibernate.jdbc.batch_size
                em.flush();                 // wyślij zaległe INSERT-y
                em.clear();                 // zapomnij zapisane encje
            }
        }
    }
    ▶ Import 10 000 zamówień ▶ flush+clear · ⚡ StatelessSession · 💥 bez clear
    Encje w persistence context: 0
    Zapisano: 0 / 10 000
    pamięć: spokojnie
    SQL wysłany do bazy
    Kliknij ▶.
    Liczby ilustracyjne: pasek pokazuje liczbę encji w kontekście, nie bajty; granica „OOM” umowna. Log SQL pokazuje co pięćdziesiątą paczkę INSERT-ów (batch po 50), licznik liczy paczki.

    StatelessSession to drugie API Hibernate, bez persistence context. Nie ma w nim cache pierwszego poziomu, odroczonego zapisu (write-behind), automatycznego dirty checkingu ani kaskad. insert, update, delete idą do bazy od razu i jawnie; leniwą asocjację dociągasz wprost przez fetch(). W Hibernate 7 hibernate.jdbc.batch_size nie działa na sesję bezstanową; zamiast tego użyj insertMultiple(List).

    try (StatelessSession ss = entityManagerFactory.unwrap(SessionFactory.class).openStatelessSession()) {
        Transaction tx = ss.beginTransaction();
        for (List<Order> chunk : chunks(orders, 50)) {
            ss.insertMultiple(chunk);       // jawny batch
        }
        tx.commit();
    }

    Przy czytaniu dużych zbiorów działa to samo: nie ładuj wszystkiego do List. Spring Data pozwala zwrócić Stream<Order> z metody repozytorium; strumień trzyma zasoby bazy, więc zamknij go (try-with-resources). Natywnie Hibernate ma ScrollableResults (kursor po stronie serwera). Przy obu nadal czyść kontekst co N encji, bo odczytane encje też w nim zostają.

    🤓 Głębiej: bytecode enhancement

    Enhancer to wtyczka budowania (Gradle: plugin org.hibernate.orm z blokiem hibernate { enhancement {} }; jest też wtyczka Maven), która modyfikuje bajtkod encji. Daje dwie rzeczy:

    • leniwe atrybuty basic: @Basic(fetch = LAZY) na dużym polu (np. PDF faktury) działa naprawdę; domyślnie wszystkie leniwe pola pojedyncze ładują się razem jako jedna „lazy group” (@LazyGroup to zmienia);
    • dirty tracking w encji: encja sama zapisuje, które pola zmieniłeś, więc flush nie porównuje całego stanu z migawką. Pomaga przy dużym persistence context.

    Uwaga na składnię Gradle: hibernate { enhancement } bez nawiasów klamrowych po cichu nie włącza enhancementu. Opcje enableLazyInitialization i enableDirtyTracking mają domyślnie true i są oznaczone jako przestarzałe do usunięcia.

    ✏️ Zaostrz ołówek

    W pętli wczytujesz przez StatelessSession zamówienie, zmieniasz mu status setterem i idziesz dalej, bez żadnego innego wywołania. Co trafi do bazy przy commicie?

    Odpowiedź

    Nic. Sesja bezstanowa nie ma dirty checkingu ani flush. Zmianę musisz wysłać jawnie: ss.update(order) (albo updateMultiple).

    Auditing i Envers

    kto, kiedy… i jak wyglądało wcześniej

    Dwa pytania z każdego audytu: kto i kiedy ostatnio zmienił zamówienie oraz jak wyglądało w zeszły wtorek. Na pierwsze odpowiada Spring Data JPA Auditing (kolumny w tej samej tabeli). Na drugie Hibernate Envers (osobna tabela historii).

    @Configuration
    @EnableJpaAuditing                         // bez tego adnotacje poniżej nic nie robią
    class AuditConfig {
        @Bean AuditorAware<String> auditor() {  // „kto”: np. z Spring Security
            return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                                 .map(Authentication::getName);
        }
    }
    
    @MappedSuperclass
    @EntityListeners(AuditingEntityListener.class)
    public abstract class Auditable {
        @CreatedDate      Instant createdAt;
        @CreatedBy        String  createdBy;
        @LastModifiedDate Instant lastModifiedAt;
        @LastModifiedBy   String  lastModifiedBy;
    }
    
    @Entity @Audited @Table(name = "orders")   // @Audited = Envers
    public class Order extends Auditable { /* ... */ }

    AuditingEntityListener to listener JPA, który przed zapisem wypełnia pola z adnotacjami. Daty bierze z DateTimeProvider (domyślnie bieżący czas), a „kto” z Twojego beana AuditorAware. Jeśli śledzisz tylko daty, AuditorAware nie jest potrzebny. Atrybut modifyOnCreate w @EnableJpaAuditing ma domyślnie true: przy tworzeniu wypełniane są też pola „last modified”.

    Envers wymaga jarki hibernate-envers na classpath i @Audited na encji; listenery rejestrują się same. Dla każdej audytowanej encji powstaje tabela z sufiksem _AUD (tu orders_AUD) z kolumnami encji plus REV (numer rewizji) i REVTYPE (0 = ADD, 1 = MOD, 2 = DEL). Do tego globalna tabela REVINFO: jeden wiersz na każdy commit, który zmienił dane audytowane.

    ▶ Ala tworzy zamówienie, Bartek je zmienia ▶ z audytem · 💥 bez @EnableJpaAuditing
    orders
    —
    orders_AUD
    —
    REVINFO
    —
    SQL wysłany do bazy
    Kliknij ▶.
    Uproszczenie: pokazane tylko wybrane kolumny, czasy i numery rewizji ilustracyjne. Wiersze Envers zapisywane są przy commicie transakcji.
    ❓ Nie ma głupich pytań
    P:
    Czy @LastModifiedDate zadziała przy @Modifying @Query("update Order o set …")?
    O:
    Nie. Masowy UPDATE trafia prosto do bazy z pominięciem encji i ich listenerów, więc ani Spring Data Auditing, ani Envers go nie widzą. Envers wprost ostrzega, że CriteriaUpdate/CriteriaDelete nie są przez niego rejestrowane.
    P:
    Po co mi Envers, skoro mam lastModifiedBy?
    O:
    Kolumny audytu mówią tylko o ostatniej zmianie. Envers trzyma każdą wersję wiersza i pozwala odczytać encję „na rewizję N” przez AuditReader.
    📌 W punktach
    • @EnableJpaAuditing + @EntityListeners(AuditingEntityListener.class) + @CreatedDate/@LastModifiedDate/@CreatedBy/@LastModifiedBy.
    • AuditorAware<T> mówi, kto jest bieżącym użytkownikiem; potrzebny tylko dla pól „by”.
    • Envers: @Audited → tabela_AUD (+REV, REVTYPE) i REVINFO (jeden wiersz na commit).
    • Masowe UPDATE/DELETE omijają oba mechanizmy.
    Część VI

    Praktyka: porównania, dobre praktyki, błędy, interview

    Ostatnia część zbiera wszystko w całość. Najpierw sprawdzisz, kiedy JPA w ogóle jest dobrym wyborem, a kiedy lepiej sięgnąć po coś prostszego. Potem przejdziesz checklistę dobrych praktyk, klinikę najczęstszych błędów i zestaw pytań, które padają na rozmowach rekrutacyjnych.

    Porównania: JPA/Hibernate vs Spring Data JDBC vs jOOQ vs JdbcClient

    młotek nie do każdego gwoździa

    Przez dwadzieścia sześć rozdziałów uczyłeś się, jak działa JPA. Czas na niewygodne pytanie: czy w Twoim projekcie w ogóle jej potrzebujesz?

    JPA robi za Ciebie bardzo dużo. Śledzi zmiany encji (dirty checking z rozdziału o flush), dociąga dane leniwie (lazy loading), trzyma persistence context i pozwala zmapować prawie każdy układ obiektów na prawie każdy schemat. Twórcy Spring Data JDBC piszą wprost, że to świetne, ale że często trudno zrozumieć, dlaczego JPA zrobiła to, co zrobiła.

    W ekosystemie Springa masz cztery popularne drogi do relacyjnej bazy. Różnią się tym, ile „magii” dostajesz i ile SQL-a piszesz sam.

    OpcjaCo robiKiedy
    JPA / Hibernate
    (+ Spring Data JPA)
    Pełny ORM: persistence context, dirty checking, lazy loading, kaskady, cache drugiego poziomu. Spring Data JPA generuje repozytoria nad EntityManager.Bogaty model domenowy z wieloma relacjami, dużo zapisów „wczytaj, zmień, zapisz”, zespół zna mechanizmy z tej strony.
    Spring Data JDBCRepozytoria Spring Data bez sesji: wczytanie encji uruchamia SQL i daje w pełni załadowany obiekt. Brak lazy loadingu, cache i dirty trackingu. save() zapisuje cały agregat.Proste agregaty w stylu DDD, chcesz przewidywalnego SQL, a relacje między agregatami trzymasz jako identyfikatory.
    jOOQGeneruje klasy Java ze schematu bazy i pozwala budować typowane zapytania SQL płynnym API (DSLContext). Spring Boot ma dla niego auto-konfigurację.Złożone zapytania i raporty, SQL specyficzny dla bazy, chcesz błędów kompilacji zamiast błędów w runtime.
    JdbcTemplate / JdbcClientTy piszesz SQL, Spring otwiera i zamyka zasoby, mapuje wiersze i tłumaczy SQLException na DataAccessException. JdbcClient (od Spring 6.1) to płynna fasada nad parametrami pozycyjnymi i nazwanymi.Pojedyncze zapytania, masowe operacje, miejsca, gdzie ORM tylko przeszkadza. Do insertów wsadowych sięgasz po JdbcTemplate albo SimpleJdbcInsert.
    ▶ Dobierz narzędzie zaznacz wymagania, potem kliknij ▶
    JPA / Hibernate
    + Spring Data JPA
    0 pkt
    Spring Data JDBC
    agregaty, bez sesji
    0 pkt
    jOOQ
    typowany SQL
    0 pkt
    JdbcTemplate / JdbcClient
    Twój SQL
    0 pkt
    SQL wysłany do bazy
    Zaznacz wymagania swojego projektu.
    Punktacja to heurystyka autora, nie benchmark: pokazuje, które cechy narzędzia odpowiadają którym wymaganiom. Scenariusz ▶: zamówienie z 3 pozycjami, zmieniasz ilość w jednej. Liczone są instrukcje SQL (nie round-tripy), aliasy uproszczone, liczby ilustracyjne.

    Zwróć uwagę na wynik dla Spring Data JDBC. Dokumentacja opisuje to wprost: jeśli korzeń agregatu nie jest nowy, wszystkie encje, do których się odwołuje, są usuwane, korzeń jest aktualizowany, a encje podrzędne wstawiane od nowa. To cena prostoty: brak dirty checkingu oznacza, że framework nie wie, która pozycja się zmieniła.

    To nie jest wybór „na zawsze i dla całej aplikacji”. JpaTransactionManager pozwala zwykłemu kodowi JDBC korzystającemu z tego samego DataSource uczestniczyć w transakcji JPA. Encje do logiki domenowej, JdbcClient albo jOOQ do raportu: to częsty i rozsądny układ.

    🔥 Rozmowa przy kominku
    JPAŁadujesz zamówienie, zmieniasz jedną pozycję, kończysz transakcję. Ja sam wyliczę, że trzeba wysłać jeden UPDATE.
    Spring Data JDBCA ja usunę i wstawię pozycje od nowa. Za to nikt nigdy nie zapyta, skąd wziął się dodatkowy SELECT w pętli, bo u mnie nie ma leniwego ładowania ani sesji.
    JPABez lazy loadingu każdy odczyt agregatu ciągnie wszystkie dzieci.
    Spring Data JDBCWłaśnie dlatego agregaty mają być małe, a odwołania do innych agregatów to identyfikatory. Mniej mapowania, mniej niespodzianek.
    📌 W punktach
    • JPA/Hibernate: najwięcej automatyki (dirty checking, lazy, cache), najwięcej rzeczy do zrozumienia.
    • Spring Data JDBC: bez sesji i lazy loadingu, save() zapisuje cały agregat (usuń i wstaw dzieci).
    • jOOQ: klasy generowane ze schematu i typowany SQL; dobry do złożonych zapytań.
    • JdbcClient/JdbcTemplate: Twój SQL, Spring zarządza zasobami i wyjątkami.
    • Narzędzia można łączyć w jednej aplikacji i jednej transakcji.

    Dobre praktyki: checklista

    odhacz, zanim odhaczy Cię produkcja

    Każdy rozdział tej strony kończył się kilkoma zasadami. Tu masz je zebrane w jedną listę, pogrupowaną według tego, czego dotyczą: mapowania, pobierania danych, transakcji, zapisów oraz schematu i testów.

    Przejdź listę z otwartym kodem swojego projektu. Każdy punkt prowadzi do rozdziału, który wyjaśnia, dlaczego warto. Punkty dla zaawansowanych pojawiają się dopiero przy większej głębokości strony.

    ▶ Checklista projektu zaznaczaj, co już masz; ▶ pokazuje braki
    Mapowanie
    Pobieranie
    Transakcje
    Zapisy
    Schemat i testy
    0 / 0
    Zaznacz to, co już masz w projekcie.
    Twoje zaznaczenia zapisują się w localStorage tej przeglądarki (zapis w try/catch; w trybie prywatnym mogą zniknąć po zamknięciu karty). Wynik liczy tylko punkty widoczne przy bieżącej głębokości strony. „Projekt z tutoriala” to przykład ilustracyjny i nie nadpisuje Twoich zaznaczeń; ↺ Reset do nich wraca.

    Kilka punktów zasługuje na słowo komentarza, bo nie miały w tej stronie osobnego rozdziału.

    Schemat. spring.jpa.hibernate.ddl-auto przyjmuje wartości none, validate, update, create i create-drop. Spring Boot sam wybiera create-drop tylko dla bazy wbudowanej (H2, HSQLDB, Derby), gdy nie wykryje Flyway ani Liquibase; w pozostałych przypadkach domyślnie jest none. Dokumentacja Hibernate zaznacza, że generowanie schematu z mapowań jest przydatne do testów i prototypów, ale na produkcji schematem lepiej zarządzać przyrostowymi skryptami migracji. Spring Boot dodaje: jeśli używasz Flyway lub Liquibase, niech tylko one tworzą schemat.

    Testy. @DataJpaTest uruchamia tylko komponenty JPA. Domyślnie każdy test jest transakcyjny i na końcu wycofywany, a zapytania SQL są logowane (spring.jpa.show-sql=true, można to wyłączyć atrybutem showSql). Do przygotowania danych możesz wstrzyknąć TestEntityManager.

    Liczenie zapytań. Ustaw hibernate.generate_statistics=true, a obiekt Statistics z sessionFactory.getStatistics() powie Ci m.in., ile instrukcji przygotowano (getPrepareStatementCount()). Asercja „ta metoda wysyła najwyżej 2 zapytania” to najtańszy strażnik przed powrotem N+1.

    @DataJpaTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
    class OrderRepositoryTest {
      @Autowired OrderRepository orders;
      @Autowired EntityManagerFactory emf;
    
      @Test
      void listWithCustomersUsesOneQuery() {
        Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
        stats.clear();
        orders.findAllWithCustomer();          // join fetch
        assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
      }
    }
    ⚠️ Uwaga

    Uważaj na test na H2, gdy produkcja stoi na PostgreSQL. Spring Boot sam ostrzega przed założeniem, że tabele i dane z bazy w pamięci będą istniały w „prawdziwej” bazie. Dialekt, typy kolumn i blokady potrafią się różnić. Krytyczne zapytania testuj na tym samym silniku co produkcja.

    ✏️ Zaostrz ołówek

    Twój kolega ustawił spring.jpa.hibernate.ddl-auto=update na produkcji, „bo samo się dopasowuje”. Zmienił nazwę pola name na fullName. Co się stanie z danymi w tabeli?

    Odpowiedź

    Tryb update tylko dodaje to, czego brakuje w schemacie, i poprawia złe typy kolumn. Hibernate doda nową kolumnę full_name (pustą), a stara kolumna name z danymi zostanie nietknięta i nieużywana. Nic nie przeniesie danych. Taką zmianę robi się migracją Flyway/Liquibase, a na produkcji ustawia validate.

    Najczęstsze błędy: klinika anty-wzorców

    objaw → przyczyna → lek

    Większość problemów z JPA w prawdziwych projektach to te same kilkanaście błędów. Każdy ma charakterystyczny objaw, który widzisz w logu albo w zgłoszeniu od użytkownika. Kiedy nauczysz się go rozpoznawać, diagnoza zajmuje minutę zamiast dnia.

    Kliknij kartę, żeby zobaczyć przyczynę, poprawkę i SQL przed oraz po. Przycisk 💥 „wstrzykuje” anty-wzorzec z zaznaczonej karty. Zacznij od N+1 i obserwuj licznik.

    ▶ Klinika anty-wzorców kliknij kartę, potem ▶ albo 💥
    L1 N+1 zapytań

    Objaw: lista 50 zamówień wysyła 51 SELECT-ów; na pustej bazie strona działa szybko, na produkcji zwalnia.

    Przyczyna: zapytanie pobiera zamówienia, a pętla dotyka leniwego o.getCustomer(). Każdy niezainicjowany klient to osobny SELECT. To nie błąd Hibernate, tylko sposób użycia.

    Poprawka: pobierz asocjację w tym samym zapytaniu (join fetch lub @EntityGraph), a hibernate.default_batch_fetch_size traktuj jako siatkę bezpieczeństwa. → rozdz. N+1

    // przed
    List<Order> findByStatus(Status s);
    // po
    @EntityGraph(attributePaths = "customer")
    List<Order> findByStatus(Status s);
    L1 LazyInitializationException

    Objaw: LazyInitializationException w kontrolerze albo podczas serializacji do JSON.

    Przyczyna: transakcja (a z nią sesja) skończyła się w serwisie, a ktoś później dotyka niezainicjowanego proxy lub kolekcji.

    Poprawka: pobierz potrzebne dane, póki sesja jest otwarta (fetch join, @EntityGraph, DTO). Nie włączaj hibernate.enable_lazy_load_no_trans: tworzy on tymczasowy persistence context dla każdego takiego dostępu, a dokumentacja Hibernate oznacza to ustawienie jako niebezpieczne i odradza je. → rozdz. lazy

    // przed: Order z serwisu, w kontrolerze
    order.getItems().size();   // 💥 sesja zamknięta
    // po: serwis zwraca DTO zbudowane w transakcji
    return new OrderView(o.getId(), o.getItems().size());
    L2 Open Session in View „bo działa”

    Objaw: przy starcie WARN spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering.; SQL w logu pojawia się w trakcie pisania JSON-a.

    Przyczyna: domyślne spring.jpa.open-in-view=true wiąże EntityManager z całym żądaniem HTTP, więc leniwe ładowanie „działa” aż do końca renderowania.

    Poprawka: spring.jpa.open-in-view=false i jawne pobieranie danych w serwisie. → rozdz. OSIV

    L2 @Data / @ToString na encji

    Objaw: log.debug("{}", order) wysyła SELECT-y albo kończy się StackOverflowError; encja ginie z HashSet.

    Przyczyna: @Data to skrót m.in. za @ToString i @EqualsAndHashCode, które domyślnie biorą wszystkie niestatyczne pola. W tym leniwe asocjacje (inicjalizacja), a przy relacji dwukierunkowej obie strony wołają się nawzajem w nieskończoność. Do tego hashCode zależy od pól zmiennych.

    Poprawka: @Getter/@Setter, @ToString.Exclude na asocjacjach, ręczne equals/hashCode. → rozdz. equals

    // przed
    @Data @Entity class Order { @OneToMany(mappedBy = "order") List<OrderItem> items; }
    // po
    @Getter @Setter @Entity class Order {
      @ToString.Exclude @OneToMany(mappedBy = "order") List<OrderItem> items; }
    L2 hashCode z generowanego id

    Objaw: dodajesz nową encję do HashSet, robisz persist, a set.contains(e) zwraca false.

    Przyczyna: id jest generowane dopiero przy utrwaleniu, więc hashCode zmienia się, gdy obiekt już siedzi w kolekcji haszującej.

    Poprawka: klucz naturalny (np. @NaturalId) w equals/hashCode albo wymuś nadanie id (persist + flush) przed dodaniem do HashSet; możesz też w ogóle nie nadpisywać equals/hashCode. → rozdz. equals

    L1 CascadeType.ALL na @ManyToOne

    Objaw: usunięcie zamówienia próbuje usunąć klienta: albo znika klient, albo dostajesz naruszenie klucza obcego, bo klient ma inne zamówienia.

    Przyczyna: ALL zawiera REMOVE, a kaskada idzie od dziecka do rodzica. Specyfikacja zaleca REMOVE tylko na @OneToOne i @OneToMany.

    Poprawka: bez kaskady na @ManyToOne; kaskady tylko z rodzica do dzieci, którymi rodzic zarządza. → rozdz. relacje

    // przed
    @ManyToOne(cascade = CascadeType.ALL) Customer customer;
    // po
    @ManyToOne(fetch = FetchType.LAZY) Customer customer;
    L1 @Enumerated domyślnie (ORDINAL)

    Objaw: po dodaniu nowej wartości enuma w środek listy stare zamówienia „zmieniają” status.

    Przyczyna: domyślnie enum zapisuje się jako liczba ordinal(), czyli pozycja na liście.

    Poprawka: @Enumerated(EnumType.STRING) (zapisuje name()). → rozdz. mapowanie

    L1 ddl-auto=update na produkcji

    Objaw: po zmianie nazwy pola w tabeli pojawia się nowa, pusta kolumna, a stara z danymi zostaje.

    Przyczyna: update tylko dodaje brakujące elementy schematu i poprawia złe typy kolumn; nie przenosi danych ani nie usuwa starych kolumn.

    Poprawka: migracje Flyway/Liquibase, a w Hibernate validate albo none. → rozdz. dobre praktyki

    L1 Self-invocation @Transactional

    Objaw: metoda z @Transactional(propagation = REQUIRES_NEW) nie otwiera nowej transakcji; zapis znika razem z rollbackiem całości.

    Przyczyna: w trybie proxy przechwytywane są tylko wywołania z zewnątrz. this.metoda() omija proxy, więc adnotacja nic nie robi.

    Poprawka: przenieś metodę do osobnego beana i wstrzyknij go. → rozdz. @Transactional

    // przed (w OrderService)
    public void place(Order o) { ...; this.audit(o); }
    // po
    public void place(Order o) { ...; auditService.audit(o); }
    L1 Checked exception bez rollbacku

    Objaw: metoda rzuciła wyjątek, a dane i tak są w bazie.

    Przyczyna: domyślnie rollback wywołuje tylko RuntimeException i Error; checked exception kończy się commitem.

    Poprawka: @Transactional(rollbackFor = Exception.class) albo od Spring 6.2 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS). → rozdz. @Transactional

    L2 Zbiorczy update bez clear

    Objaw: po @Modifying @Query("update ...") encja wczytana wcześniej w tej transakcji pokazuje starą wartość.

    Przyczyna: zapytanie zbiorcze idzie prosto do bazy i omija persistence context. Spring Data domyślnie go nie czyści.

    Poprawka: @Modifying(flushAutomatically = true, clearAutomatically = true). → rozdz. zapytania

    L1 findAll() bez limitu

    Objaw: ekran listy działa na testach, a na produkcji ładuje się kilkanaście sekund albo kończy OutOfMemoryError.

    Przyczyna: każdy wiersz staje się zarządzaną encją w persistence context, który trzyma do niej twardą referencję aż do końca sesji.

    Poprawka: Pageable/Slice/Window, projekcje, a do przetwarzania wszystkiego strumień lub batch z clear(). → rozdz. paginacja

    L2 IDENTITY + batch insert

    Objaw: ustawiłeś hibernate.jdbc.batch_size=50, a 100 encji to dalej 100 osobnych INSERT-ów.

    Przyczyna: przy IDENTITY id znasz dopiero po wykonaniu INSERT-a, więc Hibernate wyłącza batchowanie insertów.

    Poprawka: GenerationType.SEQUENCE z optymalizatorem pooled (domyślnie allocationSize = 50). → rozdz. batch

    L2 save() z ręcznie ustawionym id

    Objaw: każdy save() nowej encji z UUID ustawionym w konstruktorze wysyła SELECT przed INSERT-em.

    Przyczyna: id nie jest null, więc Spring Data uznaje encję za istniejącą i woła merge(), który najpierw czyta wiersz z bazy.

    Poprawka: Persistable<ID> z własnym isNew() albo pole @Version typu obiektowego. → rozdz. save()

    SQL wysłany do bazy
    Wybierz kartę i kliknij ▶.
    Dane ilustracyjne (50 zamówień, 50 różnych klientów, 100 nowych encji; w podglądzie ▶ długie serie są skrócone, 💥 pokazuje je w całości). SQL w stylu Hibernate 7 z uproszczonymi aliasami; dla błędów, które nie zmieniają SQL-a, log pokazuje objaw jako komentarz.
    ❓ Nie ma głupich pytań
    P:
    Skoro N+1 jest tak częste, czemu po prostu nie ustawić wszędzie EAGER?
    O:
    Bo EAGER nie łączy tabel w zapytaniach JPQL. Hibernate i tak dociąga takie asocjacje osobnymi SELECT-ami, tylko robi to zawsze, nawet gdy dane nie są potrzebne. Autorzy Hibernate nazywają EAGER wszędzie „okropnym pomysłem”. Domyślnie LAZY, a pobieranie decydujesz per zapytanie.
    P:
    Jak szybko znaleźć, który z tych błędów mam?
    O:
    Włącz log SQL i patrz na liczbę zapytań przy jednym żądaniu. Powtarzający się SELECT z where id=? to N+1, SELECT przed INSERT-em to merge, a SQL po zakończeniu metody serwisu to OSIV.
    ✏️ Zaostrz ołówek

    Zgłoszenie: „Po wprowadzeniu Lomboka endpoint /orders/7 zwraca 500 ze StackOverflowError”. Encje Order i OrderItem mają @Data i relację dwukierunkową. Które dwie zmiany wprowadzisz?

    Odpowiedź

    1) Zamień @Data na @Getter/@Setter i wyklucz asocjacje z toString (@ToString.Exclude); napisz equals/hashCode ręcznie, bez kolekcji. 2) Nie serializuj encji do JSON: zwracaj DTO, wtedy cykl Order → items → order w ogóle nie trafia do serializera.

    Pytania na interview

    fiszki: najpierw odpowiedz sam

    Poniżej masz pytania, które często padają na rozmowach o Hibernate i Spring Data JPA. Każda fiszka ma poziom: L1 (podstawy), L2 (praktyk) i L3 (ekspert). Najpierw odpowiedz na głos, potem odkryj odpowiedź.

    Odpowiedzi są celowo krótkie, tyle, ile zmieścisz w minucie rozmowy. Link przy każdej prowadzi do rozdziału z pełnym wyjaśnieniem i animacją.

    ▶ Fiszki filtruj poziom, kliknij pytanie, by odkryć odpowiedź
    L1 Czym różnią się JPA, Hibernate i Spring Data JPA?

    JPA (Jakarta Persistence) to specyfikacja i API. Hibernate to jej implementacja: dostarcza EntityManagerFactory i EntityManager. Spring Data JPA nie jest ORM-em, tylko generuje repozytoria, które wołają EntityManager (np. save() → persist lub merge).

    → rozdz. stos
    L1 Jakie stany może mieć encja?

    New (transient), managed, detached i removed. Nowa nie ma tożsamości w bazie, zarządzana jest w persistence context, odłączona ma tożsamość, ale nie jest już śledzona, usunięta czeka na DELETE.

    → rozdz. cykl życia
    L1 Czym różni się persist od merge?

    persist robi z przekazanego nowego obiektu encję zarządzaną. merge kopiuje stan obiektu na instancję zarządzaną (w razie potrzeby najpierw ją wczytując) i zwraca tę kopię; przekazany obiekt zostaje odłączony. Dalsze zmiany trzeba robić na wyniku merge.

    → rozdz. cykl życia
    L1 Co to jest persistence context?

    Cache pierwszego poziomu i identity map: dla danego typu i id trzyma najwyżej jedną instancję. Drugi find tego samego id nie idzie do bazy i zwraca tę samą referencję. Zwykle żyje tyle co transakcja i nie jest współdzielony między wątkami.

    → rozdz. persistence context
    L1 Dlaczego zmiana pola encji trafia do bazy bez save()?

    Dirty checking. Hibernate trzyma snapshot stanu encji z chwili wczytania i przy flush porównuje go z bieżącym stanem. Różnica oznacza UPDATE. save() na encji zarządzanej nie jest z punktu widzenia JPA potrzebne.

    → rozdz. flush
    L1 Kiedy Hibernate wykonuje flush w trybie AUTO?

    Przed commitem transakcji, przed wykonaniem zapytania, na którego wynik mogą wpłynąć niezapisane zmiany, oraz gdy wywołasz flush() jawnie.

    → rozdz. flush
    L1 Co to jest strona właścicielska relacji?

    Strona, której stan decyduje o kluczu obcym w bazie. W parze @ManyToOne + @OneToMany(mappedBy) właścicielem jest @ManyToOne. Zmiany zrobione tylko po stronie odwrotnej (mappedBy) nie są zapisywane.

    → rozdz. relacje
    L1 Jakie są domyślne typy pobierania asocjacji?

    Według JPA @ManyToOne i @OneToOne są EAGER, a @OneToMany, @ManyToMany i @ElementCollection LAZY. Dobra praktyka: jawnie ustawić LAZY także na *ToOne.

    → rozdz. lazy
    L1 Co to jest problem N+1 i jak go naprawić?

    Jedno zapytanie pobiera N encji, a potem dla każdej leniwa asocjacja dociąga się osobnym SELECT-em. Naprawa: pobrać asocjację w tym samym zapytaniu (join fetch, @EntityGraph); batch fetching łagodzi problem, ale go nie usuwa.

    → rozdz. N+1
    L1 Kiedy pojawia się LazyInitializationException?

    Gdy dotykasz niezainicjowanego proxy albo kolekcji po zamknięciu sesji (np. po końcu metody @Transactional). Samo getId() na proxy nie potrzebuje bazy.

    → rozdz. lazy
    L1 Co robi save() w Spring Data JPA?

    Jeśli encja jest nowa, woła entityManager.persist(), w przeciwnym razie entityManager.merge().

    → rozdz. save()
    L1 Skąd Spring Data wie, że encja jest nowa?

    Najpierw sprawdza pole @Version typu nieprymitywnego: null = nowa. Bez niego patrzy na id: null (albo 0 dla typu prymitywnego) = nowa. Możesz też zaimplementować Persistable i własne isNew().

    → rozdz. save()
    L1 Dlaczego @Enumerated(EnumType.STRING)?

    Domyślnie (ORDINAL) zapisuje się pozycja stałej w enumie. Zmiana kolejności lub wstawienie nowej stałej w środek zmienia znaczenie starych wierszy. STRING zapisuje name().

    → rozdz. mapowanie
    L1 Przy jakich wyjątkach @Transactional robi rollback?

    Domyślnie przy RuntimeException i Error. Checked exception kończy się commitem, chyba że dodasz rollbackFor.

    → rozdz. @Transactional
    L1 Dlaczego this.metoda() z @Transactional nie otwiera transakcji?

    Spring domyślnie realizuje transakcje przez proxy, które przechwytuje tylko wywołania z zewnątrz. Wywołanie wewnątrz obiektu omija proxy.

    → rozdz. @Transactional
    L1 Page czy Slice?

    Page zna łączną liczbę elementów, więc wykonuje dodatkowe zapytanie count. Slice wie tylko, czy istnieje następny kawałek, i nie liczy całości.

    → rozdz. paginacja
    L1 Co daje @Transactional(readOnly = true) z Hibernate?

    Spring ustawia flush mode na MANUAL, więc Hibernate pomija dirty checking; flaga trafia też jako wskazówka do sterownika JDBC. To optymalizacja, a nie zabezpieczenie przed zapisem.

    → rozdz. @Transactional
    L1 Jakie wymagania musi spełniać klasa encji?

    Adnotacja @Entity, identyfikator @Id, konstruktor bez argumentów (w JPA public lub protected), klasa nie może być final.

    → rozdz. mapowanie
    L1 Jakie ddl-auto na produkcji?

    validate albo none, a schemat z migracji Flyway/Liquibase. Spring Boot domyślnie ustawia create-drop tylko dla bazy wbudowanej bez narzędzia migracji; w innych przypadkach none.

    → rozdz. dobre praktyki
    L1 Czym Spring Data JDBC różni się od Spring Data JPA?

    Nie ma sesji, lazy loadingu, cache ani dirty trackingu. Wczytanie zwraca w pełni załadowany agregat, a save() zapisuje cały agregat (dla istniejącego: usuwa i wstawia od nowa encje podrzędne).

    → rozdz. porównania
    L2 Co się dzieje przy join fetch kolekcji + paginacja?

    Limit liczy wiersze, a nie encje-rodziców. Od Hibernate 7.4 jest to naprawione na bazach, które wspierają limit i offset w podzapytaniu; na pozostałych Hibernate pobiera wszystko i stronicuje w pamięci. hibernate.query.fail_on_pagination_over_collection_fetch=true zamienia to w wyjątek.

    → rozdz. fetch join
    L2 Kiedy dostaniesz MultipleBagFetchException?

    Gdy w jednym zapytaniu fetchujesz jednocześnie więcej niż jeden „bag” (kolekcję List bez kolumny indeksu). Rozwiązania: Set albo osobne zapytania na każdą kolekcję.

    → rozdz. fetch join
    L2 Dlaczego IDENTITY psuje batch insert?

    Id generuje sama baza przy INSERT, więc Hibernate musi wykonać INSERT od razu, by poznać identyfikator. Dlatego przy IDENTITY batchowanie insertów jest wyłączone.

    → rozdz. batch
    L2 Jaki jest domyślny allocationSize sekwencji i po co on jest?

    50. Hibernate rezerwuje blok identyfikatorów jednym wywołaniem sekwencji (optymalizator pooled), więc nie musi pytać bazy przy każdym persist.

    → rozdz. batch
    L2 Co to Open Session in View i jaki ma default w Spring Boot?

    spring.jpa.open-in-view domyślnie true: EntityManager jest związany z wątkiem przez całe żądanie, więc leniwe ładowanie działa w widoku. Boot loguje przy starcie ostrzeżenie, jeśli nie ustawisz tej właściwości jawnie.

    → rozdz. OSIV
    L2 Co robi clearAutomatically w @Modifying?

    Czyści persistence context po zapytaniu zbiorczym. Zapytanie modyfikujące omija kontekst, więc wczytane wcześniej encje mogą być nieaktualne; Spring Data domyślnie kontekstu nie czyści, bo wyrzuciłoby to niezapisane zmiany (dlatego jest też flushAutomatically).

    → rozdz. zapytania
    L2 Projekcja zamknięta czy otwarta?

    Zamknięta: wszystkie metody interfejsu odpowiadają właściwościom encji, więc Spring Data może pobrać tylko te kolumny. Otwarta: używa @Value z SpEL; wtedy optymalizacja zapytania nie jest możliwa i pobierana jest cała encja.

    → rozdz. projekcje
    L2 Jak działa optimistic locking?

    Pole @Version. Każdy UPDATE i DELETE encji wersjonowanej ma w where warunek na wersję. Jeśli zmieniono 0 wierszy, Hibernate rzuca OptimisticLockException; Spring tłumaczy to na ObjectOptimisticLockingFailureException.

    → rozdz. optimistic
    L2 Jak zrobić pessimistic lock w Spring Data JPA?

    Adnotacja @Lock(LockModeType.PESSIMISTIC_WRITE) na metodzie repozytorium. Hibernate wysyła wtedy select … for update (lub odpowiednik w dialekcie bazy). Czas oczekiwania ustawisz wskazówką jakarta.persistence.lock.timeout.

    → rozdz. pessimistic
    L2 REQUIRED vs REQUIRES_NEW?

    REQUIRED (domyślna) dołącza do istniejącej transakcji albo tworzy nową. REQUIRES_NEW zawsze używa niezależnej fizycznej transakcji z własnym połączeniem; zewnętrzna jest na ten czas zawieszona.

    → rozdz. propagacja
    L2 Skąd UnexpectedRollbackException?

    Wewnętrzna metoda z REQUIRED oznaczyła wspólną transakcję jako rollback-only (np. rzuciła wyjątek złapany wyżej), a zewnętrzna próbuje zrobić commit. Spring wycofuje transakcję i informuje o tym tym wyjątkiem.

    → rozdz. propagacja
    L2 Czy cache drugiego poziomu jest domyślnie włączony?

    Nie. Trzeba dodać dostawcę (np. przez JCache) i oznaczyć encje @Cache. Cache jest współdzielony między sesjami; najlepiej sprawdza się dla danych często czytanych i rzadko zmienianych.

    → rozdz. cache L2
    L2 find() czy getReference()?

    find() zwraca załadowaną encję (z kontekstu albo z bazy). getReference() zwraca referencję bez wczytywania stanu, np. żeby ustawić klucz obcy bez SELECT-a.

    → rozdz. lazy
    L2 Co daje @DataJpaTest?

    Uruchamia tylko komponenty JPA. Każdy test jest domyślnie transakcyjny z rollbackiem na końcu, SQL jest logowany (show-sql), a do przygotowania danych możesz wstrzyknąć TestEntityManager.

    → rozdz. dobre praktyki
    L2 Jak w teście sprawdzić liczbę zapytań?

    Włącz hibernate.generate_statistics i odczytaj Statistics z SessionFactory, np. getPrepareStatementCount().

    → rozdz. dobre praktyki
    L2 Czy cascade = REMOVE na @ManyToOne to dobry pomysł?

    Nie. Specyfikacja mówi, że w przenośnej aplikacji REMOVE stosuje się tylko na @OneToOne i @OneToMany. Na @ManyToOne usunięcie dziecka próbowałoby usunąć wspólnego rodzica.

    → rozdz. relacje
    L3 Jakie są strategie dziedziczenia encji?

    SINGLE_TABLE (domyślna): jedna tabela z kolumną dyskryminatora, kolumny podklas bez NOT NULL. JOINED: tabela na klasę, zapytanie robi JOIN, schemat znormalizowany. TABLE_PER_CLASS: tabela na klasę konkretną, zapytanie polimorficzne to UNION. Osobno @MappedSuperclass: tylko wspólne pola, bez polimorficznych zapytań.

    → rozdz. dziedziczenie
    L3 Jak napisać equals/hashCode encji?

    Nie używaj pól zmiennych w hashCode. Generowane id można użyć tylko ostrożnie, bo pojawia się dopiero po utrwaleniu. Dokumentacja Hibernate zaleca klucz naturalny (zgodny z ograniczeniem unikalności, np. @NaturalId).

    → rozdz. equals
    L3 Czym jest NESTED?

    Jedna fizyczna transakcja z savepointami: wewnętrzna część może wycofać się do savepointu bez wycofywania całości. Zwykle mapowane na savepointy JDBC, więc działa z zasobami JDBC.

    → rozdz. propagacja
    L3 Co to jest StatelessSession?

    Sesja bez persistence context: bez cache pierwszego poziomu, bez write-behind i automatycznego dirty checkingu. Operacje wykonują się od razu, zawsze na obiektach odłączonych, i nie kaskadują.

    → rozdz. przetwarzanie wsadowe
    L3 Jak działa Hibernate Envers?

    Encja z @Audited dostaje tabelę historii <tabela>_AUD, do której przy każdym commicie trafia jej stan, a numery rewizji trafiają do tabeli REVINFO.

    → rozdz. audyt
    L3 Jak działa auditing w Spring Data JPA?

    @EnableJpaAuditing włącza mechanizm, encje dostają @CreatedDate, @LastModifiedDate itd. (przez AuditingEntityListener). Dla @CreatedBy/@LastModifiedBy dostarczasz bean AuditorAware, który zna bieżącego użytkownika.

    → rozdz. audyt
    L3 Jaki jest domyślny poziom izolacji w @Transactional?

    Isolation.DEFAULT, czyli domyślny poziom bazy danych. Spring go nie zmienia, dopóki nie podasz isolation.

    → rozdz. propagacja
    L3 Dlaczego @Data Lomboka na encji to zły pomysł?

    @Data generuje m.in. toString, equals i hashCode ze wszystkich niestatycznych pól. To dotyka leniwych asocjacji (dodatkowe SELECT-y lub LazyInitializationException), przy relacjach dwukierunkowych daje nieskończoną rekurencję, a hashCode zależy od pól zmiennych.

    → rozdz. equals
    Wybierz poziom i kliknij ▶.
    Fiszki pokazują pytania dla bieżącej głębokości strony; filtr L1/L2/L3 zawęża je dalej. „Pułapki” to pytania, przy których kandydaci najczęściej mylą się w szczegółach (wybór autora). Odpowiedzi są skrótami; pełne wyjaśnienie z dokumentacji jest w podlinkowanych rozdziałach.
    😌 Spokojnie

    Nikt nie oczekuje, że wyrecytujesz wszystkie 44 odpowiedzi. Na rozmowie bardziej liczy się, czy umiesz wyjaśnić mechanizm: dlaczego zmiana trafia do bazy bez save(), skąd bierze się dodatkowy SELECT. Jeśli rozumiesz persistence context, flush i proxy, resztę wyprowadzisz.

    📌 W punktach
    • L1: stany encji, persistence context, dirty checking, flush, strona właścicielska, domyślne fetch, N+1, save(), @Transactional.
    • L2: fetch join i paginacja, batch i generatory id, OSIV, @Modifying, projekcje, blokady, propagacja, cache L2, testy.
    • L3: dziedziczenie, equals/hashCode, NESTED, StatelessSession, audyt, izolacja, Lombok.

    Quiz + ściągawka

    sprawdź, co zostało w głowie
    Wynik: 0 / 0

    Ściągawka

    Adnotacja / właściwośćDomyślnie / co robiKiedy
    Część I · Fundamenty ORM
    @Entityklasa nie-final, konstruktor bez argumentów (public/protected)każda encja
    @Table(name)domyślnie nazwa encji (Boot: snake_case, małe litery)gdy nazwa koliduje, np. order
    @GeneratedValuestrategy = AUTOid generowane przez bazę/Hibernate
    @Columnnullable = true, length = 255ograniczenia, nazwa kolumny
    @Enumerateddomyślnie ORDINALprawie zawsze STRING
    @Embeddable / @Embeddedpola w tabeli właściciela, brak własnego idadres, kwota, zakres dat
    persist / removeplanują INSERT / DELETE na flushnowy obiekt / obiekt managed
    mergezwraca kopię managed; często SELECTobiekt detached; pracuj na wyniku
    persistence contextjeden obiekt na id; żyje z transakcjąfind po id bez SQL
    FlushModeType.AUTOdomyślny: flush przed commitem i przed zapytaniem, na które wpływają zmianyprawie zawsze
    FlushModeType.COMMITflush tylko przed commitemoptymalizacja; ryzyko nieaktualnych wyników
    @Transactional(readOnly = true)Hibernate: FlushMode.MANUAL, bez dirty checkingmetody tylko do odczytu
    @OneToMany(mappedBy)strona odwrotna; nie zapisuje FKrelacja dwukierunkowa
    @ManyToOne + @JoinColumnstrona właścicielska; domyślnie EAGERzwykle warto dodać fetch = LAZY
    cascadedomyślnie brak; ALL = PERSIST, MERGE, REMOVE, REFRESH, DETACHrodzic–dziecko (@OneToMany)
    orphanRemovaldomyślnie false; kasuje dziecko wyjęte z kolekcjidzieci „należące” do rodzica
    Część II · Spring Data JPA
    JpaRepository<T, ID>CRUD (List) + paginacja + QBE + flush, saveAndFlush, getReferenceByIdDomyślny interfejs repozytorium w JPA
    Transakcje repozytoriumOdczyty CRUD readOnly = true, zapisy @Transactional; Twoje metody: brakGranice transakcji stawiaj w serwisie
    spring.data.jpa.repositories.bootstrap-modedefault: repozytoria tworzone zachłannie przy starciedeferred/lazy, by przyspieszyć start
    @Query / @NativeQueryJPQL na encjach / SQL na tabelach; @Query wygrywa z @NamedQueryNazwa metody za długa, złączenia, funkcje bazy
    @Modifying(flushAutomatically, clearAutomatically)Oba domyślnie false; flush przed, clear po zapytaniuBulk update/delete przy encjach w kontekście
    save() → isNewPersistable.isNew() (jeśli jest) → inaczej @Version obiektowe null → @Id null (prymityw 0)Ręczne id: dodaj @Version Long lub Persistable
    Projekcja zamknięta / DTOTylko potrzebne kolumny; DTO bez proxyListy, widoki tylko do odczytu
    Projekcja otwarta @ValueSpEL na target; ładuje całą encjęRzadko; prostą logikę zrób w default albo w widoku
    Page / Slice / WindowStrona + COUNT / rozmiar + 1 / przewijanie offset lub keysetNumery stron / „więcej” / długie listy i eksporty
    PageRequest.of(n, size, sort)Numer strony od 0Zawsze z jawnym sortowaniem
    JpaSpecificationExecutorfindAll/count/exists(spec); pusta spec pomijanaOpcjonalne filtry w wyszukiwarce
    Example.of(probe, matcher)null pomijane, prymitywy zawsze; tylko równość dla nie-tekstówProste wyszukiwanie po polach
    @Transactional domyślnieREQUIRED, izolacja DEFAULT, read-write, rollback na RuntimeException/ErrorMetody serwisu wołane z innego beana
    rollbackFor / noRollbackForDodatkowe reguły rollbackuWyjątki sprawdzane, które mają cofać zmiany
    readOnly = trueWskazówka dla JDBC; Hibernate flush mode MANUALOdczyty, zwłaszcza duże grafy
    REQUIRES_NEWOsobna fizyczna transakcja i osobne połączenieLog/audyt, który ma przetrwać rollback; pilnuj puli
    Część III · Wydajność
    @ManyToOne / @OneToOnedomyślnie EAGERustaw fetch = LAZY jawnie
    @OneToMany / @ManyToMany / @ElementCollectiondomyślnie LAZYzostaw; pobieraj joinem, gdy potrzebujesz
    join fetch w @Queryasocjacja w tym samym SELECTpodstawowe lekarstwo na N+1
    @EntityGraph(attributePaths = "…")Spring Data buduje EntityGraph; Hibernate robi left joinN+1 przy metodach pochodnych
    @BatchSize(size = n) / hibernate.default_batch_fetch_sizewyłączone; jeden SELECT inicjuje do n proxy/kolekcjisiatka bezpieczeństwa, gdy join daje iloczyn
    @Fetch(SUBSELECT) / hibernate.use_subselect_fetchwyłączone; kolekcje drugim zapytaniem z podzapytaniemdruga kolekcja obok join fetch
    hibernate.query.fail_on_pagination_over_collection_fetchfalseustaw true: wyjątek zamiast paginacji w pamięci
    MultipleBagFetchExceptionjoin fetch dwóch list narazrozbij na dwa zapytania
    hibernate.jdbc.batch_size0 (wyłączone)10–50 przy masowych zapisach
    hibernate.order_inserts / order_updatesfalsegdy zapisujesz różne encje na przemian
    GenerationType.IDENTITYwyłącza batch insertgdy wstawiasz rzadko
    @SequenceGenerator(allocationSize)50; optymalizator pooledmasowe wstawianie z batchingiem
    spring.jpa.open-in-viewtrue + WARN przy starcieustaw false, dane pobieraj w serwisie
    hibernate.enable_lazy_load_no_transfalse; oznaczone jako niebezpiecznenie włączaj
    @Cache(usage = …) + hibernate.cache.region.factory_class=jcacheL2 domyślnie wyłączonydane słownikowe, rzadko zmieniane
    jakarta.persistence.sharedCache.modeENABLE_SELECTIVEzostaw domyślne
    hibernate.cache.use_query_cache + setCacheable(true)wyłączonyrzadko; tylko powtarzalne zapytania na rzadko zmienianych tabelach
    Część IV · Współbieżność
    @Versionliczba (od 0) lub czas; podbijana przy każdym zapisie, sprawdzana w WHEREkażda encja edytowana współbieżnie lub przez merge
    OptimisticLockingFailureExceptionkonflikt wersji w Springu (ObjectOptimisticLockingFailureException)komunikat „odśwież” albo ponowienie całej transakcji
    @Retryable (Spring 7)maxRetries domyślnie 3; wymaga @EnableResilientMethodsponawianie z zewnątrz transakcji
    @Lock(PESSIMISTIC_WRITE)select … for update (zależnie od dialektu)gorący wiersz, krótka transakcja
    @Lock(PESSIMISTIC_READ)blokada współdzielona (for share)stabilny odczyt bez blokowania czytelników
    jakarta.persistence.lock.timeoutmilisekundy; nieobsługiwane → ignorowanenie czekaj w nieskończoność
    PessimisticLockingFailureExceptionnadtyp CannotAcquireLockException i in.łap ogólny typ, nie podklasy
    Część V · Ekspert
    @Inheritancedomyślnie SINGLE_TABLE; też JOINED, TABLE_PER_CLASSprawdziwa hierarchia encji z zapytaniami polimorficznymi
    @DiscriminatorColumndomyślnie DTYPE, typ STRINGSINGLE_TABLE (zawsze), JOINED (opcjonalnie)
    @MappedSuperclasswspólne pola, bez tabeli i zapytańid, version, pola audytu
    @NaturalIdklucz naturalny; UNIQUE; mutable = falsepodstawa equals/hashCode
    Lombok @Data na encjiequals/hashCode/toString ze wszystkich pólunikaj; @Getter/@Setter + @ToString.Exclude
    propagationdomyślnie REQUIREDREQUIRES_NEW dla zapisów, które mają przetrwać rollback
    UnexpectedRollbackExceptionwewnętrzny zakres oznaczył transakcję rollback-onlynie łap i nie ignoruj wyjątków z wewnętrznego REQUIRED
    nestedTransactionAllowedJpaTransactionManager: domyślnie falseNESTED z JDBC; nie cofa stanu encji
    isolationDEFAULT = poziom bazytylko tam, gdzie transakcja startuje
    flush() + clear() co Nogranicza rozmiar persistence contextimporty wsadowe
    StatelessSessionbez kontekstu, dirty checkingu i kaskad; insertMultipleprzetwarzanie masowe
    hibernate { enhancement {} }enhancer: leniwe pola basic, dirty trackingduże pola @Basic(fetch = LAZY)
    @EnableJpaAuditingmodifyOnCreate domyślnie truez AuditingEntityListener i @CreatedDate itd.
    @Audited (Envers)tabela _AUD + REVINFO; REVTYPE 0/1/2pełna historia zmian
    Część VI · Praktyka
    spring.jpa.hibernate.ddl-autocreate-drop dla bazy wbudowanej bez Flyway/Liquibase, inaczej nonena produkcji validate/none + migracje
    @DataJpaTesttylko komponenty JPA, test w transakcji z rollbackiem, show-sql włączonetesty repozytoriów i zapytań
    hibernate.generate_statisticsdomyślnie wyłączone; Statistics.getPrepareStatementCount()test liczący zapytania (strażnik N+1)
    Spring Data JDBC save()zapis całego agregatu: usuń i wstaw dzieci istniejącego agregatumałe agregaty, przewidywalny SQL
    JdbcClientpłynna fasada JDBC (od Spring 6.1), parametry ? i :nazwanepojedyncze zapytania pisane ręcznie
    jOOQ DSLContexttypowany SQL z klas generowanych ze schematuraporty, złożony SQL specyficzny dla bazy
    @Transactional(isolation)DEFAULT = poziom bazyzmieniaj tylko świadomie
    🎓 Gratulacje

    Odpowiedź na pytanie z początku: 1 zapytanie pobrało listę zamówień, a 100 kolejnych to doładowanie klienta osobno dla każdego zamówienia. To klasyczny problem N+1. EAGER go nie naprawia, bo findAll() wykonuje zapytanie (Criteria/JPQL), które nie łączy klientów; Hibernate musi więc dociągnąć je osobnymi SELECT-ami od razu, zamiast później. Naprawa to pobranie danych w jednym zapytaniu (join fetch albo @EntityGraph) lub batch fetching. Następny krok: włącz logowanie SQL w swoim projekcie, policz zapytania na najważniejszym endpoincie i porównaj z tym, czego się spodziewasz.