#05 データベースの高速化

N+1問題を根絶する

インデックスが完璧に設定されていても、クエリの本数が多すぎると遅くなります。その代表例が N+1問題? メインクエリで N 件取得した後、ループ内で N 回追加クエリを実行してしまうアンチパターン。100件の注文を取得して各注文のユーザーを1件ずつ取得すると101クエリになる。JOINかサブクエリで解消する。 です。Webアプリケーションで最もよく見かけるパフォーマンス問題の1つで、ORM(オブジェクト関係マッピング)を使っていると特に発生しやすい問題です。

N+1クエリ vs 1クエリの比較 N+1問題 (101クエリ) アプリ データベース SELECT * FROM orders 100件返却 ループ(100回) foreach order WHERE id = 1 WHERE id = 2 WHERE id = 3 ・・・(100回) 合計 101 クエリ発行 接続オーバーヘッド × 100 = 大きな遅延 1クエリ 5ms × 101回 = 505ms ページ表示に0.5秒かかる JOINで解決 (1クエリ) アプリ データベース SELECT ... INNER JOIN ... 100件(JOIN済み)返却 SELECT o.id, o.total_amount, u.name AS user_name FROM orders o INNER JOIN users u ON o.user_id = u.id 合計 1 クエリ発行 DBとの往復は1回だけ 1クエリ 8ms(JOIN分少し増加)= 8ms 63倍の高速化!
図1: N+1クエリと1クエリの比較

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クエリで完結
}

バッチ取得の考え方

N+1解決パターンの比較 パターン1: JOIN SELECT o.*, u.name FROM orders o INNER JOIN users u ON o.user_id = u.id クエリ数: 1 DB側でJOIN処理 向いている場面 1:1 / 多:1 の関係 シンプルなリレーション 注意点 結果が重複する場合は DISTINCT が必要な場合も パターン2: IN句 -- Step1 SELECT id, user_id FROM orders LIMIT 100 -- Step2 (IDまとめて一括) WHERE id IN (1,2,3,...) クエリ数: 2 アプリ側でIDを集約 向いている場面 1:多 の関係 ORM との相性が良い 注意点 IN 句の要素数が多い場合は バッチ分割を検討 パターン3: Eager Load // Laravel Order::with('user') ->limit(100) ->get() 内部的にIN句で一括取得 クエリ数: 2 ORM が自動で最適化 向いている場面 ORM を使う Web アプリ全般 コードがシンプルになる 注意点 eager load し過ぎると 不要データを大量取得する
図2: N+1解決パターンの比較

大量のデータを処理するバッチ処理では、さらにメモリ効率を考慮したバッチ取得が重要です。

-- 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: bullet gem(N+1を検出してアラート)
  • Laravel: Laravel Debugbar(クエリ数を可視化)
  • Django: django-silknplusone
  • 一般的: スロークエリログで同一クエリの大量実行を検出

N+1問題はインデックスを正しく設定していても発生します。クエリの品質だけでなく本数も常に意識しながらコードを書くことが重要です。次のエピソードでは、個々のクエリをさらに速くするチューニングテクニックを学びます。