JPA Lazy/Eager & Tranzakciókezelés Spring kontextusban
Az alapprobléma: Session lifecycle
A JPA persistence context (Hibernate session) élettartama alapvetően meghatározza, hogy mikor és hogyan lehet lazy asszociációkat betölteni.
Alapértelmezetten Spring-ben a session a tranzakció határával egyezik meg (OSIV = false esetén). Ha nincs aktív session és lazy proxy-t próbálsz elérni → LazyInitializationException.
Lazy vs Eager – a döntés szempontjai
Fetch típusok alapértelmezései
| Asszociáció | Default |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
A @ManyToOne EAGER alapból – ez csapda, mert N+1 problémát okozhat ha nem figyelsz rá.
Eager – mikor rossz ötlet
@Entity
public class Order {
@ManyToOne(fetch = FetchType.EAGER) // mindig betölti a Customer-t
private Customer customer;
@OneToMany(fetch = FetchType.EAGER) // mindig betölti az összes OrderItem-t
private List<OrderItem> items;
}
Ha csak az Order ID-jára van szükséged egy listában – mégis joinol mindent. Eager = mindig fizetsz, függetlenül attól, kell-e.
A helyes megközelítés: mindent Lazy + dedikált query
public interface OrderRepository extends JpaRepository<Order, Long> {
// Lista nézet – csak alapadatok kellenek
List<OrderSummaryProjection> findAllByStatus(OrderStatus status);
// Részletes nézet – minden kell
@Query("SELECT o FROM Order o JOIN FETCH o.items i JOIN FETCH o.customer WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);
// Számlázáshoz – csak customer kell
@EntityGraph(attributePaths = {"customer"})
Optional<Order> findWithCustomerById(Long id);
}
Az entitás struktúráját a doménnek, a fetch stratégiát a use case-nek kell meghatároznia.
Session vs Tranzakció élettartama
Ez az egyik legfontosabb különbség amit érteni kell:
@Transactional metódus:
[session nyílik]
[tranzakció nyílik]
repo.findById()
order.getItems() ← session él, lazy betöltődik
[tranzakció zárul]
[session zárul]
return order ← az order már "detached"
@Transactional esetén a session és a tranzakció egyszerre nyílik és zárul. Lazy mezők szabadon elérhetők a metóduson belül.
LazyInitializationException – hogyan fordul elő
1. Nincs @Transactional a service metóduson
// ❌ Nincs @Transactional
public OrderDTO getOrder(Long id) {
Order order = repo.findById(id).orElseThrow();
// ^ a repo saját belső tranzakciója itt már lezárult, session closed
order.getItems().size(); // LazyInitializationException
return toDTO(order);
}
A repo hívás mindig lefut (saját mini-tranzakciójában), visszaadja az entitást, de a session bezárul. Az entitás detached állapotba kerül. Ha ezután lazy mezőre nyúlsz → exception.
2. Tranzakción kívül éred el a lazy mezőt
// ✅ Tranzakció a service-ben lezárul
public Order getOrder(Long id) {
return repo.findById(id).orElseThrow();
}
// ❌ Controller – nincs session
@GetMapping("/{id}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long id) {
Order order = orderService.getOrder(id);
order.getItems().size(); // LazyInitializationException
}
3. Self-invocation – a Spring AOP csapdája
@Service
public class OrderService {
@Transactional
public void processAll() {
// ❌ Self-invocation! A proxy megkerülődik,
// a @Transactional nem lesz aktív
this.processOne(1L);
}
@Transactional(propagation = REQUIRES_NEW)
public void processOne(Long id) {
// Ez tranzakció nélkül fut le!
}
}
A @Transactional Spring AOP proxy-n keresztül működik. Ha this-en hívsz metódust, megkerülöd a proxy-t.
4. Async kontextus
@Async
public CompletableFuture<OrderDTO> processAsync(Long id) {
// Új thread, nincs ThreadLocal-ban session
Order order = repo.findById(id).orElseThrow();
order.getCustomer().getName(); // ❌ LazyInitializationException
}
Tranzakció propagáció – több service-en át
Ha több service-en megy keresztül a hívás és mindenhol van @Transactional, végig egyetlen tranzakcióban és sessionben dolgozol – ez a REQUIRED propagáció, ami az alapértelmezett.
@Transactional // új tranzakció és session nyílik
public void orderService.process(Long id) {
Order order = repo.findById(id).orElseThrow();
paymentService.charge(order); // csatlakozik a meglévőhöz
inventoryService.reserve(order); // csatlakozik a meglévőhöz
// lazy mezők végig elérhetők
}
@Transactional // nem nyit újat, a meglévőhöz csatlakozik
public void paymentService.charge(Order order) { ... }
@Transactional // nem nyit újat, a meglévőhöz csatlakozik
public void inventoryService.reserve(Order order) { ... }
Ha bármelyik service-ben exception dobódik, az egész visszagörget – atomicitás.
Propagáció típusok
| Propagáció | Viselkedés |
|---|---|
REQUIRED (default) | Ha van tranzakció, abba száll be; ha nincs, újat nyit |
REQUIRES_NEW | Mindig új tranzakciót nyit (a régit felfüggeszti) |
SUPPORTS | Ha van tranzakció, abban fut; ha nincs, anélkül fut |
NOT_SUPPORTED | Mindig tranzakció nélkül fut (felfüggeszti a meglévőt) |
REQUIRES_NEW tipikus use case: audit log, ami akkor is mentődjön ha a fő tranzakció rollback-el.
Fetch stratégiák
JOIN FETCH – a legtöbbször helyes megoldás
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"JOIN FETCH o.items i " +
"WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);
Explicit, előre meghatározott, session-független.
Entity Graph – rugalmasabb
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<Order> findById(Long id);
Ugyanazt az entitást különböző gráfokkal lehet lekérdezni különböző use case-ekben.
DTO Projection – a legtisztább megoldás
// Interface projection – Spring megcsinálja
public interface OrderSummaryProjection {
Long getId();
String getStatus();
String getCustomerName(); // customer.name → Spring lefordítja
}
// Record projection – explicitebb, jobban tesztelhető
public record OrderSummaryDTO(Long id, String status, String customerName) {}
@Query("SELECT new com.example.dto.OrderSummaryDTO(o.id, o.status, c.name) " +
"FROM Order o JOIN o.customer c")
List<OrderSummaryDTO> findAllSummaries();
Nincs entitás → nincs lazy proxy → nincs probléma.
DTO előnyei az entitáshoz képest: - Nincs lazy exception - Nincs null surprise – amit látsz, az van benne - Nincs felesleges adat – csak amit a hívó tényleg használ - Olvashatóbb – a DTO neve leírja az use case-t
Ez az irány vezet el a CQRS gondolkodásmódhoz is: a read side mindig DTO/projection, a write side dolgozik entitással.
N+1 probléma
// ❌ N+1 klasszikus eset
List<Order> orders = orderRepo.findAll(); // 1 query
for (Order o : orders) {
System.out.println(o.getCustomer().getName()); // N query (1 per order)
}
Ez lazy és eager esetén is előfordul – az OSIV-hez nincs köze, általános fetch probléma.
Detektálás:
Megoldás – @BatchSize: ahelyett hogy N query-t indít, csoportokban tölti be az asszociációkat IN clause-szal. Legacy kódban jól jön.
OSIV – Open Session In View
Az OSIV filter a HTTP kérés elejétől végéig nyitva tartja a Hibernate session-t (nem a tranzakciót!).
A tranzakció ugyanúgy lezárul a service metódus végén. De a session nyitva marad, így a lazy proxy-k még be tudnak töltődni – csak épp tranzakción kívül, ami azt jelenti hogy minden lazy access egy külön query lesz.
Miért veszélyes
// Controller – tranzakció már rég lezárult
@GetMapping("/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
Order order = orderService.getOrder(id);
order.getCustomer(); // ← csendben lefut egy query
}
Nem kapod a LazyInitializationException-t – ezért veszélyes. A DB logika kiszivárog a prezentációs rétegbe, nehezen tesztelhető és optimalizálható.
Ajánlás
Kényszeríti a helyes fetch stratégiát – fail fast fejlesztés közben, nem production-ban.
readOnly = true – nem csak szemantika
- Hibernate dirty checking ki van kapcsolva → jobb teljesítmény
- Egyes DB driver-ek read replica felé routolnak
- Flush mode
NEVERlesz → nem kísérli meg a sync-et
@Transactional tesztelésben
@SpringBootTest
@Transactional // minden teszt rollback-el automatikusan
class OrderServiceTest {
@Test
void testOrderCreation() {
// DB állapot visszaáll minden teszt után
}
}
Csapda: ha a service REQUIRES_NEW propagációt használ, az külön tranzakcióban fut és a rollback nem vonatkozik rá → adatok maradnak a DB-ben a teszt után.
Összefoglaló táblázat
| Szituáció | Ajánlott megközelítés |
|---|---|
| Read-only API endpoint | DTO Projection + readOnly = true |
| Komplex domain logika | Entity + JOIN FETCH a szükséges asszociációkra |
| Különböző use case-ek eltérő gráfja | @EntityGraph |
| N+1 gyanú legacy kódban | @BatchSize + SQL logging |
| OSIV | Kapcsold ki production kódban |
| Self-invocation probléma | Bontsd külön bean-ekre |
| Több service-en átmenő logika | REQUIRED propagáció (default) – egy tranzakció |
| Független mellékhatás (pl. audit log) | REQUIRES_NEW propagáció |