# 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. - Published: 2026-03-17 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring data jpa, n+1 problem, fetch join, entitygraph, performance - Reading time: 12 min --- 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. ```java // Order.java @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 items = new ArrayList<>(); // Getters and setters omitted } ``` ```java // Customer.java @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. ```java // OrderService.java - Problematic code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 query: SELECT * FROM orders List 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. ``` 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. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Explicit fetch join to load customers @Query("SELECT o FROM Order o JOIN FETCH o.customer") List findAllWithCustomer(); // Multiple fetch join for several associations @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "JOIN FETCH o.items i") List findAllWithCustomerAndItems(); // Fetch join with WHERE condition @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "WHERE o.createdAt > :since") List 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ụ. ```java // OrderService.java - Optimized code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // Single query with join List orders = orderRepository.findAllWithCustomer(); // No additional queries return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), order.getCustomer().getName() // Already loaded )) .toList(); } } ``` ## 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. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Inline EntityGraph with attributePaths @EntityGraph(attributePaths = {"customer"}) List findAll(); // EntityGraph with multiple attributes @EntityGraph(attributePaths = {"customer", "items"}) List findByCreatedAtAfter(LocalDateTime since); // Named EntityGraph referencing entity definition @EntityGraph(value = "Order.withCustomerAndItems") List findByCustomerId(Long customerId); // Combination with custom query @EntityGraph(attributePaths = {"customer"}) @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%") List 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. ```java // Order.java @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. ```java // Order.java @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 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. ```java // Order.java @OneToMany(mappedBy = "order") @Fetch(FetchMode.SUBSELECT) private List 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. ```java // OrderSummaryDto.java public record OrderSummaryDto( Long orderId, String orderNumber, String customerName, String customerEmail ) {} ``` ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // 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 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 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. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Dynamic EntityGraph with Specification @EntityGraph(attributePaths = {"customer"}) List findAll(Specification spec); // Pagination with EntityGraph @EntityGraph(attributePaths = {"customer"}) Page findByCustomerNameContaining( String name, Pageable pageable ); // Slice for efficient pagination @EntityGraph(attributePaths = {"customer"}) Slice 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. ```java // OrderService.java @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final EntityManager entityManager; public List 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 getOrdersForListing() { // Minimal graph for listing return getOrdersWithGraph("Order.withCustomer"); } public List getOrdersForDetail() { // Full graph for detail view return getOrdersWithGraph("Order.full"); } } ``` ## 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. ```java // OrderRepositoryPerformanceTest.java @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 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 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 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/vi/blog/spring-boot/spring-data-jpa-n-plus-1-fetch-join-entitygraph