記憶のくずかご

メモを書く 適当に書く まじめに書かない

JPAとN+1問題

JPAとは

JPAJava Persistence API)とはJavaEEのために定義された永続化(persistence)に関するAPI仕様です。JPAAPI仕様なのでJPA単体では動きません。JPAを実装したHibernateやEclipseLinkなどのO/R Mapperが必要になります。

N+1問題とは

N+1問題とはO/R Mapperを利用した際にSQL文が意図せず大量に発行されてしまう問題です。この問題はO/R MapperによるSQL文の自動生成に起因します。Object側の関連も含めてデータを取得する場合に、O/R Mapperは下記のようにN+1回SELECT文を発行してしまいます。

  • TableからN個のRecordを取得するためにSELECT文を1回発行
  • N個のRecordが関連するデータを取得するためにSELECT文を1回ずつ、計N回発行

N+1問題の解決策

N+1問題を解決するにはテーブルをJOINしてデータを取得する必要があります。具体的な解決策は大きく分けて2つです。

  1. JPQLでJOIN FETCHを使用する
  2. @Fetchアノテーションを使用する

ただし、後者の方はJPAの仕様にはなく、実装であるHibernateに依存します。EclipseLinkの場合は@JoinFetchを使用します。

注意:下記で説明する解決策はJPA + Hibernate環境の場合のものです。EclipseLinkを使用した場合でも同様の方法で解決できるとは限りません。

1. JPQLでJOIN FETCH を使用する

JPAでは下記の4つの方法でDBにアクセスすることができます。

  • Entityのメソッド(persist(), find(), remove(), merge())
  • JPQL(JPA用のSQLSQL文の方言を吸収するためのもの)
  • Criteria(Javaの型を利用してクエリを作成するためのもの)
  • Native SQL

この中のJPQLを使った解決策です。JPQLでできることはCriteiaでもできますが、Criteriaでの解決方法はここでは触れません。

単方向関連の場合

N+1問題は関連があるために発生します。ここで言う関連はObject側の関連です。 具体的には下記のようなコードです。

public class Parent {
    private List<Child> children;
    ...
}

public class Child {
    ...
}

上記ではParentだけがChildを参照しているため単方向関連と呼びます。さらにParentはChildをList型で複数持っているため、1対多(One to Many)の関連と呼びます。

単方向関連の場合にN+1問題が発生するのは、Parentと関連しているchildrenをO/R Mapperが取得するときです。したがって、Lazy Fetchの場合はParentオブジェクトを取得しただけではSELECT文は発行されません。Parentオブジェクトからchildrenを取得しようとした場合に初めてN回のSELECT文が発行されます。

JPQLで下記のようにJOIN FETCHを使うことでN+1問題を解決できます。

SELECT p FROM Parent p JOIN FETCH p.children

このとき発行されるSELECT文は1回です。JOIN FETCHはINNER JOINのSELECT文として発行されます。OUTER JOINを使用したい場合は下記のようになります。

SELECT p FROM Parent p LEFT OUTER JOIN FETCH p.children
双方向関連の場合

双方向関連とは2つのオブジェクトがお互いに参照している状態のことです。
具体的には下記のようなコードです。

public class Parent {
    private List<Child> children; // One to Many
    ...
}

public class Child {
    private Parent parent; // Many to One
    ...
}

上記は1対多の関連ですが、Childから見た場合は多対1(Many to One)の関連と呼びます。

単方向関連の場合ではParentを取得した場合のN+1問題だけを考えれば良かったのですが、双方向関連の場合はChildを取得した場合のN+1問題も考えなければいけません。ChildはParentを保持しており、そのParentはChildを複数保持しています。したがって、Childが持つParentの関連もJOIN FETCHで取得することでChildのN+1問題を解決できます。

SELECT c FROM Child c JOIN FETCH c.parent p JOIN FETCH p.children

2. @Fetchアノテーションを使用する

@Fetchアノテーションは下記のように使用します。

@Entity
public class Parent {
    @OneToMany(mappedBy="parent")
    @Fetch(FetchMode.SUBSELECT)
    private List<Child> children;
    ...
}  

@Fetchには複数のFetchModeがあります。FechModeの種類によってSELECT文の発行数が異なります。

FetchMode SELECT文の発行数
SELECT N+1
JOIN 1
SUBSELECT 2

FechModeの詳細は下記リンクを参照してください。
http://www.solidsyntax.be/lessons-learned/hibernate-fetchmode-explained-example/

ただし、FetchMode.JOINはJPQLを使用した場合は正しく動作しません。Criteriaを使用した場合は上記のように1回だけSELECT文を発行しますが、JPQLの場合はN+1回、SELECT文を発行してしまいます。これはHibernateの既知のバグのようです。
http://stackoverflow.com/questions/18891789/fetchmode-join-makes-no-difference-for-manytomany-relations-in-spring-jpa-reposi

@Fetchを使用するのであればFetchModeはSUBSELECTを選択した方が良いと思います。

ただし、SUBSELECTにも問題はあります。その問題はSUBSELECTを双方向関連で使用した場合に発生します。Many to Oneのオブジェクト(上記例ではChild)を取得しようとした場合、SUBSELECTを使用するとN+M+1回のSELECT文が発行されてしまいます。MはOne to Many(上記例ではParent)のレコード数です。

この現象はこのコードを使用して確認しました。おそらくそれぞれがお互いに関連を取得しようとしているのが原因だと思いますが、詳しくはわかりません。

まとめ

N+1問題を解決したい場合はJOIN FETCHを使用すべきです。@FetchアノテーションJPAでは定義されておらず、Hibernateのバグもあるので使用しない方が無難です。

また、可能であれば双方向関連のないクラス設計をする方が良いと思います。双方向関連は単方向関連に比べてN+1問題が発生しやすく、それを防ぐために考慮すべき箇所も増えるためコーディングが難しくなります。