← Kursa Dön
📄 Text · 30 min

JPA Query Optimization

JPA ve Hibernate kullanırken en büyük performans sorunları yanlış sorgu stratejilerinden kaynaklanır. N+1 problemi, gereksiz veri çekme, eksik index'ler — bunlar uygulamanızı dramatik şekilde yavaşlatır. Bu derste JPA'nın en kritik performans konularını problemi göster → çözümü göster yaklaşımıyla derinlemesine inceliyoruz.

Hibernate Statistics — Önce Ölçün

Optimizasyon yapabilmek için önce sorununuzu görmeniz gerekir. Hibernate Statistics ile kaç sorgu çalıştığını, ne kadar sürdüğünü görebilirsiniz:

# application.properties — Development ortamında aktif edin
spring.jpa.properties.hibernate.generate_statistics=true
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Log seviyesini ayarlayın
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.stat=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

Hibernate Statistics çıktısı:

Session Metrics {
    1234567 nanoseconds spent acquiring 12 JDBC connections;
    987654 nanoseconds spent releasing 12 JDBC connections;
    12345678 nanoseconds spent preparing 156 JDBC statements;
    23456789 nanoseconds spent executing 156 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
}

💡 Eğer bir endpoint için 50+ JDBC statement görüyorsanız, büyük olasılıkla N+1 probleminiz var.

N+1 Problemi — Detaylı Senaryo

N+1, JPA'nın en yaygın ve en zararlı performans sorunudur. Bir ana sorgu (1) ve her sonuç için ayrı bir alt sorgu (N) çalıştırılır.

Senaryo: Sipariş listesini ürünleriyle birlikte göstermek istiyorsunuz.

// Entity'ler
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private LocalDateTime orderDate;
    private OrderStatus status;

    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}

@Entity
public class OrderItem {
    @Id @GeneratedValue
    private Long id;
    private int quantity;
    private BigDecimal price;

    @ManyToOne(fetch = FetchType.LAZY)
    private Product product;

    @ManyToOne
    private Order order;
}
// ❌ N+1 PROBLEM — 101 sorgu çalışır!
List<Order> orders = orderRepository.findAll();  // 1 sorgu: SELECT * FROM orders

for (Order order : orders) {
    // Her order için 1 sorgu: SELECT * FROM order_items WHERE order_id = ?
    List<OrderItem> items = order.getItems();  // N sorgu!

    for (OrderItem item : items) {
        // Her item için 1 sorgu: SELECT * FROM products WHERE id = ?
        String productName = item.getProduct().getName();  // N×M sorgu!
    }
}

// 100 sipariş × 5 ürün = 1 + 100 + 500 = 601 SQL sorgusu!

SQL log'da göreceğiniz:

-- 1 sorgu
SELECT * FROM orders;

-- 100 sorgu (her order için)
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
SELECT * FROM order_items WHERE order_id = 3;
-- ... 97 tane daha

-- 500 sorgu (her item için)
SELECT * FROM products WHERE id = 1;
SELECT * FROM products WHERE id = 2;
-- ... 498 tane daha

N+1 Çözüm 1: JOIN FETCH

JPQL'de JOIN FETCH ile ilişkili entity'leri tek sorguda çekersiniz:

public interface OrderRepository extends JpaRepository<Order, Long> {

    // ✅ Tek sorgu ile orders + items
    @Query("SELECT DISTINCT o FROM Order o " +
           "JOIN FETCH o.items " +
           "WHERE o.status = :status")
    List<Order> findByStatusWithItems(@Param("status") OrderStatus status);

    // ✅ Birden fazla ilişki fetch
    @Query("SELECT DISTINCT o FROM Order o " +
           "JOIN FETCH o.items i " +
           "JOIN FETCH i.product " +
           "JOIN FETCH o.customer " +
           "WHERE o.status = :status")
    List<Order> findByStatusWithDetails(@Param("status") OrderStatus status);
}

Üretilen SQL:

SELECT DISTINCT o.*, i.*, p.*, c.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
JOIN products p ON i.product_id = p.id
JOIN customers c ON o.customer_id = c.id
WHERE o.status = ?
-- TEK SORGU!

⚠️ Dikkat: Birden fazla @OneToMany collection'ı aynı anda JOIN FETCH yapamazsınız — Hibernate MultipleBagFetchException fırlatır. Bu durumda Set kullanın veya ayrı sorgularda çekin.

N+1 Çözüm 2: @EntityGraph

Spring Data JPA'nın declarative fetch stratejisi:

public interface OrderRepository extends JpaRepository<Order, Long> {

    // Attribute paths ile
    @EntityGraph(attributePaths = {"items", "customer"})
    List<Order> findByStatus(OrderStatus status);

    // Named entity graph ile
    @EntityGraph(value = "Order.withItemsAndCustomer")
    List<Order> findAllByStatus(OrderStatus status);
}

// Named Entity Graph tanımı
@Entity
@NamedEntityGraph(
    name = "Order.withItemsAndCustomer",
    attributeNodes = {
        @NamedAttributeNode(value = "items", subgraph = "items-product"),
        @NamedAttributeNode("customer")
    },
    subgraphs = {
        @NamedSubgraph(name = "items-product",
            attributeNodes = @NamedAttributeNode("product"))
    }
)
public class Order { ... }

N+1 Çözüm 3: @BatchSize

Hibernate'e "lazy load yaparken tek tek değil, batch halinde yükle" dersiniz:

@Entity
public class Order {
    @OneToMany(mappedBy = "order")
    @BatchSize(size = 25)   // 25'erli gruplar halinde yükle
    private List<OrderItem> items;
}

// veya global ayar (tüm entity'ler için)
// application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25

Batch size=25 ile, 100 order için:

-- Batch yok: 100 ayrı SELECT
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
...

-- Batch size=25 ile: 4 SELECT (100/25 = 4)
SELECT * FROM order_items WHERE order_id IN (1,2,3,...,25);
SELECT * FROM order_items WHERE order_id IN (26,27,...,50);
SELECT * FROM order_items WHERE order_id IN (51,52,...,75);
SELECT * FROM order_items WHERE order_id IN (76,77,...,100);

Hangi çözümü ne zaman kullanmalı?

ÇözümNe zamanAvantajDezavantaj
JOIN FETCHİlişkili veriyi her zaman kullanacaksanızEn hızlı, tek sorguMultipleBag hatası, kartezyen çarpım
@EntityGraphFarklı endpoint'lerde farklı fetch stratejisiDeclarative, esnekKarmaşık subgraph tanımları
@BatchSizeLazy load'u tamamen kaldıramıyorsanızGlobal uygulanabilir, basitYine birden fazla sorgu (ama çok daha az)

Projection — Sadece İhtiyacınız Olan Veriyi Çekin

Tüm entity'yi çekmek yerine sadece gerekli alanları çekmek büyük performans kazancı sağlar:

1. Interface-based Projection (en yaygın):

// Sadece gerekli alanları tanımlayın
public interface ProductSummary {
    Long getId();
    String getName();
    BigDecimal getPrice();
}

// Spring Data otomatik olarak sadece bu 3 kolonu seçer
public interface ProductRepository extends JpaRepository<Product, Long> {

    List<ProductSummary> findByCategory(String category);
    // Üretilen SQL: SELECT id, name, price FROM products WHERE category = ?
    // (Tüm kolonlar değil, sadece 3 kolon!)
}

2. Class-based (DTO) Projection:

// DTO tanımlayın
public record ProductDTO(Long id, String name, BigDecimal price) {}

// JPQL ile DTO projection
@Query("SELECT new com.example.dto.ProductDTO(p.id, p.name, p.price) " +
       "FROM Product p WHERE p.category = :category")
List<ProductDTO> findProductDTOsByCategory(@Param("category") String category);

3. Dynamic Projection:

// Aynı metodu farklı projection'larla çağırın
public interface ProductRepository extends JpaRepository<Product, Long> {
    <T> List<T> findByCategory(String category, Class<T> type);
}

// Kullanım
List<ProductSummary> summaries = repo.findByCategory("electronics", ProductSummary.class);
List<ProductDTO> dtos = repo.findByCategory("electronics", ProductDTO.class);

💡 Performans etkisi: 20 kolonlu bir tabloda 3 kolon projection kullanmak, network transfer'ini %85 azaltabilir. Özellikle liste sorguları için projection kullanın.

Batch Operations — Toplu İşlemler

Batch Insert Konfigürasyonu:

# application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
@Service
@RequiredArgsConstructor
public class ProductBatchService {

    private final EntityManager entityManager;

    // ❌ YAVAŞ: Tek tek kaydetme
    public void saveProductsSlow(List<Product> products) {
        products.forEach(entityManager::persist);  // Her biri ayrı INSERT
    }

    // ✅ HIZLI: Batch insert
    @Transactional
    public void saveProductsBatch(List<Product> products) {
        int batchSize = 50;
        for (int i = 0; i < products.size(); i++) {
            entityManager.persist(products.get(i));

            if (i > 0 && i % batchSize == 0) {
                entityManager.flush();    // Batch'i gönder
                entityManager.clear();    // Persistence context'i temizle (bellek)
            }
        }
        entityManager.flush();
        entityManager.clear();
    }
}

⚠️ Dikkat: GenerationType.IDENTITY batch insert'i devre dışı bırakır çünkü Hibernate her INSERT sonrası generated ID'yi almalıdır. Batch insert istiyorsanız SEQUENCE veya TABLE strategy kullanın.

// ❌ IDENTITY — batch insert çalışmaz
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

// ✅ SEQUENCE — batch insert çalışır
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE,
    generator = "product_seq")
@SequenceGenerator(name = "product_seq", sequenceName = "product_seq",
    allocationSize = 50)
private Long id;

Second Level Cache (L2 Cache)

Hibernate, iki seviyeli cache yapısı sunar:

  • L1 Cache (First Level): Session/EntityManager seviyesinde, otomatik aktif, kapatılamaz.

  • L2 Cache (Second Level): SessionFactory seviyesinde, tüm session'lar paylaşır, konfigürasyon gerekir.

# L2 Cache aktifleştirme
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
spring.jpa.properties.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider

# Query cache (JPQL sonuçları için)
spring.jpa.properties.hibernate.cache.use_query_cache=true
// Entity'yi cacheable olarak işaretle
@Entity
@Cacheable                              // JPA standard
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_WRITE,  // Hibernate specific
    region = "products"
)
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    @OneToMany(mappedBy = "product")
    @org.hibernate.annotations.Cache(
        usage = CacheConcurrencyStrategy.READ_WRITE)
    private List<Review> reviews;
}

// Query cache kullanımı
@Query("SELECT p FROM Product p WHERE p.category = :cat")
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Product> findByCategoryCached(@Param("cat") String category);

CacheConcurrencyStrategy seçenekleri:

StratejiAçıklamaKullanım
READ_ONLYHiç güncellenmezSabit veri (ülke listesi)
READ_WRITEOkuma ağırlıklı, bazen güncellemeÜrün katalog
NONSTRICT_READ_WRITEEventual consistency tolere edilirPerformans öncelikli
TRANSACTIONALJTA transaction'larda tam tutarlılıkKritik iş verileri

Read-Only Transactions

@Transactional(readOnly = true) kullanmak performansı artırır:

@Service
@RequiredArgsConstructor
public class ProductService {

    // ✅ readOnly = true ile performans optimizasyonu
    @Transactional(readOnly = true)
    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }

    @Transactional(readOnly = true)
    public ProductDTO getProduct(Long id) {
        return productRepository.findById(id)
            .map(this::toDTO)
            .orElseThrow();
    }
}

readOnly = true ne yapar:

  1. Hibernate dirty checking kapanır: Entity değişiklik takibi yapılmaz → CPU tasarrufu

  2. Flush mode MANUAL olur: Otomatik flush yapılmaz → gereksiz UPDATE önlenir

  3. DB replica'ya yönlendirilir: DataSource routing yapılandırıldıysa okuma replica'dan yapılır

  4. JDBC hint gönderilir: Bazı DB driver'ları bunu optimize eder

💡 Okuma ağırlıklı uygulamalarda metotların %80+'ı readOnly olabilir. Default olarak readOnly tanımlayıp, sadece yazma metotlarını readOnly = false yapın.

Pagination Best Practices

Offset Pagination (geleneksel):

// Spring Data ile
Page<Product> page = productRepository.findAll(
    PageRequest.of(pageNumber, 20, Sort.by("createdAt").descending()));

// Üretilen SQL
// SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 1000;

Offset'in sorunu: Sayfa numarası arttıkça yavaşlar. OFFSET 100000 demek, veritabanının 100.000 satırı tarayıp atlaması demektir.

Sayfa 1:    OFFSET 0    → Hızlı (0.5ms)
Sayfa 100:  OFFSET 2000 → Kabul edilebilir (5ms)
Sayfa 5000: OFFSET 100000 → ÇOK YAVAŞ (500ms+)

Keyset Pagination (Cursor-based) — Daha hızlı:

public interface ProductRepository extends JpaRepository<Product, Long> {

    // İlk sayfa
    @Query("SELECT p FROM Product p ORDER BY p.createdAt DESC, p.id DESC")
    List<Product> findFirstPage(Pageable pageable);

    // Sonraki sayfalar: cursor'dan sonrası
    @Query("SELECT p FROM Product p " +
           "WHERE (p.createdAt < :lastDate) " +
           "   OR (p.createdAt = :lastDate AND p.id < :lastId) " +
           "ORDER BY p.createdAt DESC, p.id DESC")
    List<Product> findNextPage(
        @Param("lastDate") LocalDateTime lastDate,
        @Param("lastId") Long lastId,
        Pageable pageable);
}

// Kullanım
@GetMapping("/products")
public CursorPage<ProductDTO> getProducts(
        @RequestParam(required = false) LocalDateTime cursor,
        @RequestParam(required = false) Long cursorId,
        @RequestParam(defaultValue = "20") int size) {

    List<Product> products;
    if (cursor == null) {
        products = repo.findFirstPage(PageRequest.of(0, size + 1));
    } else {
        products = repo.findNextPage(cursor, cursorId, PageRequest.of(0, size + 1));
    }

    boolean hasNext = products.size() > size;
    if (hasNext) products = products.subList(0, size);

    Product last = products.get(products.size() - 1);
    return new CursorPage<>(
        products.stream().map(this::toDTO).toList(),
        hasNext ? last.getCreatedAt() : null,
        hasNext ? last.getId() : null
    );
}

Karşılaştırma:

ÖzellikOffset PaginationKeyset Pagination
PerformansSayfa arttıkça yavaşlarHer zaman sabit hız
"Sayfa X'e git"✅ Destekler❌ Desteklemez
Veri tutarlılığıEkleme/silme sırasında kayıp/tekrarTutarlı
UygulamaBasitDaha karmaşık
KullanımAdmin panel, az veriInfinite scroll, mobil, API

Index Stratejileri

-- WHERE koşullarında kullanılan kolonlara index ekleyin
CREATE INDEX idx_product_category ON products(category);
CREATE INDEX idx_product_status_date ON products(status, created_at DESC);

-- Composite index: kolon sırası önemli!
-- WHERE status = ? AND created_at > ? için:
CREATE INDEX idx_status_date ON orders(status, created_at);
-- (status önce çünkü eşitlik koşulu, created_at sonra çünkü aralık koşulu)
// JPA ile index tanımlama
@Entity
@Table(name = "products", indexes = {
    @Index(name = "idx_product_category", columnList = "category"),
    @Index(name = "idx_product_status_date", columnList = "status, createdAt DESC"),
    @Index(name = "idx_product_name", columnList = "name")
})
public class Product { ... }

Yaygın Hatalar

  • FetchType.EAGER kullanmak: Kullanılmayan ilişkiler bile yüklenir. Her zaman LAZY kullanın.

  • SELECT * ile tüm kolonları çekmek: Projection kullanın, sadece gerekli kolonları seçin.

  • Hibernate statistics'i kapamak: Kaç sorgu çalıştığını bilmeden optimize edemezsiniz.

  • IDENTITY generation ile batch insert: Batch çalışmaz. SEQUENCE kullanın.

  • Index olmadan büyük tabloda sorgulama: Full table scan → dakikalarca süren sorgular.

  • Offset pagination ile derin sayfalama: Keyset pagination kullanın.

💡 Özet: N+1'i JOIN FETCH, @EntityGraph veya @BatchSize ile çözün. Projection ile sadece gerekli veriyi çekin. Batch insert için SEQUENCE strategy kullanın. L2 Cache ile sık okunan entity'leri cache'leyin. readOnly = true ile okuma performansını artırın. Büyük listelerde Keyset pagination tercih edin.