N+1問題とは
「1回のクエリで取れるはずのデータを、N件分のループで追加クエリを発行してしまう」問題です。
例えば以下の場合のようなものです。
1件のクエリで「注文一覧」を取る
→ 注文が10件あった
→ 各注文の「ユーザー情報」を取るクエリを10回発行する
→ 合計 1 + 10 = 11回のクエリが走る
注文が100件なら101回。1000件なら1001回。件数が増えるほど線形にクエリが増えていく。
N+1問題は「動いているから大丈夫」では気づけません。件数が少ない開発環境では全く問題なく動いて、本番に近い件数のデータになって初めて露見するパターンが多いです。
なぜ起きるのか
JPA仕様では @ManyToOne のデフォルトは FetchType.EAGER(即時ロード)です。ただし、EAGERのままだと常にJOINが走って不要なデータまで取ってくるため、パフォーマンス上の理由で明示的に FetchType.LAZY に変更することがほとんどです。
LAZYに変更すると、関連エンティティへのアクセスが発生したタイミングで初めてSELECT文が走ります。これがN+1の原因になります。
@Entity
public class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // EAGERがデフォルト。LAZYに変えるとN+1の原因になる
private User user;
// ...
}
List<Order> orders = orderRepository.findAll(); // ← ここでSELECT * FROM orders
for (Order order : orders) {
String name = order.getUser().getName(); // ← ここで毎回SELECT * FROM users WHERE id = ?
}
ループの中で getUser() を動かしているのでN回クエリが飛んでいます。
怪しいところを見つけた時、実際にどのくらいクエリが飛んでいるかを確認する方法があります。
pg_stat_statementsで確認する
N+1を調査したいときに使えるのが、PostgreSQLの拡張機能 pg_stat_statements です。
実行されたSQLの統計情報(実行回数、合計実行時間など)をDBが記録してくれます。
セットアップ
まず postgresql.conf に以下を追加して、PostgreSQLを再起動します。
shared_preload_libraries = 'pg_stat_statements'
次に、対象のDBで拡張を有効化します。
CREATE EXTENSION pg_stat_statements;
実行回数でソートして確認する
SELECT
query,
calls,
total_exec_time,
mean_exec_time,
rows
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
| カラム | 内容 |
|---|---|
query |
実行されたSQL(パラメータは $1, $2 に正規化される) |
calls |
実行回数 |
total_exec_time |
合計実行時間(ミリ秒) |
mean_exec_time |
平均実行時間(ミリ秒) |
rows |
返した行数の合計 |
N+1が起きていると、同じパターンのSELECT文の calls が異常に多い値になっているのですぐわかります。
統計情報をリセットしたいときは SELECT pg_stat_statements_reset(); を実行します。
計測前にリセットしておくと、特定の操作での発行回数をクリーンに確認できます。
解決策①:JOIN FETCHを使う
最もシンプルな解決策は、JPQLでJOIN FETCHを使って一度に取ってくることです。
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
}
これで「注文一覧」と「ユーザー情報」をJOINして一発で取れます。
解決策②:findAllByに @EntityGraph を使う
@EntityGraph アノテーションを使うと、@Query を書かずに関連エンティティをまとめて取得できます。
findAll() に付けるだけでも効果はありますが、findAllBy 系の派生クエリメソッドと組み合わせると、条件付き取得+N+1対策を同時にできます。
public interface OrderRepository extends JpaRepository<Order, Long> {
// 全件取得
@EntityGraph(attributePaths = {"user"})
List<Order> findAll();
// ステータスで絞り込みつつ、userを一緒に取得する
@EntityGraph(attributePaths = {"user"})
List<Order> findAllByStatus(OrderStatus status);
// 特定ユーザーの注文一覧をuserごと取得する
@EntityGraph(attributePaths = {"user"})
List<Order> findAllByUserId(Long userId);
}
メソッドを呼んだときだけJOINが走るので、エンティティの FetchType は変えずに済みます。FetchType.EAGER をエンティティに直書きすると全クエリに影響が出るので、@EntityGraph でメソッド単位に絞る方が安全です。
まとめ
| 方法 | 特徴 |
|---|---|
JOIN FETCH |
確実に1クエリにできる。複雑なリレーションには向かないことも |
findAllBy + @EntityGraph
|
メソッド名でWHERE句を表現しつつN+1を防げる。読みやすい |