わかりやすいJPA (5):JOIN FETCH

はじめに

これはJPAを解説する連載の5回目です。

うっかりすると、JPQLでは予期しなかった大量のSQLが発行されてしまう場合があります。これは “N+1” 問題と言われているものですが、解決のためには次のような4つの選択肢があります。

  1. JPQLでJOIN FETCHを使う
  2. Criteria API(JPQLをプログラムとして書く)でFetch Joinを記述する
  3. Named Entithy Graph を使う
  4. Dynamic Entity Graph を使う

ここでは、1.のJPQLによる方法について解説します。また、そのほかの方法も、この後の章で順次、詳しく解説する予定です。

解説で出てくるエンティティやクエリの実行については、「連載:わかりやすいJPA」の予備知識 を参照してください。

N+1問題とは

次のような具体的な例で考えましょう。

// すべてのEmployeeエンティティを得る
TypedQuery<Employee> query 
    = em.createQuery("SELECT e FROM Employee e", Employee.class);
List<Country> results = query.getResultList();

// Addressメンバを出力する
for (Employee emp : results) {
    System.out.println(emp.getAddress().getCity());
}

すべてのEmployeeエンティティを取得した後、for文で、各エンティティからAddressオブジェクトを取り出し、そのcityメンバを出力しています。あまりよい処理方法ではありませんが、JPAの手軽さから、プログラムでは、ついこのように書くことが多いと思います。

この操作では、明示的には、一度しかSELECT文を実行していませんが、実は、for文の中に書いたプログラムが、emp.getAddress()を実行しているせいで、(addressメンバが遅延フェッチである場合)Addressエンティティを取得するためのSQLが、forの繰り返しごとに発行されます。

JPAではオブジェクトを対象にプログラムできるので、あまり考えずにこのような記述をしますが気を付ける必要があります。実際、1回のSELECT文でN件のエンティティが取得できた場合、理論的には、N回の追加的なSQLが発行されることになるからです。

Nの値が大きく、同時に多数のユーザーがアクセスするような状況では、パフォーマンスの低下が懸念されます。そこで、これを「N+1問題」というわけです。

JOIN FETCHとは

エンティティの読み込み時に、すぐには必要ではない大きなフィールドのデータは読まないでおいて、データが必要になりフィールドにアクセスした時、初めて読み込む機能が遅延フェッチです。N+1問題は、この遅延(Lazy)して読み込まれるメンバが原因になりますが、遅延フェッチ機能そのものが悪いわけではありません。遅延フェッチはJPAの中でも特に有用な機能のひとつです。

どのフィールドを遅延フェッチするかは、@Basicマッピングアノテーションを使って、例えば、@Basic(fetch=FetchType.LAZY) のように、事前に指定しておきます。ですから、本当の問題は、LAZY(遅延)とEAGER(即時)の設定を自由に切り替えられないところにあります。

そこで、LEFT JOIN FETCHJOIN FETCHを使います。この指定は、特定のフィールドをEAGERにして読み込むように指示します(実際には、JOINを実行して読み込んでおく機能です)。次のように使います。

 SELECT DISTINCT e FROM Employee e LEFT JOIN FETCH e.addres
[ Employee [id: 1, name: John, salary: 55000, address_Id: 1, .......] ]
[ Employee [id: 2, name: Rob, salary: 53000, address_Id: 2, ........] ]
[ Employee [id: 3, name: Peter, salary: 40000, address_Id: 3, ......] ]
[ Employee [id: 4, name: Frank, salary: 41000, address_Id: 4, ......] ]
[ Employee [id: 5, name: Scott, salary: 60000, address_Id: 5, ......] ]
[ Employee [id: 6, name: Sue, salary: 62000, address_Id: 6, ........] ]
[ Employee [id: 7, name: Stephanie, salary: 54000, address_Id: 7, ..] ]
[ Employee [id: 8, name: Jennifer, salary: 45000, address_Id: 8, ...] ]
[ Employee [id: 9, name: Sarah, salary: 52000, address_Id: 9, ......] ]
[ Employee [id: 10, name: Joan, salary: 59000, address_Id: 10, .....] ]

この例では、LEFT JOIN FETCHですが、もちろん、JOIN FETCH も使うことができます。また、LEFT JOIN FETCH  e.address の後ろに、識別変数を付けないことに注意してください。LEFT JOINでは必要だった変数ですが、ここに付けるとエラーになります。

また、重複データを取得しないように(この例では重複したデータは発生しないのですが)DISTINCTを付けるのが定石です。この点も注意してください。

この例では、内部的に、EmployeeエンティティとaddressフィールドとのLEFT JOINを実行します。実行されたSQLログを見ると、次のような記録があるので間違いありません(スクロールさせて左の方を見てください)。

SELECT t1.ID, t1.NAME, t1.SALARY, t1.STARTDATE, t1.DEPARTMENT_ID, t1.MANAGER_ID, t1.ADDRESS_ID, t0.ID, t0.CITY, t0.STATE, t0.STREET, t0.ZIP FROM EMPLOYEE t1 LEFT OUTER JOIN ADDRESS t0 ON (t0.ID = t1.ADDRESS_ID)]]

しかし、実行例から明らかなように、実際に返されるのはEmployeeエンティティだけです。LEFT JOIN操作により、すべてのEnployeeエンティティとそのAddressフィールドの値の組み合わせを求めているはずですが、それはどうしたのでしょう。

実は、その結果はメモリー上に貯蔵(キャッシュ)されているのです。ですから、この後、JavaプログラムでAddressフィールドにアクセスすると、データベースではなく、メモリーから値を返します。あらたなSQLが発行されることはありません。

JOIN FETCHの利点と欠点

さて、JOIN FETCHにも利点と欠点があります。利点は、SQLの発行回数を大幅に減らすことができる点です。欠点はメモリーを消費することで、実行されるJOINの規模により注意が必要かもしれません。

また、いろいろなフィールドがあるエンティティでは、それぞれ別のJOIN FETCHを行うので、それぞれに別のJPQLを用意しなければいけません。これはこれで、なかなか面倒ですし、管理も大変ですね。

他の方法について簡単に言っておくと、Criteria APIはプログラムでJOIN FETCHを記述するだけなので、JPQLでやる場合と大差ありません。ただ、プログラムですから、JPQLよりも使いまわしが効くかもしれません。

エンティティグラフは、ルートエンティティから末端のメンバエンティティに至る経路をあらかじめ作っておくものです。いろいろな経路を必要に応じて、いくつでも作っておけます。そして、エンティティグラフを指定してSELECT文を実行すると、経路上に出現するエンティティはすべてEAGER(即時)に取得されます。

JPA2.1で使えるようになった新しい機能ですが、エンティティグラフが最も評価が高く、人気があるようです。

読者になる

コメントをどうぞ

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

%d人のブロガーが「いいね」をつけました。