N+1 проблема в Hibernate

N+1 проблема в Hibernate состоит в том, в некоторых ситуациях один HQL select преобразуется N+1 SQL select-ов. Это отрицательно влияет на производительность,  поэтому такого поведения нужно избегать.

Эти дополнительные SQL select-ы нужны для заполнения поля, ссылающегося на другую сущность(и).
Здесь N – количество объектов, возвращаемых первым явным select-ом. Для каждого из них надо заполнить поле, вот и получается еще N select-ов.

Ниже рассмотрим пример, демонстрирующий, как легко наткнуться на N+1 проблему в Hibernate. И как этой проблемы избежать.

Сразу скажу, что выбирать мы будем комментарии – select * from comment – это один select. А так как каждый комментарий ссылается на топик, то этот запрос повлечет дополнительные N селектов select * from topic where topic.id=?, подтягивающие топик для каждого комментария, где N – количество комментариев, полученных в первом запросе. То есть всего N+1 (вместо одного) select.

Модель (Сущности)

Пример простой. Как уже говорилось, нас есть комментарии. Каждый из них относится к какому-то то топику, то есть отношение ManyToOne:

В базе так
Таблицы в базе

Класс Comment:

@Data
@NoArgsConstructor
@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String text;
    @ManyToOne//(fetch = FetchType.LAZY)
    private Topic topic;

    public Comment(String text){
        this.text=text;
    }
}

Класс Topic:

@Data
@NoArgsConstructor
@Entity
public class Topic {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String title;
    public Topic(String title){
        this.title=title;
    }
}

Если не указать стратегию явно, для поля topic подразумевается стратегия fetch = FetchType.EAGER. (Эта стратегия считается стратегией по умолчанию для всех полей, аннотированных @ManyToOne). Это означает, что при выборе комментариев (select c from Comment c) Hibernate будет заполнять значением поле topic. Для этого он выполнит дополнительный select для каждого комментария. А значит, возникнет n+1 проблема.

Продемонстрируем это в тесте.

Демонстрация N+1 проблемы

Для этого заполним базу пятью топиками с пятью комментариями (по одному на каждый) в методе @BeforeAll – то есть перед всеми тестами мы заполнили базу.

А затем просто выполним select для комментариев:

select c from Comment c

Заполнение и выборка представлены двумя методами: @BeforeAll, выполняющимся перед тестом, и самим тестом @Test:

public class NPlus1Test {

    @BeforeAll
    private static void createTopics() {
        HibernateUtil.doInHibernate(session -> {
            for (int i = 0; i < 5; i++) {
                Topic topic = new Topic("topic" + i);
                Comment comment = new Comment("comment" + i);
                comment.setTopic(topic);
                session.persist(comment);
                session.persist(topic);
            }
        });
    }

    @Test
    @DisplayName("если fetch = FetchType.EAGER, то получаем в консоли N+1 select: 5+1")
    public void whenEager_thenNplus1Problem() {
        HibernateUtil.doInHibernate(session -> {
            Query<Comment> query = session.createQuery("select c from Comment c");

            List<Comment> comments = query.getResultList();
            Assertions.assertEquals(5, comments.size());
            comments.forEach(comment -> System.out.println(comment.getText() + " " + comment.getTopic().getTitle()));

        });
    }
}

В консоли для самого теста получим:

Hibernate: select comment0_.id as id1_0_, comment0_.text as text2_0_, comment0_.topic_id as topic_id3_0_ from Comment comment0_

Hibernate: select topic0_.id as id1_1_0_, topic0_.title as title2_1_0_ from Topic topic0_ where topic0_.id=?
Hibernate: select topic0_.id as id1_1_0_, topic0_.title as title2_1_0_ from Topic topic0_ where topic0_.id=?
Hibernate: select topic0_.id as id1_1_0_, topic0_.title as title2_1_0_ from Topic topic0_ where topic0_.id=?
Hibernate: select topic0_.id as id1_1_0_, topic0_.title as title2_1_0_ from Topic topic0_ where topic0_.id=?
Hibernate: select topic0_.id as id1_1_0_, topic0_.title as title2_1_0_ from Topic topic0_ where topic0_.id=?

comment0 topic0
comment1 topic1
comment2 topic2
comment3 topic3
comment4 topic4

То есть мы написали один select, а в консоли видно, что на самом деле их выполняется 6. В этом и состоит проблема.

Происходит так потому, что для каждого комментария (а их выбралось пять в первом select-е) Hibernate под капотом выполняет дополнительный select для заполнения поля Topic. То есть делает select из Topic. В конце для наглядности мы  выводим значения comment.text и comment.topic.title.

Встает вопрос, как избавиться от проблемы: как сократить количество неожиданных select-ов, либо вовсе их убрать?

Может поставить  fetch = FetchType.LAZY?

Если сменить для поля topic сущности Comment стратегию извлечения, то есть поставить над полем аннотацию fetch = FetchType.LAZY (ленивое извлечение), то сразу после выполнения запроса – то есть после

query.getResultList())

поле Topic не заполняется и соответствующий select … from topic  не выполняется. То есть сразу N+1 проблема не возникнет.

Но когда значение поля Topic нам понадобится (а рано или поздно наверняка понадобится), и мы к нему обратимся с помощью:

comment.getTopic()

дополнительный select все же выполнится – просто это произойдет позже.

Так как же этого избежать? Есть выход – join fetch. С помощью него мы сделаем в одном запросе выборку из обеих таблиц сразу.

Устранение проблемы с помощью Join Fetch

Для выбора комментариев напишем не простой select, а select с join fetch, который преобразуется в SQL с inner join.

@Test
@DisplayName("если пофиксить проблему с помощью  join fetch, то получаем в консоли один select")
public void whenJoinFetch_thenNoProblem() {
    HibernateUtil.doInHibernate(session -> {
        Query<Comment> query = session.createQuery("select c from Comment c join fetch c.topic t", Comment.class);

        List<Comment> comments = query.getResultList();
        Assertions.assertEquals(5, comments.size());
        comments.forEach(comment -> System.out.println(comment.getText() + " " + comment.getTopic().getTitle()));
    });
}

В консоли мы увидим один SQL-запрос:

Hibernate: select comment0_.id as id1_0_0_, topic1_.id as id1_1_1_, comment0_.text as text2_0_0_, comment0_.topic_id as topic_id3_0_0_, topic1_.title as title2_1_1_ from Comment comment0_ inner join Topic topic1_ on comment0_.topic_id=topic1_.id

и также заполненное поле topic:

comment0 topic0
comment1 topic1
comment2 topic2
comment3 topic3
comment4 topic4

Чтобы sql-запросы отображались в консоли, в файле hibernate.cfg.xml включена настройка:

<property name="hibernate.show_sql">true</property>

Итоги

Исходный код тут – можно отредактировать FetchType и проследить выполняемые запросы.

Для того, чтобы избежать N+1 проблемы, рекомендуется выбирать комментарии, ссылающиеся на топик с помощью join fetch, а не с помощью обычного select.

Рекомендуется также всегда выставлять fetch = FetchType.LAZY  – на те случаи, когда заполненное поле Topic не нужно.

Также важно не увлечься join fetch, чтобы избежать Cartesian product problem, о которой речь идет в следующей статье.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *