Yazılarımız

Veri Akademi

HİBERNATE N+1 PROBLEMİ NEDİR? FETCH STRATEGY VE ÇÖZÜM YOLLARI

Uygulama ilk başta “çalışıyor” gibi görünür; birkaç kayıt listelenir, detay sayfası açılır, her şey yolundadır. Ama veri büyüdükçe sayfaların yavaşlaması, CPU’nun yükselmesi ve veritabanında ardı ardına benzer sorguların akması başlar. Çoğu ekip bu noktada aynı sürprizle karşılaşır: Hibernate N+1 problemi.

N+1, ORM kullanan projelerde performansı en hızlı baltalayan tuzaklardan biridir. Özellikle lazy loading ilişkiler, yanlış fetch tercihleri ve kontrolsüz mapping’ler birleştiğinde, tek bir ekran için yüzlerce sorgu üretmek çok kolaylaşır. Üstelik sorun yalnızca “çok sorgu” değildir; aynı zamanda ağ gecikmesi, connection pool baskısı ve DB tarafında plan/lock maliyeti de birikir.

Bu yazıda N+1’in neden oluştuğunu, nasıl tespit edileceğini ve fetch strategy seçenekleriyle birlikte pratik çözüm yollarını adım adım ele alacağız. Daha sistematik öğrenmek isterseniz JPA & Hibernate eğitimine de göz atabilirsiniz.

Bir listeme ekranında benzer SELECT sorgularının peş peşe çoğaldığını gösteren performans izleme çıktısı

N+1 Problemi Nasıl Ortaya Çıkar?

N+1 problemi, genellikle bir koleksiyonu (N adet satır) çekerken, her satır için ilişkili verinin ayrı sorguyla yüklenmesiyle oluşur. İlk sorgu “1”dir; ardından her kayıt için ek sorgular “N” olur. Toplamda 1+N sorgu üretilir. Örneğin “siparişler” listesini çekip, her siparişin “müşteri” bilgisini görüntülemek istiyorsunuz. Siparişler tek sorguyla gelir; fakat müşteri bilgisi ilişkisi her satırda tetiklenirse N kez daha sorgu çalışır.

Bu durumun sık görülmesinin nedeni, ORM’in ilişki yönetimini geliştirici adına kolaylaştırmasıdır. Kod tarafında order.getCustomer().getName() çağrısı masum görünür; fakat bu çağrı, proxy üzerinden yeni bir SQL sorgusunu tetikleyebilir. Sorun, bu davranışın ekrana yansıyan döngülerle çarpan etkisi yaratmasıdır.

Lazy loading ve proxy mantığı

Hibernate, ilişkileri varsayılan olarak çoğu durumda tembel yükleme (LAZY) ile yönetmeyi sever. LAZY ilişki, ilk anda yalnızca bir proxy referansı döndürür; gerçek veri, ilişkiye erişildiğinde yüklenir. Bu yaklaşım gereksiz veri çekimini azaltır; fakat yanlış yerde erişim yapıldığında N+1’i tetikler. Özellikle liste ekranlarında, bir döngü içinde ilişkili alanlara erişmek N+1’in en klasik formudur.

ORM’in sorgu üretim paterni

N+1, genellikle “entity grafını” uygulama akışı sırasında parça parça yükletme eğiliminden doğar. Bir service metodu, repository’den bir liste çeker; sonra mapper veya template katmanı ilişkili alanlara dokunur; Hibernate de her dokunuş için bir sorgu üretir. Burada kritik nokta, veriye ne zaman ve hangi kapsamda ihtiyaç duyduğunuzu önceden bilip fetch planını ona göre kurmaktır.

N+1’i Tespit Etme: Belirti, Log ve Ölçüm

N+1 bazen belirgin şekilde ortaya çıkar: sayfa yükleme süresi artar ve loglarda aynı pattern’de sorgular akar. Bazen de “cache var, sorun yok” sanılır; ancak farklı bir kullanıcı akışında cache ıskaladığında patlar. Bu nedenle tespit için hem semptomları hem de ölçümleri birlikte kullanmak gerekir.

SQL loglarını okumayı alışkanlık haline getirin

Geliştirme ortamında Hibernate SQL loglarını açmak, ilk teşhis için en hızlı yöntemdir. Aynı sorgunun farklı parametrelerle ardı ardına çalıştığını görüyorsanız, yüksek ihtimalle N+1 yaşıyorsunuz. Daha sağlıklı okumak için bind parametre loglarını ve sorgu sürelerini de açın; çünkü bazen sorgu sayısı az görünse bile süreler büyüyebilir.

Hibernate Statistics, p6spy ve APM kullanımı

Hibernate Statistics, session factory seviyesinde entity load sayıları, query count ve second-level cache hit oranları gibi metrikler sunar. p6spy benzeri bir proxy ile gerçek sorgu sayısını ve sürelerini kolayca izleyebilirsiniz. Üretim ortamında ise APM araçları (distributed tracing) ile endpoint başına sorgu sayısını görmek çok değerlidir. Buradaki hedef, “bu ekran kaç sorgu çalıştırmalı?” sorusuna net bir sınır koymaktır.

Fetch Strategy: EAGER vs LAZY ve Doğru Kapsam

Fetch strategy denince akla ilk olarak EAGER ve LAZY gelir; ancak asıl mesele, tek bir global tercih yapmak değildir. Aynı ilişki, farklı use-case’lerde farklı fetch planına ihtiyaç duyabilir. Bir detay sayfasında ilişkili veriyi tek seferde almak mantıklıyken, bir arama ekranında sadece temel alanlar yeterlidir.

Varsayılan fetch tiplerini bilin

JPA tarafında bazı ilişkilerin varsayılan fetch tipleri sürpriz yaratabilir. Örneğin @ManyToOne çoğu projede EAGER’a kaydığı için, fark etmeden gereksiz join’lerle sorgu şişirebilirsiniz. Öte yandan koleksiyonlar (ör. @OneToMany) LAZY olduğundan, liste ekranlarında N+1’e daha yatkındır. Bu yüzden mapping’lerde varsayılanlara güvenmek yerine, bilinçli bir strateji belirlemek gerekir.

Batch fetching ve @BatchSize ile kontrollü yükleme

Her ilişkiyi fetch join ile çözmek her zaman doğru değildir. Özellikle aynı anda çok ilişkiyi join’lemek kartesyen büyümeye ve gereksiz veri taşımaya yol açabilir. Bu noktada batch fetching iyi bir ara çözümdür: Hibernate, birden fazla entity için ilişkili veriyi tek bir IN sorgusunda toplayarak çekebilir. @BatchSize veya global batch ayarlarıyla, N ayrı sorgu yerine birkaç toplu sorgu elde edersiniz. Bu yaklaşım, hem liste performansını iyileştirir hem de bellek tüketimini daha dengeli tutar.

Sipariş ve müşteri ilişkilerinde lazy ve batch yaklaşımının sorgu sayısını düşürdüğünü anlatan örnek akış diyagramı

Kötü Örnek: N+1’i Tetikleyen Kod Deseni

Aşağıdaki örnekte siparişleri listeliyoruz ve her siparişin müşteri adını ekranda gösteriyoruz. Mapping LAZY olduğu için, döngü içinde müşteri alanına erişim her seferinde ayrı sorgu tetikleyebilir. Bu, veritabanında “N adet müşteri sorgusu” olarak patlar.

// Entity'ler (basitleştirilmiş)
@Entity
class Order {
  @Id Long id;

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

  private BigDecimal total;
}

@Entity
class Customer {
  @Id Long id;
  private String name;
}

// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
  @Query("select o from Order o order by o.id desc")
  List<Order> findLatest();
}

// Service / Mapper
public List<OrderRowDto> listOrders() {
  List<Order> orders = orderRepository.findLatest();
  return orders.stream()
      .map(o -> new OrderRowDto(o.getId(), o.getCustomer().getName(), o.getTotal()))
      .toList();
}

Burada sorgu akışı tipik olarak şöyledir: önce siparişler gelir (1 sorgu), ardından her sipariş için müşteri çekilir (N sorgu). Eğer liste 100 kayıt gösteriyorsa, toplamda 101 sorgu üretmek şaşırtıcı değildir. Üstelik bu sorguların her biri küçük bile olsa ağ gecikmesi ve connection pool tüketimiyle toplam süre dramatik artar.

Çözüm Yolları: Fetch Join, EntityGraph ve Alternatifler

Çözümün ana fikri, ihtiyacınız olan ilişkili veriyi doğru yerde ve doğru kapsamda yüklemektir. En yaygın ve etkili yöntem fetch join’dir; ancak tek seçenek değildir. Use-case’e göre EntityGraph, SUBSELECT veya batch fetching daha sağlıklı sonuç verebilir.

JPQL/HQL fetch join ile tek sorguda getirme

Liste ekranında müşteri adı mutlaka gerekiyorsa, bu ilişkiyi sorgu sırasında join ederek tek seferde çekmek en net çözümdür. Bu sayede döngü içindeki erişim yeni sorgu tetiklemez; çünkü ilişki zaten yüklenmiştir.

// Fetch join ile çözüm
public interface OrderRepository extends JpaRepository<Order, Long> {

  @Query("select o from Order o join fetch o.customer order by o.id desc")
  List<Order> findLatestWithCustomer();
}

public List<OrderRowDto> listOrders() {
  List<Order> orders = orderRepository.findLatestWithCustomer();
  return orders.stream()
      .map(o -> new OrderRowDto(o.getId(), o.getCustomer().getName(), o.getTotal()))
      .toList();
}

Fetch join, sorgu sayısını azaltırken dikkat edilmesi gereken bir konu da veri çoğalmasıdır. ManyToOne için genellikle güvenlidir; fakat koleksiyon ilişkilerinde join sayısı arttıkça satır çoğalabilir. Bu yüzden fetch join’i “ekranda gerçekten gereken” ilişkilerle sınırlı tutmak gerekir.

@EntityGraph ile esnek ve okunabilir fetch planı

EntityGraph, özellikle repository metodlarında okunabilir bir fetch planı tanımlamak için kullanışlıdır. Aynı entity’yi farklı ekranlarda farklı ilişkilerle çekmek istediğinizde, mapping’i EAGER yapmak yerine use-case bazlı graf tanımlamak daha temizdir. Ayrıca dinamik graph kullanarak, koşula göre hangi ilişkiyi yükleyeceğinizi de belirleyebilirsiniz.

@Fetch(FetchMode.SUBSELECT) ile koleksiyonları daha verimli çekme

Koleksiyon ilişkilerinde N+1’i azaltmak için SUBSELECT yaklaşımı işe yarayabilir. Hibernate, önce parent listesini çeker; sonra ilişkili koleksiyonu tek bir alt sorguyla topluca yükler. Bu yöntem, bazı senaryolarda fetch join’e göre daha stabil bellek kullanımı sağlar. Ancak yine de her ortamda “en iyi” değildir; gerçek veri dağılımınız ve sorgu planlarınız karar vermelidir.

Fetch join ve EntityGraph seçeneklerinin farklı ekran ihtiyaçlarına göre nasıl seçildiğini anlatan karşılaştırmalı tablo

DTO Projeksiyonu: Sadece İhtiyacın Olan Alanları Çekmek

Bazı ekranlar entity grafını taşımak zorunda değildir. Liste ekranında yalnızca sipariş id, müşteri adı ve toplam tutar gösterilecekse, tüm entity’leri yüklemek yerine DTO projeksiyonu ile doğrudan ihtiyaç duyulan kolonları çekmek çok etkili olur. Bu yaklaşım, hem sorgu sayısını düşürür hem de gereksiz ilişki yüklemelerini devre dışı bırakır.

Select new DTO yaklaşımı

JPQL ile doğrudan DTO döndürmek, mapping katmanında ilişkili alanlara dokunma ihtiyacını azaltır. Böylece LAZY proxy tetiklenmesi riski düşer. Ayrıca transfer edilen veri miktarı da azalır. Ancak DTO projeksiyonu kullanırken, domain davranışlarını entity’de bırakıp yalnızca okuma modellerini DTO’ya taşımak daha sağlıklıdır.

Pagination ve fetch join birlikte kullanıldığında dikkat

Koleksiyon fetch join ile pagination bir araya geldiğinde, Hibernate/JPA tarafında beklenmeyen sonuçlar oluşabilir. Satır çoğalması nedeniyle sayfalama bozulabilir ya da Hibernate bellekte “distinct” uygulamaya çalışırken performans düşebilir. Bu tür ekranlarda genellikle iki aşamalı yaklaşım daha güvenlidir: önce sayfalı olarak id listesini çekmek, sonra gerekli ilişkilerle ikinci sorguda detayları toplamak. Alternatif olarak DTO projeksiyonu ile sayfalama sorunsuz hale getirilebilir.

Performans Kontrol Listesi: N+1’i Kalıcı Olarak Önlemek

N+1’i bir kez çözüp rahatlamak yerine, ekip standardı haline getirmek daha değerlidir. Çünkü yeni bir endpoint, yeni bir mapper veya masum bir “ekranda şu alanı da gösterelim” değişikliği sorunu geri getirebilir. Aşağıdaki kontrol listesi, code review ve performans testlerinde hızlı bir güvenlik ağı sağlar.

İzlenebilirlik: sorgu sayısına sınır koyun

Her kritik endpoint için hedef bir sorgu bütçesi belirleyin. Örneğin “sipariş listesi 5 sorguyu geçmeyecek” gibi. Bu bütçe; fetch join, batch fetching ve DTO projeksiyonu kararlarını daha objektif hale getirir. APM üzerinde endpoint bazlı metriklerle bunu sürekli izlemek, regresyonları erken yakalar.

Mapping disiplini ve eğitim

Mapping’leri “kolay olsun” diye EAGER’a çevirmek, çoğu zaman sorunu gizler ama başka yerden patlatır. Bunun yerine LAZY varsayımıyla başlayıp, her use-case için doğru fetch planını repository seviyesinde tanımlamak daha sürdürülebilirdir. Ekip içinde bu konuyu standartlaştırmak için rehber doküman ve örnek repository şablonları oluşturmak işe yarar.

  • Liste ekranlarında: DTO projeksiyonu veya fetch join ile net veri planı
  • Detay ekranlarında: gerekli ilişkileri EntityGraph ile kontrollü yükleme
  • Koleksiyon yoğun senaryolarda: batch fetching / SUBSELECT gibi alternatifleri değerlendirme
  • Log ve metriklerle: sorgu sayısı ve sürelerini düzenli takip
  • Pagination varsa: koleksiyon fetch join yerine iki aşamalı strateji

Özetle, N+1 problemi “Hibernate kötü” olduğu için değil; fetch planı kontrolsüz kaldığı için ortaya çıkar. İhtiyacınızı doğru tanımlayıp, doğru stratejiyi seçtiğinizde hem kod okunabilirliği hem de performans birlikte iyileşir. Uygulamanız büyüdükçe bu disiplin daha da kritik hale gelir.


İpucu: Bir ekranda N+1 görüyorsanız, çözümü önce “neye gerçekten ihtiyacım var?” sorusuyla başlatın. Sonra fetch join, EntityGraph, batch fetching ve DTO projeksiyonu seçeneklerinden en az müdahaleyle en büyük faydayı sağlayanı seçin.

 VERİ AKADEMİ