Giải pháp N+1 trong Spring Data JPA: Fetch Join, EntityGraph và Hibernate 7.4

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 và @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. Để có cái nhìn tổng quan hơn về các thực hành tốt nhất của JPA, hãy xem module cơ bản Spring Data JPA.

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.

Hibernate 7 giới thiệu cú pháp @NamedEntityGraph dựa trên văn bản đơn giản hóa giúp giảm độ dài dòng của annotation:

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
}

Định nghĩa một dòng này thay thế các annotation @NamedAttributeNode và @NamedSubgraph dài dòng.

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.

Phân trang với Fetch Join trong Hibernate 7.4

Một hạn chế lâu dài của fetch join là sự không tương thích với phân trang. Kết hợp setMaxResults() với collection fetch join buộc Hibernate phải tải tất cả các hàng khớp, sau đó áp dụng giới hạn trong bộ nhớ. Cảnh báo firstResult/maxResults specified with collection fetch; applying in memory chỉ ra vấn đề hiệu năng này.

Hibernate 7.4 giải quyết vấn đề này. Phân trang với collection fetch join giờ đây hoạt động ở cấp cơ sở dữ liệu bằng cách sử dụng nested query. Hibernate đầu tiên xác định tập hợp giới hạn các identifier của thực thể cha, sau đó lấy các collection liên quan chỉ cho các hàng cha đó.

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

SQL được tạo ra giờ đây sử dụng subquery để phân trang ở cấp cơ sở dữ liệu:

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
)

Cải tiến này giảm đáng kể việc sử dụng bộ nhớ cho các endpoint phân trang hiển thị các quan hệ cha-con. Spring Boot 4.1 bao gồm Hibernate 7.4.5, làm cho tối ưu hóa này có sẵn theo mặc định.

Đối với các ứng dụng vẫn sử dụng các phiên bản Hibernate trước đó, mẫu two-phase fetching vẫn là cách tiếp cận an toàn nhất: lấy ID cha trước với phân trang, sau đó tải các liên kết cho các ID cụ thể đó.

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ượcTrường hợp Sử dụngƯu điểmNhược điểm
Fetch JoinQuan hệ @ManyToOneTruy vấn SQL duy nhấtTích Descartes với tập hợp
@EntityGraphTải khai báoTái sử dụng được, dễ đọcKém linh hoạt hơn JPQL
Batch FetchingTập hợp @OneToManyTránh tích DescartesNhiều truy vấn
SubselectTập hợp ít được truy cậpChỉ tải khi cầnTruy 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. Mẫu này đặc biệt phù hợp cho các endpoint tập trung đọc, như được đề cập trong module truy vấn JPA.

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 trong Spring Boot 4.x cung cấp quản lý EntityGraph được cải thiện và tối ưu hóa truy vấn. Việc hiểu transaction propagation trở nên thiết yếu khi kết hợp các chiến lược fetch này với logic nghiệp vụ phức tạp.

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. Để biết các chiến lược kiểm thử toàn diện, hãy xem hướng dẫn Testcontainers cho Spring Boot.

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.

Nguồn tham khảo

Danh sách Kiểm tra Phòng ngừa N+1 cho Spring Data JPA

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. Với Hibernate 7.4, phân trang với fetch join cuối cùng đã hoạt động ở cấp cơ sở dữ liệu.

Những gì cần áp dụng trong production:

  • 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
  • Nâng cấp lên Spring Boot 4.1+ để tận dụng các cải tiến phân trang của Hibernate 7.4
Thử thách hôm nay

Bạn có tìm ra lỗi trong Spring Boot không?

Một đoạn mã thật, một lỗi ẩn, mỗi ngày một lượt. Không cần tài khoản để thử.

Anthony Fillion-Maillet

Viết bởi

Anthony Fillion-Maillet

Người sáng lập SharpSkill

Lập trình viên fullstack hơn 10 năm. Anh điều hành SharpSkill và chịu trách nhiệm về mọi nội dung đăng tại đây.

Cập nhật ngày 2 tháng 9, 2026

Thẻ

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

Chia sẻ

Bài viết liên quan