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.

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.
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.
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.
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.
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 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.
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.
@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 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
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.

Logging Spring Boot năm 2026: log có cấu trúc trên production với Logback và JSON
Hướng dẫn đầy đủ về logging có cấu trúc trong Spring Boot. Cấu hình Logback JSON, MDC cho tracing, các best practice production và tích hợp ELK Stack.