【MySQL】N+1問題の検出と解決方法|Laravel Eloquentでの実例とEXPLAINでの確認

2026-07-12

はじめに

一覧画面が遅い、リクエストあたりの SQL が何十本もある、といった症状の裏に N+1 問題 が隠れていることがあります。Laravel の Eloquent では、リレーションをプロパティとして触ると、そのタイミングで SQL が走る(遅延ロード)ため、ループのたびにクエリが増えやすいです。対して、取得時にあらかじめ関連をまとめて取る方法が Eager Loadingwith)です。

この記事では次を整理します。

  • N+1 問題が起きる仕組み
  • 開発時の検出方法(クエリログ、preventLazyLoading
  • with / load / withCount による解決
  • Eager Loading 後の SQL を MySQL の EXPLAIN で確認する観点

リレーション種別そのものの網羅解説は扱いません。以降の例では belongsTo(投稿 → ユーザー)と hasMany(投稿 → コメント)だけを使います。定義の基礎は既存の Eloquent リレーション記事や Eloquent: Relationships を参照してください。

前提: Laravel 12 系の公式ドキュメントを主な根拠とします。手元の laravel/framework バージョンと MySQL のメジャーバージョン(例では 8.4 系の EXPLAIN を想定)に合わせて読み替えてください。実行計画はデータ量・統計情報で変わるため、数値は環境依存です。

背景:N+1 問題とは何か

N+1 問題は、親を 1 回 取得したあと、関連データをループで N 回 追加取得してしまうパターンです。公式ドキュメントの Book / Author 例では、本が 25 件あると「本 1 回 + 著者 25 回 = 26 クエリ」になります(Eager Loading)。以降は同じ構造を、ブログの Post / User で示します。

// N+1 の典型(遅延ロード)
$posts = Post::query()->limit(25)->get(); // 1 回

foreach ($posts as $post) {
    // ループのたびに users へ SELECT が走る
    echo $post->user->name;
}

Eager Loading(with)を使うと、親の取得と関連の一括取得に分かれ、上記の例では 2 クエリ に抑えられます。

-- イメージ(公式ドキュメントの説明に沿った形)
select * from posts limit 25;
select * from users where id in (1, 2, 3, ...);

N+1 を直しても、IN (...) 側の外部キーにインデックスが無いと、関連取得の 1 本は重く残ることがあります。クエリ本数の削減と、各 SQL の実行計画はセットで見る必要があります。


検出:まず「何本の SQL が走っているか」を見る

1. 開発ツールでクエリ一覧を見る

ローカルでは次のいずれかで、リクエスト内の SQL 本数と内容を確認します。

  • Laravel Debugbar などの開発用ツール
  • DB::listen でログ出力(ローカルの AppServiceProvider::boot や、調査したいルートの先頭に一時的に置く)
use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    logger()->debug($query->sql, [
        'bindings' => $query->bindings,
        'time_ms' => $query->time,
    ]);
});

一覧 API や Blade の @foreach で、同じ形の select * from users where users.id = ? が件数分並んでいれば N+1 の疑いが強いです。

2. preventLazyLoading で遅延ロードを例外にする

開発・CI では、遅延ロードそのものを禁止すると取りこぼしが減ります。公式では AppServiceProviderboot で次のように呼びます(Preventing Lazy Loading)。

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // 本番以外で遅延ロードを禁止する
    Model::preventLazyLoading(! $this->app->isProduction());
}

遅延ロードが発生すると LazyLoadingViolationException が投げられます。本番は落とさずログだけにしたい場合は、handleLazyLoadingViolationUsing で挙動を変えられます。

実装時の注意点:

  • 本番で一律禁止すると、既存の遅延ロードが障害になることがある。公式も非本番向けの使い方を示している
  • 例外が出ても「どこで触ったか」はスタックトレースで追う。一覧画面の View 側アクセスが原因のことも多い

解決:Eager Loading でクエリ本数を抑える

with で事前ロードする

use App\Models\Post;

$posts = Post::query()
    ->with('user')
    ->latest('id')
    ->limit(25)
    ->get();

foreach ($posts as $post) {
    echo $post->user->name; // 追加クエリは原則発生しない
}

複数リレーションやネストも公式どおり指定できます。

$posts = Post::query()
    ->with(['user', 'comments.user'])
    ->latest('id')
    ->limit(25)
    ->get();

すでに取得済みなら load

コレクション取得後に必要だと分かった場合は load を使います(Lazy Eager Loading)。たとえば、一覧は先に取り、権限チェックのあとでのみコメントが必要になるケースです。

$posts = Post::query()->limit(25)->get();

if ($viewer->can('viewComments')) {
    $posts->load('comments');
}

件数だけなら withCount

コメント本文が不要で件数だけ欲しいときは、関連モデルを全部載せない方が軽いです。

$posts = Post::query()
    ->withCount('comments')
    ->latest('id')
    ->limit(25)
    ->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}

取得カラムを絞るとき

with('user:id,name') のように列を制限できます。公式ドキュメントでは、リレーション解決に必要なキー(外部キー/所有者キー)を含める必要がある、とされています。belongsTouser なら、posts.user_id と結ぶ users.id が所有者キーです。キーを落とすと、関連は正しく載らないことがあります。

// users.id を含める(belongsTo の所有者キー)
$posts = Post::query()
    ->with('user:id,name')
    ->get();

EXPLAIN で「減ったあとの SQL」を確認する

N+1 を with で潰すと、関連側はだいたい次の形になります。

SELECT * FROM users WHERE id IN (1, 2, 3, 10, 25);

hasMany(例: 投稿のコメント)なら、外部キー側です。

SELECT * FROM comments WHERE post_id IN (1, 2, 3, ...);

本数が減っても、この 1 本が遅いと体感は改善しません。MySQL では EXPLAIN で実行計画を見ます(EXPLAIN Statement)。

EXPLAIN
SELECT id, name
FROM users
WHERE id IN (1, 2, 3, 10, 25);
EXPLAIN
SELECT id, post_id, body, created_at
FROM comments
WHERE post_id IN (1, 2, 3, 10, 25);

見る目安は次のとおりです(環境により表記は変わります)。一覧の IN クエリでは、refrange を狙うことが多いです。

見方の目安
typeALL(全表スキャン)より ref / range / const の方が絞り込みやすいことが多い
key使われたインデックス名。NULL ならインデックス未使用の可能性
rows調べる予定の行数の見積もり。実データと大きくずれることもある
ExtraUsing filesortUsing temporary が意図せず付いていないか

イメージ(数値は環境依存。説明用の簡略例です)。

-- post_id にインデックスが無い場合の例
type: ALL   key: NULL              rows: 大きめ

-- post_id にインデックスがある場合の例
type: range key: comments_post_id_index   rows: 小さめ

Laravel から発行 SQL を取り、手で EXPLAIN する流れです。

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

Post::query()->with('comments')->limit(25)->get();

$log = DB::getQueryLog();
// 例: comments 側の SQL が
// select * from `comments` where `comments`.`post_id` in (1, 2, 3)
// なら、MySQL クライアントで次を実行する
EXPLAIN
SELECT *
FROM comments
WHERE post_id IN (1, 2, 3);

bindings の値はログの配列を IN 句に埋めてください。プレースホルダのままでは EXPLAIN の意味が薄れます。

N+1 対策とインデックスの役割分担:

症状まず疑うこと
同じ SQL が件数分並ぶ遅延ロード(with / preventLazyLoading
SQL は 2〜3 本だが遅いEXPLAIN と外部キーインデックス
件数表示だけなのに重いwithCount への置き換え漏れ

comments.post_id にインデックスが無いと、IN クエリでもスキャンが大きくなり得ます。マイグレーション例です。

Schema::table('comments', function (Blueprint $table) {
    $table->index('post_id');
});

インデックスを足したあとも、必ず EXPLAINkey が付いたかを確認してください。


よくある失敗例:View 側で別リレーションを触る

コントローラでは with('user') したのに、Blade で $post->comments$post->category を追加で触ってしまうケースです。

// Controller
$posts = Post::query()->with('user')->paginate(20);

// resources/views/posts/index.blade.php
@foreach ($posts as $post)
  {{ $post->user->name }}
  {{-- comments をコレクション取得して count すると、関連ロードが走り N+1 になり得る --}}
  {{ $post->comments->count() }}
@endforeach

コントローラ側だけを見ると「対応済み」に見えます。実際のクエリは View まで含めて数えます。

対処:

  1. 画面で使うリレーションを洗い出し、with / withCount に載せる
  2. 開発環境で preventLazyLoading を有効にし、View アクセスでも例外にする
  3. Debugbar やクエリログで「同じ形の SQL がページ件数分」ないか確認する

件数だけなら次のようにします。

$posts = Post::query()
    ->with('user')
    ->withCount('comments')
    ->paginate(20);
{{ $post->comments_count }}

まとめ

  • N+1 は「親 1 回 + 関連 N 回」のクエリ増。Eloquent の遅延ロードとループの組み合わせで起きやすい
  • 検出はクエリ本数の可視化と、非本番での preventLazyLoading が有効
  • 解決の第一候補は with / load / withCount。その後の IN クエリは EXPLAIN と外部キーインデックスで確認する

次に試すこと

  1. 遅い一覧エンドポイントで Debugbar または DB::listen を使い、同一パターンの SQL 本数を数える
  2. 非本番で Model::preventLazyLoading(! app()->isProduction()) を有効にし、落ちる箇所を直す
  3. with 適用後の関連 SQL を EXPLAIN し、type / key / rows を記録する
  4. 外部キー(例: comments.post_id)にインデックスが無ければ追加し、再度 EXPLAIN する
  5. View で触るリレーションをチェックリスト化し、コントローラの with と突き合わせる

Source