第12回: N+1問題と対策 — ループ内のクエリが漏れを引き起こす前に

章: 第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やインデックス漏れを早期発見できる

次回はインデックス設計の基本を学びます。テーブルが大きくなるほど効く「検索の近道」の作り方です。

Related Articles