LaravelのN+1問題を解消したらAPIが速くなった話!with()以外の対策も
🙌 結論から
Laravel Eloquent で気づかずに N+1 問題を抱えていた API に with() による Eager Loading を導入したところ、クエリ数が 51 回 → 2 回に激減し、応答速度がかなり改善しました!
リレーション付きのデータを取得するとき、foreach でループしながら関連データを取っていると、気づかないうちに大量のクエリが走ることがあります。
発見した時点では「こんなにクエリが出ていたのか…(´;ω;`)」と少し焦りました。
でも解消自体は with() を 1 行加えるだけで済むので、発見できれば対処は難しくありません。
💡 N+1問題とはなにか
N+1 問題は、N 件のレコードに対して N+1 回のクエリが発行されてしまう問題です。
たとえば、投稿一覧(N 件)を取得して、それぞれの投稿に対してユーザー情報を取得するコードを書くと、投稿取得の 1 回 + 投稿数分(N 回)のユーザー取得クエリが走ります。
50 件の投稿があれば 51 回のクエリ、100 件なら 101 回という具合です(・_・;)
小規模なうちは気づきにくいですが、データが増えるほど応答速度に響いてきます。
👀 問題の発見
今回は、管理画面のユーザー一覧 API のクエリログを確認していたときに気づきました!
Laravel Debugbar を使ってローカルで確認したところ、想定の数倍のクエリが出ていることがわかって、「あ、これが N+1 か」と初めてリアルに体感しました(^^)
コードはこんな感じでした。
$users = User::all();
foreach ($users as $user) {
echo $user->profile->name; // プロフィールをリレーションで取得
}
$user->profile にアクセスするたびに 1 回クエリが走るので、ユーザーが 50 人いれば 51 回のクエリになります。
コードを見るだけではわかりにくく、クエリを可視化して初めて気づく類の問題です。
✨ Eager Loading で解消する
解消するには with() メソッドでリレーションを事前にまとめて取得します。
$users = User::with('profile')->get();
foreach ($users as $user) {
echo $user->profile->name;
}
これだけで、クエリは 「users テーブル取得 1 回 + profiles テーブル取得 1 回」の合計 2 回になります!
複数のリレーションがある場合は配列で指定できます。
$users = User::with(['profile', 'posts', 'tags'])->get();
ネストしたリレーションは . でつなぎます。
$users = User::with('posts.comments')->get();
投稿とそのコメントをまとめて取得でき、クエリ数をかなり効率よく抑えられます!
👍 解消してみた感想
with() を 1 行加えただけでクエリ数が劇的に減ったのは、本当に気持ちよかったです(^o^)/
API の応答速度も、体感でわかるくらい速くなりました。
N+1 問題の怖いところは「コードを見ただけでは気づきにくい」点です。
Laravel Debugbar や MySQL のスロークエリログなど、クエリを可視化できるツールを開発環境に入れておくと、こういった問題を早めに発見できます。
あとは、リレーションを使う場面では最初から with() を使う習慣にしておくのが一番確実だと感じています(・∀・)
🚨 preventLazyLoadingで自動検出の仕組みも入れた
with() を使う習慣をつけたつもりでも、正直「またどこかで書き忘れてるんじゃないか」という不安がずっとありました。
そこで導入したのが、Laravel 標準の preventLazyLoading() です。
AppServiceProvider の boot() に1行足すだけで、with() し忘れたリレーションにアクセスした瞬間に例外を投げてくれるようになります。
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
本番環境では例外を投げずに済むよう isProduction() で分岐しているのがポイントです。
開発環境やステージング環境だけこの防御を有効にしておけば、コードレビューやテストを待たずに、「あ、ここN+1になってる」とその場で気づけるようになります(・∀・)
私はこれを入れた初日に、実は他にも2箇所 with() を忘れている実装があることに気づけて、正直かなりゾッとしました(´;ω;`)
本番でどうしても止めたくない場合向けに、Laravel 10.30以降では handleLazyLoadingViolationUsing() を使って、例外を投げる代わりにログだけ出す設定もできます。
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
Log::warning("N+1の疑い: {$model::class}::{$relation}");
});
「開発中は例外で強制的に気づかせる、本番はログで静かに記録する」という使い分けにしてから、N+1を見逃す不安がかなり減りました!
🔧 with() 以外の N+1 対策の使い分け
N+1 の対策は with() が基本ですが、場面によってはほかの書き方のほうが向いていることもあります(^^)
| やりたいこと | 書き方 |
|---|---|
| 関連データをまとめて取りたい | with('comments') |
| 件数だけほしい | withCount('comments') |
| 合計や最大値だけほしい | withSum('orders', 'total') / withMax(...) |
| 取得したあとで関連を読みたい | $posts->load('comments') |
| 関連の一部の列だけほしい | with('author:id,name') |
特に件数だけ表示したいのに with() で全件取ってしまうのは、よくあるもったいないパターンです👀
// コメント数を出すだけなら、これで十分
$posts = Post::withCount('comments')->paginate(20);
// Blade では $post->comments_count で使える
with('author:id,name') のように列を絞るときは、主キー(id)を必ず含めるのを忘れないようにしてください(・_・;)
含めないと、関連がつながらずに null になってしまいます。
パパ系Webエンジニア〜愛用ガジェット一覧 🛍️
この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌
🙌 まとめ
N+1 問題は、LaravelのEloquentを使う場合は with() を使った Eager Loading で解消できます。
with('relation')でリレーションをまとめて取得- 複数リレーションは配列
with(['a', 'b'])で指定 - ネストは
with('posts.comments')のように.でつなぐ
Laravel Debugbar などでクエリを可視化する習慣を持つと、N+1 に早めに気づけます。
コード量の変化はほぼゼロなのに、応答速度がかなり変わるので、まだ対処していない API がある方はぜひ確認してみてください🙌
※ Laravelのバージョンによって使える機能が異なります。最新情報は Laravel公式ドキュメント をご確認ください👀