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