HIBERNATE N+1 ÇÖZÜMÜ

Hibernate N+1 problemi: tek parent sorgusunun ardından N adet alt sorguya dallanma

Bir kullanıcı listesi sayfasında üretim ortamında log'ları açıp bakanlar şu gerçekle yüzleşir: tek HTTP isteği 100'den fazla SQL sorgusu tetikleyebilir. Aynı sayfa fetch join ile yeniden yazıldığında sorgu sayısı 2-3'e düşer; yani %95'in üzerinde bir azalış mümkündür. Bu fark sadece "biraz hızlanma" değildir — database bağlantı havuzunun nefes almasını, p95 response time'ın yarıya inmesini ve oncall'da gece uyumayı sağlar. N+1 problemi Hibernate ile çalışan hemen her ekibin bir noktada karşılaştığı en sinsi performans sorunudur çünkü kod testlerde sessizce geçer, üretimde gürültüyle patlar.

N+1 Problemi Tam Olarak Nedir?

İsmindeki "N+1" formülü davranışı doğrudan tarif eder: parent kayıtları çekmek için 1 sorgu çalışır, ardından her parent için child collection'ı lazy olarak yüklemek üzere N adet ek sorgu daha gönderilir. 100 kullanıcı çektiniz, her birinin siparişlerine eriştiniz — toplam 101 SQL query. Hibernate bunu kötü niyetle yapmaz; default davranış olan FetchType.LAZY'nin doğal sonucudur.

Sorunun sinsiliği şuradadır: entity'lere getter ile eriştiğinizde sorgular sessizce arka planda akar. IDE'de bir for döngüsü görürsünüz, ama altta yatan I/O maliyeti ekrana yazılmaz.

Problemi Üreten Tipik Kod

Aşağıdaki gibi bir senaryo neredeyse her projede vardır:

List<User> users = userRepository.findAll();
for (User user : users) {
    System.out.println(user.getOrders().size());
}

Tek satır gibi görünen getOrders() çağrısı, her iterasyonda yeni bir SELECT * FROM orders WHERE user_id = ? sorgusu tetikler. 100 user için 1 + 100 = 101 query. N+1 ve JOIN FETCH karşılaştırması: 101 ayrı sorgu yerine tek birleştirilmiş sorgu

Problemi Tespit Etmenin Yolları

Tespit, çözümden daha önemlidir çünkü göremediğiniz şeyi düzeltemezsiniz. Pratikte işe yarayan tespit yöntemleri:

  • SQL log'larını açmak: spring.jpa.show-sql=true ve hibernate.format_sql=true ile her sorgu konsola düşer. Geliştirme aşamasında tek tek saymak yeterli.
  • Hibernate Statistics: SessionFactory.getStatistics() üzerinden getQueryExecutionCount() ile sayısal ölçüm alınır.
  • Datasource proxy kütüphaneleri: p6spy veya datasource-proxy her sorguyu yakalayıp endpoint başına raporlar.
  • Test seviyesinde assertion: JUnit testlerinde "bu endpoint en fazla 3 query çalıştırsın" şeklinde sınır konulabilir; CI'da regresyonu erken yakalar.
  • APM araçları: Datadog, New Relic veya Glowroot transaction trace'lerinde N+1 paterni görsel olarak ortaya çıkar.

Çözüm 1: Fetch Join (JPQL)

En doğrudan çözüm, JPQL içinde JOIN FETCH kullanmaktır. Bu, ilişkili kayıtları aynı SQL içinde tek seferde çeker:

@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();

101 query yerine 1 query. Ancak iki uyarı: birden fazla collection'ı aynı anda fetch etmek MultipleBagFetchException üretir; ayrıca cartesian product büyürse network'te şişme yapar. Fetch stratejilerinin tüm köşe durumları için resmi ORM dokümantasyonu başvurulacak en güvenilir kaynaktır.

Çözüm 2: EntityGraph

@EntityGraph annotation'ı fetch planını query'den ayrı tutar ve farklı endpoint'lerin aynı repository metodunu farklı fetch stratejileriyle kullanmasını kolaylaştırır:

@EntityGraph(attributePaths = {"orders", "orders.items"})
List<User> findByActiveTrue();

JPQL'i temiz tutmak istediğinizde tercih edilir.

Çözüm 3: Batch Size

Her yere fetch join koymak istemediğinizde, @BatchSize ile child sorguları toplu çalıştırılır:

@OneToMany(mappedBy = "user")
@BatchSize(size = 25)
private List<Order> orders;

100 user için 100 ayrı sorgu yerine 4 toplu IN (...) sorgusu üretilir. Cartesian product riski yoktur ama hâlâ birden çok round-trip vardır.

DTO Projeksiyonu ile Tam Kontrol

Listeleme endpoint'lerinde çoğu zaman entity'nin tüm field'larına ihtiyacınız yoktur. SELECT new com.app.UserListDTO(u.id, u.name, COUNT(o)) şeklinde projeksiyon kullandığınızda hem N+1 ortadan kalkar hem de bellekte gereksiz veri taşımazsınız. Konuyu derinlemesine ele almak için JPA ve Hibernate eğitimi içeriğinden yararlanabilirsiniz.

Hangi Çözümü Ne Zaman Seçmeli?

  1. Detay sayfası (tek parent + birkaç child): Fetch join en sade çözüm.
  2. Liste sayfası (çok parent + birden fazla collection): EntityGraph + batch size kombinasyonu.
  3. Salt okunur tablo/listeleme: DTO projeksiyonu — entity hiç hydrate edilmesin.
  4. Sayfalama (pagination) gereken liste: Önce ID'leri page olarak çek, sonra ikinci sorguda fetch join ile detayları al; aksi halde Hibernate sayfalamayı bellekte yapar ve uyarı log'lar.

Fetch join EntityGraph ve batch size çözüm yöntemleri için karar matrisi diyagramı

Aynı ilişkiyi farklı yerlerde farklı stratejilerle çekmek normaldir; mantra "her zaman EAGER" veya "her zaman LAZY" değil, "endpoint bazında bilinçli karar"dır. Daha geniş bir Spring Data ve sorgu optimizasyonu perspektifi için JPA Hibernate eğitim içeriklerini inceleyebilirsiniz.

Üretime Çıkmadan Önce Kontrol Listesi

Bir endpoint'i prod'a göndermeden önce şu küçük kontrol turu çoğu N+1 vakasını yakalar: SQL log'u açıp endpoint'i bir kez çağırın ve konsola düşen query sayısını sayın. Tek sayı bekleyip onlarca sorgu görüyorsanız, fetch stratejinizi gözden geçirme zamanı gelmiştir. Performans optimizasyonunun ilk %80'i çoğu zaman bu basit alışkanlıkta saklıdır.