N+1問題
このレッスンでは、JPA で最も有名な性能問題である N+1 問題を、発行されるクエリの本数として数えられるようになります。
一覧を1回取っただけのつもりが
ユーザーとその投稿を一覧で返す、よくある処理を書きます。
Java
List<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user.getName() + " " + user.getPosts().size());
}このコードでリポジトリを呼んでいるのは1回だけです。しかし SQL のログを見ると、次のようになっています。
SQL クエリ
SELECT * FROM users; -- 1本目
SELECT * FROM posts WHERE user_id = 1; -- 2本目
SELECT * FROM posts WHERE user_id = 2; -- 3本目
SELECT * FROM posts WHERE user_id = 3; -- 4本目ユーザーが100人いれば、クエリは101本です。親を取る1本と、子を取る N 本。これが N+1 問題です。100本のクエリが1本あたり2ミリ秒でも、それだけで0.2秒が消えます。1000人になれば2秒です。
なぜ増えるのでしょうか
原因は遅延ロードです。@OneToMany の既定のフェッチ方式は LAZY で、関連するコレクションは実際に触られるまで読み込まれません。findAll() の時点では posts の中身は空の入れ物のままで、user.getPosts() が呼ばれた瞬間に初めて SELECT が飛びます。ループの中でそれをやるので、回数分だけクエリが増えます。
ここで「では FetchType.EAGER にすればよいのでは」と考えたくなりますが、これは解決になりません。EAGER にしても、多くの場合は親を取った後で子を1件ずつ読みに行くだけで、本数は変わりません。しかも常に読み込まれるようになるため、投稿を必要としない画面でも余計なクエリが飛びます。既定の LAZY のままにしておくのが正解です。
1本にまとめる方法
直し方は「親と子を1回の SQL で取ってくる」ことです。JPQL で JOIN FETCH を書きます。
Java
@Query("SELECT DISTINCT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPosts();あるいは @EntityGraph を使うと、JPQL を書かずに同じことができます。
Java
@EntityGraph(attributePaths = "posts")
List<User> findAll();どちらも結合した1本のクエリになるので、親が何件あってもクエリは1本です。方式ごとの本数をまとめます。
| 方式 | 親 N 件のときのクエリ本数 |
|---|---|
| 遅延ロードのままループで触る | N + 1 |
| JOIN FETCH | 1 |
| @EntityGraph | 1 |
| バッチサイズ指定 | 1 + 切り上げ(N ÷ バッチサイズ) |
JOIN FETCH に DISTINCT を書いているのは、結合の結果として親の行が子の件数だけ重複するためです。これを取り除かないと、ユーザーが投稿の数だけリストに現れます。
気づき方
N+1 は動作としては正しいので、テストは通ってしまいます。件数が少ない開発環境では体感もできません。本番でデータが増えて初めて表面化する、という現れ方をします。開発中に見つけるには、SQL をログに出す設定を入れておき、一覧の処理でクエリが件数に比例して増えていないかを目で確かめるのが確実です。
演習で書くもの
演習では、親の件数とフェッチ方式から、発行されるクエリの本数を計算します。遅延ロードなら N+1、JOIN FETCH なら常に1本です。親が0件のときにどうなるかも考えてください。子を取るクエリは親1件につき1本なので、親が0件なら子のクエリは飛びません。
要件
- fetchMode が lazy のときは parentCount + 1 を返すこと
- fetchMode が join のときは parentCount にかかわらず 1 を返すこと
- lazy でも join でもない fetchMode のときは -1 を返すこと
入出力例
countQueries(100, "lazy") → 101
countQueries(100, "join") → 1
countQueries(1, "lazy") → 2
countQueries(0, "lazy") → 1
countQueries(0, "join") → 1
countQueries(10, "eager") → -1