in

Spring Boot'ta N+1 Problemi: Tespit, Ölçüm ve Çözüm

N+1, kurumsal Java projelerinde en sık görülen performans hatasıdır ve çoğu zaman geliştirme ortamında fark edilmez. Sorgu sayısını nasıl ölçeceğinizi, JOIN FETCH, @EntityGraph, @BatchSize ve DTO projeksiyonunu ne zaman seçeceğinizi kod örnekleriyle anlatıyoruz.

Tek sorgu yerine N+1 sorgu üreten JPA ilişkisini gösteren şema

N+1, kurumsal Java projelerinde en sık gördüğümüz performans hatasıdır ve tehlikesi tam da şuradadır: geliştirme ortamında 20 kayıtla fark edilmez, üretimde 20.000 kayıtla ekranı 40 saniyeye çıkarır. Kod doğru çalışır, testler geçer; yalnızca veritabanına bir yerine 20.001 kez gidilir.

Bu yazıda problemin JPA/Hibernate'de nasıl oluştuğunu, sorgu sayısını nasıl ölçeceğinizi ve dört çözüm yönteminin — JOIN FETCH, @EntityGraph, @BatchSize ve DTO projeksiyonu — hangi durumda doğru seçim olduğunu kod örnekleriyle anlatıyoruz.

N+1 nasıl oluşur?

Klasik örnek: sipariş ve kalemleri.

@Entity
public class Siparis {
    @Id private Long id;
    private String no;

    @ManyToOne(fetch = FetchType.LAZY)
    private Musteri musteri;

    @OneToMany(mappedBy = "siparis")   // varsayılan: LAZY
    private List<Kalem> kalemler = new ArrayList<>();
}
List<Siparis> siparisler = siparisRepository.findAll();   // 1 sorgu
for (Siparis s : siparisler) {
    toplam += s.getKalemler().stream()                      // her sipariş için +1 sorgu
                .mapToLong(Kalem::getTutar).sum();
}

Hibernate ilk satırda tek bir SELECT ile siparişleri çeker. getKalemler() LAZY olduğu için her siparişte koleksiyon ilk dokunuşta ayrı bir SELECT ... WHERE siparis_id = ? ile yüklenir. 500 sipariş, 501 sorgu. Aynı şey @ManyToOne için de geçerlidir: s.getMusteri().getAd() çağrısı, müşteri persistence context'te yoksa yeni bir sorgudur.

İki ayrıntı önemli. Birincisi, JPA'da @ManyToOne ve @OneToOne varsayılan olarak EAGER'dır; bu, N+1'i çözmez, her ana sorguya gizli JOIN'ler ya da ek SELECT'ler ekleyerek başka bir yere taşır. Kural: tüm ilişkiler LAZY, yükleme stratejisi sorgu bazında karar verilir. İkincisi, Spring Boot'un varsayılan olarak açık olan open-in-view ayarı, persistence context'i HTTP isteği boyunca açık tuttuğu için controller veya JSON serileştirme katmanında tetiklenen lazy yüklemeleri sessizce başarıya dönüştürür — hata görmezsiniz, N+1'i de görmezsiniz.

Tespit ve ölçüm: önce say, sonra düzelt

spring.jpa.show-sql=true konsola SQL basar ama saymaz; üretimde de kullanılamaz. Üç seviyeli bir ölçüm kurgusu öneriyoruz.

1. Geliştirme ortamında Hibernate istatistikleri

spring:
  jpa:
    open-in-view: false
    properties:
      hibernate:
        generate_statistics: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE     # Hibernate 6: bağlanan parametreler
    org.hibernate.stat: DEBUG              # Session Metrics satırı

Her transaction sonunda Hibernate şu tarz bir satır loglar: "Session Metrics { ... 501 JDBC statements prepared ... }". Bir ekran açılışında yüzlerce statement görüyorsanız N+1 var demektir.

2. Testte sorgu sayısını doğrulamak

Asıl güvence, regresyonu yakalayan bir testtir. Hibernate Statistics nesnesi entegrasyon testinde doğrudan kullanılabilir:

@SpringBootTest
class SiparisSorguSayisiTest {

    @Autowired EntityManagerFactory emf;
    @Autowired SiparisService service;

    @Test
    void siparisListesiSabitSorguSayisiylaYuklenir() {
        Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
        stats.clear();

        service.aylikOzet(YearMonth.of(2026, 9));   // 500 sipariş içeren veri seti

        assertThat(stats.getPrepareStatementCount())
            .as("sorgu sayısı veri miktarından bağımsız olmalı")
            .isLessThanOrEqualTo(3);
    }
}

Testin değeri eşiğin sabit olmasındadır: veri seti 50'den 5.000'e çıktığında sorgu sayısı 3'te kalmalıdır. Bunu Testcontainers ile gerçek PostgreSQL üzerinde koşturmak, H2'nin gizlediği davranış farklarını da eler.

3. Üretimde izleme

Üretimde datasource-proxy veya p6spy ile istek başına sorgu sayısını bir HTTP header'ına ya da log alanına yazmak, N+1'i anlık olarak görünür kılar. Micrometer'ın Hibernate metrikleri (hibernate.statements, hibernate.query.executions) Grafana'da "istek başına sorgu" paneline dönüştürülebilir. Bu panel, kod incelemesinde gözden kaçan her N+1'i ilk günden yakalar.

Dört çözüm ve hangisini ne zaman seçmeli

1. JOIN FETCH

Tek sorguda ana entity ile koleksiyonu birlikte çeker. Küçük ve sınırlı sonuç kümelerinde en doğrudan çözüm:

@Query("""
    select distinct s from Siparis s
    join fetch s.kalemler
    join fetch s.musteri
    where s.tarih between :bas and :bit
    """)
List<Siparis> aralikIcinKalemleriyle(LocalDate bas, LocalDate bit);

İki tuzağı var. Sayfalama: koleksiyon fetch'i ile Pageable birleştiğinde Hibernate satır çoğalması yüzünden sayfalamayı SQL'de değil bellekte yapar; tüm sonuç kümesi belleğe iner ve uyarı loglar. Sayfalanan listelerde JOIN FETCH kullanmayın. Çoklu koleksiyon: iki List koleksiyonunu aynı sorguda fetch etmek MultipleBagFetchException verir; ya Set kullanın ya da koleksiyonları ayrı sorgularla yükleyin.

2. @EntityGraph

Aynı etkiyi JPQL yazmadan, Spring Data repository metodunda bildirimsel olarak sağlar; yükleme stratejisini sorgudan ayırdığı için aynı metodu farklı ekranlar için farklı graph'larla yeniden kullanabilirsiniz:

public interface SiparisRepository extends JpaRepository<Siparis, Long> {

    @EntityGraph(attributePaths = {"kalemler", "musteri"})
    List<Siparis> findByTarihBetween(LocalDate bas, LocalDate bit);

    @EntityGraph(attributePaths = "musteri")           // liste ekranı: kalemler gerekmez
    Page<Siparis> findByDurum(Durum durum, Pageable pageable);
}

Arka planda yine JOIN üretildiği için sayfalama ve çoklu koleksiyon kısıtları aynen geçerlidir.

3. @BatchSize ve default_batch_fetch_size

Koleksiyonu ayrı sorguda ama toplu yükler: 500 sipariş için 500 yerine, 100'lük gruplar halinde 5 IN (...) sorgusu. Sayfalamayla sorunsuz çalışır, satır çoğalması yaratmaz ve global açıldığında tüm projeyi tek satırla korur:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
@OneToMany(mappedBy = "siparis")
@BatchSize(size = 50)                 // ilişki bazında farklı boyut gerekiyorsa
private List<Kalem> kalemler;

Kurumsal projelerde varsayılan önerimiz budur: default_batch_fetch_size açık, kritik sorgularda bilinçli JOIN FETCH. Alternatif olarak @Fetch(FetchMode.SUBSELECT), koleksiyonu ana sorgunun alt sorgusuyla tek seferde yükler; büyük ana sorgularda IN listesi yerine tercih edilebilir.

4. DTO projeksiyonu

Salt okunur liste ve rapor ekranlarında entity yüklemek baştan yanlıştır. İhtiyaç duyulan sütunları doğrudan bir record'a çekin; Hibernate ne persistence context'i doldurur ne de lazy proxy üretir:

public record SiparisOzet(String no, String musteriAd, BigDecimal tutar, long kalemSayisi) {}

@Query("""
    select new com.acme.erp.siparis.SiparisOzet(
        s.no, m.ad, sum(k.tutar), count(k))
    from Siparis s
    join s.musteri m
    join s.kalemler k
    where s.tarih between :bas and :bit
    group by s.no, m.ad
    """)
List<SiparisOzet> ozet(LocalDate bas, LocalDate bit);

Tek sorgu, sayfalama sorunsuz, bellek tüketimi minimum. Ekran "gösteriyor ama değiştirmiyorsa" projeksiyon ilk tercihtir.

Karşılaştırma

Yöntem

Sorgu sayısı

Sayfalama

Çoklu koleksiyon

Ne zaman

JOIN FETCH

1

Bellekte (riskli)

Bag hatası riski

Küçük, sınırlı sonuç kümesi; tek koleksiyon

@EntityGraph

1

Bellekte (riskli)

Bag hatası riski

JPQL yazmadan, metoda göre değişen yükleme

@BatchSize

1 + N/boyut

Sorunsuz

Sorunsuz

Proje geneli varsayılan; sayfalanan listeler

DTO projeksiyonu

1

Sorunsuz

Sorunsuz

Salt okunur liste ve raporlar

Ekip kuralları: N+1'i tekrar tekrar düzeltmemek için

  • Tüm ilişkiler LAZY. @ManyToOne(fetch = LAZY) ve @OneToOne(fetch = LAZY) açıkça yazılır; EAGER kod incelemesinde reddedilir.

  • open-in-view=false. Lazy yükleme servis katmanının dışında LazyInitializationException fırlatır; bu istenen davranıştır, hatayı erken gösterir.

  • Repository metodu ne yüklediğini söyler. findByDurum ile findByDurumWithKalemler ayrı metotlardır; çağıran, entity'nin hangi kısmının yüklü olduğunu bilir.

  • Lombok @ToString ve @EqualsAndHashCode ilişkileri dışlar. Bir log satırı tüm koleksiyonu yükleyebilir.

  • Her liste servisi için sorgu sayısı testi. Eşik sabit, veri seti büyük.

Sık sorulan sorular

N+1 sadece Hibernate'de mi olur?

Hayır; MyBatis, JOOQ veya elle yazılmış JDBC kodunda da döngü içinde sorgu atıldığında aynı problem oluşur. Hibernate'de sık görülmesinin nedeni lazy yüklemenin görünmez olmasıdır: sorguyu siz yazmazsınız, bir getter tetikler.

EAGER yapsam N+1 biter mi?

Bitmez, yer değiştirir. EAGER ilişkiler her sorguya otomatik JOIN veya ek SELECT ekler; ihtiyacınız olmayan ekranlarda da yüklenir ve kontrolünüz dışında çalışır. Doğru yaklaşım LAZY + sorgu bazında bilinçli yükleme stratejisidir.

default_batch_fetch_size için doğru değer nedir?

Çoğu proje için 50–100 aralığı yeterlidir. Çok yüksek değerler IN listesini büyütür ve sorgu planını bozabilir; çok düşük değerler sorgu sayısını artırır. Değeri üretim metrikleriyle doğrulayın.

Sonuç

N+1, kodun değil yükleme stratejisinin hatasıdır ve tek bir kural değişikliğiyle değil, ölçüm + varsayılan ayar + test üçlüsüyle kalıcı olarak çözülür: open-in-view=false, tüm ilişkiler LAZY, default_batch_fetch_size açık, salt okunur ekranlarda projeksiyon ve her liste servisi için sorgu sayısı testi.

Mevcut uygulamanızın veri erişim katmanını ölçüp iyileştirmek için yazılım tasarımı ve mimari hizmetimize, üretimde sürekli izleme ve performans raporu için bakım ve destek kapsamımıza göz atabilirsiniz.