Поскольку это очень распространенный вопрос, я написал
эту статью , на которой основан этот ответ.
В чем проблема N + 1?
Проблема запроса N + 1 возникает, когда структура доступа к данным выполнила N дополнительных операторов SQL для извлечения тех же данных, которые могли быть получены при выполнении основного запроса SQL.
Чем больше значение N, тем больше запросов будет выполнено, тем больше влияние на производительность. И, в отличие от медленного журнала запросов, который может помочь вам найти медленные запросы, проблема N + 1 не будет обнаружена, потому что каждый отдельный дополнительный запрос выполняется достаточно быстро, чтобы не вызывать медленный журнал запросов.
Проблема заключается в выполнении большого количества дополнительных запросов, которые в целом требуют достаточного времени для замедления времени отклика.
Давайте рассмотрим, что у нас есть следующие таблицы базы данных post и post_comments, которые образуют отношение таблицы «один ко многим» :
Мы собираемся создать следующие 4 post
строки:
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)
И мы также создадим 4 post_comment
дочерние записи:
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)
Проблема запроса N + 1 с простым SQL
Если вы выбрали post_comments
использование этого 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();
И позже вы решаете получить связанный post
title
с каждым 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
);
}
Вы собираетесь вызвать проблему запроса N + 1, потому что вместо одного запроса SQL вы выполнили 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'
Исправить проблему с запросом N + 1 очень просто. Все, что вам нужно сделать, это извлечь все данные, которые вам нужны в исходном запросе 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
);
}
На этот раз выполняется только один SQL-запрос для извлечения всех данных, которые нам еще интересны.
Проблема запроса N + 1 с JPA и Hibernate
При использовании JPA и Hibernate есть несколько способов вызвать проблему с запросом N + 1, поэтому очень важно знать, как можно избежать этих ситуаций.
В течение следующих примеров мы рассмотрим КАРТОГРАФИРОВАНИЕ post
и post_comments
таблиц для следующих лиц:
Отображения JPA выглядят так:
@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
Использование FetchType.EAGER
неявного или явного для ваших ассоциаций JPA - плохая идея, потому что вы собираетесь получать больше данных, которые вам нужны. Более того, FetchType.EAGER
стратегия также подвержена проблемам с N + 1 запросами.
К сожалению, @ManyToOne
и @OneToOne
ассоциации используют FetchType.EAGER
по умолчанию, поэтому , если ваши отображения выглядеть следующим образом :
@ManyToOne
private Post post;
Вы используете FetchType.EAGER
стратегию и каждый раз, когда вы забываете использовать ее JOIN FETCH
при загрузке некоторых PostComment
сущностей с помощью запроса API JPQL или Criteria:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Вы собираетесь вызвать проблему запроса 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
Обратите внимание на дополнительном ЗЕЬЕСТ, которые выполняются , потому что post
ассоциация должна быть извлечены до возвращения List
из PostComment
субъектов.
В отличие от плана выборки по умолчанию, который вы используете при вызове find
метода EnrityManager
, запрос API-интерфейса JPQL или Criteria определяет явный план, который Hibernate не может изменить, внедрив FETCH JOIN автоматически. Итак, вам нужно сделать это вручную.
Если вам вообще не нужна post
ассоциация, вам не повезло при использовании, FetchType.EAGER
потому что нет способа избежать ее получения. Вот почему лучше использовать FetchType.LAZY
по умолчанию.
Но, если вы хотите использовать post
ассоциацию, вы можете использовать ее JOIN FETCH
для решения проблемы 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()
);
}
На этот раз Hibernate выполнит одну инструкцию 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'
Чтобы узнать больше о том, почему вы должны избегать FetchType.EAGER
стратегии извлечения, ознакомьтесь с этой статьей .
FetchType.LAZY
Даже если вы переключитесь на использование FetchType.LAZY
явно для всех ассоциаций, вы все равно можете столкнуться с проблемой N + 1.
На этот раз post
ассоциация отображается так:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
Теперь, когда вы выбираете PostComment
объекты:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Hibernate выполнит одну инструкцию SQL:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
Но, если потом, вы будете ссылаться на ленивую post
ассоциацию:
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Вы получите вопрос 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'
Поскольку post
связь извлекается лениво, вторичный оператор SQL будет выполняться при доступе к ленивой ассоциации, чтобы построить сообщение журнала.
Опять же, исправление состоит в добавлении JOIN FETCH
предложения к запросу 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()
);
}
И, как и в FetchType.EAGER
примере, этот запрос JPQL будет генерировать один оператор SQL.
Даже если вы используете FetchType.LAZY
и не ссылаетесь на дочернюю ассоциацию двунаправленного @OneToOne
отношения JPA, вы все равно можете вызвать проблему запроса N + 1.
Для получения дополнительной информации о том, как вы можете преодолеть проблему запроса N + 1, созданную @OneToOne
ассоциациями, ознакомьтесь с этой статьей .
Как автоматически определить проблему с запросом N + 1
Если вы хотите автоматически обнаружить проблему с запросом N + 1 на уровне доступа к данным, в этой статье объясняется, как это можно сделать с помощью проекта с db-util
открытым исходным кодом.
Во-первых, вам нужно добавить следующую зависимость Maven:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
После этого вам просто нужно использовать SQLStatementCountValidator
утилиту для утверждения базовых операторов SQL, которые генерируются:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
Если вы используете FetchType.EAGER
и запускаете приведенный выше тестовый пример, вы получите следующий тестовый случай:
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!
Чтобы узнать больше о проекте с db-util
открытым исходным кодом, ознакомьтесь с этой статьей .