Поскольку это очень распространенный вопрос, я написал
эту статью , на которой основан этот ответ.
В чем проблема 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открытым исходным кодом, ознакомьтесь с этой статьей .