Giải pháp N+1 trong Spring Data JPA năm 2026: Fetch Join và EntityGraph

Hướng dẫn đầy đủ để phát hiện và khắc phục vấn đề N+1 trong Spring Data JPA. Fetch join, @EntityGraph, batch fetching và các chiến lược hiệu năng truy vấn.

Giải quyết vấn đề N+1 với Spring Data JPA bằng fetch join và EntityGraph

Vấn đề N+1 là một trong những cạm bẫy hiệu năng phổ biến nhất trong JPA. Một truy vấn vô hại để lấy 100 đơn hàng có thể kích hoạt 101 truy vấn SQL: một cho các đơn hàng, sau đó một cho mỗi khách hàng liên quan. Sự nhân bản truy vấn âm thầm này làm giảm hiệu năng và gây quá tải cơ sở dữ liệu.

Tác động Thực tế của N+1

Một endpoint trả về 50 bài viết kèm tác giả có thể nhảy từ 10ms lên 500ms do N+1. Phát hiện sớm giúp ngăn chặn các sự cố nghiêm trọng trong production.

Hiểu Vấn đề N+1 trong JPA

Vấn đề N+1 xảy ra khi JPA tải một tập hợp các thực thể rồi thực thi một truy vấn bổ sung cho mỗi thực thể để tải các liên kết của nó. Hành vi này xuất phát từ việc lazy loading mặc định của các quan hệ @OneToMany@ManyToMany.

Hãy xem xét một mô hình kinh điển với đơn hàng và khách hàng. Mỗi đơn hàng thuộc về một khách hàng, và quan hệ này mặc định sử dụng lazy loading.

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
}

Khi một truy vấn lấy các đơn hàng rồi truy cập tên khách hàng, Hibernate thực thi một truy vấn bổ sung cho mỗi đơn hàng.

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();
    }
}

Với 100 đơn hàng, đoạn mã này thực thi 101 truy vấn SQL. Log của Hibernate phơi bày mẫu phá hoại này.

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

Phát hiện Vấn đề N+1 với Log Hibernate

Bước đầu tiên là kích hoạt log SQL để xác định các truy vấn có vấn đề. Cấu hình sau đây hiển thị mọi truy vấn được Hibernate thực thi.

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

Thống kê Hibernate cung cấp một bản tóm tắt giá trị ở cuối mỗi giao dịch.

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;
}

Con số 101 câu lệnh JDBC cho một danh sách đơn hàng đơn giản rõ ràng báo hiệu một vấn đề N+1.

Vô hiệu hóa trong Production

Log SQL và thống kê ảnh hưởng đến hiệu năng. Các tùy chọn này nên được giữ vô hiệu hóa trong production và chỉ dành cho môi trường phát triển và kiểm thử.

Giải pháp 1: Fetch Join với JPQL

Fetch join tải các liên kết trong một truy vấn SQL duy nhất thông qua một join. Cách tiếp cận tường minh này giải quyết N+1 bằng cách lấy tất cả dữ liệu cần thiết cùng một lúc.

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 biến N+1 truy vấn thành một truy vấn được tối ưu hóa duy nhất.

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

Dịch vụ giờ đây sử dụng phương thức được tối ưu hóa mà không cần sửa đổi mã nghiệp vụ.

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();
    }
}

Sẵn sàng chinh phục phỏng vấn Spring Boot?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Giải pháp 2: @EntityGraph cho Kiểm soát Khai báo

Annotation @EntityGraph cung cấp một giải pháp khai báo thay thế cho fetch join. Nó định nghĩa các liên kết cần tải mà không phải viết JPQL tùy chỉnh.

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);
}

Các EntityGraph có tên được định nghĩa trực tiếp trên thực thể để tái sử dụng trong nhiều repository.

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 cho phép tải các liên kết lồng nhau. Ví dụ trên tải các đơn hàng, các mục của chúng và sản phẩm của mỗi mục trong một truy vấn duy nhất.

Giải pháp 3: Batch Fetching cho Tập hợp

Batch fetching cung cấp một giải pháp thay thế cho fetch join đối với các tập hợp @OneToMany. Thay vì tải từng tập hợp riêng lẻ, Hibernate gom các truy vấn thành các batch.

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<>();
}

Với @BatchSize(size = 25), Hibernate tải các mục cho 25 đơn hàng cùng lúc thay vì 1. Đối với 100 đơn hàng, số lượng truy vấn giảm từ 101 xuống còn 5.

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)

Cấu hình kích thước batch toàn cục áp dụng cho tất cả các tập hợp trong ứng dụng.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch và Fetch Join

Batch fetching phù hợp với các trường hợp mà fetch join tạo ra tích Descartes quá lớn. Đối với một đơn hàng có 10 mục và 5 thanh toán, fetch join trả về 50 hàng. Batch fetching thực thi 2 truy vấn riêng biệt và hiệu quả hơn.

So sánh Các Chiến lược Tải

Mỗi chiến lược cung cấp những lợi thế tùy thuộc vào ngữ cảnh sử dụng. Bảng sau tóm tắt các trường hợp sử dụng tối ưu.

| Chiến lược | Trường hợp Sử dụng | Ưu điểm | Nhược điểm | |----------|----------|------------|---------------| | Fetch Join | Quan hệ @ManyToOne | Truy vấn SQL duy nhất | Tích Descartes với tập hợp | | @EntityGraph | Tải khai báo | Tái sử dụng được, dễ đọc | Kém linh hoạt hơn JPQL | | Batch Fetching | Tập hợp @OneToMany | Tránh tích Descartes | Nhiều truy vấn | | Subselect | Tập hợp ít được truy cập | Chỉ tải khi cần | Truy vấn con có tương quan |

Chiến lược subselect tải toàn bộ tập hợp khi truy cập đầu tiên vào bất kỳ phần tử nào.

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 > ?)

Tránh N+1 với Phép chiếu DTO

Phép chiếu DTO cung cấp một cách tiếp cận triệt để nhưng hiệu quả. Bằng cách chỉ chọn các cột cần thiết, các phép chiếu hoàn toàn tránh được việc tải các thực thể và các liên kết của chúng.

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
    );
}

Cách tiếp cận này tạo ra một truy vấn SQL tối ưu mà không có chi phí ánh xạ thực thể.

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 > ?

Cấu hình Nâng cao với Spring Data JPA

Spring Data JPA 3.x giới thiệu các cải tiến cho việc quản lý EntityGraph và các truy vấn được tối ưu hóa.

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
    );
}

Việc tải có điều kiện cho phép áp dụng các chiến lược khác nhau dựa trên ngữ cảnh.

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");
    }
}

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Kiểm thử Hiệu năng để Phát hiện N+1

Các bài kiểm thử tự động xác minh sự vắng mặt của các vấn đề N+1 bằng cách đếm các truy vấn SQL được thực thi.

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();
    }
}

Khẳng định trên getQueryExecutionCount() đảm bảo các tối ưu hóa vẫn được duy trì trong quá trình mã nguồn phát triển.

Kết luận

Vấn đề N+1 là một thách thức hiệu năng lớn trong Spring Data JPA, nhưng có nhiều giải pháp hiệu quả. Fetch join và @EntityGraph giải quyết hầu hết các trường hợp bằng cách tải các liên kết trong một truy vấn duy nhất. Batch fetching cung cấp một giải pháp thay thế cho các tập hợp lớn nơi fetch join tạo ra tích Descartes.

Danh sách Kiểm tra Phòng ngừa N+1:

  • ✅ Kích hoạt log SQL trong môi trường phát triển để phát hiện nhiều truy vấn
  • ✅ Sử dụng JOIN FETCH cho các quan hệ @ManyToOne thường xuyên truy cập
  • ✅ Áp dụng @EntityGraph cho việc tải khai báo có thể tái sử dụng
  • ✅ Cấu hình @BatchSize cho các tập hợp @OneToMany lớn
  • ✅ Ưu tiên các phép chiếu DTO cho các thao tác chỉ đọc
  • ✅ Viết các bài kiểm thử xác minh số lượng truy vấn được thực thi
  • ✅ Vô hiệu hóa log SQL và thống kê trong production
  • ✅ Định kỳ phân tích các endpoint quan trọng với các chỉ số Hibernate

Thẻ

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

Chia sẻ

Bài viết liên quan