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 post
hà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_comment
hồ 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_comments
sử 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
title
cho 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ạ post
và post_comments
cá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.EAGER
mộ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.EAGER
chiế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 @ManyToOne
và @OneToOne
sử dụng FetchType.EAGER
theo 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.EAGER
chiến lược và mỗi khi bạn quên sử dụng JOIN FETCH
khi tải một số PostComment
thự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 post
hiệp hội phải được lấy trước khi trả lại List
của PostComment
cá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 find
phương thức của EnrityManager
truy 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ự post
liên kết, bạn sẽ không gặp may khi sử dụng FetchType.EAGER
vì 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.LAZY
theo mặc định.
Nhưng, nếu bạn muốn sử dụng post
liê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.EAGER
chiế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.LAZY
rõ 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, post
hiệ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 PostComment
thự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 post
hiệ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ì post
liê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 FETCH
mệ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.EAGER
ví 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.LAZY
và không tham chiếu liên kết con của @OneToOne
mố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 @OneToOne
hiệ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-util
dự á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 SQLStatementCountValidator
tiệ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.EAGER
và 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-util
dự án nguồn mở, hãy xem bài viết này .