はじめに
アドベントカレンダー12/17 = 17記事目
Web アプリケーションを開発していると、多くの場合「あるデータ (例:ユーザー) 」と「そのデータに紐づく別のデータ (例:投稿、プロフィール、履歴 など) 」を一緒に扱いたくなります。
しかし、何も考えずに「主データを取得 → ループで関連データを都度取得」のような処理を書いてしまうと、アクセス数 (クエリ数) が爆発し、パフォーマンスの低下につながる事があります。
このような典型的な落とし穴が N+1問題 です。
本記事では、
- N+1問題とは何か、
- なぜ起きるのか、
- どうやって防ぐか、
を、初学者の私がコード例を交えて解説していきたいと思います
そもそもN+1問題とは
🔹 定義
N+1問題 とは、次のような状況を指します
- データベースからまず「親 (メインとなるエンティティ) の一覧」を取得するために 1 回クエリを実行。
- その後、その親それぞれについて「関連する子 (紐づく別のエンティティ) 」を取得するために、親の数 N に応じて N 回のクエリを実行。
合計で 1 + N 回のクエリ が発生する → これが「N+1問題」
たとえば、ブログ記事 (Post) とその著者 (Author) のような関係で、以下のようなコードを書いたとします
$posts = Post::all(); // ← 投稿一覧を取得 (1回のクエリ)
foreach ($posts as $post) {
echo $post->author->name; // ← 各投稿ごとに著者を取得 (投稿の件数だけクエリ)
}
このようにすると、投稿が 100 件あれば、1 + 100 = 101 回のクエリ発行となり、無駄が多くなります
なぜ起きるのか (原因)
・デフォルトで「遅延読み込み (Lazy Loading)」が使われる ORM
多くの ORM (オブジェクト関係マッピング) は、関連データを「使うときに都度読み込む (Lazy Loading)」設定がデフォルトとなっています。
そのため、親エンティティを取得した後、ループなどで子エンティティにアクセスすると、そのたびに個別クエリが発行されてしまうのです。
・複数レコードをループで処理 → クエリがループ分だけ実行
取得した親エンティティが複数 (N 件) ある場合、ループ中に関連アクセスすると、N 回分のクエリが追加で実行される。これが N+1 問題の直接の原因
・データ量が増えると影響が大きくなる
少数のレコードなら気にならない事が多いとされていますが、データ数やアクセス頻度が多くなるほど、複数クエリによる遅延や DB 負荷が顕著になります。スケーラビリティの問題にもつながりやすいという問題点が挙げられます。
N+1問題のデメリット
- データベースへの不要なアクセスが増える → レイテンシ (応答時間) が増える
- DBサーバーに余計な負荷がかかる (CPU / ネットワーク / 接続回数) → アプリ全体のパフォーマンス低下や、スケールの限界が早まる
- 見た目にはシンプルなコードでも、裏側で多くのクエリが実行される ― デバッグやチューニングをしづらくなる
N+1問題の改善 (解決) 方法
✅ Eager Loading を使って関連データをまとめて取得
多くの ORM では、関連データをあらかじめまとめて取得 (事前ロード) する機能があり、これを使うことで N+1 問題を回避できます
前の例を改善すると
$posts = Post::with('author')->get(); // 投稿と著者を一緒に取得 (JOIN またはバッチ取得などで最適化)
foreach ($posts as $post) {
echo $post->author->name;
}
このように書くと、投稿と著者の取得で 1〜2 回のクエリ にまとまり、N+1問題を防ぐことができます
✅ JOIN やバッチ取得 (IN句など) を使って SQL レベルで最適化
Eager Loading に加えて、SQL の JOIN や WHERE IN(...) を使って、親子含めた必要データを一度に取得する方法もあります。ORM を使わずに SQL を書いたり、ORM の機能でこれらを活用したりするのが一般的
✅ キャッシュやデータ取得戦略を見直す
頻繁に参照するデータや、不変に近いデータであればキャッシュを導入することで、そもそもの DB アクセスを減らすという対策も有効。ただし、キャッシュの整合性や管理コストに注意が必要
コード例:改善前 vs 改善後
❌ 改善前 (N+1問題あり) — 疑似コード (ORM)
// 全ての UserWalking を取得
$userWalkings = UserWalking::all();
foreach ($userWalkings as $walk) {
// 各 UserWalking から関連 User を取得
echo $walk->user->name;
}
- 最初のクエリで
user_walkingsを取得 → 1 回 - その後、ループごとに
userを取得 → N 回 - 合計で 1 + N 回のクエリ実行 (=N+1問題)
✅ 改善後 (Eager ロードでリレーションを事前取得)
// UserWalking と関連する User をまとめて取得
$userWalkings = UserWalking::with('user')->get();
foreach ($userWalkings as $walk) {
echo $walk->user->name;
}
- ORM が関連する User も一緒に取得 (JOIN または二つのクエリなど)
- ループ内で個別クエリを発行せず、クエリ回数は固定 (1〜数回)
- N+1問題を回避
さいごに
ORM を使っていると、データを取るコードは一見シンプルで「これで OK!」と思いがちですが、
裏ではたくさんの SQL が自動で飛んでいて、気づかないうちに DB に負担をかけてしまう事に― それが N+1問題
少ないデータでは「遅い」と感じないかもしれないけど、ユーザーが増えたりデータが増えると、「動きが遅い」「画面がカクつく」「負荷で DB が重くなる」など、いろいろなトラブルの原因になる。
だからこそ、ただ「動けばいい」ではなくて、 「ちゃんとしたデータ取得方法を意識する」 ことが大事ということを今回改めてしることができてよかったです。
今回の記事が何か参考になれば幸いです