İçeriğe geç

JPA N+1 Problemi

Orta 10 dk Çok sık karşılaşılır

Önce şunu oku: IoC ve Dependency Injection

30 saniyede özet

Bir liste çektin, sonra her elemanın detayına baktın: 1 sorgu yerine 1+N sorgu çalıştı. Sorun sorguların yavaşlığı değil, sayısı. EAGER bunu çözmez; fetch join, EntityGraph ya da BatchSize çözer.

Markete bir kere gidip her şeyi almak yerine her ürün için ayrı gittiğini düşün. Yol kısa olsa bile akşam olur.

Aynı alışveriş: altı tur ya da tek tur.
Adım adım oku
  1. Önce siparişlerin listesi çekilir: bir sorgu.
  2. Sonra her siparişin kalemlerine bakılırken, her biri için ayrı bir sorgu daha gider. Beş sipariş için toplam altı tur.
  3. JOIN FETCH ile siparişler ve kalemleri tek sorguda, tek turda getirilir.
  4. Sorun tek bir sorgunun yavaşlığı değil, tur sayısıdır. Sipariş sayısı arttıkça tur sayısı da artar.
  1. Bayt: Siparişleri listeleyip her birinin kalemlerini saydım. Tek satır kod!

  2. Sen: Log'larda kaç sorgu görünüyor peki?

  3. Bayt: Yüzlerce! Her sipariş için bir tane daha.

  4. Bayt: Markete her ürün için ayrı gitmek gibi. Sorun yol değil, gidiş sayısı.

Bu kod bir sorgu gibi görünür ama altı sorgu çalıştırır:

OrderService.java
List<Order> orders = orderRepository.findAll(); // 1 sorgu
for (Order order : orders) {
total += order.getLineItems().size(); // her sipariş için 1 sorgu daha
}

Beş sipariş varsa 6 sorgu. Beş bin sipariş varsa 5001. Buna N+1Bir sorgu listeyi getirir, sonra her eleman için bir sorgu daha atılır. Her biri hızlı olduğu için yavaş sorgu logunda görünmez; yavaşlayan şey toplam gidiş-dönüş sayısıdır.Sözlükte gör → problemi denir.

Sorun hız değil, sayı

Her sorgu tek başına milisaniyeler sürer ve veritabanı logunda hiçbiri yavaş görünmez. Maliyet sayıdadır: her sorgu bir ağ gidiş dönüşü, bir bağlantı kullanımı ve bir tur parse/plan işi demektir.

Bu yüzden N+1 üretimde geç fark edilir — profiler “yavaş sorgu” göstermez, sadece uygulama yavaştır.

Kafam karıştı, daha basit anlat

Adı buradan geliyor: 1 sorgu siparişleri getirir, sonra N sipariş için N ayrı sorgu daha gider. Markete her ürün için ayrı ayrı gitmek gibi: her yol kısa, ama toplamı uzun.

Hızlı kontrolBaşlangıç

N+1 sorgu problemi tam olarak nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kendin gör

N+1 sorgu problemi ve dört çözüm denemesi

Tohum 3
Sorgu sayısı
0/6
Çekilen satır
0
Naif yaklaşım
6

Siparişler · her birinde 3 kalem

#1#2#3#4#5

Çalışan SQL

henüz sorgu yok

Hız
Adım 0

Şu an ne oldu?

Kök sorgu çalışacak

Önce siparişler yüklenir. Koleksiyonların ne zaman ve kaç sorguyla geleceğini seçtiğin strateji belirler.

Aklında kalsın: Strateji entity üzerinde değil, sorgu başına seçilmelidir. Aynı entity bir ekranda koleksiyonsuz, başka bir ekranda koleksiyonla gerekebilir.

Görevler0/3

  • En az 10 siparişte N+1’i gör: 1 + N sorguaçık

    İpucu

    LAZY ile sipariş sayısını 10’un üstüne çıkar ve sonuna kadar oynat. Sorgu listesini say.

  • Aynı işi, koleksiyona dokunarak ve uyarısız, tek sorguyla yapaçık

    İpucu

    JOIN FETCH ya da @EntityGraph dene. Koleksiyona dokunmayı kapatmak hile sayılmaz.

  • JOIN FETCH ile sayfalamanın tuzağını tetikleaçık

    İpucu

    JOIN FETCH seçiliyken sayfalamayı aç. Tek sorgu çıkıyor ama LIMIT nerede uygulanıyor?

Olay günlüğü (0)

Henüz olay yok. Oynat veya adımla.

Sırayla dene:

  • LAZY (varsayılan) — kök sorgu + sipariş başına bir sorgu. Sorgu sayacına bak.
  • Kod order.getLineItems() çağırıyor kutusunu kapat. Tek sorgu. LAZY’nin kendisi sorun değil; sorun ona döngü içinde erişmek.
  • EAGER yap ve kutuyu tekrar kapat. Sorgu sayısı yine 1 + N. Koleksiyona hiç dokunmasan bile. Bu, dersin en önemli gözlemi.
  • JOIN FETCH — tek sorgu, ama Çekilen satır sayacı zıplıyor: kartezyen çarpım.
  • @BatchSize — sorgu sayısı 1 + tavan(N/batch). Kartezyen çarpım yok.
  • JOIN FETCH + Sayfalama birlikte — kırmızı HHH000104 uyarısı.

Simülatörün altındaki Görevler listesi bu gözlemleri üç hedefe topluyor. İkinci görevde koleksiyona dokunmayı kapatmak kabul edilmiyor, çünkü gerçek kod o veriye ihtiyaç duyuyor.

Kafam karıştı, daha basit anlat

Simülatörde sorgu sayısına bak, sürelere değil. Tek tek hızlı görünen sorgular, sayıları artınca uygulamayı yavaşlatır.

Hızlı kontrolOrta

Bu kod kaç SQL sorgusu üretir? (10 sipariş var, her birinin bir müşterisi)

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.
NPlusBir.java
1@Transactional(readOnly = true)
2public void rapor() {
3 List<Order> orders = orderRepository.findAll();
4
5 for (Order o : orders) {
6 System.out.println(o.getCustomer().getName());
7 }
8}
Java 21UTF-8LF

EAGER neden çözmez

100 sipariş listelenirken her siparişin kalemleri ayrı bir sorguyla geliyor: 101 sorgu. lineItems ilişkisini EAGER yaptın. Sorgu sayısı kaça iner? Cevabı göster

İnmez. Koleksiyonlar için Hibernate yine sipariş başına bir SELECT çalıştırır; yalnızca bunu sen istemeden ve her seferinde yapar.

En yaygın yanlış refleks fetch = FetchType.EAGER yazmaktır. Koleksiyonlar için Hibernate yine parent başına ayrı bir SELECT çalıştırır; sadece bunu sen istemeden yapar.

Sorgu sayısını değiştirmeyen değişiklik
@OneToMany(mappedBy = "order", fetch = FetchType.EAGER) // hâlâ 1 + N
private List<LineItem> lineItems;

Üstelik durumu kötüleştirir: artık koleksiyona ihtiyaç duymayan ekranlarda bile yükleniyor ve bundan kaçınmanın bir yolu yok. EAGER bir optimizasyon değil, bir seçeneği elinden almaktır.

Aşağıdaki örnek bir bankanın kredi kartı ekstre ekranından. N+1’in üç çözümü bir arada: fetch join, entity graph, sayfalama için iki adımlı sorgu ve sorgu sayısını ölçen bir test.

Derinleş · Kredi kartı ekstreleri: N+1'siz, sayfalı ve test edilmiş 5 dosya · ~109 satır · ilk okumada atlayabilirsin
Proje dosyaları

src/main/java/com/bank/card/ CardStatement.java İlişkiler LAZY kalır. @BatchSize, unutulan bir yükleme olursa bile N+1'i N/50'ye indirir.

src/main/java/com/bank/card/CardStatement.java
@Entity
@Table(name = "card_statements")
public class CardStatement {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private CardAccount account;
// Stays LAZY. Fetching is decided per query, not per mapping.
@OneToMany(mappedBy = "statement")
@BatchSize(size = 50)
private List<CardTransaction> transactions = new ArrayList<>();
private LocalDate cutOffDate; // "hesap kesim tarihi"
private LocalDate dueDate; // "son ödeme tarihi"
private BigDecimal totalDue;
private BigDecimal minimumPayment;
public List<CardTransaction> getTransactions() {
return Collections.unmodifiableList(transactions);
}
// other getters omitted
}

src/main/java/com/bank/card/ CardStatementRepository.java Üç sorgu biçimi: tek koleksiyon için JOIN FETCH, JPQL yazmadan @EntityGraph, sayfalama için önce id'ler.

src/main/java/com/bank/card/CardStatementRepository.java
interface CardStatementRepository extends JpaRepository<CardStatement, Long> {
// One collection, no paging: a fetch join loads statements and transactions in one query.
@Query("""
select distinct s from CardStatement s
join fetch s.transactions
where s.cutOffDate = :cutOff
""")
List<CardStatement> findWithTransactions(LocalDate cutOff);
// The customer's statement history screen, without writing JPQL.
@EntityGraph(attributePaths = {"account", "transactions"})
List<CardStatement> findByAccountIdOrderByCutOffDateDesc(long accountId);
// Paging step 1: only ids, so LIMIT/OFFSET is applied by the database.
@Query("select s.id from CardStatement s where s.cutOffDate = :cutOff order by s.id")
Page<Long> findIdsByCutOff(LocalDate cutOff, Pageable pageable);
// Paging step 2: the page's statements with their transactions.
@Query("select distinct s from CardStatement s join fetch s.transactions where s.id in :ids")
List<CardStatement> findWithTransactionsByIds(Collection<Long> ids);
}

src/main/java/com/bank/card/ StatementRunService.java Sayfalı ekstre listesi: sayfanın id'leri, sonra o ekstrelerin harcamaları. Bellekte sayfalama yok.

src/main/java/com/bank/card/StatementRunService.java
@Service
class StatementRunService {
private final CardStatementRepository statements;
StatementRunService(CardStatementRepository statements) {
this.statements = statements;
}
// The back-office screen lists every statement cut on one day: hundreds of
// thousands of rows on a busy cut-off date, so it has to page in the database.
// A fetch join combined with Pageable would page in memory (Hibernate warns:
// "firstResult/maxResults specified with collection fetch; applying in memory").
@Transactional(readOnly = true)
public Page<StatementRow> page(LocalDate cutOff, Pageable pageable) {
Page<Long> ids = statements.findIdsByCutOff(cutOff, pageable); // ids + count
Map<Long, CardStatement> loaded = statements.findWithTransactionsByIds(ids.getContent())
.stream()
.collect(Collectors.toMap(CardStatement::getId, Function.identity()));
// Keep the database's order, not the map's.
return ids.map(id -> StatementRow.of(loaded.get(id)));
}
}

src/main/resources/ application.yml Güvenlik ağı: varsayılan batch boyutu ve kapalı open-in-view; eksik yükleme testte patlasın.

src/main/resources/application.yml
spring:
jpa:
open-in-view: false # lazy loading outside a transaction fails fast
properties:
hibernate:
default_batch_fetch_size: 50 # the global safety net behind @BatchSize
generate_statistics: false # turned on in tests only
logging:
level:
org.hibernate.SQL: DEBUG # see every statement while developing

src/test/java/com/bank/card/ StatementRunQueryCountTest.java Sorgu sayısını ölçen test: biri LAZY ilişkiye döngüde dokunursa test kırılır.

src/test/java/com/bank/card/StatementRunQueryCountTest.java
@DataJpaTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
@Import(StatementRunService.class)
class StatementRunQueryCountTest {
@Autowired StatementRunService runs;
@Autowired EntityManagerFactory emf;
@Autowired TestEntityManager em;
private static final LocalDate CUT_OFF = LocalDate.of(2026, 9, 20);
@Test
void statementPageUsesAFixedNumberOfQueriesWhateverThePageSize() {
Fixtures.statementsWithTransactions(em, CUT_OFF, 30, 3); // 30 statements, 3 transactions each
em.clear();
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.clear();
Page<StatementRow> page = runs.page(CUT_OFF, PageRequest.of(0, 20));
page.forEach(row -> assertThat(row.transactionCount()).isEqualTo(3));
// ids, count (the page is full, so Spring Data asks for the total), statements
// with transactions. N+1 would show 22 here, and 42 with a page size of 40.
assertThat(stats.getPrepareStatementCount()).isEqualTo(3);
}
}
Hızlı kontrolBaşlangıç

`LAZY` ve `EAGER` fetch arasındaki fark nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

N+1'i çözmek için `@OneToMany(fetch = EAGER)` yapmak neden kötü bir fikirdir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Üç gerçek çözüm

1. JOIN FETCH — tek sorgu, açık ve net:

OrderRepository.java
@Query("select distinct o from Order o join fetch o.lineItems")
List<Order> findAllWithItems();

2. @EntityGraph — aynı şey, JPQL yazmadan:

OrderRepository.java
@EntityGraph(attributePaths = "lineItems")
List<Order> findAll();

3. @BatchSize — sorguları gruplar:

Order.java
@OneToMany(mappedBy = "order")
@BatchSize(size = 25) // 1 + tavan(N/25) sorgu, kartezyen çarpım yok
private List<LineItem> lineItems;

hibernate.default_batch_fetch_size ile bunu uygulama genelinde varsayılan yapabilirsin — tek satırlık, düşük riskli ve çoğu projede en iyi ilk hamledir.

JOIN FETCH’in iki tuzağı. Kartezyen çarpım. Her sipariş, kalem sayısı kadar satırda tekrarlanır. İki koleksiyonu aynı anda fetch etmeye çalışırsan Hibernate durur:

MultipleBagFetchException: cannot simultaneously fetch multiple bags

Çözüm: List yerine Set kullanmak, ya da koleksiyonları ayrı sorgulara bölmek.

Sayfalama ile birlikte çalışmaz. Üretimde en çok şaşırtan detay bu:

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory

Hibernate bir koleksiyon fetch’ine LIMIT uygulayamaz — satır sınırı koleksiyonun ortasını keserdi. Bunun yerine tüm tabloyu çeker ve sayfalamayı bellekte yapar. Küçük veride fark edilmez, büyük veride uygulamayı düşürür.

Doğru çözüm iki adımlıdır:

Sayfalama + fetch join
// 1. Yalnızca id'leri sayfalayarak çek — koleksiyon yok, LIMIT çalışır
Page<Long> ids = orderRepository.findIdsBy(pageable);
// 2. O id'ler için tek bir fetch join
List<Order> orders = orderRepository.findWithItemsByIdIn(ids.getContent());
Kafam karıştı, daha basit anlat

Çözüm, alışveriş listesini tek seferde vermektir. join fetch siparişleri ve satırlarını tek bir sorguda getirir.

Hızlı kontrolOrta

Her yaklaşımı tek sorgu üretip üretmediğine göre yerleştir.

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sınıflandırılmamış

Tek sorgu

Koleksiyon kök sorguda birleştirilir

    Birden fazla sorgu

    Koleksiyonlar ayrı SELECT'lerle gelir

      Tuzaklar ve seçim

      DurumÇözüm
      Tek koleksiyon, sayfalama yokJOIN FETCH veya @EntityGraph
      Birden fazla koleksiyon@BatchSize (kartezyen çarpımdan kaçın)
      Sayfalama gerekliİki adımlı: önce id’ler, sonra fetch
      Yalnızca birkaç alan gerekliDTO projeksiyonu — entity hiç yükleme
      Genel güvenlik ağıhibernate.default_batch_fetch_size

      Son satır özellikle değerli: DTO projeksiyonu yalnızca ihtiyacın olan sütunları çeker, persistence contextHibernate'in yönettiği entity'leri tuttuğu birinci seviye önbellek. Transaction ile birlikte açılır ve kapanır; kapandıktan sonra lazy ilişkilere dokunmak hata verir.Sözlükte gör →’i kirletmez ve dirty checking maliyeti oluşturmaz.

      Kendini sına

      Şimşek turu1/5

      N+1 sorunu, liste için bir sorgu ve her eleman için birer sorgu daha çalışmasıdır.

      Soru 1/4Orta

      Bir `@OneToMany` koleksiyonunu `FetchType.EAGER` yaparsanız N+1 problemi çözülür mü?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Aklında kalacak üç şey

      1. 1 EAGER bir çözüm değildir: koleksiyonlarda EAGER de sipariş başına bir SELECT çalıştırır. Sorgu sayısı LAZY ile aynıdır, tek fark kaçamamandır.
      2. 2 Sorgular yavaş değil, sayıları fazla. Her biri veritabanına ayrı bir gidiş dönüştür.
      3. 3 Sayfalama ile JOIN FETCH birlikte çalışmaz: Hibernate sınırı veritabanında uygulayamaz, bütün tabloyu belleğe çekip orada sayfalar.
      Sonraki kapı Hatayı yakaladın ve devam ettin. Sipariş neden yine de kaydedilmedi? Transaction Propagation ve Rollback Yayılımı · 10 dk

      5 kart sonraki derste seni bekliyor

      0/5 kart bu dersten toplandı

      Bu dersin üstüne kurulanlar

      Bunlar bu dersi temel alıyor; hazır olduğunda devam edebilirsin.