Vì đây là một câu hỏi rất phổ biến, tôi đã viết
bài viết này , trên đó câu trả lời này dựa trên.
Vấn đề truy vấn N + 1 là gì
Sự cố truy vấn N + 1 xảy ra khi khung truy cập dữ liệu thực thi N câu lệnh SQL bổ sung để tìm nạp cùng một dữ liệu có thể được truy xuất khi thực hiện truy vấn SQL chính.
Giá trị của N càng lớn, càng nhiều truy vấn sẽ được thực hiện, tác động hiệu suất càng lớn. Và, không giống như nhật ký truy vấn chậm có thể giúp bạn tìm các truy vấn chạy chậm, vấn đề N + 1 sẽ không được phát hiện vì mỗi truy vấn bổ sung riêng lẻ chạy đủ nhanh để không kích hoạt nhật ký truy vấn chậm.
Vấn đề là thực thi một số lượng lớn các truy vấn bổ sung, nói chung, mất đủ thời gian để làm chậm thời gian phản hồi.
Hãy xem xét chúng tôi có các bảng cơ sở dữ liệu post và post_comments sau đây tạo thành mối quan hệ một-nhiều bảng :

Chúng tôi sẽ tạo ra 4 posthàng sau:
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 1', 1)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 2', 2)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 3', 3)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 4', 4)
Và, chúng tôi cũng sẽ tạo 4 post_commenthồ sơ con:
INSERT INTO post_comment (post_id, review, id)
VALUES (1, 'Excellent book to understand Java Persistence', 1)
INSERT INTO post_comment (post_id, review, id)
VALUES (2, 'Must-read for Java developers', 2)
INSERT INTO post_comment (post_id, review, id)
VALUES (3, 'Five Stars', 3)
INSERT INTO post_comment (post_id, review, id)
VALUES (4, 'A great reference book', 4)
Vấn đề truy vấn N + 1 với SQL đơn giản
Nếu bạn chọn post_commentssử dụng truy vấn SQL này:
List<Tuple> comments = entityManager.createNativeQuery("""
SELECT
pc.id AS id,
pc.review AS review,
pc.post_id AS postId
FROM post_comment pc
""", Tuple.class)
.getResultList();
Và, sau đó, bạn quyết định tìm nạp liên kết post titlecho mỗi post_comment:
for (Tuple comment : comments) {
String review = (String) comment.get("review");
Long postId = ((Number) comment.get("postId")).longValue();
String postTitle = (String) entityManager.createNativeQuery("""
SELECT
p.title
FROM post p
WHERE p.id = :postId
""")
.setParameter("postId", postId)
.getSingleResult();
LOGGER.info(
"The Post '{}' got this review '{}'",
postTitle,
review
);
}
Bạn sẽ kích hoạt vấn đề truy vấn N + 1 bởi vì, thay vì một truy vấn SQL, bạn đã thực hiện 5 (1 + 4):
SELECT
pc.id AS id,
pc.review AS review,
pc.post_id AS postId
FROM post_comment pc
SELECT p.title FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
SELECT p.title FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
SELECT p.title FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
SELECT p.title FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
Khắc phục sự cố truy vấn N + 1 rất dễ dàng. Tất cả những gì bạn cần làm là trích xuất tất cả dữ liệu bạn cần trong truy vấn SQL gốc, như sau:
List<Tuple> comments = entityManager.createNativeQuery("""
SELECT
pc.id AS id,
pc.review AS review,
p.title AS postTitle
FROM post_comment pc
JOIN post p ON pc.post_id = p.id
""", Tuple.class)
.getResultList();
for (Tuple comment : comments) {
String review = (String) comment.get("review");
String postTitle = (String) comment.get("postTitle");
LOGGER.info(
"The Post '{}' got this review '{}'",
postTitle,
review
);
}
Lần này, chỉ có một truy vấn SQL được thực thi để tìm nạp tất cả dữ liệu mà chúng tôi quan tâm hơn nữa khi sử dụng.
Vấn đề truy vấn N + 1 với JPA và Hibernate
Khi sử dụng JPA và Hibernate, có một số cách bạn có thể kích hoạt sự cố truy vấn N + 1, vì vậy điều rất quan trọng là phải biết cách bạn có thể tránh những tình huống này.
Đối với các ví dụ tiếp theo, hãy xem xét chúng tôi đang ánh xạ postvà post_commentscác bảng tới các thực thể sau:

Các ánh xạ JPA trông như thế này:
@Entity(name = "Post")
@Table(name = "post")
public class Post {
@Id
private Long id;
private String title;
//Getters and setters omitted for brevity
}
@Entity(name = "PostComment")
@Table(name = "post_comment")
public class PostComment {
@Id
private Long id;
@ManyToOne
private Post post;
private String review;
//Getters and setters omitted for brevity
}
FetchType.EAGER
Sử dụng FetchType.EAGERmột cách ngầm định hoặc rõ ràng cho các hiệp hội JPA của bạn là một ý tưởng tồi bởi vì bạn sẽ tìm nạp thêm dữ liệu mà bạn cần. Hơn nữa, FetchType.EAGERchiến lược cũng dễ xảy ra sự cố truy vấn N + 1.
Thật không may, các hiệp hội @ManyToOnevà @OneToOnesử dụng FetchType.EAGERtheo mặc định, vì vậy nếu ánh xạ của bạn trông như thế này:
@ManyToOne
private Post post;
Bạn đang sử dụng FetchType.EAGERchiến lược và mỗi khi bạn quên sử dụng JOIN FETCHkhi tải một số PostCommentthực thể bằng truy vấn JPQL hoặc Tiêu chí API:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Bạn sẽ kích hoạt vấn đề truy vấn N + 1:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
Hãy chú ý câu SELECT bổ sung được thực hiện bởi vì các posthiệp hội phải được lấy trước khi trả lại Listcủa PostCommentcác thực thể.
Không giống như gói tìm nạp mặc định mà bạn đang sử dụng khi gọi findphương thức của EnrityManagertruy vấn, JPQL hoặc Tiêu chí API xác định một kế hoạch rõ ràng mà Hibernate không thể thay đổi bằng cách tự động tiêm THAM GIA FETCH. Vì vậy, bạn cần phải làm bằng tay.
Nếu bạn hoàn toàn không cần sự postliên kết, bạn sẽ không gặp may khi sử dụng FetchType.EAGERvì không có cách nào để tránh lấy nó. Đó là lý do tại sao nó tốt hơn để sử dụng FetchType.LAZYtheo mặc định.
Nhưng, nếu bạn muốn sử dụng postliên kết, thì bạn có thể sử dụng JOIN FETCHđể tránh vấn đề truy vấn N + 1:
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
join fetch pc.post p
""", PostComment.class)
.getResultList();
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Lần này, Hibernate sẽ thực thi một câu lệnh SQL duy nhất:
SELECT
pc.id as id1_1_0_,
pc.post_id as post_id3_1_0_,
pc.review as review2_1_0_,
p.id as id1_0_1_,
p.title as title2_0_1_
FROM
post_comment pc
INNER JOIN
post p ON pc.post_id = p.id
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
Để biết thêm chi tiết về lý do tại sao bạn nên tránh FetchType.EAGERchiến lược tìm nạp, hãy xem bài viết này .
FetchType.LAZY
Ngay cả khi bạn chuyển sang sử dụng FetchType.LAZYrõ ràng cho tất cả các hiệp hội, bạn vẫn có thể gặp phải vấn đề N + 1.
Lần này, posthiệp hội được ánh xạ như thế này:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
Bây giờ, khi bạn tìm nạp các PostCommentthực thể:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Hibernate sẽ thực thi một câu lệnh SQL duy nhất:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
Nhưng, nếu sau đó, bạn sẽ tham khảo posthiệp hội tải lười biếng :
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Bạn sẽ gặp vấn đề truy vấn N + 1:
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
Vì postliên kết được tìm nạp một cách lười biếng, nên một câu lệnh SQL thứ cấp sẽ được thực thi khi truy cập vào liên kết lười biếng để xây dựng thông điệp tường trình.
Một lần nữa, sửa chữa bao gồm thêm một JOIN FETCHmệnh đề vào truy vấn JPQL:
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
join fetch pc.post p
""", PostComment.class)
.getResultList();
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Và, giống như trong FetchType.EAGERví dụ, truy vấn JPQL này sẽ tạo ra một câu lệnh SQL.
Ngay cả khi bạn đang sử dụng FetchType.LAZYvà không tham chiếu liên kết con của @OneToOnemối quan hệ JPA hai chiều , bạn vẫn có thể kích hoạt vấn đề truy vấn N + 1.
Để biết thêm chi tiết về cách bạn có thể khắc phục sự cố truy vấn N + 1 do các @OneToOnehiệp hội tạo ra , hãy xem bài viết này .
Cách tự động phát hiện sự cố truy vấn N + 1
Nếu bạn muốn tự động phát hiện vấn đề truy vấn N + 1 trong lớp truy cập dữ liệu của mình, bài viết này giải thích cách bạn có thể làm điều đó bằng cách sử dụng db-utildự án nguồn mở.
Trước tiên, bạn cần thêm phụ thuộc Maven sau:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
Sau đó, bạn chỉ cần sử dụng SQLStatementCountValidatortiện ích để xác nhận các câu lệnh SQL cơ bản được tạo:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
Trong trường hợp bạn đang sử dụng FetchType.EAGERvà chạy trường hợp thử nghiệm ở trên, bạn sẽ gặp phải trường hợp thử nghiệm sau:
SELECT
pc.id as id1_1_,
pc.post_id as post_id3_1_,
pc.review as review2_1_
FROM
post_comment pc
SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 2
-- SQLStatementCountMismatchException: Expected 1 statement(s) but recorded 3 instead!
Để biết thêm chi tiết về db-utildự án nguồn mở, hãy xem bài viết này .