[Высокая производительность, Программирование, SQL] Проблема с N+1 запросами в JPA и Hibernate (перевод)

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
24-Ноя-2020 21:31
В преддверии курса"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 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 с помощью 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 = 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
Обратите внимание на дополнительные запросы 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 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
Но если позже вы обратитесь к этой 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 pc
WHERE 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 pc
WHERE 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 pc
WHERE pc.id = 1
В кэше запросов хранятся только идентификаторы сущностей PostComment. Таким образом, если сущности PostComment не находятся в кэше, они будут извлечены из базы данных и вы получите N дополнительных SQL-запросов.
===========
Источник:
habr.com
===========

===========
Автор оригинала: Vlad Mihalcea
===========
Похожие новости: Теги для поиска: #_vysokaja_proizvoditelnost (Высокая производительность), #_programmirovanie (Программирование), #_sql, #_sql, #_jpa, #_hibernate, #_blog_kompanii_otus._onlajnobrazovanie (
Блог компании OTUS. Онлайн-образование
)
, #_vysokaja_proizvoditelnost (
Высокая производительность
)
, #_programmirovanie (
Программирование
)
, #_sql
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 20:11
Часовой пояс: UTC + 5