HIBERNATE N+1 ÇÖZÜMÜ

Veri Akademi6 dk okuma
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.

1. 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.

2. 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

3. 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.

4. Çö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.

5. Çö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.

6. Çö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.

7. 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.

8. 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.

9. Ü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.

Sıkça Sorulan Sorular

N+1 problemi sadece OneToMany ilişkilerde mi görülür?

Hayır. ManyToOne ve OneToOne ilişkilerinde de aynı problem ortaya çıkabilir. Örneğin 100 Order kaydı çektiğinizde her birinin tek bir User'ı olsa bile, LAZY proxy üzerinden user.getName() çağrıldığında 100 ayrı SELECT user sorgusu tetiklenir. ManyToOne tarafı default olarak EAGER olduğundan daha az fark edilir ama LAZY'ye çekildiğinde aynı tuzak yeniden ortaya çıkar.

FetchType.EAGER kullansam N+1 problemini çözmez mi?

Çözmez, sadece problemi gizler ya da farklı bir yere taşır. EAGER olduğunda Hibernate yine ayrı sorgular çalıştırabilir; ayrıca ihtiyacınız olmadığı endpoint'lerde bile ilişkili veriyi her zaman yüklemek bellek ve I/O israfıdır. EAGER, fetch stratejisini endpoint bazında ayarlama özgürlüğünüzü tamamen kaldırır; bu yüzden modern uygulamalarda anti-pattern olarak değerlendirilir.

Fetch join ile pagination birlikte neden sorun çıkarıyor?

JOIN FETCH ile collection getirildiğinde SQL sonuç kümesindeki satır sayısı parent sayısına eşit değildir; bir parent birden fazla satır olarak döner. Hibernate doğru sayfalama için tüm sonucu belleğe alıp orada limitlemek zorunda kalır ve "firstResult/maxResults specified with collection fetch" uyarısı verir. Çözüm, önce ID'leri sayfalı çekmek, ardından ikinci sorguda bu ID'ler için fetch join uygulamaktır.

MultipleBagFetchException hatası ne anlama gelir?

Aynı sorguda birden fazla List tipi collection'ı JOIN FETCH ile çekmeye çalıştığınızda alırsınız. List, sıralı ve duplicate kabul ettiği için iki collection'ın cartesian product'ı tutarsız sonuç üretir. İki çıkış yolu vardır: collection'ları List yerine Set olarak tanımlamak veya birini fetch join ile diğerini @BatchSize ile yüklemek. Set yaklaşımı daha temizdir ancak insertion order'ı korumaz.

Hibernate Statistics üretim ortamında açık tutulmalı mı?

Sürekli açık tutmak küçük bir CPU ve bellek maliyeti getirir, ancak modern uygulamalarda bu maliyet ihmal edilebilir düzeydedir. Daha güvenli yaklaşım, statistics'i sadece teşhis sırasında veya bir feature flag ile geçici olarak açmaktır. Üretimde sürekli izleme için APM araçları (Datadog, New Relic) genelde daha düşük overhead'le daha zengin trace sağlar.

Spring Data JPA'da findAll() metodunu N+1 olmadan kullanabilir miyim?

Doğrudan findAll() entity'leri çeker ve ilişkilere eriştiğinizde N+1 oluşur. Çözüm, repository'de @EntityGraph annotation'ı ile findAll metodunu override etmek veya özel bir JPQL/native query yazmaktır. Alternatif olarak salt okunur listeleme için Projection interface'leri veya record bazlı DTO döndüren custom metotlar entity hydration'ı tamamen atlayarak hem N+1'i hem gereksiz field okumayı önler.

Batch size değerini kaç seçmek gerekir?

Yaygın değerler 10, 25, 50 ve 100 arasındadır. Çok küçük seçilirse hâlâ fazla round-trip kalır; çok büyük seçilirse SQL IN clause'u şişer ve bazı veritabanlarında (özellikle Oracle'ın 1000 limit'i gibi) sınırlara takılabilir. Tipik uygulamalar için 25-50 dengeli bir başlangıçtır; gerçek değer profilleme ile belirlenmelidir.

Open Session in View (OSIV) açıkken N+1 daha mı kötü olur?

Evet. OSIV açıkken Hibernate session HTTP response render edilene kadar açık kalır ve template katmanı entity getter'larını çağırdıkça yeni sorgular sessizce tetiklenir. Servis katmanında "sorgu yok" sanırsınız, oysa view rendering sırasında onlarca sorgu çalışır. Spring Boot'ta varsayılan açık olan spring.jpa.open-in-view=false yaparak bu davranışı kapatmak ve fetch stratejisini servis katmanında bilinçli tasarlamak önerilir.