← Kursa Dön
📄 Text · 20 min

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 FetchTypeAçıklama
@OneToOneEAGERTek nesne — "küçük maliyet" varsayımı
@ManyToOneEAGERTek nesne — "küçük maliyet" varsayımı
@OneToManyLAZYKoleksiyon — potansiyel olarak büyük
@ManyToManyLAZYKoleksiyon — 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! @ManyToOne ve @OneToOne için de FetchType.LAZY kullanmanı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 = 1

Proxy, 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 Session

Neden 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ı

  1. İhtiyacınız olan verileri transaction içinde yükleyin

  2. JOIN FETCH kullanarak ilişkili verileri tek sorguda çekin

  3. @EntityGraph kullanarak dinamik fetch planı belirleyin

  4. 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. DISTINCT bunu ö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_id

3. 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 sorgu

Global olarak da ayarlanabilir:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25

4. 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ı

StratejiSorgu SayısıAvantajDezavantaj
JOIN FETCH1En az sorguKartezyen çarpım, çoklu koleksiyon sorunu
@EntityGraph1JPQL yazmadan çalışırKarmaşık subgraph'larda okunabilirlik düşer
@BatchSize1 + ceil(N/size)Basit konfigürasyonTam olarak 1 sorgu değil
SUBSELECT2Tüm veriyi 2 sorguda çekerBüyük veri setlerinde bellek tüketimi
DTO Projection1En 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?

  1. 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.

  2. 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.

  3. 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: false

Open-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:

StratejiSorgu SayısıSüre (yaklaşık)Bellek
LAZY (N+1)1001~2000msDüşük
EAGER (N+1 gizli)1001~2000msYüksek
JOIN FETCH1~50msOrta
@BatchSize(25)1 + 40 = 41~200msDüşük
SUBSELECT2~60msOrta
DTO Projection1~30msEn düşük

Özet

  • FetchType.LAZY her zaman tercih edilmelidir — @ManyToOne ve @OneToOne default'u EAGER'dır, değiştirin

  • N+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