2026'da Spring Data JPA N+1 Sorgu Çözümleri: Fetch Join ve EntityGraph

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.

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.

Çö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.

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.

| Strateji | Kullanım Durumu | Avantajları | Dezavantajları | |----------|----------|------------|---------------| | Fetch Join | @ManyToOne ilişkileri | Tek SQL sorgusu | Koleksiyonlarla Kartezyen çarpım | | @EntityGraph | Bildirimsel yükleme | Yeniden kullanılabilir, okunabilir | JPQL'den daha az esnek | | Batch Fetching | @OneToMany koleksiyonları | Kartezyen çarpımı önler | Birden fazla sorgu | | Subselect | Nadiren erişilen koleksiyonlar | Yalnı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.

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 Data JPA 3.x, EntityGraph yönetimi ve optimize edilmiş sorgular için iyileştirmeler sunar.

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.

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.

Sonuç

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.

N+1 Önleme Kontrol Listesi:

  • ✅ Ç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

Etiketler

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

Paylaş

İlgili makaleler