【MySQL】N+1問題の検出と解決方法|Laravel Eloquentでの実例とEXPLAINでの確認
はじめに
一覧画面が遅い、リクエストあたりの SQL が何十本もある、といった症状の裏に N+1 問題 が隠れていることがあります。Laravel の Eloquent では、リレーションをプロパティとして触ると、そのタイミングで SQL が走る(遅延ロード)ため、ループのたびにクエリが増えやすいです。対して、取得時にあらかじめ関連をまとめて取る方法が Eager Loading(with)です。
この記事では次を整理します。
- 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 では、遅延ロードそのものを禁止すると取りこぼしが減ります。公式では AppServiceProvider の boot で次のように呼びます(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') のように列を制限できます。公式ドキュメントでは、リレーション解決に必要なキー(外部キー/所有者キー)を含める必要がある、とされています。belongsTo の user なら、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 クエリでは、ref や range を狙うことが多いです。
| 列 | 見方の目安 |
|---|---|
type | ALL(全表スキャン)より ref / range / const の方が絞り込みやすいことが多い |
key | 使われたインデックス名。NULL ならインデックス未使用の可能性 |
rows | 調べる予定の行数の見積もり。実データと大きくずれることもある |
Extra | Using filesort や Using 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');
});
インデックスを足したあとも、必ず EXPLAIN で key が付いたかを確認してください。
よくある失敗例: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 まで含めて数えます。
対処:
- 画面で使うリレーションを洗い出し、
with/withCountに載せる - 開発環境で
preventLazyLoadingを有効にし、View アクセスでも例外にする - 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と外部キーインデックスで確認する
次に試すこと
- 遅い一覧エンドポイントで Debugbar または
DB::listenを使い、同一パターンの SQL 本数を数える - 非本番で
Model::preventLazyLoading(! app()->isProduction())を有効にし、落ちる箇所を直す with適用後の関連 SQL をEXPLAINし、type/key/rowsを記録する- 外部キー(例:
comments.post_id)にインデックスが無ければ追加し、再度EXPLAINする - View で触るリレーションをチェックリスト化し、コントローラの
withと突き合わせる





