Статьи

Советы JPA: как избежать проблемы выбора N + 1

Вступление

Платформы ORM, такие как JPA, упрощают процесс разработки, помогая нам избежать большого количества стандартного кода во время объектного <-> реляционного отображения данных. Тем не менее, они также приносят некоторые дополнительные проблемы к таблице, и N + 1 является одним из них. В этой статье мы кратко рассмотрим проблему, а также несколько способов ее избежать.

Проблема

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

01
02
03
04
05
06
07
08
09
10
11
@Entity
public class PurchaseOrder {
 
    @Id
    private String id;
    private String customerId;
 
    @OneToMany(cascade = ALL, fetch = EAGER)
    @JoinColumn(name = "purchase_order_id")
    private List<PurchaseOrderItem> purchaseOrderItems = new ArrayList<>();
}

Заказ на покупку состоит из идентификатора заказа, идентификатора клиента и одного или нескольких покупаемых товаров. Сущность PurchaseOrderItem может иметь следующую структуру:

1
2
3
4
5
6
7
8
@Entity
public class PurchaseOrderItem {
 
    @Id
    private String id;
 
    private String bookId;
}

Эти сущности были значительно упрощены, но для целей этой статьи это подойдет.

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

1
2
3
4
5
6
SELECT
    P
FROM
    PurchaseOrder P
WHERE
    P.customerId = :customerId

который при переводе на SQL выглядит примерно так:

1
2
3
4
5
6
7
select
    purchaseor0_.id as id1_1_,
    purchaseor0_.customer_id as customer2_1_
from
    purchase_order purchaseor0_
where
    purchaseor0_.customer_id = ?

Этот один запрос вернет все заказы на покупку, которые есть у клиента. Однако для получения позиций заказа JPA будет выдавать отдельные запросы для каждого отдельного заказа. Если, например, у клиента есть 5 заказов, то JPA выдаст 5 дополнительных запросов для извлечения элементов заказа, включенных в эти заказы. Это в основном известно как проблема N + 1 — 1 запрос для получения всех N заказов на покупку и N запросов для получения всех позиций заказа.

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

Решение

Избежание нетерпеливого извлечения

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

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

Только выборка данных, которые действительно необходимы

Это всегда рекомендуется, независимо от того, решено ли вам пойти на ленивую или ленивую выборку.

Я помню одну сделанную мной оптимизацию N + 1, которая увеличила максимальное время отклика конечной точки REST с 17 минут до 1,5 секунд . Конечная точка выбирала одну сущность на основе некоторых критериев, что для нашего текущего примера будет чем-то вроде:

1
2
3
4
5
6
TypedQuery<PurchaseOrder> jpaQuery = entityManager.createQuery("SELECT P FROM PurchaseOrder P WHERE P.customerId = :customerId", PurchaseOrder.class);
jpaQuery.setParameter("customerId", "Sayem");
PurchaseOrder purchaseOrder = jpaQuery.getSingleResult();
 
// after some calculation
anotherRepository.findSomeStuff(purchaseOrder.getId());

Идентификатор — единственные данные из результата, которые были необходимы для последующих вычислений.

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

1
2
3
4
5
6
TypedQuery<String> jpaQuery = entityManager.createQuery("SELECT P.id FROM PurchaseOrder P WHERE P.customerId = :customerId", String.class);
jpaQuery.setParameter("customerId", "Sayem");
String orderId = jpaQuery.getSingleResult();
 
// after some calculation
anotherRepository.findSomeStuff(orderId);

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

1
2
3
4
5
6
7
8
9
"SELECT " +
"NEW com.codesod.example.jpa.nplusone.dto.PurchaseOrderDTO(P.id, P.orderDate) " +
"FROM " +
"PurchaseOrder P " +
"WHERE " +
"P.customerId = :customerId",
PurchaseOrderDTO.class);
jpaQuery.setParameter("customerId", "Sayem");
List<PurchaseOrderDTO> orders = jpaQuery.getResultList();

Несколько предостережений от использования выражения конструктора —

  1. Целевой DTO должен иметь конструктор, список параметров которого соответствует выбранным столбцам
  2. Полное имя класса DTO должно быть указано

Использование объединяющих графиков / диаграмм сущностей

Мы можем использовать JOIN FETCH в наших запросах всякий раз, когда нам нужно извлечь объект со всеми его потомками одновременно. Это приводит к гораздо меньшему трафику базы данных, что приводит к повышению производительности.

Спецификация JPA 2.1 представила Entity Graphs, которая позволяет нам создавать статические / динамические планы загрузки запросов.
Торбен Янссен написал пару постов ( здесь и здесь ), подробно описывающих их использование, которые стоит проверить.
Некоторые примеры кода для этого поста можно найти на Github .

Опубликовано на Java Code Geeks с разрешения Саима Ахмеда, партнера нашей программы JCG . Смотрите оригинальную статью здесь: Советы JPA: Как избежать проблемы выбора N + 1

Мнения, высказанные участниками Java Code Geeks, являются их собственными.