LaravelでN+1問題を解決する方法

LaravelでN+1問題を解決する方法

Laravelで発生するN+1問題は、Eloquentを使い始めた段階で非常によく遭遇するパフォーマンス問題の1つ。最初はデータ件数が少ないため気づきにくいが、本番データが増えてくると一覧画面、APIレスポンス、管理画面、バッチ処理などで急激に遅くなる原因になる。問題の本質は「親データを取得したあと、関連データを1件ずつ追加で取りに行ってしまうこと」にある。つまり、1回で済むはずの問い合わせが、親件数分だけ増殖する。Laravelでは with()load()withCount()select() の整理、クエリ設計の見直しなどでかなりの場面を改善できる。重要なのは、N+1を単なる“遅いクエリ”としてではなく、「関連の取り方が原因で発生する構造的な問題」として理解すること。

N+1問題とは何か

N+1問題とは、最初の1回のクエリで親データを取得したあと、その親データの件数Nに応じて追加クエリが発生する状態を指す。
たとえば、投稿一覧を取得したあと、各投稿の著者名を表示するために $post->user->name をループ内で参照すると、投稿件数分だけ users テーブルへのクエリが追加される。
つまり、
・最初の1クエリで posts を取得
・その後 N件分の user 取得クエリ
となるため、合計で N+1 クエリになる。

最もよくある発生条件

LaravelでN+1が起きやすい典型条件は次の通り。
・一覧画面でリレーションをループ内参照している
・API ResourceやBladeの中で関連モデルを直接呼んでいる
・ネストしたリレーション(post.user.company など)をまとめて読み込んでいない
・件数表示のために ->comments->count() をループ内で使っている
・Polymorphicリレーションをそのまま辿っている
・開発中はデータ件数が少なく、問題に気づいていない
特に「書き方として自然に見える」のがN+1の厄介な点で、コードレビューでも見逃しやすい。

典型的なN+1の悪い例

以下のようなコードは非常に典型的。
投稿一覧を取得したあと、著者を1件ずつ取りに行ってしまう。

$posts = Post::latest()->take(20)->get();

foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
}

このコードは見た目には問題なさそうだが、
・posts取得で1クエリ
・20件の投稿ごとに user を取得するので20クエリ
合計21クエリになる。
投稿数が200件なら201クエリになるため、データ量に比例して悪化する。

基本の解決方法はwith()によるEager Loading

最も基本的な解決策は、最初から必要な関連をまとめて取得する with() を使うこと。
これにより、親データ取得と関連データ取得が分離されても、クエリ回数は大幅に減る。

$posts = Post::with(‘user’)->latest()->take(20)->get();

foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
}

この場合、
・posts取得で1クエリ
・関連するusersをまとめて取得で1クエリ
合計2クエリ程度で済む。
これがEager Loadingの基本になる。

複数の関連をまとめて読み込む

一覧では1つの関連だけでなく、複数の関連を一緒に表示することが多い。
その場合も、必要なものを最初に with() へ並べる。

$posts = Post::with([‘user’, ‘category’, ‘tags’])->latest()->paginate(20);

このようにしておけば、BladeやAPI Resourceの中で関連を参照しても追加クエリが発生しにくくなる。
ただし、何でもかんでも with() に入れすぎると、逆に不要データまで大量取得して重くなるので、表示に必要な関連だけに絞ることが重要。

ネストした関連もまとめて読み込む

N+1は1段階の関連だけでなく、ネストした関連でも発生する。
たとえば、投稿の著者、その著者が所属する会社名まで表示したい場合は、1段目だけでなく2段目までまとめて読む必要がある。

$posts = Post::with(‘user.company’)->latest()->get();

foreach ($posts as $post) {
echo $post->user->company->name;
}

もし with('user') だけで止めてしまうと、company の参照で再びN+1が発生する。
つまり「どこまで画面で使うか」を先に考えて、必要な階層までまとめて読み込む必要がある。

後から読み込むならload()を使う

すでに取得済みのコレクションやモデルに対して、後から関連をまとめて読み込みたい場合は load() が使える。
条件分岐の後で必要になった関連を足すときに便利。

$posts = Post::latest()->take(20)->get();

if ($needAuthorInfo) {
$posts->load(‘user’);
}

単一モデルに対しても同じように使える。

$post = Post::findOrFail($id);
$post->load([‘user’, ‘comments’]);

with() は取得時、load() は取得後という使い分けになる。

件数表示で起きるN+1はwithCount()で解決する

コメント数や注文数などを一覧で表示したいとき、ループ内で count()comments->count() を呼ぶとN+1が発生しやすい。
この場合は withCount() を使うのが定番。

悪い例。

$posts = Post::latest()->get();

foreach ($posts as $post) {
echo $post->comments->count();
}

改善例。

$posts = Post::withCount(‘comments’)->latest()->get();

foreach ($posts as $post) {
echo $post->comments_count;
}

これにより、各投稿ごとにコメントを取りに行くのではなく、件数をまとめて取得できる。
一覧画面やダッシュボードでは特に効果が大きい。

必要なカラムだけ取得して無駄を減らす

N+1を解消しても、関連モデルを select * で全部持ってくると、転送量とメモリ使用量が増える。
Eager Loadingと合わせて、必要なカラムだけに絞るとさらに効率が良い。

$posts = Post::select([‘id’, ‘user_id’, ‘title’, ‘created_at’])
->with([‘user:id,name’])
->latest()
->get();

ここで重要なのは、関連を結ぶためのキー(この例では user_idid)を落とさないこと。
キーを削るとリレーション解決ができず、別の不具合を招く。

BladeやAPI Resourceの中でN+1が再発しやすい

Controllerでは with() していたつもりでも、BladeやResourceで別の関連を触ってN+1が再発することがある。
特に次のような書き方は要注意。

@foreach ($posts as $post)

{{ $post->user->name }}

{{ $post->comments->count() }}

@endforeach

Controllerで with('user') しかしていなければ、comments でN+1が起きる。
つまり、N+1対策はControllerだけでは完結せず、「最終的にどこで何を表示しているか」まで見て設計しなければならない。

PolymorphicリレーションでもN+1は起きる

コメントの親がPostだったりProductだったりする Polymorphic リレーションでも、N+1は普通に起きる。
たとえば、コメント一覧から commentable を毎回辿ると、件数分だけ追加クエリが走ることがある。

$comments = Comment::with(‘commentable’)->latest()->get();

foreach ($comments as $comment) {
echo $comment->commentable?->id;
}

Polymorphicは便利だが、関連先が複数モデルにまたがるため、負荷を意識しないと一覧系で急に遅くなりやすい。
件数が多い画面では特に注意が必要。

デバッグ方法:実際にクエリ数を見る

N+1は体感だけではなく、クエリ数で確認するのが確実。
開発中はログや監視ツールを使って、実際に何件のSQLが発行されているかを確認する。

簡易的には次のようにクエリログを見ることもできる。

\DB::enableQueryLog();

$posts = Post::with(‘user’)->latest()->take(10)->get();

dd(\DB::getQueryLog());

また、Laravel TelescopeやDebugbarを使えば、画面ごとのクエリ数や実行時間も確認しやすい。
「一覧1ページで何十件もSQLが出ていないか」を見るだけでも発見につながる。

“とりあえずwith()”で終わらせない

N+1を見つけると、とりあえず with() を足したくなるが、それだけでは不十分なことも多い。
たとえば、
・不要な関連まで大量に読む
・ネストしすぎて逆に重い
・件数だけ欲しいのにwithCountを使っていない
・そもそも一覧にその情報が必要ない
といったケースがある。
つまり、N+1対策は単にクエリ数を減らすことではなく、「本当に必要なデータだけを、必要な形で取る」ことが本質になる。

設計段階で防ぐ意識を持つ

N+1は後から見つけて直すこともできるが、最初から次の視点を持っておくとかなり防げる。
・一覧画面で使う関連はControllerで明示的にまとめて読む
・件数はwithCountを使う
・ネストした関連も表示要件に応じて読み込む
・BladeやResourceで何を参照しているか確認する
・クエリ数を開発中から見る習慣を持つ
このあたりをチーム内でルール化すると、後から性能問題として爆発しにくい。

まとめ

LaravelのN+1問題は、Eloquentを便利に使うほど自然に発生しやすいパフォーマンス問題になる。
主な解決策は、
with() によるEager Loading
load() による後読み込み
withCount() による件数取得
・必要カラムだけの select()
・BladeやResourceでの関連参照の見直し
といった方法。
単に「遅いから直す」ではなく、「関連をどう取得しているか」を設計として見直すことで、一覧画面、API、管理画面のパフォーマンスは大きく改善しやすい。
Laravelでは非常に起きやすい問題だからこそ、早い段階から意識しておく価値が大きい。