【Laravel】N+1問題の検出と解消方法|with・load・lazy loadingの使い分け

はじめに

Laravelでアプリケーションを開発していると、気づかないうちに大量のSQLが発行されてパフォーマンスが劣化することがあります。その原因の多くが N+1問題 です。

ページの表示が遅い、DBへの負荷が高い、といった症状が出たとき、まず疑うべきなのがN+1問題です。

この記事では、N+1問題の仕組みから検出方法、withload・遅延ローディングの使い分けまで、実務で役立つ知識を整理します。

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を、実行時に自動で検知してくれる非常に強力な機能です。