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() です。

AppServiceProviderboot() に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公式ドキュメント をご確認ください👀