Come work in Estonia – the most advanced digital society. Many Ukrainians already know that Estonia is affordable – become one of them and check out the jobs available!

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

Понимание стратегий загрузки коллекций в JPA и Hibernate является ключевым для производительности приложения, использующего ORM.

Отношениям один-ко-многим или многие-ко-многим между таблицами реляционной базы данных в объектном виде соответствуют свойства сущности типа List или Set, размеченные аннотациями @OneToMany или @ManyToMany. При работе с сущностями, которые содержат коллекции других сущностей, возникает проблема известная как «N+1 selects». Первый запрос выберет только корневые сущности, а каждая связанная коллекция будет загружена отдельным запросом. Таким образом, ORM выполняет N+1 SQL запросов, где N — количество корневых сущностей в результирующей выборке запроса.

В данной статье будут рассмотрены детали различных типов и стратегий загрузки коллекций в JPA, а в следующей части — режимы загрузки коллекций в Hibernate.

Два типа загрузки

В JPA есть 2 типа загрузки (FetchType): EAGER and LAZY. EAGER загрузка заставляет ORM загружать связанные сущности и коллекции сразу, вместе с корневой сущностью. LAZY загрузка означает, что ORM загрузит сущность или коллекцию отложено, при первом обращении к ней из кода.

FetchType в JPA говорит когда мы хотим, чтоб связанная сущность или коллекция была загружена. По умолчанию JPA провайдер загружает связанные коллекции (отношения один-ко-многим и многие-ко-многим) отложено (lazy loading). В большинстве случаев отложенная загрузка — оптимальный вариант. Нет смысла инициализировать все связанные коллекции, если к ним не будет обращений.

JPA предоставляет две основных стратегии загрузки: SELECT и JOIN.

Когда выбрана стратегия загрузки SELECT, ORM загружает связанные коллекции отдельным SQL запросом. Иногда эта стратегия может негативно повлиять на производительность, особенно, когда в результирующей выборке большое количество элементов. Эту проблему часто называют «N+1 selects».

Стратегия JOIN указывает ORM, что загружать связанные коллекции необходимо и одном SQL запросе с корневой сущностью, используя оператор LEFT JOIN в сгенерированном SQL запросе. Часто эта стратегия лучше с точки зрения производительности, особенно, когда в результирующей выборке большое количество элементов. Конечно, при условии, что в дальнейшем к загруженным коллекциям будут обращения в коде. Есть несколько способов указать ORM использовать стратегию загрузки JOIN: JPQL оператор JOIN FETCH, метод fetch класса Root (JPA Criteria), entity graph, добавленные в JPA 2.1.

У стратегии загрузки JOIN есть и недостатки.

JPQL и JPA Criteria запросы со стратегией загрузки JOIN возвращают декартово произведение (cartesian product). Это значит, что если корневая сущность содержит связанную коллекцию с 3-мя элементами, результирующая выборка будет иметь размер 3. Оператор DISTINCT может использоваться, чтобы этого избежать. Он уберет все дублирующиеся строки из результирующей выборки. Но, если результат может содержать дубликаты и это ожидаемо, оператор DISTINCT все равно уберет их.

Только одна связанная коллекция, которая загружается стратегией JOIN может быть типа java.util.List, остальные коллекции должны быть типа java.util.Set. В обратном случае, будет выброшено исключение:

HibernateException: cannot simultaneously fetch multiple bags

При использовании стратегии загрузки JOIN методы setMaxResults и setFirstResult не добавят необходимых условий в сгенерированный SQL запрос. Результат SQL запроса будет содержать все строки без ограничения и смещения согласно firstResult/maxResults. Ограничение количества и смешение строк будет применено в памяти. Также будет выведено предупреждение:

WARN HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Пример

Давайте для примера рассмотрим следующую модель. Сущность Book владеет отношениями многие-ко-многим с сущностями Author и Category. Пример целиком доступен на Github.

@Entity
public class Book implements Serializable {

    @Id
    @GeneratedValue
    private Long id;

    private String isbn;

    private String title;

    @Temporal(TemporalType.DATE)
    private Date publicationDate;

    @ManyToMany(fetch = FetchType.EAGER)
    private List<Author> authors = new ArrayList();
    
    @ManyToMany
    private List<Category> categories = new ArrayList();

    /*...*/
}

@Entity
public class Author implements Serializable {
    
    @Id
    @GeneratedValue
    private Long id;
    
    private String fullName;
    
    @ManyToMany(mappedBy = "authors")
    private List<Book> books = new ArrayList();
    
    /*...*/
}

@Entity
public class Category implements Serializable {
    
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    private String description;

    /*...*/
}

Давайте добавим тестовых данных.

Category softwareDevelopment = new Category();
softwareDevelopment.setName("Software development");
em.persist(softwareDevelopment);
            
Category systemDesign = new Category();
systemDesign.setName("System design");
em.persist(systemDesign);
            
Author martinFowler = new Author();
martinFowler.setFullName("Martin Fowler");
em.persist(martinFowler);

Book poeaa = new Book();
poeaa.setIsbn("007-6092019909");
poeaa.setTitle("Patterns of Enterprise Application Architecture");
poeaa.setPublicationDate(df.parse("2002/11/15"));
poeaa.setAuthors(asList(martinFowler));
poeaa.setCategories(asList(softwareDevelopment, systemDesign));
em.persist(poeaa);

Author gregorHohpe = new Author();
gregorHohpe.setFullName("Gregor Hohpe");
em.persist(gregorHohpe);
            
Author bobbyWoolf = new Author();
bobbyWoolf.setFullName("Bobby Woolf");
em.persist(bobbyWoolf);

Book eip = new Book();
eip.setIsbn("978-0321200686");
eip.setTitle("Enterprise Integration Patterns");
eip.setPublicationDate(df.parse("2003/10/20"));
eip.setAuthors(asList(gregorHohpe, bobbyWoolf));
eip.setCategories(asList(softwareDevelopment, systemDesign));
em.persist(eip);

Тесты будут запускаться на WildFly 8.2.1.Final с JPA 2.1 провайдером Hibernate 4.3.7.Final.

Поиск по первичному ключу

При поиске сущности по первичному ключу будет использована стратегия загрузки JOIN для коллекций с типом загрузки EAGER. Коллекции с типом загрузки LAZY будут загружены при первом обращении к ним в коде.

Book eip = em.find(Book.class, eipId);

Сгенерированный SQL:

select
    book0_.id as id1_1_0_,
    book0_.isbn as isbn2_1_0_,
    book0_.publicationDate as publicat3_1_0_,
    book0_.title as title4_1_0_,
    authors1_.books_id as books_id1_1_1_,
    author2_.id as authors_2_2_1_,
    author2_.id as id1_0_2_,
    author2_.fullName as fullName2_0_2_
from
    Book book0_
left outer join
    Book_Author authors1_
 on book0_.id=authors1_.books_id
left outer join
    Author author2_
 on authors1_.authors_id=author2_.id
where
    book0_.id=?

JPQL и JPA Criteria запросы

В JPQL запросах стандартной является стратегия загрузки SELECT. Для каждой сущности из списка результатов JQPL запроса будет выполнен дополнительный SQL запрос для загрузки связанных коллекций. Коллекции с типом загрузки LAZY будут загружены при первом обращении к ним в коде.

List<Book> books = em.createQuery("select b from Book b order by b.publicationDate")
    .getResultList();
assertEquals(2, books.size());

JPA Criteria запросы по умолчанию имеют такое же поведение, как и JQPL запросы.

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Book> cq = cb.createQuery(Book.class);
Root<Book> book = cq.from(Book.class);
cq.orderBy(cb.asc(book.get(Book_.publicationDate)));
TypedQuery<Book> q = em.createQuery(cq);
List<Book> books = q.getResultList();
assertEquals(2, books.size());

Сгенерированный SQL:

select
    book0_.id as id1_1_,
    book0_.isbn as isbn2_1_,
    book0_.publicationDate as publicat3_1_,
    book0_.title as title4_1_
from
    Book book0_
order by
    book0_.publicationDate

select
    authors0_.books_id as books_id1_1_0_,
    authors0_.authors_id as authors_2_2_0_,
    author1_.id as id1_0_1_,
    author1_.fullName as fullName2_0_1_
from
    Book_Author authors0_
inner join
    Author author1_
 on authors0_.authors_id=author1_.id
where
    authors0_.books_id=?

select
    authors0_.books_id as books_id1_1_0_,
    authors0_.authors_id as authors_2_2_0_,
    author1_.id as id1_0_1_,
    author1_.fullName as fullName2_0_1_
from
    Book_Author authors0_
inner join
    Author author1_
 on authors0_.authors_id=author1_.id
where
    authors0_.books_id=?

JPQL и JPA Criteria запросы с «join fetch»

Чтобы использовать стратегию загрузки JOIN в JQPL запросах, используйте оператор JOIN FETCH. Корневые сущности со связанными коллекциями будут загружены в одном SQL запросе. Результатом запроса будет декартово произведение (cartesian product). Вместо 2 элементов в результирующей выборке, запрос с JOIN FETCH вернет 3, потому что книга «Enterprise Integration Patterns» имеет двух авторов, поэтому будет дважды встречаться в результатах запроса.

List<Book> books = em.createQuery("select b from Book b left join fetch b.authors order by b.publicationDate")
    .getResultList();
assertEquals(3, books.size());

JPA Criteria запрос, как и JQPL запрос, вернет 3 результата из-за декартова произведения. Чтобы установить стратегию загрузки JOIN в JPA Criteria запросах необходимо использовать метод fetch c JoinType.LEFT.

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Book> cq = cb.createQuery(Book.class);
Root<Book> book = cq.from(Book.class);
book.fetch(Book_.authors, JoinType.LEFT);
cq.orderBy(cb.asc(book.get(Book_.publicationDate)));
TypedQuery<Book> q = em.createQuery(cq);
List<Book> books = q.getResultList();      
assertEquals(3, books.size());

Сгенерированный SQL:

select
    book0_.id as id1_1_0_,
    author2_.id as id1_0_1_,
    book0_.isbn as isbn2_1_0_,
    book0_.publicationDate as publicat3_1_0_,
    book0_.title as title4_1_0_,
    author2_.fullName as fullName2_0_1_,
    authors1_.books_id as books_id1_1_0__,
    authors1_.authors_id as authors_2_2_0__
from
    Book book0_
left outer join
    Book_Author authors1_
 on book0_.id=authors1_.books_id
left outer join
    Author author2_
 on authors1_.authors_id=author2_.id
order by
    book0_.publicationDate

JPQL и JPA Criteria запросы с «distinct» и «join fetch»

Оператор DISTINCT удаляет дубликаты из результатов запроса. В этом примере результат JPQL запроса с оператором DISTINCT будет содержать 2 элемента. Это хороший «workaround», когда декартово произведение, которое возвращает JPQL запрос с JOIN FETCH является проблемой.

List<Book> books = em.createQuery("select distinct b from Book b left join fetch b.authors order by b.publicationDate")
    .getResultList();
assertEquals(2, books.size());

В JPA Criteria чтобы удалить дубликаты из результатов запроса, используется метод CriteriaQuery#distinct(boolean).

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Book> cq = cb.createQuery(Book.class);
Root<Book> book = cq.from(Book.class);
cq.distinct(true);
book.fetch(Book_.authors, JoinType.LEFT);
cq.orderBy(cb.asc(book.get(Book_.publicationDate)));
TypedQuery<Book> q = em.createQuery(cq);
List<Book> books = q.getResultList();
assertEquals(2, books.size());

Сгенерированный SQL:

select
    distinct book0_.id as id1_1_0_,
    author2_.id as id1_0_1_,
    book0_.isbn as isbn2_1_0_,
    book0_.publicationDate as publicat3_1_0_,
    book0_.title as title4_1_0_,
    author2_.fullName as fullName2_0_1_,
    authors1_.books_id as books_id1_1_0__,
    authors1_.authors_id as authors_2_2_0__
from
    Book book0_
left outer join
    Book_Author authors1_
 on book0_.id=authors1_.books_id
left outer join
    Author author2_
 on authors1_.authors_id=author2_.id
order by
    book0_.publicationDate

JPQL запрос с entity graph

В JPA 2.1 был добавлен новый способ управления стратегией загрузки — entity graph.

EntityGraph<Book> fetchAuthors = em.createEntityGraph(Book.class);
fetchAuthors.addSubgraph(Book_.authors);
List<Book> books = em.createQuery("select b from Book b order by b.publicationDate")
    .setHint("javax.persistence.fetchgraph", fetchAuthors)
    .getResultList();        
assertEquals(3, books.size());

Сгенерированный SQL:

select
    book0_.id as id1_1_0_,
    author2_.id as id1_0_1_,
    book0_.isbn as isbn2_1_0_,
    book0_.publicationDate as publicat3_1_0_,
    book0_.title as title4_1_0_,
    author2_.fullName as fullName2_0_1_,
    authors1_.books_id as books_id1_1_0__,
    authors1_.authors_id as authors_2_2_0__
from
    Book book0_
left outer join
    Book_Author authors1_
 on book0_.id=authors1_.books_id
left outer join
    Author author2_
 on authors1_.authors_id=author2_.id
order by
    book0_.publicationDate

JPQL запрос с «join fetch» нескольких коллекций

Следующее исключение возникнет, если несколько коллекций типа java.util.List загружаются одновременно. Только одна коллекций, которая загружается со стратегией JOIN может быть типа java.util.List, остальные коллекции, которые загружаются стратегией JOIN должны быть типа java.util.Set.

Обратите внимание, что загружать несколько коллекций стратегией JOIN — это не всегда оптимальный вариант. Если обе коллекции будут иметь по 100 элементов, SQL запрос вернет 10000 строк. Иногда вместо этого более эффективно выполнить 2 запроса: первый, загружающий первую коллекцию, и второй, загружающий вторую коллекцию. Это значительно уменьшит суммарное количество строк в результатах запросов.

List<Book> books = em.createQuery("select b from Book b left join fetch b.authors left join fetch b.categories")
    .getResultList();

Будет выброшено исключение:

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

JPQL запрос с «join fetch» и «max results»

Когда используется стратегия загрузки JOIN, методы setMaxResults и setFirstResult не добавят соответствующих условий в сгенерированный SQL запрос. Запрос вернет все строки без ограничений и смещений, указанных в firstResult/maxResults. Вместо этого, ограничения будут применены в памяти. Если фильтрация в памяти вызывает проблемы, не используйте setFirsResult, setMaxResults и getSingleResult со стратегией загрузки JOIN.

List<Book> books = em.createQuery("select b from Book b left join fetch b.authors order by b.publicationDate")
    .setFirstResult(0)
    .setMaxResults(1)
    .getResultList();
assertEquals(1, books.size());

Будет выведено предупреждение:
WARN [org.hibernate.hql.internal.ast.QueryTranslatorImpl] HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Сгенерированный SQL:

select
    book0_.id as id1_1_0_,
    author2_.id as id1_0_1_,
    book0_.isbn as isbn2_1_0_,
    book0_.publicationDate as publicat3_1_0_,
    book0_.title as title4_1_0_,
    author2_.fullName as fullName2_0_1_,
    authors1_.books_id as books_id1_1_0__,
    authors1_.authors_id as authors_2_2_0__
from
    Book book0_
left outer join
    Book_Author authors1_
 on book0_.id=authors1_.books_id
left outer join
    Author author2_
 on authors1_.authors_id=author2_.id
order by
    book0_.publicationDate

Выводы

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

Но самая популярная реализация JPA, Hibernate предоставляет еще больше способов управления загрузкой отношений один-ко-многим и многие-ко-многим. FetchMode в Hibernate говорит как мы хотим, чтоб связанные сущности или коллекции были загружены: используя по дополнительному SQL запросу на коллекцию, в одном запросе с корневой сущностью, используя JOIN, или в дополнительном запросе, используя SUBSELECT. Об этом и других средствах загрузки связанных коллекций, которые предоставляет Hibernate, поговорим в следующей части.

15 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

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

Хороший вопрос, полноценный ответ на который заслуживает отдельной статьи.

Например, есть свойство hibernate.enable_lazy_load_no_trans, которое можно добавить в persistence.xml. Добавив это свойство, даже после коммита транзакии и закрытия сессии, в которой был загружен объект, к его коллекциям с типом загрузки LAZY можно будет обращаться.

Пример persistence.xml:

<persistence version="2.1" xmlns="..." xsi:schemaLocation="...">
    <persistence-unit name="myexample">
        <jta-data-source>java:/myexampleDS</jta-data-source>
        <properties>
            <property name="hibernate.hbm2ddl.auto" value="update"/>
            <property name="hibernate.enable_lazy_load_no_trans" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

Спасибо. А в чем принципиальное отличие этого решения от EntityManagerInViewFilter?

OpenEntityManagerInViewFilter — это класс из Spring Framework, соответственно в Java EE приложениях без Spring его использовать не получится. Более того, на мой взгляд, свойство в persistece.xml выглядит менее инвазивно, чем специальный фильтр.

Ось такого посту дуже не вистачало. Проблема у тому що ці та інші граблі доволі часті для розробників початківців, а ресурси часто дають лише шаблонні уривки з документації, а моменти на які явно варто звернути увагу — опускають.
На рахунок

hibernate.enable_lazy_load_no_trans
 — пишуть що можливі проблеми, але як зрозумів більше для версій 4.x.x. Більшість пофікшені. Хтось взагалі називає Антипаттерном. Користуюсь hibernate 5.1.2. Є якісь критичні моменти з використанням цієї функції?
Спасибі, Євген!

Стоп! Стоп! Стоп!

Вы действительно опубликовали техническую статью на DOU?!
А как же зарплаты, чёрные списки рекуртёров, проблемы меньшинств, элитноё жильё для элитных разработчиков и т.д.?

Такие статьи наносят непоправимую пользу разработчикам.
«Астанавитесь!» ©

А если серьёзно, то спасибо автору за хороший материал.

Это была уже третья техническая статья Евгения на DOU:
dou.ua/...rs/evgeniykhist/articles

Спасибо, Сергей. Уже увидел и читаю.

Хорошая статья, хочу добавить от себя:
1. EAGER — полное зло и его лучше избегать. EAGER приводит к неявным N+1 в HQL запросах. Так же плох тем что если у тебя есть две ситуации: в одной ты хочешь получить Book с authors то все хорошо, но если тебе просто нужен обьект book, а у тебя всегда будут подтягиваться авторы, когда тебе это не нужно в твоей ситуации. Я уже не говорю о том что если в авторе есть еще EAGER поля и в них тоже могут быть EAGER, и получается что достается большой граф обьектов. Проблема в том что с LAZY в EAGER можно сделать, но наоборот — к сожалению нет, а так же управление фетчингом на уровне ентити это глобальная конфигурация и вы не можете поменять ее в рантайме, в отличии от EntityGraph, где можно динамически управлять стратегий фетчинга на уровне запроса.
2. оператор DISTINCT на самом деле убирает дубликаты на стороне сервера, весь данные запроса летят на сервер в любом случае, хибернейт когда маршалит обьекты убирает дубликаты, это так же важно понимать. А так же можно обернуть результат в Set.
3. Хорошо что были показаны сравнения HQL и Criteria Builder API, но не показали что будет если использовать Hibernate Criteria, а на самом деле результат отличается от Criteria Builder. Так как если в Book есть EAGER поля то Hibernate Criteria для запроса списка книг сразу сгенерирует JOIN в отличие от Criteria Builder, где EAGER поля подтянутся отдельными запросами.
4. И круто было бы если показать решения проблемы HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! А оно есть.
5. Так же есть совет чтоб избежать Cartesian Product — нужно следовать простому правилу и все будет хорошо — нельзя делать join fetch или entity graph на больше чем одну коллекцию, на many-to-one и one-to-one можно делать много джоинов. Следуя этому простому правилу все будет хорошо.
6. Так же полезно знать про Batch Fetching и FetchMode Subselect.

Я не могу рекомендовать никогда не использовать EAGER. Все зависит от конкретной ситуации. Мой подход — знать все варианты и проанализировав ситуацию выбрать оптимальный.

Я специально добавил примеры на GitHub, чтоб каждый смог убедиться, что JPQL оператор DISTINCT транслируется в SQL оператор DISTINCT и сам Hibernate никакие дубликаты уже не удаляет, так как SQL запрос возвращает только уникальные записи. Можно обернуть результат в Set, но тогда вы должны не забыть реализовать equals и hashcode и будете получать по сети, если база данных на другом узле в сети, дубликаты.

По поводу join fetch, entity graph и cartesian product. Во-первых, делать join fetch более одной коллекции просто не получится, если эти коллекции типа List. Будет MultipleBagFetchException. Подробности в секции «JPQL запрос с „join fetch“ нескольких коллекций». Там даже есть тест, который можно запустить на сервере WildFly. Во-вторых, даже 1 SQL JOIN возвращает декартово произведение. У одной книги 3 автора. Мы делаем BOOK JOIN AUTHOR и получаем 3 строки. Это описано во многих секциях статьи, например, «JPQL и JPA Criteria запросы с „join fetch“», которая также сопровождается тестом, который можно запустить при помощи Maven.

Спасибо за статью :)

LAZY загрузка означает, что ORM загрузит сущность или коллекцию отложено, при первом обращении к ней из кода.
Это не всегда так. JPA может вернуть mock объект, а саму entity подгрузить именно тогда, когда на mock объекте будет вызван метод, а не при первом обращении к сущности / коллекции.

Самая полезная статья на ДОУ!
Спасибо, Евгений.

Нужно больше технических статей на доу. А то котики, девушки, квартиры и темы о том, как стать сеньором — надоели =)

без таких статей сеньйором не станеш.

«Когда собираются критики, они обсуждают композицию, форму и содержание. Когда собираются художники, они ведут разговор о том, где можно купить дешевый скипидар»
— Pablo Picasso

Так что с темами на DOU все ок, вроде бы.

Подписаться на комментарии