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.
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.
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.
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.EntityManager i adnotacji jakarta.persistence.*, piszesz „pod JPA”. Gdy sięgasz po Session albo adnotacje org.hibernate.annotations.*, wiążesz się z Hibernate.EntityManager. Spring Data tylko go woła. Zobaczysz to w rozdziale o persistence context.hibernate.show_sql=true albo włącz poziom DEBUG dla kategorii org.hibernate.SQL. Logger jest lepszy, bo trafia tam, gdzie reszta logów.- JDBC wysyła SQL. JPA to specyfikacja. Hibernate to implementacja JPA i ORM.
- Spring Data JPA generuje repozytoria.
SimpleJpaRepositorydeleguje doEntityManager(np.findById→find). SessionrozszerzaEntityManager,SessionFactoryrozszerzaEntityManagerFactory.- Metody odczytu w
SimpleJpaRepositorydziałają w transakcjireadOnly.
Encja i mapowanie
klasa Javy ↔ tabela, adnotacja po adnotacjiEncja 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.
orders| id | w bazie | odczytane jako |
|---|
check, które Hibernate może dodać dla enumów; kolejność kolumn uproszczona. Trzy wiersze są ilustracyjne.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).
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.
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 stanyObiekt 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.
| Stan | Co to znaczy | Jak tu trafia |
|---|---|---|
| new / transient | zwykły obiekt, bez tożsamości w bazie, nikt go nie śledzi | new Order() |
| managed (Hibernate: persistent) | ma id, jest w persistence context; zmiany trafią do bazy przy flush | persist, find, zapytanie, wynik merge |
| detached | ma id, ale kontekst już go nie śledzi | koniec transakcji (kontekst per transakcja), detach, clear, close, serializacja |
| removed | wciąż w kontekście, ale zaplanowany do usunięcia | remove 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.
@DynamicUpdate). Aliasy i kolumny skrócone.EntityExistsException (albo błąd wyjdzie dopiero przy flush lub commicie). Jestem od nowych obiektów.remove na obiekcie detached kończy się IllegalArgumentException. Najpierw zrób go managed (find albo merge), dopiero potem usuń.
- Cztery stany: new, managed, detached, removed.
persist/removeplanują SQL na flush; nie maupdate().- Po commicie (kontekst per transakcja) obiekty stają się detached.
mergezwraca inną instancję. Pracuj na wyniku:order = em.merge(order).
Persistence context: cache 1. poziomu i identity map
dwa razy find, jeden SELECTKaż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.)
@3f2a) są ilustracyjne. Pomijamy cache drugiego poziomu (wyłączony domyślnie, rozdz. 19). Aliasy i kolumny skrócone.find.EntityManager na całą aplikację i mieć wielki cache?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ą.
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:
- przed commitem transakcji,
- przed zapytaniem, na którego wynik mogą wpłynąć niezapisane zmiany,
- gdy sam wołasz
flush().
| Tryb | Co robi | Kiedy |
|---|---|---|
AUTO (JPA i Hibernate) | flush przed commitem i przed zapytaniem, na które wpływają zmiany | domyślnie; prawie zawsze |
COMMIT (JPA i Hibernate) | flush tylko przed commitem | optymalizacja; zapytania mogą zwrócić nieaktualne dane |
MANUAL (tylko Hibernate) | nigdy automatycznie | transakcje tylko do odczytu (tak ustawia go Spring dla readOnly) |
ALWAYS (tylko Hibernate) | flush przed każdym zapytaniem | rzadko, np. natywny SQL w Hibernate bez JPA |
| Order#7 | snapshot | w pamięci |
|---|
commit pokazujemy w logu, ale nie liczymy jako zapytanie. Uproszczenie: w trybie COMMIT zapytanie zwraca stan sprzed zmian w tej transakcji.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().
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ść.
- 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 = truew 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_items| id | order_id | product |
|---|
order_id przyjmuje NULL; z NOT NULL wariant 💥 skończyłby się błędem ograniczenia zamiast cichej sieroty. Kolumny skrócone.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.
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).
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.
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łaPiszesz 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.
Order mapuje tabelę orders; aliasy SQL w stylu Hibernate (o1_0), lista kolumn skrócona.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).
| Interfejs | Co daje | Kiedy |
|---|---|---|
Repository<T, ID> | Nic poza znacznikiem typu; same Twoje metody | Chcesz wystawić tylko wybrane operacje (np. bez delete) |
CrudRepository | save, findById, findAll, count, delete, existsById; zwraca Iterable | Kod niezależny od technologii (ten sam interfejs w Mongo, JDBC…) |
ListCrudRepository | To samo, ale List zamiast Iterable | Nie chcesz konwertować Iterable na listę |
PagingAndSortingRepository | findAll(Sort), findAll(Pageable); nie dziedziczy CRUD-a | Łączysz z CrudRepository, gdy potrzebujesz stron |
JpaRepository | Wszystko powyżej (wersje List) + flush, saveAndFlush, deleteAllInBatch, getReferenceById, Query by Example | Domyś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ż kodDopisujesz 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.
—
?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ą.
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.
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żkisave() 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);.
—
@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.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).
save=persist, gdy encja nowa; w przeciwnym raziemerge.- „Nowa”:
Persistable.isNew(), jeśli encja go implementuje; inaczej wersja obiektowanull→ a bez wersji@Idnull(prymityw:0). mergeencji 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: pomergezarzą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 dwieLista 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).
—
ordersfindByCustomerEmail. Encja ma tu tylko 5 kolumn; w prawdziwych tabelach różnica bywa większa. SQL w stylu Hibernate, uproszczony.getCustomer().getName()) też pobierze tylko jedną kolumnę?@PersistenceCreator.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 1Do 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.
offset … fetch first … albo limit … offset …) zależy od dialektu bazy.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.
PageRequest.of(numer, rozmiar, sort), numer od 0. Brak sortowania/stron:Sort.unsorted(),Pageable.unpaged(), nienull.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óryEkran 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.
—
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.
(:x is null or …).Order_ literówka w polu nie skompiluje się.fetch — moje. Formularz wyszukiwania z opcjonalnymi polami — Twój.JpaSpecificationExecutor+ małe, nazwane specyfikacje składaneand/or.- Specyfikacja bez ograniczenia (
unrestricted(), predykatnull) 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 proxyOznaczasz 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ę).
TransactionInterceptor
placeOrder()
save(order), save(payment)
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.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.
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.
@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”).
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.
| Adnotacja | Domyślny fetch | Co to znaczy w praktyce |
|---|---|---|
@ManyToOne | EAGER | klient zamówienia ładuje się od razu, nawet gdy go nie potrzebujesz |
@OneToOne | EAGER | jak wyżej |
@OneToMany | LAZY | pozycje zamówienia ładują się dopiero, gdy ich dotkniesz |
@ManyToMany | LAZY | jak wyżej |
@ElementCollection | LAZY | kolekcja 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.
@ManyToOne(LAZY)items
@OneToMany
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<>();
}
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).emf.getPersistenceUnitUtil().isLoaded(x). W Hibernate prościej: Hibernate.isInitialized(x). Wymuszone załadowanie: Hibernate.initialize(x), ale to kolejna podróż do bazy.LAZY to gwarancja?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.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)).
- Domyślnie:
*ToOneto EAGER,*ToManyto 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:
= 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.
| Opcja | Co robi | Kiedy |
|---|---|---|
@Query("select o from Order o join fetch o.customer where o.status = :s") | jedno zapytanie z JOIN; klient przychodzi razem z zamówieniem | domyślny wybór, gdy wiesz, czego potrzebujesz |
@EntityGraph(attributePaths = "customer") na metodzie repozytorium | Spring Data buduje z tego EntityGraph JPA; Hibernate dokłada left join | chcesz zostać przy metodzie pochodnej (findByStatus) bez pisania JPQL |
@BatchSize(size = 5) na klasie Customer albo globalnie hibernate.default_batch_fetch_size | leniwe ł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_fetch | dla 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.
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).
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 niespodziankiZłą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.
| Ustawienie | Co robi | Kiedy |
|---|---|---|
hibernate.query.fail_on_pagination_over_collection_fetch | domyślnie false; true zamienia paginację w pamięci na wyjątek | zawsze warto włączyć: wolisz błąd w teście niż cichy odczyt całej tabeli |
Set zamiast List | znika MultipleBagFetchException, zostaje iloczyn kartezjański | gdy kolekcja i tak nie ma duplikatów; nie jako „naprawa” dwóch joinów |
drugie zapytanie / @Fetch(SUBSELECT) | pierwsza kolekcja przez join, druga osobnym zapytaniem | potrzebujesz dwóch kolekcji naraz |
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.
join fetchdo*ToOne: bezpieczny. Do kolekcji: najwyżej jednej naraz.- Duplikaty z
join fetchHibernate 6+ usuwa sam; nie dodawajdistinct. - 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, nieSet.
Zapisy wsadowe i generatory ID
100 INSERT-ów w kilku podróżachKaż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
| Opcja | Domyślnie / co robi | Kiedy |
|---|---|---|
hibernate.jdbc.batch_size | 0 (wyłączone); każda wartość dodatnia włącza batching w sesji stanowej | dużo INSERT/UPDATE/DELETE do jednej tabeli w jednej transakcji; dokumentacja proponuje 10–50 |
hibernate.order_inserts | false; sortuje INSERT-y wg typu encji i klucza, z poszanowaniem kluczy obcych | zapisujesz na przemian różne encje (np. zamówienie, pozycje, zamówienie…) |
hibernate.order_updates | false; to samo dla UPDATE-ów i zmian kolekcji | jak 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.
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.
hibernate.jdbc.batch_sizedomyślnie 0. Ustaw 10–50.order_inserts/order_updates(domyślniefalse) pomagają, gdy zapisujesz różne encje na przemian.- IDENTITY wyłącza batch insert. SEQUENCE z
allocationSize(domyślnie 50) i optymalizatorempooledrzadko pyta bazę o id. - Masowe zmiany najlepiej robić jednym zapytaniem HQL
update/delete.
Open Session in View
wygodne, dopóki nie zajrzysz w logUruchamiasz 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.
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.
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.
CustomerStrategia (usage) | Co robi | Kiedy |
|---|---|---|
READ_ONLY | dane niezmienne; aktualizacja rzuca wyjątek; najprostsza i najszybsza | dane słownikowe: kraje, waluty |
NONSTRICT_READ_WRITE | bez blokad; unieważnia wpis przed i po transakcji zmieniającej | zmiany 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 bazy | równoległe zmiany możliwe, ale nieczęste |
TRANSACTIONAL | wymaga 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.
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.
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).
- 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.
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 sprawdzamAla 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.
orders--. 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
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.
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.
| LockModeType | Co robi (Hibernate) | Kiedy |
|---|---|---|
PESSIMISTIC_WRITE | blokada „for update”: inni nie zablokują ani nie zmienią wiersza | czytasz, żeby zaraz zmienić |
PESSIMISTIC_READ | blokada „for share” (jeśli baza ją ma): inni czytają, nikt nie zmienia | potrzebujesz stabilnego odczytu |
PESSIMISTIC_FORCE_INCREMENT | blokada + natychmiastowe podbicie version | łączysz oba podejścia |
ordersfor 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.
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ą.
@Lock(PESSIMISTIC_WRITE)→SELECT … FOR UPDATE(składnia zależy od dialektu); blokada do końca transakcji.- Timeout: wskazówka
jakarta.persistence.lock.timeoutw 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.
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 tabelZamó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; }
| Strategia | Tabele | Kiedy |
|---|---|---|
SINGLE_TABLE (domyślna) | jedna tabela na całą hierarchię + kolumna dyskryminatora | podklasy mają mało własnych pól; najszybsze zapytania |
JOINED | tabela na klasę, każda tylko z własnymi polami; złączenia po id | zależy Ci na ograniczeniach i normalizacji |
TABLE_PER_CLASS | tabela na klasę konkretną, ze wszystkimi polami odziedziczonymi | rzadko; zapytania polimorficzne przez UNION |
Payment jest abstrakcyjna.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.
@MappedSuperclass= wspólne pola, bez tabeli i bez zapytań polimorficznych.SINGLE_TABLE(domyślna): jedna tabela, jeden SELECT, kolumny podklas bezNOT NULL.JOINED: znormalizowane tabele i ograniczenia, za to złączenia w każdym zapytaniu polimorficznym.TABLE_PER_CLASS:UNIONi 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ą.
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(); }
}
@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
idi pól zmiennych: dokładnie błąd z figury powyżej; - dotknięcie leniwych asocjacji:
toString()w logu alboequals()w zbiorze może dociągnąć kolekcję z bazy, a po zamknięciu sesji rzucićLazyInitializationException; - przy asocjacji dwukierunkowej
Order.toString()wołaOrderItem.toString(), które wołaOrder.toString()… aż doStackOverflowError.
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.
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.
| Propagation | Gdy transakcja już jest | Gdy jej nie ma |
|---|---|---|
REQUIRED (domyślna) | dołącza | tworzy nową |
REQUIRES_NEW | zawiesza ją i tworzy nową, niezależną | tworzy nową |
NESTED | transakcja zagnieżdżona (savepoint JDBC) | jak REQUIRED |
SUPPORTS | dołącza | działa bez transakcji |
MANDATORY | dołącza | wyjątek |
NOT_SUPPORTED | zawiesza ją, działa bez transakcji | działa bez transakcji |
NEVER | wyjątek | działa bez transakcji |
@Transactional (REQUIRED)REQUIREDJpaTransactionManager (ustawienia domyślne), record() zapisuje przez repozytorium Spring Data. Linie begin/commit to uproszczony zapis tego, co dzieje się na połączeniu JDBC.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.
- Wewnętrzny
REQUIREDrzucił wyjątek, zewnętrzny go złapał → przy commicieUnexpectedRollbackException, bo transakcja była już rollback-only. REQUIRES_NEW= osobna fizyczna transakcja i osobne połączenie; commit/rollback niezależne.NESTED= savepoint; zJpaTransactionManagerdomyślnie wyłączony i nie cofa stanu encji w pamięci.- Izolacja
DEFAULT= poziom bazy;readOnlyw Hibernate →FlushMode.MANUAL.
Bytecode enhancement, StatelessSession i przetwarzanie wsadowe
10 000 encji i pamięć, która nie rośniePersistence 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
}
}
}
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ą.
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” (@LazyGroupto 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.
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śniejDwa 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.
@LastModifiedDate zadziała przy @Modifying @Query("update Order o set …")?CriteriaUpdate/CriteriaDelete nie są przez niego rejestrowane.lastModifiedBy?AuditReader.@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) iREVINFO(jeden wiersz na commit). - Masowe UPDATE/DELETE omijają oba mechanizmy.
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ździaPrzez 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.
| Opcja | Co robi | Kiedy |
|---|---|---|
| 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 JDBC | Repozytoria 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. |
| jOOQ | Generuje 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 / JdbcClient | Ty 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. |
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.
- 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ę produkcjaKaż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.
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);
}
}
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.
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 → lekWię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.
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()
where id=? to N+1, SELECT przed INSERT-em to merge, a SQL po zakończeniu metody serwisu to OSIV.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 samPoniż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ą.
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).
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 życiaL1 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.
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.
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.
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.
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.
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.
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.
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.
L1 Co robi save() w Spring Data JPA?
Jeśli encja jest nowa, woła entityManager.persist(), w przeciwnym razie entityManager.merge().
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().
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().
L1 Przy jakich wyjątkach @Transactional robi rollback?
Domyślnie przy RuntimeException i Error. Checked exception kończy się commitem, chyba że dodasz rollbackFor.
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. @TransactionalL1 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.
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.
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.
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.
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).
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.
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ę.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
L2 Jak w teście sprawdzić liczbę zapytań?
Włącz hibernate.generate_statistics i odczytaj Statistics z SessionFactory, np. getPrepareStatementCount().
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.
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ń.
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).
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. propagacjaL3 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 wsadoweL3 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.
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.
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.
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.
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.
- 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Ściągawka
| Adnotacja / właściwość | Domyślnie / co robi | Kiedy |
|---|---|---|
| Część I · Fundamenty ORM | ||
@Entity | klasa 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 |
@GeneratedValue | strategy = AUTO | id generowane przez bazę/Hibernate |
@Column | nullable = true, length = 255 | ograniczenia, nazwa kolumny |
@Enumerated | domyślnie ORDINAL | prawie zawsze STRING |
@Embeddable / @Embedded | pola w tabeli właściciela, brak własnego id | adres, kwota, zakres dat |
persist / remove | planują INSERT / DELETE na flush | nowy obiekt / obiekt managed |
merge | zwraca kopię managed; często SELECT | obiekt detached; pracuj na wyniku |
| persistence context | jeden obiekt na id; żyje z transakcją | find po id bez SQL |
FlushModeType.AUTO | domyślny: flush przed commitem i przed zapytaniem, na które wpływają zmiany | prawie zawsze |
FlushModeType.COMMIT | flush tylko przed commitem | optymalizacja; ryzyko nieaktualnych wyników |
@Transactional(readOnly = true) | Hibernate: FlushMode.MANUAL, bez dirty checking | metody tylko do odczytu |
@OneToMany(mappedBy) | strona odwrotna; nie zapisuje FK | relacja dwukierunkowa |
@ManyToOne + @JoinColumn | strona właścicielska; domyślnie EAGER | zwykle warto dodać fetch = LAZY |
cascade | domyślnie brak; ALL = PERSIST, MERGE, REMOVE, REFRESH, DETACH | rodzic–dziecko (@OneToMany) |
orphanRemoval | domyślnie false; kasuje dziecko wyjęte z kolekcji | dzieci „należące” do rodzica |
| Część II · Spring Data JPA | ||
JpaRepository<T, ID> | CRUD (List) + paginacja + QBE + flush, saveAndFlush, getReferenceById | Domyślny interfejs repozytorium w JPA |
| Transakcje repozytorium | Odczyty CRUD readOnly = true, zapisy @Transactional; Twoje metody: brak | Granice transakcji stawiaj w serwisie |
spring.data.jpa.repositories.bootstrap-mode | default: repozytoria tworzone zachłannie przy starcie | deferred/lazy, by przyspieszyć start |
@Query / @NativeQuery | JPQL na encjach / SQL na tabelach; @Query wygrywa z @NamedQuery | Nazwa metody za długa, złączenia, funkcje bazy |
@Modifying(flushAutomatically, clearAutomatically) | Oba domyślnie false; flush przed, clear po zapytaniu | Bulk update/delete przy encjach w kontekście |
save() → isNew | Persistable.isNew() (jeśli jest) → inaczej @Version obiektowe null → @Id null (prymityw 0) | Ręczne id: dodaj @Version Long lub Persistable |
| Projekcja zamknięta / DTO | Tylko potrzebne kolumny; DTO bez proxy | Listy, widoki tylko do odczytu |
Projekcja otwarta @Value | SpEL na target; ładuje całą encję | Rzadko; prostą logikę zrób w default albo w widoku |
Page / Slice / Window | Strona + COUNT / rozmiar + 1 / przewijanie offset lub keyset | Numery stron / „więcej” / długie listy i eksporty |
PageRequest.of(n, size, sort) | Numer strony od 0 | Zawsze z jawnym sortowaniem |
JpaSpecificationExecutor | findAll/count/exists(spec); pusta spec pomijana | Opcjonalne filtry w wyszukiwarce |
Example.of(probe, matcher) | null pomijane, prymitywy zawsze; tylko równość dla nie-tekstów | Proste wyszukiwanie po polach |
@Transactional domyślnie | REQUIRED, izolacja DEFAULT, read-write, rollback na RuntimeException/Error | Metody serwisu wołane z innego beana |
rollbackFor / noRollbackFor | Dodatkowe reguły rollbacku | Wyjątki sprawdzane, które mają cofać zmiany |
readOnly = true | Wskazówka dla JDBC; Hibernate flush mode MANUAL | Odczyty, zwłaszcza duże grafy |
REQUIRES_NEW | Osobna fizyczna transakcja i osobne połączenie | Log/audyt, który ma przetrwać rollback; pilnuj puli |
| Część III · Wydajność | ||
@ManyToOne / @OneToOne | domyślnie EAGER | ustaw fetch = LAZY jawnie |
@OneToMany / @ManyToMany / @ElementCollection | domyślnie LAZY | zostaw; pobieraj joinem, gdy potrzebujesz |
join fetch w @Query | asocjacja w tym samym SELECT | podstawowe lekarstwo na N+1 |
@EntityGraph(attributePaths = "…") | Spring Data buduje EntityGraph; Hibernate robi left join | N+1 przy metodach pochodnych |
@BatchSize(size = n) / hibernate.default_batch_fetch_size | wyłączone; jeden SELECT inicjuje do n proxy/kolekcji | siatka bezpieczeństwa, gdy join daje iloczyn |
@Fetch(SUBSELECT) / hibernate.use_subselect_fetch | wyłączone; kolekcje drugim zapytaniem z podzapytaniem | druga kolekcja obok join fetch |
hibernate.query.fail_on_pagination_over_collection_fetch | false | ustaw true: wyjątek zamiast paginacji w pamięci |
MultipleBagFetchException | join fetch dwóch list naraz | rozbij na dwa zapytania |
hibernate.jdbc.batch_size | 0 (wyłączone) | 10–50 przy masowych zapisach |
hibernate.order_inserts / order_updates | false | gdy zapisujesz różne encje na przemian |
GenerationType.IDENTITY | wyłącza batch insert | gdy wstawiasz rzadko |
@SequenceGenerator(allocationSize) | 50; optymalizator pooled | masowe wstawianie z batchingiem |
spring.jpa.open-in-view | true + WARN przy starcie | ustaw false, dane pobieraj w serwisie |
hibernate.enable_lazy_load_no_trans | false; oznaczone jako niebezpieczne | nie włączaj |
@Cache(usage = …) + hibernate.cache.region.factory_class=jcache | L2 domyślnie wyłączony | dane słownikowe, rzadko zmieniane |
jakarta.persistence.sharedCache.mode | ENABLE_SELECTIVE | zostaw domyślne |
hibernate.cache.use_query_cache + setCacheable(true) | wyłączony | rzadko; tylko powtarzalne zapytania na rzadko zmienianych tabelach |
| Część IV · Współbieżność | ||
@Version | liczba (od 0) lub czas; podbijana przy każdym zapisie, sprawdzana w WHERE | każda encja edytowana współbieżnie lub przez merge |
OptimisticLockingFailureException | konflikt wersji w Springu (ObjectOptimisticLockingFailureException) | komunikat „odśwież” albo ponowienie całej transakcji |
@Retryable (Spring 7) | maxRetries domyślnie 3; wymaga @EnableResilientMethods | ponawianie 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.timeout | milisekundy; nieobsługiwane → ignorowane | nie czekaj w nieskończoność |
PessimisticLockingFailureException | nadtyp CannotAcquireLockException i in. | łap ogólny typ, nie podklasy |
| Część V · Ekspert | ||
@Inheritance | domyślnie SINGLE_TABLE; też JOINED, TABLE_PER_CLASS | prawdziwa hierarchia encji z zapytaniami polimorficznymi |
@DiscriminatorColumn | domyślnie DTYPE, typ STRING | SINGLE_TABLE (zawsze), JOINED (opcjonalnie) |
@MappedSuperclass | wspólne pola, bez tabeli i zapytań | id, version, pola audytu |
@NaturalId | klucz naturalny; UNIQUE; mutable = false | podstawa equals/hashCode |
Lombok @Data na encji | equals/hashCode/toString ze wszystkich pól | unikaj; @Getter/@Setter + @ToString.Exclude |
propagation | domyślnie REQUIRED | REQUIRES_NEW dla zapisów, które mają przetrwać rollback |
UnexpectedRollbackException | wewnętrzny zakres oznaczył transakcję rollback-only | nie łap i nie ignoruj wyjątków z wewnętrznego REQUIRED |
nestedTransactionAllowed | JpaTransactionManager: domyślnie false | NESTED z JDBC; nie cofa stanu encji |
isolation | DEFAULT = poziom bazy | tylko tam, gdzie transakcja startuje |
flush() + clear() co N | ogranicza rozmiar persistence context | importy wsadowe |
StatelessSession | bez kontekstu, dirty checkingu i kaskad; insertMultiple | przetwarzanie masowe |
hibernate { enhancement {} } | enhancer: leniwe pola basic, dirty tracking | duże pola @Basic(fetch = LAZY) |
@EnableJpaAuditing | modifyOnCreate domyślnie true | z AuditingEntityListener i @CreatedDate itd. |
@Audited (Envers) | tabela _AUD + REVINFO; REVTYPE 0/1/2 | pełna historia zmian |
| Część VI · Praktyka | ||
spring.jpa.hibernate.ddl-auto | create-drop dla bazy wbudowanej bez Flyway/Liquibase, inaczej none | na produkcji validate/none + migracje |
@DataJpaTest | tylko komponenty JPA, test w transakcji z rollbackiem, show-sql włączone | testy repozytoriów i zapytań |
hibernate.generate_statistics | domyś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 agregatu | małe agregaty, przewidywalny SQL |
JdbcClient | płynna fasada JDBC (od Spring 6.1), parametry ? i :nazwane | pojedyncze zapytania pisane ręcznie |
jOOQ DSLContext | typowany SQL z klas generowanych ze schematu | raporty, złożony SQL specyficzny dla bazy |
@Transactional(isolation) | DEFAULT = poziom bazy | zmieniaj tylko świadomie |
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.