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

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

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

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

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

Пример простой. У нас есть комментарии. Каждый из них относится к какому-то то топику, то есть отношение 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;
    }
}

По умолчанию в отношении @ManyToOne подразумевается режим fetch = FetchType.EAGER, что и означает принудительное извлечение полей Topic при выборке Comment.

Проверим это в тесте.

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

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

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

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-е) Hibernate выполняет дополнительный select для заполнения поля Topic. То есть делает select из Topic. В конце выводятся значения

comment.text и 
comment.topic.title

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

Если сделать fetch = FetchType.LAZY, то сразу после выполнения запроса (то есть после query.getResultList()) N+1 проблема не возникнет, ведь поле Topic в этом случае сразу не заполняется. Но когда оно нам понадобится (а рано или поздно понадобится), то при обращении к полю тот же 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 не нужно, чтобы можно было воспользоваться простым select и не наткнуться при этом на N+1 проблему.

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

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

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