JPA N+1クエリ問題と解決方法

はじめに

このような顧客/注文E-R図があるとします。

JPA Entityで表現します。

OrderEntity

@Entity
@Table(name = "Orders")
public class OrderEntity {
    @Id
    @Column(name = "order_id")
    private Long orderId;

    // default fetch type = EAGER
    @ManyToOne(optional = false)
    @JoinColumn(name = "customer_id")
    CustomerEntity customer;

    // default fetch type = LAZY
    @OneToMany
    @JoinColumn(name = "order_id")
    List<OrderItemEntity> orderItems;

}

OrderItemEntity

@Entity
@Table(name = "OrderItems")
public class OrderItemEntity {
    @Id
    @Column(name = "order_line_id")
    private Long orderLineId;

    // default fetch type = EAGER
    @ManyToOne
    @JoinColumn(name = "item_id")
    ItemEntity item;

}

Repository interface

OrderRepository

@Repository("orderRepository")
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
    OrderEntity findOne(Long orderId);

    List<OrderEntity> findAll();

}

JPA N+1クエリ問題

OrderRepository.findOne()呼び出し時に実行されるクエリ

Javaコード
OrderEntity orderEntity = orderRepository.findOne(1L);
実際に実行されるクエリ
SELECT
    orderentit0_.`order_id`      AS order_id1_22_0_,
    orderentit0_.`customer_id`   AS customer2_22_0_,
    orderentit0_.`order_date`    AS order_da3_22_0_,
    customeren1_.`customer_id`   AS customer1_4_1_,
    customeren1_.`customer_name` AS customer2_4_1_
FROM `Orders` orderentit0_
INNER JOIN `Customers` customeren1_
    ON orderentit0_.customer_id=customeren1_.`customer_id`
WHERE orderentit0_.`order_id`=1;

1クエリ!No Problem.

OrderRepository.findAll()を呼び出す

Javaコード
List<OrderEntity> orders = orderRepository.findAll();
実際に実行されるクエリ
SELECT * FROM `Orders` orderentit0_;
上記クエリの結果としてN個のレコードが返されるとき、追加で実行されるクエリ
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=1;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=2;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=3;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=4;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=5;
-- ...
-- (N個)

合計でN+1個のクエリが実行されました! – これが、N+1クエリ問題です。

さらにOrderItemまで参照してみる

Javaコード
List<OrderEntity> orders = orderRepository.findAll();

// すべての注文に含まれる商品一覧
List<ItemEntity> items = orders.stream()
        .flatMap(e -> e.getOrderItems().stream())
        .map(OrderItemEntity::getItem)
        .collect(Collectors.toList());
実際に実行されるクエリ
SELECT * FROM `Orders` orderentit0_;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=1;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=2;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=3;
-- ...
SELECT * 
FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ ON orderitems0_.item_id=itementity1_.`item_id` 
WHERE orderitems0_.order_id=1;

SELECT * 
FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ ON orderitems0_.item_id=itementity1_.`item_id` 
WHERE orderitems0_.order_id=2;
-- ...

グローバルフェッチ戦略をEAGERに変更する

N+1クエリ問題は、コレクションのLazy Loadingによって発生するので、グローバルフェッチ戦略をEAGER Fetch(即時ローディング)に変更すると解決できるかな?

OrderEntityでorderItemsをEAGER Fetchに変更してみよう
@Entity
@Table(name = "Orders")
public class OrderEntity {
    // ...

    // default fetch type = LAZY <--- EAGERに変更すると?
    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "order_id")
    List<OrderItemEntity> orderItems;

}

グローバルフェッチ戦略をEAGERに変更- OrderRepository.findOne()呼び出し時

Javaコード
OrderEntity orderEntity = orderRepository.findOne(1L);
実際に実行されるクエリ
SELECT
     *
FROM `Orders` orderentit0_
INNER JOIN `Customers` customeren1_
    ON orderentit0_.customer_id=customeren1_.`customer_id`
LEFT OUTER JOIN `OrderItems` orderitems2_
    ON orderentit0_.`order_id`=orderitems2_.order_id
LEFT OUTER JOIN `Items` itementity3_
    ON orderitems2_.item_id=itementity3_.`item_id`
WHERE orderentit0_.`order_id`=1;

OrderEntityを取得ながらcustomerとorderItemsまでJOINしたクエリを取得します。
解決できそう?!

グローバルフェッチ戦略をEAGERに変更- OrderRepository.findAll()呼び出し時

Javaコード
List<OrderEntity> orders = orderRepository.findAll();
実際に実行されるクエリ
SELECT * FROM `Orders` orderentit0_;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=1;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=2;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=3;
-- ...
SELECT * FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ 
ON orderitems0_.item_id=itementity1_.`item_id` WHERE orderitems0_.order_id=1;

SELECT * FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ 
ON orderitems0_.item_id=itementity1_.`item_id` WHERE orderitems0_.order_id=2;
-- ...

JPQLを実行するときは、LAZY FetchやEAGER Fetchと大差ありません。

結論

単一のエンティティを参照するとき(ex. OrderRepository.findOne())は、グローバルフェッチ戦略をEAGERに変更すると、JOINを使って実際に実行されるクエリの数を削減できます。
しかし、グローバルフェッチ戦略は、JPQL実行時(ex. OrderRepository.findOne())には適用されません。
つまり、JPQL実行時、N+1クエリ問題はEAGER Fetchでは解決されません。

解決方法

  • Fetch Joinを使用
  • グローバルフェッチ戦略 – LAZY使用
  • OSIV適用

Fetch Joinを使用

List orders = orderRepository.findAll();

List<OrderEntity> orders = orderRepository.getAllOrders();
// using QueryDSL
public List<OrderEntity> getAllOrders() {
    QOrderEntity order = QOrderEntity.orderEntity;
    QCustomerEntity customer = QCustomerEntity.customerEntity;
    QOrderItemEntity orderItem = QOrderItemEntity.orderItemEntity;

    return from(order)
            .innerJoin(order.customer, customer)
            .fetch() // <--- FETCH JOIN
            .leftJoin(order.orderItems, orderItem)
            .fetch() // <--- FETCH JOIN
            .list(order);
}
-- JPQL
select orderEntity
from OrderEntity orderEntity
  inner join fetch orderEntity.customer as customerEntity
  left join fetch orderEntity.orderItems as orderItemEntity;

Fetch Join使用時の注意点

MultipleBagFetchException発生:コレクションタイプをList -> Setに変更
@Entity
@Table(name = "Orders")
public class OrderEntity {
    @OneToMany
    @JoinColumn(name = "order_id")
    Set<OrderItemEntity> orderItems;

}
  • Bag:Hibernateで要素の重複を許可する非順次(unordered)コレクション
  • 2つ以上の@OneToManyコレクション(Bag)に対するEAGER Fetch時、その結果が作成される直積集合(Cartesian Product)で、どの行が有効な重複を含み、どの行がそうでないかを判断できず、Bagコレクションに変換できないことから、例外が発生
  • ListをSetに変更すると、重複を非許可
  • このとき、Listは入力シーケンスが保証されるが、Setは入力順序が保証されていないため、入力シーケンスの保証が必要な場合、LinkedHashSetなどの他のデータ構造を使用する

グローバルフェッチ戦略 – LAZY使用

グローバルフェッチ戦略デフォルト
  • @OneToOne、@ManyToOne:EAGER
  • @OneToMany、@ManyToMany:LAZY

Fetch Joinを使用しても、グローバルフェッチ戦略がEAGERに設定された関連関係では直ちにロードされるため、追加のクエリが実行されます。

select * from `Items` itementity0_ where itementity0_.`item_id`=1;
select * from `Items` itementity0_ where itementity0_.`item_id`=2;
select * from `Items` itementity0_ where itementity0_.`item_id`=3;
-- ...

したがって、直ぐにロードする必要がない@OneToOne、@ManyToOneの関連関係は、グローバルフェッチ戦略をLAZYに変更して、不必要なクエリ実行を防止します。

@Entity
@Table(name = "Orders")
public class OrderEntity {
    // default fetch type = EAGER -> LAZY fetchに変更
    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    CustomerEntity customer;

}
@Entity
@Table(name = "OrderItems")
public class OrderItemEntity {
    // default fetch type = EAGER -> LAZY fecthに変更
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    ItemEntity item;

}

OSIV適用

グローバルフェッチ戦略をLAZYに変更したとき、永続性コンテキストを逸脱してLazy Loadingを試みると、LazyInitializationExceptionが発生します。
-> OSIVを適用で解決

OSIV:永続性コンテキストをビューまで拡張
  • Open Session In View:Hibernate
  • Open EntityManager In View:JPA
Spring Data JPAでOSIVを使用する方法
  • Interceptor利用
org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor
  • Servlet Filter利用
org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter
OSIV Interceptor設定例
<mvc:interceptors>
  <mvc:interceptor>
    <mvc:mapping path="/**" />
    <bean
class="org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor"
    />
  </mvc:interceptor>
</mvc:interceptors>

 

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop