В преддверии курса "Highload Architect" приглашаем вас посетить открытый урок по теме "Паттерны горизонтального масштабирования хранилищ".
А пока делимся традиционным переводом полезной статьи.
Введение
В этой статье я расскажу, в чем состоит проблема N + 1 запросов при использовании JPA и Hibernate, и как ее лучше всего исправить.
Проблема N + 1 не специфична для JPA и Hibernate, с ней вы можете столкнуться и при использовании других технологий доступа к данным.
Что такое проблема N + 1
Проблема N + 1 возникает, когда фреймворк доступа к данным выполняет N дополнительных SQL-запросов для получения тех же данных, которые можно получить при выполнении одного SQL-запроса.
Чем больше значение N, тем больше запросов будет выполнено и тем больше влияние на производительность. И хотя лог медленных запросов может вам помочь найти медленные запросы, но проблему N + 1 он не обнаружит, так как каждый отдельный дополнительный запрос выполняется достаточно быстро.
Проблема заключается в выполнении множества дополнительных запросов, которые в сумме выполняются уже существенное время, влияющее на быстродействие.
Рассмотрим следующие таблицы БД: post (посты) и post_comments (комментарии к постам), которые связаны отношением "один-ко-многим":
Вставим в таблицу 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)
А в таблицу 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
Как уже говорилось, проблема 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();
А позже решите получить заголовок (title) связанного поста (post) для каждого комментария (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-запроса вы выполнили пять (1 + 4):
SELECT pc.id AS id, pc.review AS review, pc.post_id AS postIdFROM 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
, поэтому, если ваши маппинги выглядят
следующим образом:
@ManyToOneprivate Post post;
У вас используется FetchType.EAGER
и каждый раз,
когда вы забываете указатьJOIN FETCH
при загрузке
сущностей PostComment
с помощью JPQL-запроса или
Criteria API:
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 = 1SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
Обратите внимание на дополнительные запросы SELECT, которые
появились, потому что перед возвращением списка сущностей
PostComment
необходимо извлечь ассоциацию с
post
.
В отличие от значений по умолчанию, используемых в методе
find
из EntityManager
, в JPQL-запросах и
Criteria API явно указывается план выборки (fetch plan), который
Hibernate не может изменить, автоматически применив 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 pcINNER 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
Но если позже вы обратитесь к этой lazy-load ассоциации с
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
, вы все равно можете получить N + 1.
Подробнее о том, как преодолеть проблему N+1 c
@OneToOne
-ассоциациями, читайте в
этой статье.
Кэш второго уровня
Проблема N + 1 также может возникать при использовании кэша второго уровня для обработки коллекций или результатов запроса.
Например, если выполните следующий JPQL-запрос, использующий кэш запросов:
List<PostComment> comments = entityManager.createQuery(""" select pc from PostComment pc order by pc.post.id desc """, PostComment.class).setMaxResults(10).setHint(QueryHints.HINT_CACHEABLE, true).getResultList();
Если PostComment
не находится в кэше второго
уровня, то будет выполнено N запросов для получения каждого
отдельного PostComment
:
-- Checking cached query results in region: org.hibernate.cache.internal.StandardQueryCache-- Checking query spaces are up-to-date: [post_comment]-- [post_comment] last update timestamp: 6244574473195524, result set timestamp: 6244574473207808-- Returning cached query results SELECT pc.id AS id1_1_0_, pc.post_id AS post_id3_1_0_, pc.review AS review2_1_0_FROM post_comment pcWHERE pc.id = 3 SELECT pc.id AS id1_1_0_, pc.post_id AS post_id3_1_0_, pc.review AS review2_1_0_FROM post_comment pcWHERE pc.id = 2 SELECT pc.id AS id1_1_0_, pc.post_id AS post_id3_1_0_, pc.review AS review2_1_0_FROM post_comment pcWHERE pc.id = 1
В кэше запросов хранятся только идентификаторы сущностей
PostComment
. Таким образом, если сущности
PostComment
не находятся в кэше, они будут извлечены
из базы данных и вы получите N дополнительных SQL-запросов.