MySQLのN+1問題を解消したら、APIの応答速度がかなり改善した話
LaravelのEloquentでN+1問題が起きていたAPIにEager Loadingを導入したところ、クエリ数が大幅に減り、応答速度がかなり速くなりました。その手順と感想をまとめます。
🙌 結論から
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を見逃す不安がかなり減りました!
パパ系Webエンジニア〜愛用ガジェット一覧 🛍️
この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌
🙌 まとめ
N+1 問題は、LaravelのEloquentを使う場合は with() を使った Eager Loading で解消できます。
with('relation')でリレーションをまとめて取得- 複数リレーションは配列
with(['a', 'b'])で指定 - ネストは
with('posts.comments')のように.でつなぐ
Laravel Debugbar などでクエリを可視化する習慣を持つと、N+1 に早めに気づけます。
コード量の変化はほぼゼロなのに、応答速度がかなり変わるので、まだ対処していない API がある方はぜひ確認してみてください🙌
※ Laravelのバージョンによって使える機能が異なります。最新情報は Laravel公式ドキュメント をご確認ください👀