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.

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.
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.
@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
}@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.
@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.
-- 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 queriesPhá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.
# 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: TRACEThố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.
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.
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.
-- 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.idDị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ụ.
@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.
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.
@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:
@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.
@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.
-- 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.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch 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 đó.
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:
-- 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ượ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.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- 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.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}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ể.
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.
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.
@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.
@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
- Hibernate 7.4 What's New - phân trang với fetch join giờ đây an toàn ở cấp cơ sở dữ liệu
- Spring Boot 4.1 Release Notes - bao gồm Hibernate 7.4.5.Final
- Hibernate ORM Documentation - tài liệu tham khảo chính thức cho các chiến lược fetch và EntityGraph
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 FETCHcho các quan hệ@ManyToOnethường xuyên truy cập - Áp dụng
@EntityGraphcho việc tải khai báo có thể tái sử dụng - Cấu hình
@BatchSizecho các tập hợp@OneToManylớ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
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ử.

Viết bởi
Anthony Fillion-MailletNgườ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ẻ
Chia sẻ
Bài viết liên quan

Phỏng vấn Spring GraphQL: Resolver, DataLoader và Giải pháp cho Vấn đề N+1
Chuẩn bị cho phỏng vấn Spring GraphQL với hướng dẫn đầy đủ này. Resolver, DataLoader, xử lý vấn đề N+1, mutation và các thực hành tốt nhất cho câu hỏi kỹ thuật.

Spring Boot 3.4 Virtual Threads: Câu Hỏi Phỏng Vấn và Benchmark Hiệu Năng
Làm chủ Virtual Threads của Java 21 với Spring Boot 3.4: 15 câu hỏi phỏng vấn, benchmark hiệu năng và mẫu di chuyển để vượt qua các buổi phỏng vấn kỹ thuật.

Observability trong Spring Boot 2026: OpenTelemetry, Distributed Tracing va Cau hoi Phong van
Lam chu observability trong Spring Boot voi OpenTelemetry va Micrometer Tracing. Huong dan cau hinh distributed tracing, Observation API, xuat OTLP, va chuan bi phong van ky thuat.