N+1問題を根絶する
インデックスが完璧に設定されていても、クエリの本数が多すぎると遅くなります。その代表例が N+1問題? メインクエリで N 件取得した後、ループ内で N 回追加クエリを実行してしまうアンチパターン。100件の注文を取得して各注文のユーザーを1件ずつ取得すると101クエリになる。JOINかサブクエリで解消する。 です。Webアプリケーションで最もよく見かけるパフォーマンス問題の1つで、ORM(オブジェクト関係マッピング)を使っていると特に発生しやすい問題です。
N+1問題とは
「1回のSELECTでN件のレコードを取得し、各レコードに対してさらに1回ずつSELECTを発行する」パターンです。合計でN+1回のクエリが発行されます。
具体例を見てみましょう。「注文一覧を表示し、各注文のユーザー名も表示する」というケースです。
問題のあるコード(PHP/疑似コード)
// 1回目: 注文を100件取得
$orders = DB::query("SELECT * FROM orders LIMIT 100");
foreach ($orders as $order) {
// ループ内で毎回ユーザー取得 → 100回クエリが走る!
$user = DB::query("SELECT * FROM users WHERE id = ?", [$order->user_id]);
echo $user->name . ': ' . $order->total_amount;
}
発行されるクエリ:
-- 1回目
SELECT * FROM orders LIMIT 100;
-- 2回目〜101回目(100回繰り返し)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- ...(100回)
合計101回のクエリ。N件の注文があればN+1回のクエリが発行されます。1クエリが1ms でも合計100msを超えます。実際には接続オーバーヘッドも加わるため、体感できるほど遅くなります。
解決策1:JOINで一括取得
最もシンプルな解決策は JOIN? 複数のテーブルを結合して一つの結果セットにする操作。INNER JOIN(両方に存在する行のみ)・LEFT JOIN(左テーブルの全行 + 一致した右テーブル)などがある。 を使って1クエリにまとめることです。
-- Before: N+1(101クエリ)
SELECT * FROM orders LIMIT 100;
SELECT * FROM users WHERE id = ?; -- 100回
-- After: JOIN で1クエリ
SELECT
o.id,
o.total_amount,
o.created_at,
u.name AS user_name,
u.email AS user_email
FROM orders o
INNER JOIN users u ON o.user_id = u.id
LIMIT 100;
INNER JOIN? 結合条件が一致する行だけを返すJOIN。両テーブルに対応するデータが存在する場合のみ結果に含まれる。最もよく使われる結合方法。 は両テーブルに一致する行だけを返します。注文にユーザーが必ず存在する(外部キー制約がある)場合は INNER JOIN が適切です。
-- ユーザーが削除されている可能性がある場合は LEFT JOIN
SELECT
o.id,
o.total_amount,
u.name AS user_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LIMIT 100;
LEFT JOIN? 左テーブルの全行を返し、右テーブルに一致するデータがない場合はNULLで補うJOIN。「注文がないユーザーも含めて一覧表示したい」といった用途に使う。 はユーザーが存在しない場合も注文行を返し、user_name は NULL になります。
解決策2:IN句で一括取得
JOINではなく、まずIDを集めてからIN句で一括取得する方法もあります。
-- Step 1: 注文IDとユーザーIDを取得
SELECT id, user_id, total_amount FROM orders LIMIT 100;
-- Step 2: ユーザーIDを集めてIN句で一括取得(2クエリで完結)
SELECT * FROM users WHERE id IN (1, 2, 3, 5, 8, 13, ...);
アプリケーション側でIDを集めてIN句を構築するパターンです。JOINより直感的で、ORM との相性も良いです。
解決策3:ORMのEager Load
Ruby on Rails の includes、Laravel の with、Django の select_related など、多くのORMはN+1問題を解消するためのEager Load(事前ロード)機能を提供しています。
Rails の例
# Before: N+1
orders = Order.limit(100)
orders.each { |o| puts o.user.name } # ← N+1発生
# After: includes で事前ロード
orders = Order.includes(:user).limit(100)
orders.each { |o| puts o.user.name } # ← 2クエリで完結
Rails の includes は内部的に IN 句で一括取得します(または JOIN に書き換える joins を使うこともできます)。
Laravel の例
// Before: N+1
$orders = Order::limit(100)->get();
foreach ($orders as $order) {
echo $order->user->name; // N+1発生
}
// After: with で Eager Load
$orders = Order::with('user')->limit(100)->get();
foreach ($orders as $order) {
echo $order->user->name; // 2クエリで完結
}
バッチ取得の考え方
大量のデータを処理するバッチ処理では、さらにメモリ効率を考慮したバッチ取得が重要です。
-- keyset pagination でバッチ取得(後のエピソードで詳説)
SELECT * FROM orders WHERE id > ? ORDER BY id LIMIT 1000;
// Laravel の chunk を使うと内部的にバッチ取得
Order::with('user')->chunk(1000, function ($orders) {
foreach ($orders as $order) {
// 1000件ずつ処理
}
});
N+1 を検出するツール
開発環境でN+1を自動検出するツールも活用しましょう。
- Rails:
bulletgem(N+1を検出してアラート) - Laravel:
Laravel Debugbar(クエリ数を可視化) - Django:
django-silkやnplusone - 一般的: スロークエリログで同一クエリの大量実行を検出
N+1問題はインデックスを正しく設定していても発生します。クエリの品質だけでなく本数も常に意識しながらコードを書くことが重要です。次のエピソードでは、個々のクエリをさらに速くするチューニングテクニックを学びます。