章: 第2章: データベース応用
「10件の投稿の著者名を表示」——これで何回クエリが発行される?
投稿一覧を取得したあと、各投稿の著者名を取得するためにループ内でまたクエリを実行する——。N件の投稿に対して「1+N」回クエリが発行され、データが増えるほど応答時間が急増します。
N+1問題を放置するとレコード数に比例してクエリ数が増加し、内部第一のパフォーマンスボトルネックになります。
N+1問題の実装と対策
<?php
// NG: N+1が発生するパターン
$posts = $pdo->query('SELECT * FROM posts')->fetchAll();
foreach ($posts as $post) {
$stmt = $pdo->prepare("SELECT name FROM users WHERE id = ?");
$stmt->execute([$post['user_id']]);
$author = $stmt->fetch();
echo $author['name'];
}
// OK: JOINで1回にまとめる
$posts = $pdo->query(
'SELECT posts.*, users.name FROM posts JOIN users ON posts.user_id = users.id'
)->fetchAll();
NGパターン vs OKパターン
| 観点 | NG(ループ内でクエリ) | OK(JOINでまとめる) |
| クエリ数 | 1 + N回 | 1回 |
| レコード100件の時 | 101回 | 1回 |
| レコード1000件の時 | 1001回 | 1回 |
| 対対策の難しさ | コードを見るだけでは気づきにくい | JOINで明確に解決 |
チェックポイント: Laravelの場合は
with('user')のイーガーローディングがこのN+1を自動解決します。素のクエリビルダの場合は辺り辺り確認が必要です。
INで取得する方法
JOINのほかに、一度アイディを取得してからINでまとめ取得する方法もあります。
<?php
$posts = $pdo->query('SELECT * FROM posts')->fetchAll();
$userIds = array_unique(array_column($posts, 'user_id'));
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM users WHERE id IN ({$placeholders})");
$stmt->execute($userIds);
$users = array_column($stmt->fetchAll(), null, 'id');
foreach ($posts as $post) {
echo $users[$post['user_id']]['name'];
}
チェックポイント:
EXPLAINを実行すると現在のクエリがインデックスを使っているか、フルスキャンになっていないかを確認できます。
まとめ & 次のステップ
- N+1問題はループ内のクエリがレコード数に比例して増加するパフォーマンス問題
- JOINまたはIN取得にまとめることで根本的に解決できる
EXPLAINを習慣にすることでN+1やインデックス漏れを早期発見できる
次回はインデックス設計の基本を学びます。テーブルが大きくなるほど効く「検索の近道」の作り方です。