Оскільки це дуже поширене питання, я написав
цю статтю , на якій ґрунтується ця відповідь.
У чому полягає проблема запиту 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 або критеріїв:
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 або критеріїв визначає явний план, який в режимі глибокого сну не може змінити, автоматично вводячи JOIN FETCH. Отже, робити це потрібно вручну.
Якщо вам взагалі не потрібна 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відкритим кодом див. У цій статті .