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.
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ışındaLazyInitializationExceptionfırlatır; bu istenen davranıştır, hatayı erken gösterir.Repository metodu ne yüklediğini söyler.
findByDurumilefindByDurumWithKalemlerayrı metotlardır; çağıran, entity'nin hangi kısmının yüklü olduğunu bilir.Lombok
@ToStringve@EqualsAndHashCodeiliş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.