N+1 проблема в Hibernate состоит в том, в некоторых ситуациях один HQL select преобразуется N+1 SQL 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; } }
Если не указать стратегию явно, для поля 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, о которой речь идет в следующей статье.
«дополнительный select все же выполнится — просто это произойдет позже.» — один дополнительный select выполнится при получении одного topic-а одного comment-а, или при обращении к одному любому topic-у выполнится сразу 5 дополнительных select-ов?
Просто, если будет один дополнительный select, то это вроде и хорошо.
первый вариант — один дополнительный select выполнится при получении одного topic-а одного comment-а
Но это не хорошо, если изначальная задача — получить все комментарии с топиками. То, что можно сделать одним select-ом, будет сделано шестью.
Будет ли адекватным решение проблемы если навесить
@BatchSize(size = 256) над
private Topic topic;
?
Нет, конечно, это не решит твою проблему.
Есть аннотация @Fetch, она поможет в исключительных ситуациях и только для коллекций!
Всё верно в статье, нужно использовать join fetch + Lazy везде
Также ещё есть Entity Graphs, которые прикольно юзать
Можно аннотацию @FetchProfile посмотреть (хотя она тоже не идеальна).
Большое спасибо!