Статьи

Использование @NamedEntityGraph для более избирательной загрузки объектов JPA в сценариях N + 1

Проблема N + 1 является распространенной проблемой при работе с решениями ORM. Это происходит, когда вы устанавливаете fetchType для некоторого отношения @OneToMany как lazy, чтобы загружать дочерние объекты только при доступе к Set / List. Предположим, у нас есть сущность Customer с двумя отношениями: набор заказов и набор адресов для каждого клиента.

1
2
3
4
5
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Set<OrderEntity> orders;
 
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Set<AddressEntity> addresses;

Чтобы загрузить всех клиентов, мы можем выполнить следующую инструкцию JPQL и затем загрузить все заказы для каждого клиента:

1
2
3
4
5
6
7
List<CustomerEntity> resultList = entityManager.createQuery("SELECT c FROM CustomerEntity AS c", CustomerEntity.class).getResultList();
for(CustomerEntity customerEntity : resultList) {
    Set<OrderEntity> orders = customerEntity.getOrders();
    for(OrderEntity orderEntity : orders) {
    ...
    }
}

Hibernate 4.3.5 (поставляется с JBoss AS Wildfly 8.1.0CR2) сгенерирует из него следующую серию операторов SQL только для двух (!) Клиентов в базе данных:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Hibernate:
     select
         customeren0_.id as id1_1_,
         customeren0_.name as name2_1_,
         customeren0_.numberOfPurchases as numberOf3_1_
     from
         CustomerEntity customeren0_
Hibernate:
     select
         orders0_.CUSTOMER_ID as CUSTOMER4_1_0_,
         orders0_.id as id1_2_0_,
         orders0_.id as id1_2_1_,
         orders0_.campaignId as campaign2_2_1_,
         orders0_.CUSTOMER_ID as CUSTOMER4_2_1_,
         orders0_.timestamp as timestam3_2_1_
     from
         OrderEntity orders0_
     where
         orders0_.CUSTOMER_ID=?
Hibernate:
     select
         orders0_.CUSTOMER_ID as CUSTOMER4_1_0_,
         orders0_.id as id1_2_0_,
         orders0_.id as id1_2_1_,
         orders0_.campaignId as campaign2_2_1_,
         orders0_.CUSTOMER_ID as CUSTOMER4_2_1_,
         orders0_.timestamp as timestam3_2_1_
     from
         OrderEntity orders0_
     where
         orders0_.CUSTOMER_ID=?

Как видим, первый запрос выбирает всех клиентов из таблицы CustomerEntity. Следующие два выбора выбирают, а затем заказы для каждого клиента, которые мы загрузили в первом запросе. Когда у нас будет 100 клиентов вместо двух, мы получим 101 запрос. Один начальный запрос для загрузки всех клиентов, а затем для каждого из 100 клиентов дополнительный запрос для заказов. Вот почему эта проблема называется N + 1.

Распространенная идиома для решения этой проблемы — заставить ORM генерировать запрос внутреннего соединения. В JPQL это можно сделать с помощью предложения JOIN FETCH, как показано в следующем фрагменте кода:

1
entityManager.createQuery("SELECT c FROM CustomerEntity AS c JOIN FETCH c.orders AS o", CustomerEntity.class).getResultList();

Как и ожидалось, ORM теперь генерирует внутреннее соединение с таблицей OrderEntity, и для этого требуется только один оператор SQL для загрузки всех данных:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
select
    customeren0_.id as id1_0_0_,
    orders1_.id as id1_1_1_,
    customeren0_.name as name2_0_0_,
    orders1_.campaignId as campaign2_1_1_,
    orders1_.CUSTOMER_ID as CUSTOMER4_1_1_,
    orders1_.timestamp as timestam3_1_1_,
    orders1_.CUSTOMER_ID as CUSTOMER4_0_0__,
    orders1_.id as id1_1_0__
from
    CustomerEntity customeren0_
inner join
    OrderEntity orders1_
        on customeren0_.id=orders1_.CUSTOMER_ID

В ситуациях, когда вы знаете, что вам придется загружать все заказы для каждого клиента, предложение JOIN FETCH минимизирует количество операторов SQL с N + 1 до 1. Это, конечно, связано с недостатком, который вы теперь переносите для всех заказов одного покупатель данные о клиенте снова и снова (из-за дополнительных столбцов клиента в запросе)

Спецификация JPA представляет версию 2.1 так называемых NamedEntityGraphs. Эта аннотация позволяет вам описать график, который запрос JPQL должен загружать более подробно, чем может сделать предложение JOIN FETCH, и, следовательно, это еще одно решение проблемы N + 1. В следующем примере демонстрируется NamedEntityGraph для нашего объекта клиента, который должен загружать только имя клиента и его заказы. Заказы описаны в подграфе ordersGraph более подробно. Здесь мы видим, что мы хотим загрузить только поля id и campaignId заказа.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@NamedEntityGraph(
        name = "CustomersWithOrderId",
        attributeNodes = {
                @NamedAttributeNode(value = "name"),
                @NamedAttributeNode(value = "orders", subgraph = "ordersGraph")
        },
        subgraphs = {
                @NamedSubgraph(
                        name = "ordersGraph",
                        attributeNodes = {
                                @NamedAttributeNode(value = "id"),
                                @NamedAttributeNode(value = "campaignId")
                        }
                )
        }
)

NamedEntityGraph указывается в качестве подсказки для запроса JPQL после его загрузки через EntityManager с использованием его имени:

1
2
EntityGraph entityGraph = entityManager.getEntityGraph("CustomersWithOrderId");
entityManager.createQuery("SELECT c FROM CustomerEntity AS c", CustomerEntity.class).setHint("javax.persistence.fetchgraph", entityGraph).getResultList();

Hibernate поддерживает аннотацию @NamedEntityGraph начиная с версии 4.3.0.CR1 и создает следующую инструкцию SQL для запроса JPQL, показанного выше:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
Hibernate:
    select
        customeren0_.id as id1_1_0_,
        orders1_.id as id1_2_1_,
        customeren0_.name as name2_1_0_,
        customeren0_.numberOfPurchases as numberOf3_1_0_,
        orders1_.campaignId as campaign2_2_1_,
        orders1_.CUSTOMER_ID as CUSTOMER4_2_1_,
        orders1_.timestamp as timestam3_2_1_,
        orders1_.CUSTOMER_ID as CUSTOMER4_1_0__,
        orders1_.id as id1_2_0__
    from
        CustomerEntity customeren0_
    left outer join
        OrderEntity orders1_
            on customeren0_.id=orders1_.CUSTOMER_ID

Мы видим, что Hibernate не выдает N + 1 запросов, но вместо этого аннотация @NamedEntityGraph вынуждает Hibernate загружать заказы на левое внешнее соединение. Это, конечно, тонкое отличие от предложения FETCH JOIN, где Hibernate создал внутреннее соединение. Левое внешнее объединение также будет загружать клиентов, для которых нет заказа, в отличие от предложения FETCH JOIN, где мы будем загружать только клиентов, у которых есть хотя бы один заказ.

Интересно также, что Hibernate загружает больше, чем указанные атрибуты для таблиц CustomerEntity и OrderEntity. Поскольку это противоречит спецификации @NamedEntityGraph (раздел 3.7.4), я создал для этого проблему JIRA .

Вывод

Мы видели, что в JPA 2.1 у нас есть два решения для проблемы N + 1: мы можем либо использовать предложение FETCH JOIN для быстрого извлечения отношения @OneToMany, которое приводит к внутреннему объединению, либо мы можем использовать функцию @NamedEntityGraph, которая позволяет мы указываем, какое отношение @OneToMany загрузить через левое внешнее соединение.