【Laravel】N+1問題とは?eager loading(with)で解決する

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回はQuery Builderの基本を扱い、Eloquentとの使い分けを紹介しました。

今回は、Eloquentでリレーションを扱う人が必ずと言っていいほど一度は踏む落とし穴、N+1問題を取り上げます。

「画面表示が急に遅くなった」というときの原因の多くはこれなので、今のうちに仕組みと対処法をしっかり理解しておきましょう。

N+1問題とは?

何が起きているのか

N+1問題とは、リレーションを持つデータを一覧表示する際に、必要以上に大量のクエリが発行されてしまう問題のことです。

「N件のデータを取得するために、1回のクエリ+N回のクエリ、合計 N+1 回のクエリが発行される」ことからこの名前がついています。

たとえば、記事一覧に投稿者名を表示する処理を考えてみます。

<?php

use App\Models\Article;

$articles = Article::all(); // 記事を10件取得(クエリ1回)

foreach ($articles as $article) {
    echo $article->user->name; // ここで記事ごとにクエリが1回ずつ発行される
}

一見すると自然なコードに見えますが、これが典型的なN+1問題です。

実際に発行されるクエリを確認する

このコードを実行すると、裏側では次のようなクエリが発行されています。

-- 記事一覧の取得(1回)
SELECT * FROM articles;

-- 記事ごとに投稿者を取得(記事の件数分繰り返される)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- ... 記事の件数だけ繰り返す

記事が10件なら合計11回、100件なら合計101回のクエリが発行されます。

私が実際にこれを踏んだのは、開発中は10件程度のテストデータで動かしていたので全く気づかず、Seederで1,000件のダミーデータを投入した瞬間に管理画面の表示が固まって、初めて異変に気づいたときでした。

なぜ気づきにくいのか

N+1問題が厄介なのは、コード自体は一見正しく動いているように見えることです。

エラーは出ないですし、データも正しく表示されます。

ただ、データ件数が増えるにつれてクエリの発行回数も比例して増えていくため、開発環境の少ないデータでは問題なくても、本番環境で件数が増えたときに急激に遅くなる、という形で表面化しやすいのが特徴です。

eager loading(with)で解決する

基本の書き方

N+1問題の解決方法が、eager loading(イーガーローディング)です。

Eloquentでは with() メソッドを使って実現します。

<?php

use App\Models\Article;

// あらかじめ関連するuserもまとめて取得しておく
$articles = Article::with('user')->get();

foreach ($articles as $article) {
    echo $article->user->name; // 追加のクエリは発行されない
}

with('user') を指定すると、発行されるクエリは次の2回だけになります。

-- 記事一覧の取得(1回)
SELECT * FROM articles;

-- 関連するユーザーをまとめて取得(1回)
SELECT * FROM users WHERE id IN (1, 2, 3, ...);

記事が何件になろうと、クエリの発行回数は常に2回のまま増えません。

これが「N+1回」から「2回」に減る、eager loadingの効果です。

複数のリレーションをまとめて読み込む

配列で複数指定すれば、複数のリレーションを一度にまとめて読み込めます。

<?php

use App\Models\Article;

$articles = Article::with(['user', 'tags'])->get();

foreach ($articles as $article) {
    echo $article->user->name;
    foreach ($article->tags as $tag) {
        echo $tag->name;
    }
}

usertags のどちらもN+1問題を起こさず、それぞれ1回ずつのクエリで済みます。

ネストしたリレーションを読み込む

さらに深いリレーション(リレーション先のリレーション)も、ドット記法でまとめて指定できます。

<?php

use App\Models\Article;

// 記事 → コメント → コメント投稿者、まで一気に読み込む
$articles = Article::with('comments.user')->get();

'comments.user' のようにドットでつなぐことで、「記事に紐づくコメント」だけでなく、「そのコメントに紐づく投稿者」まで、N+1問題を起こさずまとめて取得できます。

つまずきやすいポイント:withを書いたのにN+1が消えない

条件付きリレーションで見落としがちなケース

私が実際に悩まされたのが、with() を書いたはずなのにN+1問題が解消されないケースです。

❌ Before:ループの中で条件を指定してリレーションを再取得している

<?php

use App\Models\Article;

$articles = Article::with('tags')->get();

foreach ($articles as $article) {
    // ループの中でリレーションを"新たに"クエリしてしまっている
    $publishedTags = $article->tags()->where('status', '公開')->get();
    foreach ($publishedTags as $tag) {
        echo $tag->name;
    }
}

$article->tags (プロパティとしてアクセス)ならeager loadingの結果がそのまま使われますが、$article->tags() (メソッドとして呼び出し、さらに条件を追加)はその場で新しくクエリを発行してしまいます

見た目が似ているため、私はこの違いに気づかず「with を書いてるのになんで遅いんだ」としばらく悩みました。

✅ After:あらかじめwithの中で条件を絞り込んでおく

<?php

use App\Models\Article;

$articles = Article::with(['tags' => function ($query) {
    $query->where('status', '公開');
}])->get();

foreach ($articles as $article) {
    foreach ($article->tags as $tag) {
        echo $tag->name;
    }
}

with() にクロージャを渡すことで、「eager loadingする際に、あらかじめ条件を絞り込んでおく」ことができます。

こうすればループの中では純粋にプロパティとしてアクセスするだけになり、N+1問題を避けられます。

$article->tags(プロパティ)と $article->tags()(メソッド+クエリ)の違いを意識するのが、このつまずきを避ける一番のポイントです。

応用:N+1問題に気づくための調べ方

クエリログを可視化するデバッグバー

「そもそも今どれだけクエリが発行されているか分からない」という人には、barryvdh/laravel-debugbar のようなパッケージの導入がおすすめです。

composer require barryvdh/laravel-debugbar --dev

画面下部にクエリの発行回数と内容が一覧表示されるため、「同じようなSELECT文が何十回も並んでいる」状態を一目で発見できます。

コード上でN+1を検知する

Laravel 8以降では、Model::preventLazyLoading() を使うと、eager loadingせずにリレーションへアクセスしようとした瞬間に例外を投げさせることもできます。

<?php
// app/Providers/AppServiceProvider.php

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // ローカル環境だけN+1問題を例外で検知する
        Model::preventLazyLoading(! app()->isProduction());
    }
}

本番環境ではパフォーマンスへの影響を避けるため無効化しつつ、開発環境だけ有効にしておくことで、N+1問題を「気づかないまま本番に出す」事故を未然に防げます。

まとめ

この記事のポイント

  • N+1問題は、リレーション先をループ内で都度取得することで発生する、クエリ発行回数の爆発的な増加
  • 開発中の少ないデータでは気づきにくく、データが増える本番環境で表面化しやすい
  • with() によるeager loadingで、クエリの発行回数を件数に依存しない一定回数に抑えられる
  • $article->tags(プロパティ)と $article->tags()(メソッド)の違いに注意し、条件付きの絞り込みは with() のクロージャ内で行う
  • デバッグバーや preventLazyLoading() を使うと、N+1問題を早期に発見しやすくなる

次に読むべき記事

大量データを扱うようになったら、一覧画面をどう分割して表示するかも重要なテーマになります。

→ 次の記事:LengthAwarePaginatorでページネーションを実装する

タイトルとURLをコピーしました