【Laravel】N+1問題の検出と解消方法|with・load・lazy loadingの使い分け
はじめに
Laravelでアプリケーションを開発していると、気づかないうちに大量のSQLが発行されてパフォーマンスが劣化することがあります。その原因の多くが N+1問題 です。
ページの表示が遅い、DBへの負荷が高い、といった症状が出たとき、まず疑うべきなのがN+1問題です。
この記事では、N+1問題の仕組みから検出方法、with・load・遅延ローディングの使い分けまで、実務で役立つ知識を整理します。
N+1問題とは
N+1問題とは、1件のレコードを取得するクエリに加えて、関連レコードを取得するクエリがN件発行される問題です。
具体例
投稿(Post)一覧を表示するとき、各投稿のユーザー名も表示するケースを考えます。
$posts = Post::all(); // クエリ1回:全投稿を取得
foreach ($posts as $post) {
echo $post->user->name; // 投稿ごとに1回:N回のクエリが発行される
}
発行されるSQLはこうなります。
-- 1回目:投稿を全件取得
SELECT * FROM posts;
-- 以降、投稿の件数分だけ繰り返される(N回)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...
100件の投稿があれば、合計101回のSQLが発行されます。件数が増えるほど指数的に遅くなります。
Eager Loading(with)で解消する
with() を使うことで、関連データを最初のクエリと一緒に取得(Eager Loading)できます。
$posts = Post::with('user')->get();
foreach ($posts as $post) {
echo $post->user->name; // クエリは追加で発行されない
}
発行されるSQLはたったの2回です。
-- 1回目:投稿を全件取得
SELECT * FROM posts;
-- 2回目:関連するユーザーをIN句でまとめて取得
SELECT * FROM users WHERE id IN (1, 2, 3, ...);
複数のリレーションを同時に読み込む
$posts = Post::with(['user', 'comments', 'tags'])->get();
ネストしたリレーションを読み込む
// commentsとそのコメントを書いたuserも一緒に取得
$posts = Post::with('comments.user')->get();
条件付きEager Loading
$posts = Post::with(['comments' => function ($query) {
$query->where('approved', true)->orderBy('created_at', 'desc');
}])->get();
withCountでレコード数だけ取得する
関連レコードの中身は不要で件数だけ欲しい場合は withCount が使えます。
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count; // コメント数のみ取得(JOIN不要)
}
load()で遅延Eager Loadingを行う
with() はクエリ実行前に指定しますが、すでに取得済みのコレクションに対して後からリレーションを読み込む場合は load() を使います。
$posts = Post::all(); // すでに取得済み
// 後からリレーションを読み込む
$posts->load('user');
foreach ($posts as $post) {
echo $post->user->name;
}
withとloadの使い分け
with() | load() | |
|---|---|---|
| 使うタイミング | クエリ実行前(Builder段階) | コレクション取得後 |
| 主な用途 | 最初から必要とわかっている場合 | 条件によって後から読み込む場合 |
| パフォーマンス | 同等 | 同等 |
実務では with() を基本として使い、コントローラでの分岐処理や、サービスクラスで取得済みコレクションに追加でリレーションを読み込む場合に load() を使うのが自然です。
lazyEagerLoadingとlazyを使う
lazy()
cursor() に似た遅延実行ですが、Collectionとして扱えます。大量データを扱う場合のメモリ節約に使います。
foreach (Post::lazy() as $post) {
// 1件ずつ処理される
}
loadMissing()
すでに読み込まれているリレーションをスキップして、まだ読み込んでいないリレーションだけ取得します。二重読み込みを防げます。
$posts->loadMissing('user');
N+1問題の検出方法
Laravel Debugbar
barryvdh/laravel-debugbar を使うと、画面下部にクエリ数・実行時間が表示されます。
composer require barryvdh/laravel-debugbar --dev
画面に表示されるクエリ数が異常に多い場合(ページ一覧で50件表示しているのに60クエリ以上発行されているなど)はN+1を疑いましょう。
Laravel Telescope
laravel/telescope を使うと、WebUIでリクエストごとのクエリを詳細に確認できます。
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
DB::listen を使ったデバッグ
一時的にクエリをログに出力して確認する方法です。
// AppServiceProvider::boot() などに追記
\\DB::listen(function ($query) {
\\Log::debug($query->sql, $query->bindings);
});
preventLazyLoading()
Laravel 8.1以降では、遅延ロードが発生した際に例外をスローする設定ができます。開発環境でN+1の発生を自動検知できる強力な機能です。
// AppServiceProvider::boot()
use Illuminate\\Database\\Eloquent\\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
本番環境では無効にし、開発・ステージング環境でのみ有効にすることで安全に使えます。N+1が発生すると LazyLoadingViolationException がスローされるため、見落としを防げます。
実務でやりがちなアンチパターン
条件分岐の中で都度リレーションアクセス
// NG:ループ内でリレーションアクセス
foreach ($posts as $post) {
if ($post->user->role === 'admin') { // ここでN+1発生
// ...
}
}
// OK:withで先に読み込む
$posts = Post::with('user')->get();
foreach ($posts as $post) {
if ($post->user->role === 'admin') {
// ...
}
}
Bladeテンプレートでのリレーションアクセス
{{-- NG:テンプレート側でN+1が発生しているケース --}}
@foreach ($posts as $post)
{{ $post->user->name }} {{-- コントローラでwithしていないとN+1 --}}
@endforeach
コントローラ側で必ず with() してから渡しましょう。
whereHasの多用
// 注意:whereHasはサブクエリを使うため大量データでは遅くなることがある
$posts = Post::whereHas('comments', fn($q) => $q->where('approved', true))->get();
// JOINで書き直すほうが速い場合も
$posts = Post::join('comments', 'posts.id', '=', 'comments.post_id')
->where('comments.approved', true)
->select('posts.*')
->distinct()
->get();
whereHas はシンプルに書けますが、大量データでは join のほうがパフォーマンスが良いケースがあります。EXPLAIN で実行計画を確認しましょう。
withですべてのカラムを取得している
// 注意:必要なカラムだけ取得するほうがメモリ効率が良い
$posts = Post::with('user')->get();
// 必要なカラムだけ指定
$posts = Post::with('user:id,name,email')->get();
// ※ with側にidカラムは必須(外部キー結合に使われるため)
まとめ
| 方法 | 使うタイミング |
|---|---|
with() | クエリ実行前に必要なリレーションがわかっている場合 |
load() | 取得済みコレクションに後からリレーションを追加する場合 |
loadMissing() | すでに読み込み済みのリレーションをスキップしたい場合 |
withCount() | 関連レコードの件数だけ必要な場合 |
preventLazyLoading() | 開発環境でN+1を自動検知したい場合 |
N+1問題は Debugbarや preventLazyLoading() で早期に検出し、with() で対処するのが基本です。
特に preventLazyLoading() は開発環境で必ず有効にしておくことをおすすめします。コードレビューやテストでは気づきにくいN+1を、実行時に自動で検知してくれる非常に強力な機能です。




