NHN Cloud Meetup 編集部
JPA N+1クエリ問題と解決方法
2019.07.01
11,894
はじめに
このような顧客/注文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>