Spring Data JPA N+1 Sorgu Çözümleri: Fetch Join, EntityGraph ve Hibernate 7.4

Spring Data JPA'da N+1 sorununu tespit etme ve düzeltme rehberi. Fetch join, @EntityGraph, batch fetching ve sorgu performansı stratejileri.

Spring Data JPA ile fetch join ve EntityGraph kullanarak N+1 sorununu çözme

N+1 sorunu, JPA'da en yaygın performans tuzaklarından birini temsil eder. 100 siparişi getirmek için yapılan masum bir sorgu, 101 SQL sorgusunu tetikleyebilir: bir tane siparişler için ve ardından her ilişkili müşteri için bir tane. Bu sessiz sorgu çoğalması performansı düşürür ve veritabanını aşırı yükler.

N+1'in Gerçek Etkisi

Yazarlarıyla birlikte 50 makale döndüren bir endpoint, N+1 nedeniyle 10ms'den 500ms'ye çıkabilir. Erken tespit, kritik üretim sorunlarını önler.

JPA'da N+1 Sorununu Anlamak

N+1 sorunu, JPA bir varlık koleksiyonunu yüklediğinde ve ardından ilişkilerini yüklemek için her varlık için ek bir sorgu yürüttüğünde meydana gelir. Bu davranış, @OneToMany ve @ManyToMany ilişkilerinin varsayılan tembel yüklemesinden kaynaklanır.

Siparişler ve müşterilerle klasik bir model düşünülebilir. Her sipariş bir müşteriye aittir ve bu ilişki varsayılan olarak tembel yüklemeyi kullanır.

Order.javajava
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String orderNumber;

    private LocalDateTime createdAt;

    // ManyToOne relationship is lazy by default since JPA 2.0
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    // OneToMany relationship lazy by default
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();

    // Getters and setters omitted
}
Customer.javajava
@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String email;

    // Getters and setters omitted
}

Bir sorgu siparişleri getirip ardından müşteri adına eriştiğinde, Hibernate her sipariş için ek bir sorgu yürütür.

OrderService.java - Problematic codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // 1 query: SELECT * FROM orders
        List<Order> orders = orderRepository.findAll();

        // For each order, accessing customer triggers a query
        return orders.stream()
            .map(order -> new OrderDto(
                order.getId(),
                order.getOrderNumber(),
                // N queries: SELECT * FROM customers WHERE id = ?
                order.getCustomer().getName()
            ))
            .toList();
    }
}

100 sipariş için bu kod 101 SQL sorgusu yürütür. Hibernate logları bu yıkıcı kalıbı ortaya çıkarır.

sql
-- Query 1: fetch orders
SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o

-- Queries 2-101: fetch each customer
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3
-- ... 97 more identical queries

Hibernate Logları ile N+1 Sorununu Tespit Etme

İlk adım, sorunlu sorguları tanımlamak için SQL loglarını etkinleştirmektir. Aşağıdaki yapılandırma, Hibernate tarafından yürütülen her sorguyu görüntüler.

yaml
# application.yml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        # Format SQL for better readability
        format_sql: true
        # Display session statistics (queries, time)
        generate_statistics: true

logging:
  level:
    # Detailed SQL query logging
    org.hibernate.SQL: DEBUG
    # Display prepared statement parameters
    org.hibernate.orm.jdbc.bind: TRACE

Hibernate istatistikleri, her işlem sonunda değerli bir özet sağlar.

text
Session Metrics {
    23421 nanoseconds spent acquiring 1 JDBC connection;
    0 nanoseconds spent releasing 0 JDBC connections;
    1254789 nanoseconds spent preparing 101 JDBC statements;
    15478963 nanoseconds spent executing 101 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
}

Basit bir sipariş listesi için 101 JDBC ifadesi sayısı, açıkça bir N+1 sorununun göstergesidir.

Üretimde Devre Dışı Bırakın

SQL logları ve istatistikler performansı etkiler. Bu seçenekler üretimde devre dışı bırakılmalı ve geliştirme ile test ortamlarına ayrılmalıdır.

Çözüm 1: JPQL ile Fetch Join

Fetch join, ilişkileri tek bir SQL sorgusunda bir join aracılığıyla yükler. Bu açık yaklaşım, gerekli tüm verileri tek seferde getirerek N+1'i çözer.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Explicit fetch join to load customers
    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();

    // Multiple fetch join for several associations
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "JOIN FETCH o.items i")
    List<Order> findAllWithCustomerAndItems();

    // Fetch join with WHERE condition
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "WHERE o.createdAt > :since")
    List<Order> findRecentOrdersWithCustomer(
        @Param("since") LocalDateTime since
    );
}

Fetch join, N+1 sorgularını tek bir optimize edilmiş sorguya dönüştürür.

sql
-- Single query with join
SELECT o.id, o.order_number, o.created_at, o.customer_id,
       c.id, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id

Servis artık iş kodunu değiştirmeden optimize edilmiş yöntemi kullanır.

OrderService.java - Optimized codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // Single query with join
        List<Order> orders = orderRepository.findAllWithCustomer();

        // No additional queries
        return orders.stream()
            .map(order -> new OrderDto(
                order.getId(),
                order.getOrderNumber(),
                order.getCustomer().getName() // Already loaded
            ))
            .toList();
    }
}

Spring Boot mülakatlarında başarılı olmaya hazır mısın?

İnteraktif simülatörler, flashcards ve teknik testlerle pratik yap.

Çözüm 2: Bildirimsel Kontrol için @EntityGraph

@EntityGraph ek açıklaması, fetch join'e bildirimsel bir alternatif sunar. Özel JPQL yazmadan hangi ilişkilerin yükleneceğini tanımlar. JPA en iyi uygulamalarına genel bir bakış için Spring Data JPA temelleri modülüne bakılabilir.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Inline EntityGraph with attributePaths
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll();

    // EntityGraph with multiple attributes
    @EntityGraph(attributePaths = {"customer", "items"})
    List<Order> findByCreatedAtAfter(LocalDateTime since);

    // Named EntityGraph referencing entity definition
    @EntityGraph(value = "Order.withCustomerAndItems")
    List<Order> findByCustomerId(Long customerId);

    // Combination with custom query
    @EntityGraph(attributePaths = {"customer"})
    @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
    List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}

Adlandırılmış EntityGraph'lar, birden fazla repository'de yeniden kullanılmak üzere doğrudan varlık üzerinde tanımlanır.

Order.javajava
@Entity
@Table(name = "orders")
@NamedEntityGraph(
    name = "Order.withCustomer",
    attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
    name = "Order.withCustomerAndItems",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode("items")
    }
)
@NamedEntityGraph(
    name = "Order.full",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode(value = "items", subgraph = "items-product")
    },
    subgraphs = @NamedSubgraph(
        name = "items-product",
        attributeNodes = @NamedAttributeNode("product")
    )
)
public class Order {
    // Fields unchanged
}

Subgraph, iç içe ilişkilerin yüklenmesini sağlar. Yukarıdaki örnek, siparişleri, kalemlerini ve her kalemin ürününü tek bir sorguda yükler.

Hibernate 7, ek açıklama ayrıntısını azaltan basitleştirilmiş bir metin tabanlı @NamedEntityGraph sözdizimi sunar:

Order.java - Hibernate 7 text-based syntaxjava
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
    name = "Order.full",
    graph = "customer, items(product)"
)
public class Order {
    // Fields unchanged
}

Bu tek satırlık tanım, ayrıntılı iç içe @NamedAttributeNode ve @NamedSubgraph ek açıklamalarının yerini alır.

Çözüm 3: Koleksiyonlar için Batch Fetching

Batch fetching, @OneToMany koleksiyonları için fetch join'e bir alternatif sunar. Her koleksiyonu ayrı ayrı yüklemek yerine, Hibernate sorguları gruplara ayırır.

Order.javajava
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String orderNumber;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    // Batch fetching on the collection
    @OneToMany(mappedBy = "order")
    @BatchSize(size = 25)
    private List<OrderItem> items = new ArrayList<>();
}

@BatchSize(size = 25) ile Hibernate, 1 yerine bir seferde 25 sipariş için kalemleri yükler. 100 sipariş için sorgu sayısı 101'den 5'e düşer.

sql
-- Without batch fetching: 100 queries
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
-- ... 98 more queries

-- With @BatchSize(size = 25): 4 queries
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100)

Genel batch boyutu yapılandırması, uygulamadaki tüm koleksiyonlara uygulanır.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch ile Fetch Join Karşılaştırması

Batch fetching, fetch join'in çok büyük bir Kartezyen çarpım oluşturduğu durumlar için uygundur. 10 kalem ve 5 ödeme içeren bir sipariş için fetch join 50 satır döndürür. Batch fetching, 2 ayrı ve daha verimli sorgu yürütür.

Hibernate 7.4'te Fetch Join ile Sayfalama

Fetch join'lerin uzun süredir devam eden bir sınırlaması, sayfalama ile uyumsuzluğuydu. setMaxResults() yöntemini koleksiyon fetch join'leriyle birleştirmek, Hibernate'i tüm eşleşen satırları yüklemeye ve ardından limitleri bellekte uygulamaya zorladı. firstResult/maxResults specified with collection fetch; applying in memory uyarısı bu performans sorununu gösteriyordu.

Hibernate 7.4 bu sorunu çözüyor. Koleksiyon fetch join'leriyle sayfalama artık iç içe sorgular kullanarak veritabanı seviyesinde çalışıyor. Hibernate önce sınırlı üst varlık tanımlayıcıları kümesini belirliyor, ardından yalnızca bu üst satırlar için ilişkili koleksiyonları getiriyor.

OrderRepository.java - Safe pagination with Hibernate 7.4java
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.items")
    Page<Order> findAllWithItems(Pageable pageable);

    @Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items")
    Slice<Order> findAllWithCustomerAndItems(Pageable pageable);
}

Üretilen SQL artık veritabanı seviyesinde sayfalama için alt sorgu kullanıyor:

sql
-- Hibernate 7.4: database-level pagination with fetch join
SELECT o.*, i.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
WHERE o.id IN (
    SELECT id FROM orders
    ORDER BY created_at DESC
    LIMIT 10 OFFSET 0
)

Bu iyileştirme, üst-alt ilişkilerini görüntüleyen sayfalanmış endpoint'ler için bellek kullanımını önemli ölçüde azaltır. Spring Boot 4.1, Hibernate 7.4.5 içerir ve bu optimizasyon varsayılan olarak kullanılabilir.

Hâlâ önceki Hibernate sürümlerindeki uygulamalar için iki aşamalı getirme deseni en güvenli yaklaşım olmaya devam eder: önce sayfalama ile üst ID'leri getirin, ardından bu belirli ID'ler için ilişkileri yükleyin.

Yükleme Stratejilerinin Karşılaştırılması

Her strateji, kullanım bağlamına bağlı olarak avantajlar sunar. Aşağıdaki tablo, en uygun kullanım durumlarını özetler.

StratejiKullanım DurumuAvantajlarıDezavantajları
Fetch Join@ManyToOne ilişkileriTek SQL sorgusuKoleksiyonlarla Kartezyen çarpım
@EntityGraphBildirimsel yüklemeYeniden kullanılabilir, okunabilirJPQL'den daha az esnek
Batch Fetching@OneToMany koleksiyonlarıKartezyen çarpımı önlerBirden fazla sorgu
SubselectNadiren erişilen koleksiyonlarYalnızca gerektiğinde yüklerİlişkili alt sorgu

Subselect stratejisi, herhangi bir öğeye ilk erişimde tüm koleksiyonu yükler.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();
sql
-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)

DTO Projeksiyonları ile N+1'den Kaçınma

DTO projeksiyonları radikal ama etkili bir yaklaşım sunar. Yalnızca gerekli sütunları seçerek, projeksiyonlar varlıkların ve ilişkilerinin yüklenmesinden tamamen kaçınır. Bu desen, JPA sorguları modülünde ele alındığı gibi, okuma ağırlıklı endpoint'ler için özellikle uygundur.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // DTO projection with constructor
    @Query("SELECT new com.example.dto.OrderSummaryDto(" +
           "o.id, o.orderNumber, c.name, c.email) " +
           "FROM Order o JOIN o.customer c")
    List<OrderSummaryDto> findAllOrderSummaries();

    // DTO projection with condition
    @Query("SELECT new com.example.dto.OrderSummaryDto(" +
           "o.id, o.orderNumber, c.name, c.email) " +
           "FROM Order o JOIN o.customer c " +
           "WHERE o.createdAt > :since")
    List<OrderSummaryDto> findRecentOrderSummaries(
        @Param("since") LocalDateTime since
    );
}

Bu yaklaşım, varlık eşleme yükü olmadan optimum bir SQL sorgusu üretir.

sql
SELECT o.id, o.order_number, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > ?

Spring Data JPA ile Gelişmiş Yapılandırma

Spring Boot 4.x'teki Spring Data JPA, gelişmiş EntityGraph yönetimi ve sorgu optimizasyonu sağlar. Bu getirme stratejilerini karmaşık iş mantığıyla birleştirirken transaction propagation konusunu anlamak önemlidir.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Dynamic EntityGraph with Specification
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll(Specification<Order> spec);

    // Pagination with EntityGraph
    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findByCustomerNameContaining(
        String name,
        Pageable pageable
    );

    // Slice for efficient pagination
    @EntityGraph(attributePaths = {"customer"})
    Slice<Order> findByCreatedAtBefore(
        LocalDateTime date,
        Pageable pageable
    );
}

Koşullu yükleme, bağlama göre farklı stratejiler uygulamayı sağlar.

OrderService.javajava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final EntityManager entityManager;

    public List<Order> getOrdersWithGraph(String graphName) {
        // Dynamic EntityGraph retrieval
        EntityGraph<?> graph = entityManager
            .getEntityGraph(graphName);

        return entityManager
            .createQuery("SELECT o FROM Order o", Order.class)
            .setHint("jakarta.persistence.loadgraph", graph)
            .getResultList();
    }

    public List<Order> getOrdersForListing() {
        // Minimal graph for listing
        return getOrdersWithGraph("Order.withCustomer");
    }

    public List<Order> getOrdersForDetail() {
        // Full graph for detail view
        return getOrdersWithGraph("Order.full");
    }
}

Pratik yapmaya başla!

Mülakat simülatörleri ve teknik testlerle bilgini test et.

N+1'i Tespit Etmek için Performans Testleri

Otomatik testler, yürütülen SQL sorgularının sayısını sayarak N+1 sorunlarının yokluğunu doğrular. Kapsamlı test stratejileri için Spring Boot için Testcontainers rehberine bakılabilir.

OrderRepositoryPerformanceTest.javajava
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPerformanceTest {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private EntityManager entityManager;

    @PersistenceContext
    private EntityManager em;

    private Statistics statistics;

    @BeforeEach
    void setUp() {
        // Enable Hibernate statistics
        Session session = entityManager.unwrap(Session.class);
        SessionFactory factory = session.getSessionFactory();
        statistics = factory.getStatistics();
        statistics.setStatisticsEnabled(true);
        statistics.clear();
    }

    @Test
    void findAllWithCustomer_shouldExecuteSingleQuery() {
        // Given: 50 orders in database
        createTestOrders(50);
        entityManager.clear();
        statistics.clear();

        // When: fetch with fetch join
        List<Order> orders = orderRepository.findAllWithCustomer();

        // Then: single query executed
        assertThat(orders).hasSize(50);
        assertThat(statistics.getQueryExecutionCount())
            .as("Should execute only 1 query with fetch join")
            .isEqualTo(1);
    }

    @Test
    void findAll_withoutOptimization_triggersNPlus1() {
        // Given: 50 orders in database
        createTestOrders(50);
        entityManager.clear();
        statistics.clear();

        // When: fetch without optimization
        List<Order> orders = orderRepository.findAll();

        // Access customers to trigger lazy loading
        orders.forEach(o -> o.getCustomer().getName());

        // Then: N+1 queries (51 instead of 1)
        assertThat(statistics.getQueryExecutionCount())
            .as("Should detect N+1 problem")
            .isGreaterThan(1);
    }

    private void createTestOrders(int count) {
        for (int i = 0; i < count; i++) {
            Customer customer = new Customer();
            customer.setName("Customer " + i);
            customer.setEmail("customer" + i + "@test.com");
            entityManager.persist(customer);

            Order order = new Order();
            order.setOrderNumber("ORD-" + i);
            order.setCustomer(customer);
            order.setCreatedAt(LocalDateTime.now());
            entityManager.persist(order);
        }
        entityManager.flush();
    }
}

getQueryExecutionCount() üzerindeki iddia, kod evrimi sırasında optimizasyonların yerinde kalmasını sağlar.

Kaynaklar

Spring Data JPA için N+1 Önleme Kontrol Listesi

N+1 sorunu Spring Data JPA'da büyük bir performans zorluğu temsil eder, ancak birkaç etkili çözüm mevcuttur. Fetch join ve @EntityGraph, ilişkileri tek bir sorguda yükleyerek çoğu durumu çözer. Batch fetching, fetch join'in Kartezyen çarpım oluşturduğu büyük koleksiyonlar için bir alternatif sunar. Hibernate 7.4 ile fetch join'lerle sayfalama artık veritabanı seviyesinde çalışıyor.

Üretimde uygulanması gerekenler:

  • Çoklu sorguları tespit etmek için geliştirme ortamında SQL loglarını etkinleştirin
  • Sık erişilen @ManyToOne ilişkileri için JOIN FETCH kullanın
  • Yeniden kullanılabilir bildirimsel yükleme için @EntityGraph uygulayın
  • Büyük @OneToMany koleksiyonları için @BatchSize yapılandırın
  • Salt okunur işlemler için DTO projeksiyonlarını tercih edin
  • Yürütülen sorgu sayısını doğrulayan testler yazın
  • Üretimde SQL loglarını ve istatistikleri devre dışı bırakın
  • Kritik endpoint'leri Hibernate metrikleri ile düzenli olarak profilleyin
  • Hibernate 7.4 sayfalama düzeltmelerinden yararlanmak için Spring Boot 4.1+ sürümüne yükseltin
Günün meydan okuması

Spring Boot kodundaki hatayı bulabilir misin?

Gerçek bir kod parçası, gizli bir hata, günde bir deneme. Denemek için hesap gerekmez.

Anthony Fillion-Maillet

Yazan:

Anthony Fillion-Maillet

SharpSkill kurucusu

10 yılı aşkın süredir fullstack geliştirici. SharpSkill’i yönetiyor ve burada yayımlanan her şeyden sorumlu.

2 Eylül 2026 tarihinde güncellendi

Etiketler

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

Paylaş

İlgili makaleler