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

https://dou.ua/lenta/articles/jpa-fetch-types/

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

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

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

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

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

В JPA есть 2 типа загрузки (FetchType): EAGER and LAZYEAGER загрузка заставляет 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. Вместо этого, ограничения будут применены в памяти. Если фильтрация в памяти вызывает проблемы, не используйте setFirsResultsetMaxResults и 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, поговорим в следующей части.