Ponieważ jest to bardzo częste pytanie, napisałem
ten artykuł , na którym opiera się ta odpowiedź.
Na czym polega problem zapytania N + 1
Problem zapytania N + 1 występuje, gdy struktura dostępu do danych wykonała N dodatkowych instrukcji SQL w celu pobrania tych samych danych, które mogły zostać pobrane podczas wykonywania podstawowego zapytania SQL.
Im większa wartość N, tym więcej zapytań zostanie wykonanych, tym większy wpływ na wydajność. I w przeciwieństwie do dziennika powolnych zapytań, które mogą pomóc Ci znaleźć wolno działające zapytania, problem N + 1 nie zostanie wykryty, ponieważ każde dodatkowe zapytanie działa wystarczająco szybko, aby nie wyzwalać dziennika wolnych zapytań.
Problem polega na wykonywaniu dużej liczby dodatkowych zapytań, które ogólnie wymagają wystarczającej ilości czasu, aby spowolnić czas odpowiedzi.
Rozważmy, że mamy następujące tabele bazy danych post i post_comments, które tworzą relację jeden do wielu :
Utworzymy następujące 4 post
rzędy:
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)
Stworzymy również 4 post_comment
rekordy potomne:
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)
Problem zapytania N + 1 ze zwykłym SQL
Jeśli wybierzesz post_comments
użycie tego zapytania SQL:
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();
A później decydujesz się pobrać powiązane post
title
dla każdego 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
);
}
Wywołujesz problem zapytania N + 1, ponieważ zamiast jednego zapytania SQL wykonałeś 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'
Naprawienie problemu zapytania N + 1 jest bardzo łatwe. Wszystko, co musisz zrobić, to wyodrębnić wszystkie dane, których potrzebujesz w oryginalnym zapytaniu SQL:
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
);
}
Tym razem wykonywane jest tylko jedno zapytanie SQL, aby pobrać wszystkie dane, których jesteśmy dalej zainteresowani.
Problem zapytania N + 1 z JPA i Hibernacją
Podczas korzystania z JPA i Hibernacji istnieje kilka sposobów na wywołanie problemu zapytania N + 1, dlatego bardzo ważne jest, aby wiedzieć, jak można uniknąć takich sytuacji.
W kolejnych przykładach rozważmy, że mapujemy tabele post
i post_comments
na następujące elementy:
Odwzorowania JPA wyglądają tak:
@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
Używanie FetchType.EAGER
niejawnie lub jawnie dla stowarzyszeń JPA jest złym pomysłem, ponieważ masz zamiar pobrać znacznie więcej potrzebnych danych. Więcej,FetchType.EAGER
strategia jest również podatna na problemy z zapytaniami N + 1.
Niestety, skojarzenia @ManyToOne
i @OneToOne
używają FetchType.EAGER
domyślnie, więc jeśli twoje odwzorowania wyglądają tak:
@ManyToOne
private Post post;
Używasz FetchType.EAGER
strategii i za każdym razem, gdy zapomnisz użyć jej JOIN FETCH
podczas ładowania niektórych PostComment
encji za pomocą zapytania JPQL lub Criteria API:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Wywołujesz problem zapytania 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
Zwróć uwagę na dodatkowe SELECT, które są wykonywane, ponieważ post
stowarzyszenie musi być pobrana przed zwróceniem List
od PostComment
podmiotów.
W przeciwieństwie do domyślnego planu pobierania, którego używasz podczas wywoływania find
metody EnrityManager
zapytania, JPQL lub Criteria API definiuje jawny plan, którego Hibernacja nie może zmienić, automatycznie wstrzykuj JOIN FETCH. Musisz to zrobić ręcznie.
Jeśli w ogóle nie potrzebujesz post
skojarzenia, nie masz szczęścia, FetchType.EAGER
ponieważ nie ma sposobu, aby go nie pobrać. Dlatego lepiej jest używać FetchType.LAZY
domyślnie.
Ale jeśli chcesz użyć post
powiązania, możesz użyć, JOIN FETCH
aby uniknąć problemu z zapytaniem 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()
);
}
Tym razem Hibernacja wykona jedną instrukcję SQL:
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'
Aby uzyskać więcej informacji o tym, dlaczego należy unikać FetchType.EAGER
strategii pobierania, zapoznaj się również z tym artykułem .
FetchType.LAZY
Nawet jeśli przejdziesz na używanie FetchType.LAZY
jawnie dla wszystkich skojarzeń, nadal możesz natknąć się na problem N + 1.
Tym razem post
powiązanie jest mapowane w następujący sposób:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
Teraz, gdy pobierasz PostComment
podmioty:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Hibernacja wykona jedną instrukcję SQL:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
Ale jeśli później odniesiesz się do leniwie załadowanego post
skojarzenia:
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Otrzymasz problem z zapytaniem 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'
Ponieważ post
skojarzenie jest pobierane leniwie, podczas uzyskiwania dostępu do leniwego skojarzenia zostanie wykonana dodatkowa instrukcja SQL w celu zbudowania komunikatu dziennika.
Ponownie, poprawka polega na dodaniu JOIN FETCH
klauzuli do zapytania 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()
);
}
I podobnie jak w FetchType.EAGER
przykładzie, to zapytanie JPQL wygeneruje pojedynczą instrukcję SQL.
Nawet jeśli używasz FetchType.LAZY
powiązania podrzędnego dwukierunkowego @OneToOne
JPA i nie odwołujesz się do niego , nadal możesz wywołać problem zapytania N + 1.
Aby uzyskać więcej informacji na temat sposobu rozwiązania problemu zapytania N + 1 generowanego przez @OneToOne
powiązania, sprawdź ten artykuł .
Jak automatycznie wykryć problem zapytania N + 1
Jeśli chcesz automatycznie wykryć problem zapytania N + 1 w warstwie dostępu do danych, w tym artykule wyjaśniono, jak to zrobić za pomocą projektu typu db-util
open source.
Najpierw musisz dodać następującą zależność Maven:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
Następnie wystarczy użyć SQLStatementCountValidator
narzędzia do potwierdzenia wygenerowanych instrukcji SQL:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
Jeśli używasz FetchType.EAGER
i uruchamiasz powyższy przypadek testowy, otrzymasz następujący błąd przypadku testowego:
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!
Aby uzyskać więcej informacji na temat projektu typu db-util
open source, sprawdź ten artykuł .