Lazy vs Eager Loading & N+1 Problemi
Giriş — Neden Bu Konu Önemli?
JPA'daki en kritik performans konularından biri fetching stratejisidir. Bir entity yüklendiğinde, ilişkili entity'ler de hemen mi yoksa gerektiğinde mi yüklenmeli? Bu karar, uygulamanızın performansını dramatik şekilde etkiler.
Yanlış fetching stratejisi seçimi, bir e-ticaret sitesinde sayfa yükleme süresini 200ms'den 5 saniyeye çıkarabilir. Veritabanına gereksiz yüzlerce sorgu atılır, bellek şişer, connection pool tükenir. Bu derste lazy/eager loading stratejilerini, N+1 problemini ve çözüm yollarını derinlemesine inceleyeceğiz.
Gerçek Hayat Analojisi: Bir kütüphaneye gittiğinizi düşünün. "Eager loading", bir yazarın bilgisini istediğinizde yazarın tüm kitaplarını, kitapların tüm yorumlarını, yorumcuların profillerini de getirmektir — çoğu bilgi gereksizdir. "Lazy loading" ise sadece istediğiniz yazarı getirmek, kitapları ancak istediğinizde raftan almaktır. Doğru strateji, ne kadar veriye ihtiyacınız olduğuna bağlıdır.
FetchType.LAZY vs FetchType.EAGER
JPA iki fetching stratejisi sunar:
FetchType.LAZY: İlişkili entity, erişildiğinde yüklenir (tembel yükleme)
FetchType.EAGER: İlişkili entity, parent ile birlikte hemen yüklenir (hevesli yükleme)
@Entity
public class Author {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books; // Kitaplara erişilene kadar yüklenmez
}
@Entity
public class Book {
@Id
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
private Author author; // Author'a erişilene kadar yüklenmez
}JPA Default Fetch Tipleri
JPA spesifikasyonu şu default'ları belirler:
| İlişki Türü | Default FetchType | Açıklama |
|---|---|---|
@OneToOne | EAGER | Tek nesne — "küçük maliyet" varsayımı |
@ManyToOne | EAGER | Tek nesne — "küçük maliyet" varsayımı |
@OneToMany | LAZY | Koleksiyon — potansiyel olarak büyük |
@ManyToMany | LAZY | Koleksiyon — potansiyel olarak büyük |
Genel kural: "Bir" tarafındaki ilişkiler (ToOne) default olarak EAGER, "çok" tarafındaki ilişkiler (ToMany) default olarak LAZY'dir.
⚠️ Bu default'lar genellikle uygun değildir!
@ManyToOneve@OneToOneiçin deFetchType.LAZYkullanmanız önerilir. EAGER default'u JPA spesifikasyonunun tartışmalı tasarım kararlarından biridir.
Hibernate Proxy Mekanizması
Lazy loading, Hibernate'in proxy mekanizması sayesinde çalışır. Hibernate, lazy ilişki için gerçek entity yerine bir proxy (vekil) nesnesi oluşturur:
Author author = entityManager.find(Author.class, 1L);
// → SELECT * FROM author WHERE id = 1
// author.books → HibernateProxy (PersistentBag)
List<Book> books = author.getBooks();
// → Bu noktada books bir Hibernate proxy'sidir
// Henüz veritabanına sorgu atılmadı!
int count = books.size(); // ← İLK ERİŞİMDE sorgu atılır
// → SELECT * FROM book WHERE author_id = 1Proxy, entity'nin alt sınıfıdır (subclass). İlk erişimde gerçek veriyi veritabanından yükler. Bu mekanizmanın çalışması için:
Entity sınıfları `final` olmamalıdır — Hibernate proxy (subclass) oluşturamaz
No-args constructor olmalıdır — Hibernate instance oluşturmak için gerekir
Field'lar doğrudan erişilmemeli, getter kullanılmalıdır — proxy getter'ı intercept eder
// ❌ Bu entity'den proxy oluşturulamaz
public final class Author { // final!
// ...
}
// ✅ Doğru
public class Author {
protected Author() {} // no-args constructor
// ...
}LazyInitializationException
Lazy loading'in en sık karşılaşılan hatası `LazyInitializationException`'dır. Bu hata, persistence context (Hibernate session) kapandıktan sonra lazy bir ilişkiye erişmeye çalıştığınızda oluşur:
Author author;
// Transaction 1: Entity yüklenir
entityManager.getTransaction().begin();
author = entityManager.find(Author.class, 1L);
entityManager.getTransaction().commit();
// Persistence context kapandı!
// Transaction dışı: LazyInitializationException!
author.getBooks().size();
// 💥 org.hibernate.LazyInitializationException:
// could not initialize proxy - no SessionNeden Oluşur?
Lazy proxy, veriyi yüklemek için aktif bir Hibernate Session'a ihtiyaç duyar. Session kapandıktan sonra proxy "yetim" kalır ve veri yükleyemez.
Spring Boot'ta Yaygın Senaryo
// ❌ Service'te transaction biter, Controller'da lazy erişim
@Service
public class AuthorService {
@Transactional(readOnly = true)
public Author findById(Long id) {
return authorRepository.findById(id).orElseThrow();
// Transaction burada biter
}
}
@RestController
public class AuthorController {
@GetMapping("/authors/{id}")
public AuthorDto getAuthor(@PathVariable Long id) {
Author author = authorService.findById(id);
// Transaction bitti, session kapandı
int bookCount = author.getBooks().size();
// 💥 LazyInitializationException!
return new AuthorDto(author.getName(), bookCount);
}
}Çözüm Yolları
İhtiyacınız olan verileri transaction içinde yükleyin
JOIN FETCH kullanarak ilişkili verileri tek sorguda çekin
@EntityGraph kullanarak dinamik fetch planı belirleyin
DTO projection kullanarak sadece gerekli alanları çekin
// ✅ Çözüm 1: Service'te verileri hazırlayın
@Transactional(readOnly = true)
public AuthorDto findById(Long id) {
Author author = authorRepository.findByIdWithBooks(id); // JOIN FETCH
return new AuthorDto(author.getName(), author.getBooks().size());
}
// ✅ Çözüm 2: DTO projection
@Query("SELECT new com.example.AuthorDto(a.name, SIZE(a.books)) FROM Author a WHERE a.id = :id")
Optional<AuthorDto> findAuthorDto(@Param("id") Long id);N+1 Problemi
N+1 problemi, JPA uygulamalarında en yaygın performans sorunudur. Adını, çalıştırılan sorgu sayısından alır: 1 ana sorgu + N ek sorgu.
Senaryo
10 author ve her birinin kitaplarını listelemek istiyorsunuz:
// 1 sorgu: Tüm author'ları getir
List<Author> authors = authorRepository.findAll();
// → SELECT * FROM author (1 sorgu)
// N sorgu: Her author için kitapları getir
for (Author author : authors) {
System.out.println(author.getName() + ": " + author.getBooks().size());
// → SELECT * FROM book WHERE author_id = 1 (2. sorgu)
// → SELECT * FROM book WHERE author_id = 2 (3. sorgu)
// → SELECT * FROM book WHERE author_id = 3 (4. sorgu)
// ... 10 author = 10 ek sorgu
}
// Toplam: 1 + 10 = 11 sorgu!10 author için 11 sorgu tolere edilebilir. Ama 1000 author için 1001 sorgu? Bu, veritabanını felç edebilir!
Analoji: Bir sınıfta 30 öğrenci var. Öğretmen her öğrencinin notunu öğrenmek için muhasebe odasına 30 kez gidiyor — her seferinde tek bir öğrencinin notunu soruyor. Oysa tüm notları tek seferde isteyebilirdi!
EAGER Fetching N+1'i Çözmez
EAGER fetching N+1 problemini çözmez — sadece sorguların zamanlamasını değiştirir:
@OneToMany(mappedBy = "author", fetch = FetchType.EAGER)
private List<Book> books;
List<Author> authors = authorRepository.findAll();
// Hibernate yine 1 + N sorgu atar:
// SELECT * FROM author (1 sorgu)
// SELECT * FROM book WHERE author_id = 1 (ek sorgu)
// SELECT * FROM book WHERE author_id = 2 (ek sorgu)
// ...EAGER fetch, sadece entityManager.find() (ID ile tek entity çekme) durumunda JOIN ile tek sorgu yapabilir. findAll() veya JPQL sorguları için N+1 oluşmaya devam eder.
// entityManager.find() — EAGER ile tek sorgu
Author author = entityManager.find(Author.class, 1L);
// → SELECT a.*, b.* FROM author a LEFT JOIN book b ON a.id = b.author_id WHERE a.id = 1
// JPQL — EAGER olsa bile N+1!
@Query("SELECT a FROM Author a")
List<Author> findAll();
// → SELECT * FROM author
// → SELECT * FROM book WHERE author_id = 1 (N ek sorgu)N+1 Çözümleri
1. JOIN FETCH (En Yaygın)
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
// → SELECT a.*, b.* FROM author a
// INNER JOIN book b ON a.id = b.author_id
// Tek sorgu! Ama kitabı olmayan author'lar gelmez (INNER JOIN)
// LEFT JOIN FETCH — kitabı olmayan author'lar da gelir
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
List<Author> findAllWithBooksIncludingEmpty();
// → SELECT DISTINCT a.*, b.* FROM author a
// LEFT JOIN book b ON a.id = b.author_id⚠️ DISTINCT kullanımı: JOIN FETCH ile Kartezyen çarpım oluşabilir. Bir author'ın 3 kitabı varsa, o author sonuç kümesinde 3 kez görünür.
DISTINCTbunu önler. Hibernate 6+ ile DISTINCT SQL'e yansımaz — Hibernate bunu in-memory yapar.
2. @EntityGraph
@EntityGraph(attributePaths = {"books"})
List<Author> findAll();
// → SELECT a.*, b.* FROM author a LEFT JOIN book b ON a.id = b.author_id3. Hibernate Batch Fetching
@OneToMany(mappedBy = "author")
@BatchSize(size = 20)
private List<Book> books;
// → Author'lar yüklendiğinde, kitaplar 20'li gruplar halinde yüklenir
// 100 author için: 1 + 5 = 6 sorgu (1 + ceil(100/20))
// Üretilen SQL:
// SELECT * FROM book WHERE author_id IN (?, ?, ?, ..., ?)
// 20 parametre ile tek sorguGlobal olarak da ayarlanabilir:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 254. Subselect Fetching
@OneToMany(mappedBy = "author")
@Fetch(FetchMode.SUBSELECT)
private List<Book> books;
// Üretilen SQL:
// 1. sorgu: SELECT * FROM author
// 2. sorgu: SELECT * FROM book WHERE author_id IN (SELECT id FROM author)
// Toplam 2 sorgu!Strateji Karşılaştırması
| Strateji | Sorgu Sayısı | Avantaj | Dezavantaj |
|---|---|---|---|
| JOIN FETCH | 1 | En az sorgu | Kartezyen çarpım, çoklu koleksiyon sorunu |
| @EntityGraph | 1 | JPQL yazmadan çalışır | Karmaşık subgraph'larda okunabilirlik düşer |
| @BatchSize | 1 + ceil(N/size) | Basit konfigürasyon | Tam olarak 1 sorgu değil |
| SUBSELECT | 2 | Tüm veriyi 2 sorguda çeker | Büyük veri setlerinde bellek tüketimi |
| DTO Projection | 1 | En performanslı | Entity yerine DTO döner |
N+1 Tespit Etme
SQL Log'ları ile Tespit
# application.yml
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.stat: DEBUG
spring:
jpa:
properties:
hibernate:
generate_statistics: true// Her request sonrası Hibernate istatistikleri loglanır:
// Session Metrics {
// 23 nanoseconds spent acquiring 1 JDBC connections;
// 25432 nanoseconds spent executing 11 JDBC statements; ← 11 sorgu!
// }datasource-proxy ile Otomatik Tespit
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>1.0.7</version>
</dependency>// Test'te sorgu sayısını kontrol etme
@Test
void shouldNotHaveNPlus1() {
SQLStatementCountValidator.reset();
List<Author> authors = authorRepository.findAllWithBooks();
for (Author a : authors) {
a.getBooks().size(); // Bu ek sorgu tetiklememeli!
}
SQLStatementCountValidator.assertSelectCount(1); // Sadece 1 SELECT olmalı!
}Open-in-View Anti-Pattern
Spring Boot'ta spring.jpa.open-in-view property'si default olarak true'dur. Bu, Hibernate session'ının HTTP request boyunca açık kalmasını sağlar — böylece controller ve view katmanlarında lazy loading çalışır.
Neden Anti-Pattern?
Veritabanı bağlantısı gereksiz yere uzun süre açık kalır: Request boyunca bağlantı havuzundan bir bağlantı tutulur. Yavaş bir API yanıtı (örneğin harici servis çağrısı) bağlantıyı bloklar.
N+1 problemleri gizlenir: Lazy loading her yerde çalıştığı için geliştirici sorgu sayısını fark etmez. "Çalışıyor" diye geçer, production'da performans çöker.
Katman ihlali: View/Controller katmanı veritabanı sorgusu tetikler — bu separation of concerns prensibine aykırıdır.
# application.yml — kapatın!
spring:
jpa:
open-in-view: falseOpen-in-view kapatıldığında, tüm veritabanı işlemleri @Transactional sınırları içinde yapılmak zorunda kalır. Bu, geliştiriciyi doğru tasarıma zorlar ve N+1 sorunlarını erken tespit etmenizi sağlar.
// open-in-view: false ile bu kod LazyInitializationException fırlatır
@GetMapping("/authors/{id}")
public AuthorDto getAuthor(@PathVariable Long id) {
Author author = authorService.findById(id);
author.getBooks().size(); // 💥 Session kapandı!
}
// ✅ Service'te her şeyi hazırlayın
@Service
public class AuthorService {
@Transactional(readOnly = true)
public AuthorDto findById(Long id) {
Author author = authorRepo.findByIdWithBooks(id);
return AuthorDto.from(author); // Transaction içinde DTO'ya dönüştür
}
}MultipleBagFetchException
Birden fazla koleksiyonu aynı anda JOIN FETCH etmek MultipleBagFetchException hatasına neden olur:
// ❌ İki List koleksiyonu aynı anda fetch edilemez
@Query("SELECT a FROM Author a JOIN FETCH a.books JOIN FETCH a.articles")
List<Author> findAllWithBooksAndArticles();
// → MultipleBagFetchException!Neden? İki koleksiyonun kartezyen çarpımı oluşur ve Hibernate hangi satırın hangi koleksiyona ait olduğunu ayırt edemez.
Çözüm 1: Koleksiyonlardan birini Set yapın
@OneToMany(mappedBy = "author")
private Set<Book> books; // Set olarak tanımla
@OneToMany(mappedBy = "author")
private List<Article> articles; // List kalabilir
// Artık ikisi birden fetch edilebilir
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books LEFT JOIN FETCH a.articles")
List<Author> findAllWithBooksAndArticles();Çözüm 2: İki ayrı sorgu yapın
// İlk sorgu: author + books
List<Author> authors = authorRepo.findAllWithBooks();
// İkinci sorgu: aynı author'ların articles'ını yükle
// Hibernate first-level cache sayesinde merge eder
authorRepo.findAllWithArticles();
// Artık authors listesindeki her author'ın
// hem books hem articles koleksiyonu dolu!Gerçek Dünya Örneği: E-ticaret Ürün Listeleme
Bir e-ticaret sitesinde ürün listeleme sayfası — N+1 probleminin en sık yaşandığı yerlerden biri:
// ❌ N+1 problemi — her ürün için kategori, marka ve resimler ayrı sorgu
@GetMapping("/products")
public Page<ProductDto> listProducts(Pageable pageable) {
Page<Product> products = productRepo.findAll(pageable);
// SELECT * FROM product LIMIT 20 OFFSET 0 (1 sorgu)
// Her product için:
// SELECT * FROM category WHERE id = ? (N sorgu)
// SELECT * FROM brand WHERE id = ? (N sorgu)
// SELECT * FROM product_image WHERE product_id = ? (N sorgu)
// Toplam: 1 + 20*3 = 61 sorgu!
return products.map(ProductDto::from);
}
// ✅ Optimize edilmiş — tek sorgu
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("""
SELECT DISTINCT p FROM Product p
LEFT JOIN FETCH p.category
LEFT JOIN FETCH p.brand
LEFT JOIN FETCH p.images
WHERE p.active = true
""")
List<Product> findActiveWithDetails();
// Sayfalama ile (iki adımlı yaklaşım)
@Query("SELECT p.id FROM Product p WHERE p.active = true")
Page<Long> findActiveProductIds(Pageable pageable);
@Query("""
SELECT DISTINCT p FROM Product p
LEFT JOIN FETCH p.category
LEFT JOIN FETCH p.brand
LEFT JOIN FETCH p.images
WHERE p.id IN :ids
""")
List<Product> findWithDetailsByIds(@Param("ids") List<Long> ids);
}
@Service
@Transactional(readOnly = true)
public class ProductService {
public Page<ProductDto> listProducts(Pageable pageable) {
// 1. Adım: Sayfalama ile sadece ID'leri çek
Page<Long> idPage = productRepo.findActiveProductIds(pageable);
// 2. Adım: ID'lere göre detaylı veriyi çek (JOIN FETCH)
List<Product> products = productRepo
.findWithDetailsByIds(idPage.getContent());
// Sıralamayı koru
Map<Long, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getId, p -> p));
List<ProductDto> dtos = idPage.getContent().stream()
.map(id -> ProductDto.from(productMap.get(id)))
.toList();
return new PageImpl<>(dtos, pageable, idPage.getTotalElements());
// Toplam: 2 sorgu! (1 count + 1 data)
}
}Gerçek Dünya Performans Karşılaştırması
1000 author, her birinde ortalama 5 kitap olan bir senaryo:
| Strateji | Sorgu Sayısı | Süre (yaklaşık) | Bellek |
|---|---|---|---|
| LAZY (N+1) | 1001 | ~2000ms | Düşük |
| EAGER (N+1 gizli) | 1001 | ~2000ms | Yüksek |
| JOIN FETCH | 1 | ~50ms | Orta |
| @BatchSize(25) | 1 + 40 = 41 | ~200ms | Düşük |
| SUBSELECT | 2 | ~60ms | Orta |
| DTO Projection | 1 | ~30ms | En düşük |
Özet
FetchType.LAZY her zaman tercih edilmelidir —
@ManyToOneve@OneToOnedefault'u EAGER'dır, değiştirinN+1 problemi en yaygın JPA performans sorunudur — 1 ana sorgu + N ek sorgu
EAGER fetching N+1'i çözmez — sadece zamanlamasını değiştirir, JPQL ile hâlâ N+1 oluşur
JOIN FETCH en etkili çözümdür — tek sorgu ile ilişkili veriler yüklenir
@BatchSize basit konfigürasyon ile sorgu sayısını dramatik azaltır — global default olarak aktif edin
open-in-view: false yapın — N+1 problemlerini erken tespit eder, doğru tasarıma zorlar
Hibernate SQL loglarını açın — sorgu sayısını izleyin, N+1'i tespit edin
AI Asistan
Sorularını yanıtlamaya hazır