İçeriğe geç

JPA ve Hibernate Performans Optimizasyonu

T
Tolgahan
· · 14 dk okuma · 459 görüntülenme

Neden JPA Performansı Bu Kadar Kritik?

JPA ve Hibernate, Java dünyasında veritabanı işlemleri için standart ORM çözümüdür. Ancak "sihirli" çalışan bu araçlar, dikkatli kullanılmazsa ciddi performans sorunlarına yol açar. Bir uygulamanın production'da yavaşlamasının en sık nedeni optimize edilmemiş veritabanı sorgularıdır.

Bu yazıda en yaygın JPA performans sorunlarını ve çözümlerini gerçek örneklerle inceleyeceğiz.

N+1 Problemi: En Yaygın Performans Katili

N+1 problemi, bir sorgu ile N adet ana kayıt çekilirken, her kayıt için ayrı bir sorgu daha çalıştırılması durumudur. Yani toplam N+1 sorgu çalışır.

// Entity tanımları
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

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

@Entity
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;

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

    private int quantity;
    private BigDecimal price;
}

N+1 sorunu:

// 1 sorgu: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();

for (Order order : orders) {
    // Her order için 1 sorgu daha: SELECT * FROM order_items WHERE order_id = ?
    System.out.println("Items: " + order.getItems().size());
}
// Toplam: 1 + N sorgu (N = order sayısı)

100 sipariş varsa 101 sorgu çalışır. 1000 sipariş varsa 1001 sorgu. Bu bir felaket senaryosudur.

Çözüm 1: JOIN FETCH

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.items")
    List<Order> findAllWithItems();

    @Query("SELECT o FROM Order o JOIN FETCH o.items JOIN FETCH o.customer WHERE o.id = :id")
    Optional<Order> findByIdWithDetails(@Param("id") Long id);
}

Çözüm 2: @EntityGraph

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"items", "items.product"})
    List<Order> findAll();

    @EntityGraph(attributePaths = {"items", "customer"})
    Optional<Order> findById(Long id);
}

Çözüm 3: @BatchSize

@Entity
public class Order {

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 20)  // 20'şerli gruplar halinde yükle
    private List<OrderItem> items;
}

@BatchSize(size = 20) kullanıldığında, 100 sipariş için 1 + 5 = 6 sorgu çalışır (100 / 20 = 5 batch). N+1'den çok daha iyi.

Tavsiye: Global batch size ayarı için application.yml dosyasına spring.jpa.properties.hibernate.default_batch_fetch_size: 20 ekleyebilirsiniz.

Fetch Stratejileri: LAZY vs EAGER

Hibernate'de iki fetch stratejisi vardır:

  • LAZY: İlişki yalnızca erişildiğinde yüklenir (varsayılan: @OneToMany, @ManyToMany)

  • EAGER: İlişki ana sorgu ile birlikte hemen yüklenir (varsayılan: @ManyToOne, @OneToOne)

// YANLIS: Eager loading her yerde
@Entity
public class Product {

    @ManyToOne(fetch = FetchType.EAGER)  // Her product sorgusu category'yi de çeker
    private Category category;

    @OneToMany(mappedBy = "product", fetch = FetchType.EAGER)  // Her product TÜM review'ları çeker
    private List<Review> reviews;

    @ManyToMany(fetch = FetchType.EAGER)  // Her product TÜM tag'leri çeker
    private Set<Tag> tags;
}

Bu entity'yi findAll() ile çektiğinizde Hibernate devasa bir JOIN sorgusu oluşturur ve belki megabyte'larca gereksiz veri çekersiniz.

// DOGRU: Her yerde Lazy, ihtiyaç halinde fetch
@Entity
public class Product {

    @ManyToOne(fetch = FetchType.LAZY)
    private Category category;

    @OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
    private List<Review> reviews;

    @ManyToMany(fetch = FetchType.LAZY)
    private Set<Tag> tags;
}

Altın kural: Tüm ilişkileri LAZY yapın. İhtiyaç duyduğunuz yerlerde JOIN FETCH veya @EntityGraph ile yükleyin.

DTO Projection ile Sadece İhtiyacınız Olanı Çekin

Bir entity'nin tüm alanlarına her zaman ihtiyacınız olmaz. Örneğin bir ürün listesi sayfasında yalnızca id, name ve price yeterlidir. DTO projection ile sadece gerekli alanları çekersiniz:

Interface Projection

public interface ProductSummary {
    Long getId();
    String getName();
    BigDecimal getPrice();
    String getCategoryName();
}

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("SELECT p.id AS id, p.name AS name, p.price AS price, " +
           "c.name AS categoryName " +
           "FROM Product p JOIN p.category c " +
           "WHERE p.price > :minPrice")
    List<ProductSummary> findExpensiveProducts(@Param("minPrice") BigDecimal minPrice);
}

Record DTO Projection

public record ProductDto(Long id, String name, BigDecimal price) {}

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("SELECT new com.example.dto.ProductDto(p.id, p.name, p.price) " +
           "FROM Product p WHERE p.category.id = :categoryId")
    List<ProductDto> findByCategoryAsDto(@Param("categoryId") Long categoryId);
}

DTO projection'ın faydaları:

  • Daha az veri transfer edilir — network ve memory tasarrufu

  • Hibernate change detection çalışmaz — performans artışı

  • Lazy loading riski yoktur — çünkü entity değil, düz veri dönüyor

Pagination ile Büyük Veri Setlerini Yönetmek

Binlerce kaydı tek seferde çekmek yerine sayfalama kullanın:

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    Page<Product> findByCategory(Category category, Pageable pageable);

    @Query("SELECT p FROM Product p WHERE p.price > :minPrice")
    Slice<Product> findExpensive(@Param("minPrice") BigDecimal minPrice, Pageable pageable);
}
@Service
public class ProductService {

    public Page<ProductDto> getProducts(int page, int size, String sortBy) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy).descending());

        return productRepository.findAll(pageable)
            .map(p -> new ProductDto(p.getId(), p.getName(), p.getPrice()));
    }
}

Page vs Slice: Page toplam eleman sayısını hesaplar (COUNT sorgusu çalıştırır). Slice sadece "bir sonraki sayfa var mı?" bilgisini verir. Toplam sayıya ihtiyacınız yoksa Slice kullanın — bir sorgu tasarrufu.

Keyset Pagination (Cursor-Based)

Offset-based pagination büyük offset'lerde yavaşlar (OFFSET 100000 tüm önceki kayıtları taramak zorundadır). Keyset pagination bu sorunu çözer:

@Query("SELECT p FROM Product p WHERE p.id > :lastId ORDER BY p.id ASC")
List<Product> findNextPage(@Param("lastId") Long lastId, Pageable pageable);
// Kullanım
Long lastSeenId = 0L;
List<Product> page = productRepository.findNextPage(lastSeenId, PageRequest.of(0, 20));
lastSeenId = page.get(page.size() - 1).getId();  // Sonraki sayfa için

Query Cache ve Second-Level Cache

Hibernate iki seviyeli cache mekanizması sunar:

  • First-Level Cache: Session (EntityManager) seviyesinde, otomatik aktif

  • Second-Level Cache: SessionFactory seviyesinde, yapılandırma gerektirir

Second-Level Cache için EHCache veya Caffeine kullanabilirsiniz:

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
</dependency>
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}

Dikkat: Second-level cache, sık okunan ve nadiren güncellenen veriler için idealdir (kategoriler, konfigürasyonlar). Sık güncellenen veriler için cache invalidation karmaşıklığı sorun yaratır.

Bulk Operations ile Toplu İşlemler

Tek tek entity güncellemek yerine bulk operation kullanın:

// YANLIS: N sorgu
List<Product> products = productRepository.findByCategory(category);
for (Product p : products) {
    p.setDiscounted(true);
    productRepository.save(p);  // Her biri için UPDATE
}

// DOGRU: Tek sorgu
@Modifying
@Query("UPDATE Product p SET p.discounted = true WHERE p.category = :category")
int bulkDiscount(@Param("category") Category category);

JDBC batch insert için:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
@Transactional
public void saveAll(List<Product> products) {
    for (int i = 0; i < products.size(); i++) {
        entityManager.persist(products.get(i));
        if (i % 50 == 0) {
            entityManager.flush();
            entityManager.clear();  // Memory temizle
        }
    }
}

Performans İzleme

Hibernate istatistiklerini aktif ederek sorgu performansını izleyebilirsiniz:

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
    show-sql: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.stat: DEBUG

Production'da p6spy veya datasource-proxy ile sorgu sürelerini ve sayılarını loglayabilirsiniz.

Özet

  • N+1 problemini JOIN FETCH, @EntityGraph veya @BatchSize ile çözün

  • Tüm ilişkileri LAZY yapın, ihtiyaç halinde fetch edin

  • DTO Projection ile sadece gerekli alanları çekin — entity dönmeyin

  • Pagination kullanın, büyük offset'ler için keyset pagination tercih edin

  • Bulk operations ile toplu işlemleri tek sorguda yapın

  • Second-level cache sık okunan, az güncellenen veriler için kullanın

  • Performansı izleyin: Hibernate istatistikleri ve slow query log aktif edin

Paylaş:
Son güncelleme: Jun 04, 2026

Yorumlar

Giriş yapın ve yorum bırakın.

Henüz yorum yok

Düşüncelerinizi paylaşan ilk siz olun!

Bu yazıyı beğendiniz mi?

Bültene abone olun ve yeni yazılardan ilk siz haberdar olun. Spam yok, söz.

İlgili Yazılar