JPA Teljesítmény
N+1 probléma, fetch stratégiák, OSIV, batch processing, query optimalizálás
JPA Teljesítmény
A JPA/Hibernate teljesítményoptimalizálás a fetch stratégiák, N+1 probléma kezelése, batch feldolgozás és az OSIV anti-pattern megértéséről szól.
1. Definíció
- Mi ez? — A JPA teljesítmény-tuning azon technikák és minták összessége, amelyekkel a Hibernate által generált SQL-ek hatékonyságát maximalizáljuk. Ide tartozik a query optimalizálás, a fetch stratégiák helyes beállítása, a batch feldolgozás és a connection pool kezelés.
- Miért létezik? — A Hibernate „varázslatosan" működik, de ez a varázslat rejtett teljesítményproblémákat okozhat: túl sok query (N+1), túl nagy result set (Cartesian product), felesleges adatbetöltés (EAGER). Tudatos optimalizálás nélkül az alkalmazás lassú lesz.
- Hol helyezkedik el? — A teljesítmény-tuning a development és a production monitoring között helyezkedik el. Fejlesztés közben
spring.jpa.show-sql=true+hibernate.format_sql=true, production-ben Hibernate Statistics és slow query monitoring.
2. Alapfogalmak
LAZY vs EAGER fetch
| Stratégia | Mikor tölt | Alapértelmezés | Kockázat |
|---|---|---|---|
| EAGER | Azonnal a fő entity-vel | @ManyToOne, @OneToOne | Felesleges adat betöltés |
| LAZY | Első hozzáféréskor (proxy) | @OneToMany, @ManyToMany | LazyInitializationException |
⚠️ Golden rule: Mindent LAZY-ra állíts, és JOIN FETCH-csel vagy @EntityGraph-fal töltsd be, amit tényleg használsz.
// ❌ EAGER mindenhol → Cartesian product, felesleges adat
@ManyToOne(fetch = FetchType.EAGER)
private Customer customer;
// ✅ LAZY + explicit fetch → kontroll a kezedben
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
Az N+1 probléma
Az N+1 a legelterjedtebb JPA teljesítményprobléma:
// 1 query: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();
// N query: minden order-höz külön SELECT a customer-ért
for (Order o : orders) {
System.out.println(o.getCustomer().getName());
// → SELECT * FROM customers WHERE id = ? (N-szer!)
}
// Összesen: 1 + N query (100 order → 101 query!)
Hibernate proxy mechanizmus
A LAZY asszociációk proxy-t kapnak (ByteBuddy/CGLIB):
order.getCustomer() → visszaad egy proxy-t (NEM null, hanem proxy!)
↓
customer.getName() → proxy intercept
↓
session.load(Customer, id) → SQL SELECT
↓
proxy mezők inicializálása
Ha nincs nyitott session → LazyInitializationException
3. Gyakorlati használat
N+1 megoldás 1: JOIN FETCH
// ✅ Egy query-ben tölti a customer-t is
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// ✅ Több asszociáció
@Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithDetails(@Param("status") OrderStatus status);
⚠️ JOIN FETCH korlátok:
- Két
@OneToManyJOIN FETCH ugyanabban a query-ben → Cartesian product →MultipleBagFetchException - Megoldás: az egyiket
Set-re cserélni, vagy két külön query
N+1 megoldás 2: @EntityGraph
// ✅ Deklaratív, @Query nélkül is használható
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);
// ✅ Named EntityGraph
@Entity
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("items")
}
)
public class Order { ... }
// Használat:
@EntityGraph("Order.withCustomerAndItems")
List<Order> findByStatus(OrderStatus status);
EntityGraph vs JOIN FETCH:
@EntityGraph→ LEFT JOIN (null-okat is visszaadja)JOIN FETCH→ INNER JOIN (csak ahol van kapcsolat)
N+1 megoldás 3: @BatchSize
@Entity
public class Order {
@BatchSize(size = 25) // Hibernate-specifikus
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
}
BatchSize: nem egy query-ben tölt, hanem batch-elt IN query-vel:
-- @BatchSize nélkül (N+1):
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
...
-- @BatchSize(size=25)-tel:
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, ..., 50)
N+1 megoldás 4: DTO Projection
// ✅ Csak a szükséges oszlopok → nincs entity, nincs proxy, nincs N+1
public interface OrderSummary {
Long getId();
String getCustomerName();
BigDecimal getTotalAmount();
}
@Query("SELECT o.id AS id, c.name AS customerName, o.totalAmount AS totalAmount " +
"FROM Order o JOIN o.customer c WHERE o.status = :status")
List<OrderSummary> findSummaryByStatus(@Param("status") OrderStatus status);
DTO projection előnyei: kevesebb adat, nincs dirty checking overhead, nincs LAZY probléma.
4. Kód példák
JDBC batch insert konfiguráció
# application.yml
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true
order_updates: true
generate_statistics: true # fejlesztéshez
datasource:
hikari:
maximum-pool-size: 10
@Transactional
public void batchInsert(List<ProductDto> dtos) {
int batchSize = 50;
for (int i = 0; i < dtos.size(); i++) {
Product p = new Product(dtos.get(i));
entityManager.persist(p);
if (i > 0 && i % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
}
}
⚠️ IDENTITY ID stratégia → batch insert LETILTVA! Használj SEQUENCE-t allocationSize-zal.
Pagination helyes megvalósítása
// ❌ ROSSZ — fetchAll + memóriában szűr
List<Order> orders = orderRepository.findAll();
orders.subList(0, 20);
// ✅ JÓ — DB szintű pagination
Page<Order> page = orderRepository.findAll(PageRequest.of(0, 20, Sort.by("createdAt").descending()));
// ✅ Hatékony count + data külön query
@Query(value = "SELECT o FROM Order o JOIN FETCH o.customer",
countQuery = "SELECT COUNT(o) FROM Order o")
Page<Order> findAllWithCustomer(Pageable pageable);
⚠️ JOIN FETCH + Pagination → Hibernate memóriában page-el! Külön count query szükséges.
Hibernate Statistics
@Configuration
public class HibernateStatsConfig {
@Bean
public StatisticsService statisticsService(EntityManagerFactory emf) {
SessionFactory sf = emf.unwrap(SessionFactory.class);
Statistics stats = sf.getStatistics();
stats.setStatisticsEnabled(true);
return new StatisticsService(stats);
}
}
// Logolás:
// Queries: stats.getQueryExecutionCount()
// Cache hits: stats.getSecondLevelCacheHitCount()
// Slowest query: stats.getQueryExecutionMaxTimeQueryString()
Native query nagy adathalmazokhoz
// Ha a JPA overhead túl nagy, natív SQL + DTO:
@Query(value = """
SELECT o.id, c.name as customer_name, o.total_amount
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > :since
""", nativeQuery = true)
List<Object[]> findRecentOrdersNative(@Param("since") LocalDateTime since);
// Vagy: Spring JDBC Template a leggyorsabb
@Autowired
JdbcTemplate jdbcTemplate;
public List<OrderSummary> findFast() {
return jdbcTemplate.query("SELECT ...", (rs, i) ->
new OrderSummary(rs.getLong("id"), rs.getString("name"))
);
}
5. Trade-offok
| Megoldás | Előny | Hátrány |
|---|---|---|
| JOIN FETCH | Egy query, hatékony | Cartesian product 2+ kollekcióknál |
| @EntityGraph | Deklaratív, clean | LEFT JOIN, nem mindig optimális |
| @BatchSize | Egyszerű, nem kell query-t írni | Nem 1 query, hanem batch-elt N |
| DTO Projection | Legjobb teljesítmény, nincs dirty checking | Több kód, nincs entity feature |
| Native SQL | Maximális kontroll | Nem portábilis, nincs entity management |
| L2 Cache | Drasztikus gyorsulás | Invalidáció bonyolult |
| OSIV kikapcsolás | Tiszta architektúra | LazyInitializationException kockázat |
Mikor melyiket
| Eset | Ajánlott megoldás |
|---|---|
| N+1 egy @ManyToOne-nál | JOIN FETCH |
| N+1 egy @OneToMany-nál | @EntityGraph vagy @BatchSize |
| Read-only lista/report | DTO Projection |
| Ritkán változó referencia adat | L2 Cache |
| Tömeges adat import | JDBC batch + flush/clear |
| Komplex analitikai query | Native SQL / JdbcTemplate |
6. Gyakori hibák
❌ EAGER fetch mindenhol
// ROSSZ — az Order betöltésekor MINDIG betölti a customer-t, items-t, tags-et
@ManyToOne(fetch = FetchType.EAGER)
private Customer customer;
@OneToMany(fetch = FetchType.EAGER)
private List<OrderItem> items;
@ManyToMany(fetch = FetchType.EAGER)
private Set<Tag> tags;
// JÓ — minden LAZY, és csak ott fetch-elsz ahol kell
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
❌ N+1 figyelmen kívül hagyása
Mindig ellenőrizd a Hibernate SQL log-ot fejlesztés közben:
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
generate_statistics: true # query count ellenőrzés
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE # paraméterek
❌ JOIN FETCH + Pagination
// ROSSZ — Hibernate WARN: HHH90003004: firstResult/maxResults specified with collection fetch
@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);
// ↑ A Hibernate MEMÓRIÁBAN page-el (az összes sort betölti)!
// JÓ — Két query stratégia:
// 1. ID-k lekérdezése pagination-nel
// 2. Entity-k betöltése JOIN FETCH-csel az ID-k alapján
@Query("SELECT o.id FROM Order o")
Page<Long> findOrderIds(Pageable pageable);
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findByIdsWithItems(@Param("ids") List<Long> ids);
❌ Open Session in View (OSIV) elfogadása
# Spring Boot alapértelmezés: true (!)
spring:
jpa:
open-in-view: true # ❌ Production-ben problémás
# JÓ:
spring:
jpa:
open-in-view: false # ✅ Explicit fetch, tiszta architektúra
OSIV problémák:
- Controller-ből is futhatnak LAZY-trigger query-k → N+1
- A DB connection a teljes HTTP request alatt foglalt → connection pool kimerülés
- Nehéz debugolni, mert a query-k rejtetten futnak
❌ Cartesian product több collection fetch-nél
// ROSSZ — Cartesian product: items × tags mindkettő betölt
@Query("SELECT o FROM Order o JOIN FETCH o.items JOIN FETCH o.tags")
List<Order> findWithItemsAndTags();
// Ha 10 item és 5 tag → 50 sor eredmény → MultipleBagFetchException (List-nél)
// JÓ — két külön query:
@EntityGraph(attributePaths = {"items"})
List<Order> findWithItems();
default List<Order> findWithItemsAndTags() {
List<Order> orders = findWithItems();
// A tags LAZY → @BatchSize kezeli
return orders;
}
7. Mélyebb összefüggések
Open Session in View (OSIV) részletesen
HTTP Request lifecycle OSIV=true:
┌─ Request arrives ──────────────────────────────────────────┐
│ OpenEntityManagerInViewInterceptor opens EntityManager │
│ ┌─ @Transactional service method ──────────┐ │
│ │ DB queries, entity modifications │ │
│ │ Transaction commits │ │
│ └────────────────────────────────────────────┘ │
│ Controller returns → view rendering │
│ LAZY collections still accessible (EM still open!) │
│ → N+1 queries from view/controller layer │
│ → DB connection still held │
└─ Response sent → EntityManager closes ─────────────────────┘
OSIV=false esetén a controller/view rétegben LazyInitializationException keletkezik, ami jelzi, hogy explicit fetch szükséges. Ez jobb design-t kényszerít ki.
Hibernate Proxy és bytecode enhancement
Hibernate 6+ a ByteBuddy-t használja proxy-generáláshoz:
Order order = orderRepository.findById(1L).orElseThrow();
Customer customer = order.getCustomer();
// customer.getClass() → Customer$HibernateProxy$xyz
customer.getName(); // ← ITT fut a valódi SQL
// → SELECT * FROM customers WHERE id = ?
Bytecode enhancement alternatíva:
<!-- pom.xml Hibernate bytecode enhancement plugin -->
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<configuration>
<enableLazyInitialization>true</enableLazyInitialization>
<enableDirtyTracking>true</enableDirtyTracking>
</configuration>
</plugin>
Ez compile-time-ban módosítja a bytecode-ot → nincs runtime proxy overhead, a dirty checking is gyorsabb.
Connection pool és teljesítmény
spring:
datasource:
hikari:
maximum-pool-size: 10 # CPU core count * 2 + disk spindle count
minimum-idle: 5
connection-timeout: 30000 # 30s
idle-timeout: 600000 # 10min
max-lifetime: 1800000 # 30min
leak-detection-threshold: 60000 # 60s — OSIV ezzel detektálható!
Az OSIV=true + lassú view rendering → a connection a teljes request alatt foglalt. 10 connection pool + 100 egyidejű request = bottleneck.
Subselect fetch stratégia
@Entity
public class Order {
@Fetch(FetchMode.SUBSELECT) // Hibernate-specifikus
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
}
-- Az eredeti query-t subselect-ként használja:
SELECT * FROM order_items WHERE order_id IN (
SELECT id FROM orders WHERE status = 'ACTIVE'
)
Subselect vs BatchSize: a subselect mindig egy query-ben oldja meg, a BatchSize N/batchSize query-ben.
Második szintű cache és teljesítmény mérés
// Cache hit rate monitoring
Statistics stats = sessionFactory.getStatistics();
double hitRatio = (double) stats.getSecondLevelCacheHitCount() /
(stats.getSecondLevelCacheHitCount() + stats.getSecondLevelCacheMissCount());
// Ha hitRatio < 0.8 → a cache nem hatékony, review szükséges
8. Interjúkérdések
K: Hogyan oldod meg az N+1 problémát? V: JOIN FETCH egyedi query-vel (legjobb egyetlen asszociációhoz), @EntityGraph annotáció (deklaratív, Spring Data-val jól működik), @BatchSize (automatikus, Hibernate-specifikus), vagy DTO projection (legjobb teljesítmény).
K: Mi az Open Session in View és miért problémás? V: Az OSIV a session-t a teljes HTTP request alatt nyitva tartja. Megakadályozza a LazyInitializationException-t, de a controller/view rétegből is futhatnak query-k (N+1) és a DB connection a teljes request alatt foglalt (connection pool kimerülés).
K: Miért nem szabad JOIN FETCH-et pagination-nel használni? V: A Hibernate MEMÓRIÁBAN page-el: betölti az összes sort, majd Java oldalon vágja a listát. Megoldás: két-query stratégia — először ID-k pagination-nel, majd entity-k betöltése JOIN FETCH-csel.
K: Mikor használnál DTO Projection-t entity betöltés helyett? V: Read-only listáknál, reportoknál, ahol nem kell dirty checking és nem kell az entity-t módosítani. Kevesebb adatot tölt (csak a szükséges oszlopokat), nincs proxy overhead.
K: Mi a különbség JOIN FETCH és @EntityGraph között? V: JOIN FETCH INNER JOIN-t használ (ahol nincs kapcsolat, ott nincs eredmény). @EntityGraph LEFT JOIN-t használ (null-okat is visszaadja). @EntityGraph deklaratív és @Query nélkül is használható.
K: Miért nem batch-elhető az IDENTITY ID stratégia és miért fontos ez? V: Az IDENTITY az adatbázis auto-increment-jét használja, amely csak INSERT után adja vissza az ID-t. A Hibernate nem tudja batch-elni → minden INSERT külön roundtrip. SEQUENCE allocationSize-zal batch-elhető.
K: Hogyan konfigurálnád a Hibernate batch-et?
V: hibernate.jdbc.batch_size=50, hibernate.order_inserts=true, hibernate.order_updates=true. Kód oldalon: flush/clear ciklus a batch méretenként. ID stratégia: SEQUENCE.
9. Szószedet
| Fogalom | Jelentés |
|---|---|
| N+1 probléma | 1 + N query a LAZY asszociáció miatt |
| JOIN FETCH | JPQL-ben explicit JOIN a LAZY asszociáció betöltésére |
| @EntityGraph | Deklaratív fetch terv annotáció |
| @BatchSize | Batch-elt IN query LAZY kollekcióhoz |
| DTO Projection | Csak szükséges oszlopok lekérdezése, nincs entity |
| OSIV | Open Session in View — session nyitva a request alatt |
| Cartesian product | Több collection fetch → n×m eredmény sor |
| Proxy | ByteBuddy/CGLIB generált wrapper LAZY loading-hoz |
| LazyInitializationException | LAZY proxy elérés zárt session-nél |
| Batch insert | Több INSERT egy DB roundtrip-ben |
| Connection pool | DB kapcsolatok újrafelhasználható készlete |
| Hibernate Statistics | Runtime metrikák (query count, cache hit) |
10. Gyorsreferencia
N+1 MEGOLDÁSOK:
JOIN FETCH egy query, INNER JOIN
@EntityGraph deklaratív, LEFT JOIN
@BatchSize(size=25) batch-elt IN query
DTO Projection legjobb teljesítmény, nincs entity
@Fetch(SUBSELECT) az eredeti query-t subselect-ként
FETCH STRATEGY GOLDEN RULE:
Mindent LAZY → explicit fetch ami kell
@ManyToOne default EAGER → állítsd LAZY-re
@OneToOne default EAGER → állítsd LAZY-re
OSIV:
spring.jpa.open-in-view=false ✅ production
spring.jpa.open-in-view=true ❌ problémás
JOIN FETCH KORLÁTOK:
2+ Collection fetch → Cartesian product
+ Pagination → memóriában page-el
Megoldás: 2-query stratégia (ID + fetch)
BATCH INSERT:
hibernate.jdbc.batch_size=50
hibernate.order_inserts=true
GenerationType.SEQUENCE (nem IDENTITY!)
flush() + clear() ciklusban
SQL DEBUG:
spring.jpa.show-sql=true
hibernate.format_sql=true
hibernate.generate_statistics=true
org.hibernate.SQL=DEBUG
CONNECTION POOL:
hikari.maximum-pool-size = CPU*2 + disk
hikari.leak-detection-threshold = 60000
OSIV + lassú view → connection kimerülés
🎮 Játékok
10 kérdés