はじめに
- データ件数が増えた途端、急に遅くなった
- 本番だけ重い
そんな経験はありませんか?
その原因として頻出するのがN+1クエリ問題です。
コードは綺麗。
例外も出ない。
しかし裏側では、大量のSQLが発行されている——
それがN+1クエリ問題の怖さです。
N+1クエリ問題とは
N+1クエリ問題とは、親データ取得後、ループ内で子データ取得のクエリが実行される問題です。
構造としては以下の形になります。
1件:親データ取得のクエリを実行
+
N件:親データの件数分ループして、子データ取得のクエリを実行
⇒合計 N+1 回のSQLが発行されます。
典型的な発生例
// ユーザー一覧を取得
var users = context.Users.ToList();
foreach (var user in users)
{
// 各ユーザーの注文を取得
var orders = context.Orders
.Where(o => o.UserId == user.Id)
.ToList();
}
発行されるSQL
Users 取得(1回) + Orders 取得(N回) = N+1回
なぜ問題なのか
処理時間の大半が計算ではなく、通信で消費されます。
通信回数が爆発する
- SQL自体は軽い
- しかし DBとの往復が大量に発生
データ量が増えると一気に悪化する
- 開発環境:ユーザー100件 → 問題なし
- 本番環境:ユーザー10万件 → 遅い
気付きにくい
- コードはシンプル
- エラーも出ない
- SQLログを見るまで分からない
対策①:JOINで一括取得する
var result = context.Users
.Join(
context.Orders,
user => user.Id,
order => order.UserId,
(user, order) => new
{
UserName = user.Name,
OrderId = order.Id
}
)
.ToList();
ポイント
- SQLは 1回
- DBに「まとめて取る」判断を任せる
対策②:IN句で一括取得する
// ユーザーID一覧を取得
var userIds = context.Users
.Select(u => u.Id)
.ToList();
// 注文を一括取得
var orders = context.Orders
.Where(o => userIds.Contains(o.UserId))
.ToList();
// メモリ上で紐付け
foreach (var userId in userIds)
{
var userOrders = orders
.Where(o => o.UserId == userId)
.ToList();
}
ポイント
- SQL発行は 2回
- ループ内でSQLを発行しない
JOIN か IN か
JOIN は速い、IN は遅いと言われがちですが、そんなことはありません。
- DB、インデックス、データ件数によっては IN の方が速い場合もあります
- IN句はDBが最適化できます
重要なのは「どこで処理すべきか」を考えることです。
- DBが得意なこと → DBに任せる(JOIN)
- アプリが得意なこと → アプリで組み立てる(IN)
判断に迷ったときのチェックリスト
- 画面表示・一覧? → JOIN
- ID一覧がすでにある? → IN
- 集計・検索条件が多い? → JOIN
- ロジックの流れを壊したくない? → IN
おわりに
ORMは非常に便利ですが、
- 何回SQLが発行されているか
- どのタイミングでDBにアクセスしているか
を意識しないと簡単にN+1が生まれます。
本記事がN+1クエリ問題の理解と防止に役立てば幸いです。