ORM kullanırken en sık karşılaşılan performans problemlerinden biri N+1 query‘dir.
İlk başta her şey gayet normal görünür: ana kayıtları tek bir sorguyla çekersiniz. Sonra her kaydın ilişkisine erişirsiniz ve Hibernate arka planda her biri için bir sorgu daha çalıştırır.
Sonuç:
1 ana sorgu + N adet ilişki sorgusu = N+1 sorgu.
Veri az olduğunda bunu fark etmek kolay değildir. Development ortamında 10 kayıtla çalışan endpoint gayet hızlıdır. Production’da kayıt sayısı 10.000’e çıktığında ise aynı kod bir anda yüzlerce, hatta binlerce SQL sorgusu çalıştırmaya başlayabilir.
Bu yazıdaki örnekler JPA/Hibernate üzerinden ama problem ORM’e özel değil. Entity Framework, Sequelize, ActiveRecord, Django ORM — ilişkiyi tembel yükleyen her ORM’de aynı tuzak var. Sadece adları ve çözümlerin sözdizimi değişiyor.
Problemin şekli
100 yazarımız ve her yazarın kitapları olduğunu düşünelim:
List<Author> authors = authorRepository.findAll(); // 1 sorgu
for (Author a : authors) {
a.getBooks().size(); // her yazar için 1 sorgu daha → 100 sorgu
}
// Toplam: 101 sorgu
Kodun kendisine baktığınızda çok masum görünüyor. findAll() bir kez çağrılıyor, sonra da yazarların kitaplarına erişiliyor.
Ama Hibernate’in yaptığı şey kabaca şu:
graph TD
A["authorRepository.findAll()"] --> B["SELECT * FROM author<br/>— 1 sorgu"]
B --> C{"döngüde her yazar için<br/>getBooks()"}
C --> D["SELECT * FROM book WHERE author_id = 1"]
C --> E["SELECT * FROM book WHERE author_id = 2"]
C --> F["…"]
C --> G["SELECT * FROM book WHERE author_id = 100"]Önce ana sorgu:
SELECT * FROM author;
Ardından her yazar için ayrı ayrı:
SELECT * FROM book WHERE author_id = 1;
SELECT * FROM book WHERE author_id = 2;
SELECT * FROM book WHERE author_id = 3;
...
SELECT * FROM book WHERE author_id = 100;
Yani toplamda:
1 + 100 = 101 queries
İşte N+1 problemi tam olarak bu.
Neden oluyor?
JPA/Hibernate’te bazı ilişkiler lazy olarak yüklenir.
Özellikle @OneToMany ve @ManyToMany ilişkileri varsayılan olarak LAZY‘dir. Entity’yi çektiğinizde ilişkinin tamamı hemen yüklenmez. Hibernate bunun yerine bir proxy/collection wrapper koyar.
İlişkiye ilk kez dokunduğunuz anda Hibernate gerçek veriyi almak için bir SELECT çalıştırır.
Örneğin:
List<Author> authors = authorRepository.findAll();
Bu noktada kitaplar henüz çekilmemiştir. Ama şunu yaptığınızda:
author.getBooks().size();
Hibernate artık kitaplara gerçekten ihtiyaç olduğunu görür ve yeni bir sorgu gönderir.
Sorun, bunu bir loop içinde yaptığınızda ortaya çıkar:
for (Author author : authors) {
author.getBooks().size();
}
100 yazar varsa 100 ayrı sorgu daha çalışabilir.
Ters yönde de olur
@ManyToOne ve @OneToOne ilişkilerin varsayılanı EAGER‘dır. Yani bookRepository.findAll() çağırdığınızda, hiçbir şey yapmasanız bile Hibernate her kitabın yazarını çekmek için ayrı sorgular gönderebilir. N+1’in bu hâli daha da sinsidir, çünkü kodunuzda ilişkiye dokunan tek bir satır bile yoktur.
Kritik nokta şu: veri az olduğunda bu problemi fark etmeyebilirsiniz. Production’da veri büyüdüğünde ise aynı endpoint bir anda yavaşlamaya başlar.
Spring Boot / JPA tarafında çözümler
N+1 problemini çözmek için tek bir yöntem yok. Hangi çözümün daha doğru olduğu sorgunun yapısına, pagination kullanıp kullanmadığınıza ve gerçekten hangi veriye ihtiyacınız olduğuna bağlı.
En sık kullanılan çözümler şunlar:
1. JOIN FETCH — en yaygın çözüm
Eğer ilişkili veriye ihtiyacınız olduğunu biliyorsanız, onu aynı sorguda getirebilirsiniz:
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks(); // tek sorgu
Hibernate artık önce author’ları çekip sonra her author için ayrı bir sorgu çalıştırmak yerine ilişkiyi aynı sorguda getirir. Kabaca oluşan SQL şu fikirdedir:
SELECT *
FROM author a
JOIN book b ON b.author_id = a.id;
Böylece N+1 yerine tek sorguyla işi bitirebilirsiniz.
Ama burada önemli bir detay var. İki farklı collection’ı aynı sorguda fetch join ederseniz sonuç seti büyüyebilir. Örneğin:
Author
├── Books
└── Awards
Hem books hem awards collection’ını aynı sorguda fetch etmek kartezyen çarpıma ve çok büyük sonuç setlerine yol açabilir.
MultipleBagFetchException
Aynı sorguda birden fazla List tipinde collection fetch etmeye çalıştığınızda Hibernate MultipleBagFetchException fırlatır. 10 kitabı ve 5 ödülü olan bir yazar için sonuç seti 50 satıra çıkacağı ve Hibernate bunu ayrıştıramayacağı için baştan reddeder.
Birden fazla collection gerekiyorsa ayrı sorgular ya da uygun collection tipleri gibi alternatifleri değerlendirmek gerekir.
List yerine Set kullanmak neden işe yarıyor?
Hibernate terminolojisinde sırasız ve tekrar barındırabilen List‘e bag denir. Birden fazla bag’i aynı anda fetch etmek satırları hangi collection’a ait olduğuna göre ayrıştırmayı imkânsız kılar; Hibernate de bu yüzden MultipleBagFetchException ile durur.
Set kullandığınızda aynı kısıt yoktur:
@OneToMany(mappedBy = "author")
private Set<Book> books = new HashSet<>();
@OneToMany(mappedBy = "author")
private Set<Award> awards = new HashSet<>();
Fakat bu, kartezyen çarpımı ortadan kaldırmaz — sadece Hibernate’i itiraz etmemeye ikna eder. Veritabanı yine 10 × 5 = 50 satır döner, Hibernate tekrarları bellekte eler. İki collection da büyükse asıl çözüm ayrı sorgular veya batch fetching olur.
2. @EntityGraph — deklaratif fetch
Repository metodunda hangi ilişkinin fetch edilmesini istediğinizi @EntityGraph ile belirtebilirsiniz. Aynı işi iki farklı şekilde yazabilirsiniz:
@Query("SELECT a FROM Author a JOIN FETCH a.books WHERE a.name LIKE %:name%")
List<Author> findByNameContaining(@Param("name") String name);
@EntityGraph(attributePaths = {"books"})
List<Author> findByNameContaining(String name);
İkisi de benzer bir sonuç verir. @EntityGraph özellikle derived query kullanıyorsanız oldukça pratiktir: ayrı bir JPQL sorgusu yazmadan repository metoduna hangi ilişkinin yüklenmesi gerektiğini söyleyebilirsiniz.
Yani şunun yerine:
List<Author> findByNameContaining(String name);
şunu diyebilirsiniz:
@EntityGraph(attributePaths = {"books"})
List<Author> findByNameContaining(String name);
3. Batch fetching — N sorguyu N/batch sorguya indirmek
Her ilişki için ayrı sorgu göndermek yerine Hibernate’in ilişkileri gruplar halinde getirmesini sağlayabilirsiniz:
spring.jpa.properties.hibernate.default_batch_fetch_size=50
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 50
Bu durumda Hibernate ilişkileri tek tek:
SELECT * FROM book WHERE author_id = 1;
SELECT * FROM book WHERE author_id = 2;
SELECT * FROM book WHERE author_id = 3;
çekmek yerine kabaca şöyle toplu sorgular çalıştırabilir:
SELECT *
FROM book
WHERE author_id IN (?, ?, ?, ...);
100 yazar için örneğin 100 ayrı sorgu yerine yaklaşık 2 batch sorgusu çalışabilir.
Bu yaklaşım özellikle pagination ile fetch join’in birlikte kullanıldığı durumlarda önemlidir. Örneğin:
Page<Author> findAll(Pageable pageable);
gibi sayfalı bir sorguda collection’ı fetch join etmek problem çıkarabilir.
Pagination + collection fetch join
Bir collection’ı fetch join’lediğinizde her yazar sonuç setinde kitap sayısı kadar satıra dönüşür. LIMIT 10 artık “10 yazar” değil “10 satır” anlamına geldiği için veritabanı seviyesinde doğru sayfalama yapılamaz.
Hibernate bu durumda tüm sonuç setini çekip sayfalamayı bellekte yapmak zorunda kalır ve firstResult/maxResults specified with collection fetch; applying in memory uyarısını verir. Sürümünüze ve hibernate.query.fail_on_pagination_over_collection_fetch ayarına göre uyarı yerine doğrudan hata da alabilirsiniz — hangisi olursa olsun, istediğiniz davranış değil.
Bu durumda ana sorguyu pagination ile çalıştırıp ilişkileri batch fetching ile getirmek daha temiz bir çözüm olabilir.
4. DTO Projection — entity’ye hiç ihtiyacınız yoksa entity çekmeyin
Bazı ekranlarda aslında entity’nin tamamına ihtiyacınız yoktur. Örneğin bir liste ekranında sadece:
- yazar adı
- kitap adı
gösterecekseniz Author entity’sini ve ilişkilerini komple yüklemek gereksiz olabilir. Bunun yerine doğrudan ihtiyacınız olan alanları DTO’ya çekebilirsiniz:
@Query("SELECT new com.app.dto.AuthorDto(a.name, b.title) FROM Author a JOIN a.books b")
List<AuthorDto> findAuthorSummaries();
Bu yaklaşım özellikle salt okunur listeleme ekranlarında oldukça etkilidir. Çünkü database’den sadece ihtiyacınız olan alanları alırsınız ve Hibernate’in entity graph’ını yönetmek zorunda kalmazsınız.
DTO’nun constructor’ını JPQL içinde tam paket adıyla yazmak zorunda kalmak istemiyorsanız, Spring Data’nın interface projection’ı da aynı işi görür:
public interface AuthorSummary {
String getName();
String getTitle();
}
Metot imzasında dönüş tipi olarak bunu verdiğinizde Spring gerekli alanları kendisi seçer.
Peki hangisini ne zaman kullanmalı?
Kabaca şöyle düşünebilirsiniz:
JOIN FETCH
İlişkiye ihtiyacınız var ve tek sorguda getirmek istiyorsanız.
Özellikle belirli bir use case için çok net bir fetch ihtiyacınız varsa iyi bir seçimdir.
EntityGraph
Derived repository metodlarını kullanıyorsanız ve fetch planını deklaratif şekilde tanımlamak istiyorsanız oldukça pratiktir.
Batch Fetching
Pagination kullanıyorsanız veya ilişkileri ayrı sorgularla ama toplu şekilde getirmek istiyorsanız iyi bir seçenektir.
DTO Projection
Entity’ye ihtiyacınız yoksa ve sadece belirli alanları okuyorsanız en temiz ve performanslı yaklaşımlardan biridir.
Aynı şeyin tablo hâli:
| Durum | Çözüm | Sorgu sayısı |
|---|---|---|
| Tek bir collection’a ihtiyaç var, sayfalama yok | JOIN FETCH | 1 |
| Derived query kullanılıyor, fetch planı deklaratif olsun | @EntityGraph | 1 |
| Sayfalama var ya da birden fazla collection gerekiyor | Batch fetching | 1 + N/batch |
| Ekran salt okunur, entity’ye ihtiyaç yok | DTO projection | 1 |
N+1 problemini nasıl fark edersin?
İşin zor kısmı bazen problemi çözmek değil, önce fark etmektir.
Development ortamında SQL loglarını açabilirsiniz:
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=DEBUG
Sonra endpoint’i çalıştırıp kaç tane SQL sorgusu üretildiğine bakın. Örneğin loglarda şunu görüyorsanız:
select ... from author
select ... from book where author_id=?
select ... from book where author_id=?
select ... from book where author_id=?
select ... from book where author_id=?
...
burada bir N+1 problemi olma ihtimali oldukça yüksek.
Asıl önemli olan tek tek sorguların hızlı olması değil, bir request’in toplam kaç sorgu ürettiğidir.
100 tane sorgunun her biri 2 ms sürüyor diye problem yokmuş gibi düşünmeyin. Database round-trip’leri, connection pool kullanımı, network latency ve artan veri miktarı birlikte ciddi bir maliyet oluşturabilir.
Testlerde sorgu sayısını doğrudan assert etmek mümkün. Hypersistence Utils gibi araçlarla “bu endpoint en fazla 2 sorgu atsın” diye bir test yazarsanız, N+1 bir daha sessizce geri gelemez — birisi ilişkiye dokunan bir satır eklediğinde test kırılır.
Eager yapmak çözüm mü?
Genelde hayır. İlk akla gelen çözüm şu olabilir:
@OneToMany(fetch = FetchType.EAGER)
private List<Book> books;
“Lazy olmasın, her şeyi baştan getirsin” diye düşünebilirsiniz. Ama bu N+1 problemini çözmenin iyi bir yolu değildir.
EAGER, her use case’in her zaman ilişkiye ihtiyacı olduğu anlamına gelmez. Bir endpoint sadece author bilgisine ihtiyaç duyarken kitapların da yüklenmesi gereksiz olabilir. Daha kötüsü, eager loading başka yerlerde gereksiz join’lere veya ek sorgulara yol açabilir.
EAGER bir anotasyon, sorgu değil. Entity’nin üzerine yazdığınız anda o entity’ye dokunan her sorguyu etkiler — sizin haberiniz olmayanlar dahil. Fetch stratejisi entity’nin değil, use case’in kararıdır; o yüzden yeri sorgunun içidir.
Bu yüzden ilişkileri genel olarak LAZY bırakmak ve hangi use case’in hangi ilişkiye ihtiyacı olduğunu sorgu seviyesinde açıkça belirtmek daha sağlıklı bir yaklaşımdır.
Kural
N+1 için akılda tutulması gereken birkaç basit kural var:
İlişkileri genel olarak lazy bırak
Eager yapmak N+1’in gerçek çözümü değildir; sadece problemi başka bir yere taşıyabilir.
Hangi ekran hangi ilişkiye ihtiyaç duyuyorsa, bunu sorguda açıkça belirt
Gerekiyorsa
JOIN FETCHveya@EntityGraphkullan.Pagination ile fetch join’i dikkatli kullan
Özellikle collection fetch join’lerinde sonuç setinin büyümesine ve Hibernate’in in-memory pagination yapmasına dikkat et.
Batch fetching’i özellikle sayfalı listelerde düşün
Ana kayıtları pagination ile getirip ilişkileri batch halinde yüklemek çoğu durumda daha sağlıklı olabilir.
Salt okunur ekranlarda DTO projection kullan
Entity’ye ihtiyacınız yoksa entity çekmeyin.
Sonuçta amaç “tek sorgu kullanmak” değil. Amaç, database’e kaç sorgu attığınızı bilinçli olarak kontrol etmek.
ORM’nin en tehlikeli taraflarından biri de zaten bu: kodunuz temiz ve kısa görünürken arka planda yüzlerce SQL sorgusu çalışabilir.
N+1 problemi de tam olarak bu yüzden tehlikelidir — kodda bir satır, database’de yüzlerce sorgu olabilir.
