Стратегии загрузки коллекций в Hibernate

Всего в хибернейте существует 4 способа управлять загрузкой дочерних коллекций:

  1. SELECT
  2. JOIN
  3. SUBSELECT
  4. BATCH

Каждая из стратегий не лишена недостатков и достоинств. Рассмотрим их на примере интернет магазина. Представим, что наш интернет магазин продает только книги, а пользователи могут оставлять к ним комментарии. В базе будет всего 2 таблицы book и comment.

Доменные объекты опишем так:

@Entity
@Table(name="book")
public class SelectBook implements java.io.Serializable {
    @Id
    @SequenceGenerator(name = "book_seq")
    @Column(name = "book_id")
    Long id;    
    String title;    
    String author;    
    Set<SelectComment> comments;
    
//getters, setters...
@Entity
@Table(name="comment")
public class SelectComment {

    @Id
    @SequenceGenerator(name = "comment_seq")
    @Column(name = "comment_id")
    Long id;

    @ManyToOne
    SelectBook book;
    String text;    
    
//getters, setters...

Данные которые лежат в БД:

Таблица book

book_id author title
46 J. K. Rowling Garry Potter
47 Joseph Heller Catch-22: 50th Anniversary Edition
48 J.R.R. Tolkien The Lord of the Rings

Таблица comment

comment_id text comment_id
1 Best book I have ever read! 46
2 Whoever wants a lovely interesting… 46
3 I’ve loved Harry Potter since it first came out… 46
4 Great read even for the second time! 47
5 I love it 47
6 Life is a comedy to those who think, and a tragedy… 47

Select

Это самая распространенная стратегия загружки дочерних данных, именно ее используется хибернейт по умолчанию, если стратегия загрузки не указана явно. Она выполняет загрузку дочерней коллекций с помощью отдельного select запроса.

Пример (укажем только связь с комментариями аннотацией @OneToMany):

    @OneToMany(fetch = FetchType.EAGER, mappedBy = "book")
    Set<SelectComment> comments;

Получим все книги с помощью HQL:

List<Book> books = session.createQuery("from Book b").list();

Что приведет к следующим запросам в БД:

Hibernate: 
    select
        selectbook0_.book_id as book_id1_0_,
        selectbook0_.author as author2_0_,
        selectbook0_.title as title3_0_ 
    from
        book selectbook0_ 
Hibernate: 
    select
        comments0_.book_book_id as book_boo3_1_0_,
        comments0_.comment_id as comment_1_1_0_,
        comments0_.comment_id as comment_1_1_1_,
        comments0_.book_book_id as book_boo3_1_1_,
        comments0_.text as text2_1_1_ 
    from
        comment comments0_ 
    where
        comments0_.book_book_id=?
Hibernate: 
    select
        comments0_.book_book_id as book_boo3_1_0_,
        comments0_.comment_id as comment_1_1_0_,
        comments0_.comment_id as comment_1_1_1_,
        comments0_.book_book_id as book_boo3_1_1_,
        comments0_.text as text2_1_1_ 
    from
        comment comments0_ 
    where
        comments0_.book_book_id=?
Hibernate: 
    select
        comments0_.book_book_id as book_boo3_1_0_,
        comments0_.comment_id as comment_1_1_0_,
        comments0_.comment_id as comment_1_1_1_,
        comments0_.book_book_id as book_boo3_1_1_,
        comments0_.text as text2_1_1_ 
    from
        comment comments0_ 
    where
        comments0_.book_book_id=?

Итого выполнилось 4 запроса: 1 запрос на получение всех книг и 3 запроса комментариев для каждой книги. Чем больше книг, тем больше паразитных запросов будет выполнено к БД. Это проблема называется проблемой N+1 запроса (см. https://secure.phabricator.com/book/phabcontrib/article/n_plus_one/). Такой способ выборки данных удобен, если комментарии запрашиваются для некоторых книг. Если же необходимо получить комментарии для всех книг участвующих в запросе – стратегия SELECT самый плохой способ.

Достоинства
  1. Рабочая стратегия без подводных камней для данных небольшого объема.
Недостатки
  1. Проблема N+1 запроса, которая при больших объемах данных может существенно снизить скорость.

JOIN

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

Пример:

session.createQuery("select b from JoinBook b join fetch b.comments comments")

Итого в логе видим только 1 запрос к БД:

Hibernate: 
    select
        joinbook0_.book_id as book_id1_0_0_,
        joinbook0_.author as author2_0_0_,
        joinbook0_.title as title3_0_0_,
        comments1_.book_book_id as book_boo3_1_1_,
        comments1_.comment_id as comment_1_1_1_,
        comments1_.comment_id as comment_1_1_2_,
        comments1_.book_book_id as book_boo3_1_2_,
        comments1_.text as text2_1_2_ 
    from
        book joinbook0_ 
    left outer join
        comment comments1_ 
            on joinbook0_.book_id=comments1_.book_book_id 
    where
        joinbook0_.book_id=?
Особенности использования
  • Объявление данной стратегии применит режим джойнов только для запросов поиска сущностей по id: session.find(class, id).

  • Для остальных запросов установленный режим будет игнорироваться (http://stackoverflow.com/questions/36796798/why-hibernate-sometimes-ignores-fetchmode-join), поэтому в hql необходимо явно указывать таблицы для джойна (более того, когда хибернейт встречает join в запросе, он игнорирует любую установленную ранее стратегию и забирает данные в режиме JOIN).

  • Работа стратегия Join подразумевает энергичную загрузку данных, поэтому даже если указать ленивую (FetchType = LAZY), параметр будет проигнорирован и данные загрузятся сразу же.

Недостатки стратегии
  1. Большой паразитный размер ответа БД. Основным недостатком данной стратегии является особенность работы оператора join, который при соединении второй таблицы выполняет декартово произведение строк. Это означает, что если у нас есть 2 книги по 3 комментария, результат выборки будет содержать 6 строк. Что влечет за собой существенное увеличение размера ответа БД.

  2. Отсутствие пейджинации в sql-запросе. Исходя из первого недостатка, в выборке будут дублирующиеся книги, что не позволяет использовать пейджинацию на уровне запросов. Поэтому при указании setMaxResults(), setFirstResult(), хибернейт сначала скачает две таблицы полностью (book и comments), а только потом самостоятельно отделит дубли и применит пейджинацию. Если представить, что в базе содержится 50 000 книг, то запрос 10 книг с пейджинацией, приведет к скачиванию всей таблицы book, то есть 50 000 книг!

Batch

Один из способов получения данных без недостатков стратегии JOIN - использование режима SELECT с пакетной выборкой зависимых коллекций. Это способ отличается от стратегии SELECT только тем, что зависимые коллекции загружаются не одиночными запросами, а пакетно. Где размером пакета управляет аннотация @BatchSize:

    @OneToMany(fetch = FetchType.EAGER, mappedBy = "book")
    @Fetch(FetchMode.SELECT)
    @BatchSize(size = 5)
    Set<BatchComment> comments;

Запросы:

Hibernate: 
    select
        batchbook0_.book_id as book_id1_0_,
        batchbook0_.author as author2_0_,
        batchbook0_.title as title3_0_ 
    from
        book batchbook0_
Hibernate: 
    select
        comments0_.book_book_id as book_boo3_1_1_,
        comments0_.comment_id as comment_1_1_1_,
        comments0_.comment_id as comment_1_1_0_,
        comments0_.book_book_id as book_boo3_1_0_,
        comments0_.text as text2_1_0_ 
    from
        comment comments0_ 
    where
        comments0_.book_book_id in (
            ?, ?, ?
        )

Данная стратегия позволяет использовать пейджинацию, т.к. первым выполняется запрос на получение родительских объектов, где возможно применение интервалов к выборке. А затем, для полученных объектов, запрашиваются дочерние коллекции.

Достоинства
  1. Решает проблему N+1 не создавая других: нет проблемы Декартова произведения, работает пейджинация в самом sql запросе.
Недостатки
  1. Необходимо вручную устанавливать размер пакета. Не всегда ясно чему он должен быть равен. Это скорее не недостаток, а небольшое неудобство.

Subselect

Как в предыдущем, для данного режима ленивая загрузка игнорируется. Всего формируется 2 запроса, первый для получения книг, второй - для получения комментариев с подзапросом.

Включение режима subselect:

    @OneToMany(fetch = FetchType.EAGER, mappedBy = "book")
    @Fetch(FetchMode.SUBSELECT)
    Set<SelectComment> comments;

Пример:

Hibernate: 
    select
        selectbook0_.book_id as book_id1_0_,
        selectbook0_.author as author2_0_,
        selectbook0_.title as title3_0_ 
    from
        book selectbook0_ limit ?
Hibernate: 
    select
        comments0_.book_book_id as book_boo3_1_1_,
        comments0_.comment_id as comment_1_1_1_,
        comments0_.comment_id as comment_1_1_0_,
        comments0_.book_book_id as book_boo3_1_0_,
        comments0_.text as text2_1_0_ 
    from
        comment comments0_ 
    where
        comments0_.book_book_id in (
            select
                selectbook0_.book_id 
            from
                book selectbook0_
        )
Недостатки
  1. Отсутствие пейджинации. Хотя стратегии Subselect ничто не мешает использовать в подзапросе пейджинацию из родительского запроса, в хибернейте это не работает. Баг заведен в 2007 году https://hibernate.atlassian.net/browse/HHH-2666 и не исправлен даже в версии 5.2.3.Final. Более подробно можно почитать здесь http://www.christophbrill.de/de_DE/hibernate-fetch-subselect-performance/
Достоинства
  1. Всего 2 запроса к БД.
  2. Нет проблемы Декартова произведения.

Динамический выбор стратегии загрузки

Установленный один раз режим запросов будет распространяться на все существующие и новые запросы. На практике же часто необходима использовать в разных запросах различные стратегии загрузки. Небольшую свободу, конечно, предоставляет режим JOIN, позволяя определять коллекции для загрузки прямо в запросах, но в целом удобного выбора стратегий в хибернейте нет.

Written on November 15, 2016