Стратегии загрузки коллекций в 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). Это значит, что если корневая сущность содержит связанную коллекцию с 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, поговорим в следующей части.