こんにちは、かつコーチです。
@OneToMany・@ManyToOneでリレーションを組んだあと、多くの人がハマる落とし穴がN+1問題です。
「機能としては正しく動いているのに、なぜか本番でレスポンスが遅い」という相談の多くが、このN+1問題に起因しています。
今回は原因の見極め方と、JOIN FETCHをはじめとした複数の解決策を比較しながら整理します。
N+1問題とは
発行されるSQLの実態
N+1問題とは、1件の親データを取得するために1回のSELECT文を発行し、それに紐づくN件の子データを取得するためにさらにN回のSELECT文が発行されてしまう現象です。
例えば、著者一覧(10件)とそれぞれの書籍を取得する処理を考えます。
List<Author> authors = authorRepository.findAll(); // 1回のSELECT
for (Author author : authors) {
System.out.println(author.getBooks().size()); // author.getBooks()のたびにSELECTが発行される
}
FetchType.LAZY(遅延ロード)で関連を定義している場合、author.getBooks()を呼び出すたびに個別のSELECT文がその都度発行されます。
著者が1人なら1+1=2回で済みますが、著者が10人いれば1+10=11回、100人いれば1+100=101回とSQL発行回数が線形に増えていきます。
筆者が実際に検証環境で管理画面の一覧APIを実装した際、テストデータでは気づかず、本番相当データ(著者500件)を投入した途端にレスポンスが3秒近くかかるようになり、ログをspring.jpa.show-sql=trueで確認して初めてSQLが500回以上発行されていることに気づいた経験があります。
ローカルの数件のテストデータだけで動作確認をしていると、この問題は表面化しにくいという点が厄介なところです。
解決策の比較
N+1問題への対処法はひとつではなく、状況に応じて使い分ける必要があります。
JOIN FETCHによる解決
もっとも直接的な解決策が、JPQLでJOIN FETCHを使う方法です。
@Query("SELECT DISTINCT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
通常のJOINは絞り込み条件としてのみ使われ、関連エンティティ自体は取得しませんが、JOIN FETCHは関連エンティティの中身も同時に1回のSQLで取得します。
これにより、SELECT文が1回にまとまり、N+1問題が解消されます。
DISTINCTを付けているのは、1対多のJOINでは親のレコードが子の件数分だけ重複して返ってくるため、アプリケーション側での重複除去が必要になるためです。
ただし、JOIN FETCHはページネーション(Pageable)と組み合わせると、メモリ上で全件をページング処理してしまう警告が出ることがあり、大量データの一覧表示には不向きな場合があります。
@EntityGraphによる解決
アノテーションベースで解決したい場合は@EntityGraphが使えます。
@EntityGraph(attributePaths = {"books"})
@Query("SELECT a FROM Author a")
List<Author> findAllWithBooksGraph();
JPQL自体は変更せず、「このクエリを実行するときはbooksも一緒に取得してください」という指示を別に与える形になります。
クエリメソッドの命名規則と組み合わせて使えるのも利点です。
@EntityGraph(attributePaths = {"books"})
List<Author> findByNameContaining(String keyword);
バッチサイズ設定による緩和
根本解決ではなく緩和策として、application.propertiesにバッチサイズを設定する方法もあります。
spring.jpa.properties.hibernate.default_batch_fetch_size=100
これを設定すると、個別に発行されていたSELECT文がWHERE author_id IN (?, ?, ?, ...)という形にまとめられ、N回だったクエリが「N ÷ バッチサイズ」回程度に減ります。
JOIN FETCHが使いにくいページネーションを伴う一覧画面では、この設定が現実的な落としどころになることが多いです。
使い分けの判断軸
| 状況 | 推奨する解決策 |
|---|---|
| 詳細画面など、関連データを必ず全件表示する | JOIN FETCH |
| クエリメソッドの命名規則を維持したい | @EntityGraph |
| ページネーションを伴う一覧画面 | バッチサイズ設定 |
| 複数の関連(books・reviewsなど)を同時に解消したい | バッチサイズ設定(JOIN FETCHの多重JOINはデカルト積で行数が爆発しやすい) |
JOIN FETCHで複数の@OneToManyを同時にフェッチしようとすると、MultipleBagFetchExceptionが発生することがあります。
これは、複数の1対多コレクションを同時にJOINすると、結果セットの行数が掛け算的に増える(デカルト積)ため、Hibernateが安全のためにエラーとして検出する仕組みです。
この場合は片方だけJOIN FETCHにし、もう片方はバッチサイズ設定や別クエリでの取得に切り替えるのが定石です。
まとめ
この記事のポイント
- N+1問題は、
FetchType.LAZYの関連データにループ内でアクセスすることで発生する JOIN FETCHは1回のSQLで解決できるが、ページネーションとの相性が悪い@EntityGraphはクエリメソッドを維持したまま関連データを事前取得できる- バッチサイズ設定はIN句にまとめる緩和策で、一覧画面には現実的な選択肢になりやすい
- 複数の
@OneToManyを同時にJOIN FETCHするとMultipleBagFetchExceptionが起きやすい
次に読むべき記事
- @OneToMany・@ManyToOneでリレーションを組む
- @Queryでカスタムクエリを書く
- Bean Validationでフォームの入力チェックを実装する
タグ: Spring Boot, 上級者向け, データベース