Haladó Olvasási idő: ~12 perc

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 @OneToMany JOIN 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