Laravelで理解する N+1問題
〜ORMの便利さと遅延ロードの正体〜
Laravel(Eloquent)を使っていると、
N+1問題 という言葉をよく耳にします。
N+1問題とは、
本来まとめて取得できるはずのデータを、必要になるたびに個別取得してしまうことで、
結果的に「1 + N 回」のクエリが発行されてしまう問題です。
引越しを例にイメージを掴む
Eager Loading(先に全部取る)
引っ越し当日に
「使うかもしれない物を全部トラックに積む」
メリット
- 運ぶ回数は1回
- 必要な物はすぐ使える
デメリット
- トラックが重くなる(引越し料金が高くなる)
- 引越し先に収納スペースが必要
- 結果的に不要なものも運ぶ
Lazy Loading(後で取る)
引っ越し後に
「必要になった物だけ、実家に取りに行く」
メリット
- 無駄な荷物を運ばない
- トラックが軽くなる(引越し料金が安くなる)
- 引越し先に収納スペースが少なくて済む
デメリット
- 必要になったときに取りに行く必要がある
- 何度も行くと大変
この記事では、
- N+1問題とは何か(根本)
- Post / comments のテーブル構造とEloquentの関係
- なぜ原因が ORM の遅延ロードなのか
- なぜ ORM を使うと N+1 が起きやすいのか
- Laravelでの正しい対策
- SQL直書き系(MyBatis等)との違い
を整理します。
前提:Post と comments のテーブル構造
この記事では、以下のような「1対多」の関係を前提にします。
- 1つの Post に複数の Comment
- Comment は
post_idで Post に紐づく
posts テーブル
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
title VARCHAR(255)
);
comments テーブル
CREATE TABLE comments (
id BIGINT PRIMARY KEY,
post_id BIGINT,
body TEXT
);
Eloquentのリレーション定義
class Post extends Model
{
public function comments()
{
return $this->hasMany(Comment::class);
}
}
class Comment extends Model
{
public function post()
{
return $this->belongsTo(Post::class);
}
}
N+1問題とは何か(根本)
N+1問題とは、
本来はまとめて取得できる関連データを、
ループの中で1件ずつ取得してしまい、
クエリ数が「1 + N」になってしまう問題
です。
重要なのは、
「クエリが多い」ことではなく、
「ORMの挙動によって 1 + N という構造になること」
です。
なぜ「N+1」と呼ばれるのか
$posts = Post::all(); // 親を取得
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
// コメント処理
}
}
発行されるSQL
SELECT * FROM posts;
SELECT * FROM comments WHERE post_id = 1;
SELECT * FROM comments WHERE post_id = 2;
SELECT * FROM comments WHERE post_id = 3;
...
- 親取得:1回
- 子取得:N回
👉 1 + N = N+1
なぜ原因は「ORMの遅延ロード」なのか
Eloquentのリレーションは、デフォルトで 遅延ロード(Lazy Loading) です。
$posts = Post::all();
この時点では、
- posts テーブルのみ取得
- comments は まだ取得していない
リレーションに初めてアクセスした瞬間
foreach ($posts as $post) {
$post->comments;
}
ORMは内部で次の判断をします。
- 親はすでに取得済み
- この Post の comments は未取得
- 今必要になった
- その場でSQLを発行
これが N 回繰り返され、N+1になります。
N+1が発生する「本当の根本理由」
N+1問題は、ORMが便利に作られていることの副作用です。
ORMはなぜ便利か
- オブジェクトとして扱える
- SQLを書かなくても動く
- リレーションをプロパティのように扱える
$post->comments;
この1行で、
- SQLが発行される
- DBにアクセスしている
ことを 意識しなくても動く。
その結果どうなるか
- 開発者が「どのタイミングでSQLが発行されるか」を意識しない
- ループ内で何気なくリレーションに触る
- 気づいたらN+1
👉 N+1は「知識不足」ではなく「抽象化の副作用」
Laravelでの正しい対策(Eager Loading)
$posts = Post::with('comments')->get();
SELECT * FROM posts;
SELECT * FROM comments WHERE post_id IN (...);
👉 2クエリで完了
SQL直書き系(MyBatis等)ではどうか?
SQL直書きの場合
SELECT p.*, c.*
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id;
- 実行されるSQLが明示的
- JOINしないと関連が取れないことが可視化されている
👉 同じ構造のN+1は起きにくい
遅延ロードが本当に適切な典型例
条件次第で「後から」必要になる
$posts = Post::all();
foreach ($posts as $post) {
if ($post->is_commentable) {
foreach ($post->comments as $comment) {
// 初めてここで取得
}
}
}
- 最初は取得しない
- 条件成立時のみ取得
- Lazy Loading が意味を持つ
詳細ページ(Nが小さい)
$post = Post::findOrFail($id);
$comments = $post->comments;
- 最大でも2クエリ
- N+1の N が大きくなりにくい
N+1とは少し異なるが、同じような設計上のミス
- forでIDを回してSELECT
- DAOをループで呼ぶ
といった実装でも、
- N回クエリ
- パフォーマンス劣化
は普通に起きます。
ただしこれは ORMのN+1とは性質が違う ものです。
| 観点 | ORMのN+1 | SQL直書き |
|---|---|---|
| 原因 | 遅延ロード | 実装ミス |
| 見えにくさ | 高い | 低い |
| 起きやすさ | 高い | 低い |
クエリパラメータ100件をforで回すケース
クエリパラメータで100件のIDが渡ってきて、
それをforで回して100回SELECTしているコード
foreach ($ids as $id) {
User::where('id', $id)->first();
}
- これはN+1ではない
- 単なるループクエリ
- しかし危険
複数(それも大量)のIDを取得する可能性があるなら、
User::whereIn('id', $ids)->get();
とすべきです。
まとめ
- N+1問題の正体は ORMの便利さの副作用
- 遅延ロード自体は悪ではない
- 「どのタイミングでSQLが発行されるか」を意識することが重要
- SQL直書きでは処理が表面化しやすく、N+1は起きにくい
- Laravelでは
- 必ず使う →
with() - 後で使うかも → Lazy Loading
- Nが大きい → 要注意
- 必ず使う →
N+1問題を理解するとは、
ORMの裏で何が起きているかを想像できるようになることです。