laravel-query-detectorでN+1問題を検出・改善するハンズオン

概要

Laravelで一覧画面を作っていると、いつの間にか発行SQLが数十〜数百本に膨れ上がる「N+1問題」に遭遇します。本記事では beyondcode/laravel-query-detector を使ってN+1を自動検出し、with() によるEager Loadingで改善するまでをハンズオン形式でまとめます。再現コード・検出の見え方・改善後の確認方法まで通しで追えるようにしています。

環境

  • PHP 8.3 以上
  • Laravel 12 / 13 系
  • beyondcode/laravel-query-detector 2.x
  • 開発環境(APP_ENV=local)での利用を前提とします

laravel-query-detector は開発用パッケージです。本番では無効化する前提で require-dev に入れます。

なお、本記事のコード・設計方針は執筆時点の最新環境でもそのまま有効です。Eloquent の with() / withCount() / $with / DB::getQueryLog() のAPIは変わっておらず、laravel-query-detector も 2.x で vendor:publish のプロバイダ名(BeyondCode\QueryDetector\QueryDetectorServiceProvider)と config キー(enabled / threshold / output / except)が 1.x と同一のため、以下の手順はそのまま踏襲できます。

発生した問題

ブログ記事の一覧画面で、各記事の著者名を表示するコードを考えます。

// Controller
$posts = Post::all();
// Blade
@foreach ($posts as $post)
    <li>{{ $post->title }} / {{ $post->user->name }}</li>
@endforeach

一見問題なさそうですが、$post->user にアクセスするたびに users テーブルへのクエリが1本走ります。記事が100件あれば、posts を取る1本と各記事の user を取る100本で合計101本のSQLが発行されます。これがN+1問題です。件数が増えるほどレスポンスが悪化し、DB負荷も跳ね上がります。

問題は「動いてしまう」こと。開発中は記事が数件しかなく体感できず、データが増えた本番で初めて遅延として顕在化しがちです。だからこそ、開発段階で機械的に検出する仕組みが要ります。

原因

ORM(Eloquent)はリレーションを 遅延ロード(Lazy Loading) します。$post->user に初めてアクセスした瞬間にクエリを発行する挙動のため、ループ内でアクセスすると件数分だけクエリが走ります。便利さと引き換えに、コードの見た目からはSQL本数が読み取りにくいのが厄介な点です。

対応方法

laravel-query-detector を導入します。同一リクエスト内で「同じリレーションに対して閾値以上のクエリが発行された」場合に警告を出してくれるパッケージです。

composer require beyondcode/laravel-query-detector --dev
php artisan vendor:publish --provider="BeyondCode\QueryDetector\QueryDetectorServiceProvider"

config/querydetector.php の主な項目です。

return [
    // 有効化(本番では必ず false)
    'enabled' => env('QUERY_DETECTOR_ENABLED', false),

    // 同一リレーションで何回クエリが走ったら警告するか
    'threshold' => 1,

    // 検出結果の出力先
    'output' => [
        \BeyondCode\QueryDetector\Outputs\Alert::class,
        \BeyondCode\QueryDetector\Outputs\Log::class,
    ],

    // 特定リクエストを無視したい場合のパターン
    'except' => [
        // '/admin*',
    ],
];

.env で開発時のみ有効化します。

QUERY_DETECTOR_ENABLED=true

この状態で一覧画面を開くと、「Post で user リレーションがN+1で呼ばれている」旨の警告と修正案(Post::with('user') を使え)が出力されます。出力先を Log にしておけば storage/logs/laravel.log にも残るので、API開発やテスト経由でも拾えます。

実装例

検出された箇所をEager Loadingで直します。

// Before: N+1発生
$posts = Post::all();

// After: 著者をまとめて取得(クエリは2本に収束)
$posts = Post::with('user')->get();

ネストしたリレーションや複数リレーションもまとめて指定できます。

$posts = Post::with([
    'user',
    'comments.user',
])->get();

一覧の各行で「コメント件数」だけ欲しいケースは、リレーション本体を取らず withCount() を使う方が軽くなります。

$posts = Post::withCount('comments')->get();
// Blade: $post->comments_count で参照できる

常に一緒に読むリレーションが決まっているなら、モデル側に $with を定義する手もあります(ただし常時JOIN相当の負荷になるため、本当に毎回必要なものに限定します)。

class Post extends Model
{
    protected $with = ['user'];
}

確認方法

改善できたかは、発行SQL本数で確認します。検出警告が消えることが一次確認ですが、数値でも裏を取ります。

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$posts = Post::with('user')->get();
foreach ($posts as $post) {
    $post->user->name;
}

logger()->info('query count: ' . count(DB::getQueryLog()));
// Before: 101 → After: 2 になっていれば成功

テストで本数を固定しておくと、将来のリグレッションも防げます。

public function test_一覧取得でN1が起きないこと(): void
{
    Post::factory()->count(10)->create();

    DB::enableQueryLog();
    $this->get('/posts')->assertOk();

    // posts 1本 + users 1本 = 2本以内に収まること
    $this->assertLessThanOrEqual(2, count(DB::getQueryLog()));
}

注意点

  • 本番で有効化しない。enabled は env() 経由にし、本番の .env では false を徹底します。検出処理自体がオーバーヘッドになります。
  • threshold を 1 にすると厳しすぎて誤検知が増えることがあります。まず 1 で洗い出し、意図的な少数クエリは except で除外するなど運用で調整します。
  • Eager Loadingは「使うリレーションだけ」指定します。使わないリレーションまで with() すると、今度は無駄なクエリ・メモリ消費を招きます。
  • $with(モデル定義)は全クエリに効くため、重い一覧では with() を都度書く方が安全です。

まとめ

  • N+1はLazy Loadingの副作用で、データ量が増える本番で初めて牙をむく
  • laravel-query-detector を require-dev で入れ、開発時だけ有効化すると機械的に検出できる
  • 検出箇所は with() / withCount() で潰し、DB::getQueryLog() の本数とテストで裏を取る
  • 「開発段階で検出 → Eager Loadingで改善 → テストで固定」までを習慣化すると、N+1は再発しにくくなる

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。