N+1問題とは何か
本来1回で済むデータベースへの問い合わせを、N+1回も繰り返してしまうことです。
具体例
ブログの記事一覧ページで、「記事のタイトル」と「書いた人の名前」を表示する場面を考えます。
// 問題のあるコード
$posts = getAllPosts(); // 記事を全部取得
foreach ($posts as $post) {
echo $post->title;
echo $post->author->name; // ここで毎回データベースに問い合わせ
}
このコードでは、裏側でこんなことが起きています。
-- 1回目:記事を取得
SELECT * FROM posts;
-- 2回目以降:各記事の著者を1件ずつ取得
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...
記事が10件なら、合計11回の問い合わせが発生します。これが「N+1問題」です。
なぜダメなのか?
1. とにかく遅い
データベースへの問い合わせは、普通の処理より圧倒的に時間がかかります。
例えば、1回の問い合わせに0.1秒かかるとします。
- 問題なし:2回 × 0.1秒 = 0.2秒
- N+1問題あり(100件):101回 × 0.1秒 = 10.1秒
10秒も待たされたら、ユーザーは離れていきます。
2. データが増えると悪化する
開発中は少量のテストデータで動かすので気づきませんが、本番環境でデータが増えると突然使い物にならなくなります。
- 記事10件 → 11回の問い合わせ
- 記事100件 → 101回の問い合わせ
- 記事1,000件 → 1,001回の問い合わせ
最初は快適だったのに、使っているうちにどんどん重くなっていくのがN+1問題の怖いところです。
3. サーバーに負荷がかかる
大量の問い合わせが同時に発生すると、データベースサーバーが耐えられなくなります。
複数のユーザーが同時にアクセスすると、さらに深刻です。10人が同時にアクセスして、それぞれ100回の問い合わせを発生させたら、合計1,000回の問い合わせがサーバーに降りかかります。
よくあるケース
SNSのタイムライン
$posts = getTimelinePosts(); // 投稿100件を取得
foreach ($posts as $post) {
echo $post->user->name; // ユーザー名
echo $post->likeCount; // いいね数
}
投稿100件に対して、ユーザー情報といいね数を取得すると、1 + 100 + 100 = 201回の問い合わせが発生します。
商品一覧ページ
$products = getAllProducts(); // 商品500件
foreach ($products as $product) {
echo $product->category->name; // カテゴリー名を表示
}
商品500件のカテゴリー名を表示するだけで、501回の問い合わせになります。
陥りやすいパターン
ループの中でデータ取得
// NG
$items = getItems();
foreach ($items as $item) {
$owner = getUser($item->ownerId); // 毎回DB問い合わせ
echo $owner->name;
}
ループで回しながらデータベースに問い合わせると、ほぼ確実にN+1問題が発生します。
テンプレートでデータ取得
<!-- NG -->
{% for post in posts %}
<p>{{ post.author.name }}</p> <!-- 毎回DB問い合わせ -->
{% endfor %}
見た目はシンプルですが、裏側では毎回データベースにアクセスしています。
N+1問題の対策3つ
1. Eager Loading(先読み込み)を使う
フロントエンドではLazy Loadという手法を使うことがよくありますが、それの逆バージョンみたいなものがEager Loadingです。
これは、関連データを最初にまとめて取得する方法です。ORMの多くがこの機能を持っています。
Laravelの例
// NG:N+1問題が発生
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // 毎回DB問い合わせ
}
// OK:with()で先読み込み
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // すでに読み込み済み
}
実行されるSQL
-- 1回目:記事を取得
SELECT * FROM posts;
-- 2回目:関連する著者をまとめて取得
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5);
2回のクエリで済むようになります。
2. JOINを使って一度に取得する
SQLのJOIN句を使えば、1回のクエリで全データを取得できます。
$query = "
SELECT
posts.*,
users.name as author_name
FROM posts
LEFT JOIN users ON posts.user_id = users.id
";
$results = DB::query($query);
1回のクエリで記事と著者の情報が両方手に入ります。
3. データを事前にまとめて取得する
必要なIDをすべて集めてから、一度にまとめて取得する方法です。
// 記事を取得
$posts = getAllPosts();
// 必要なユーザーIDを集める
$userIds = array_map(function($post) {
return $post->userId;
}, $posts);
// ユーザーをまとめて取得
$users = User::whereIn('id', $userIds)->get();
// 配列に変換して使いやすくする
$userMap = [];
foreach ($users as $user) {
$userMap[$user->id] = $user;
}
// マッピング
foreach ($posts as $post) {
$post->author = $userMap[$post->userId];
}
少し手間ですが、ORMがEager Loadingをサポートしていない場合に有効です。
まとめ
N+1問題とは、本来1回で済むDB問い合わせをN+1回も繰り返してしまうこと。
なぜダメか
- ページの読み込みが遅くなる
- データが増えると指数関数的に悪化する
- サーバーがパンクする
よくあるパターン
- ループの中でデータを取得している
- テンプレートで関連データを表示している
N+1問題は初心者が最も陥りやすい落とし穴ですが、理解すれば避けられます。
コードを書くときは「ループの中でデータベースに問い合わせていないか?」を意識するだけで、大きく改善できます。