N+1問題の仕組みと解決パターン:JOINとIN句で防ぐ方法

two diskettes SQL

ORMを使って「投稿一覧と投稿者名をセットで表示する」処理を書いたとき、画面は正常に動いているのに実行ログにSQLが大量に流れることがあります。これがN+1問題です。一覧取得のクエリ1回に加え、各行の関連データを取得するクエリがN回走るパターンで、10件表示なら11クエリ、100件表示なら101クエリになります。データが少ない開発環境では気づきにくく、本番で件数が増えてから初めて遅延が顕在化するのが厄介な点です。この記事では、N+1問題の仕組みをSQLログで確認しながら、JOINとIN句という2つの解決パターンを実例つきで解説します。

このシナリオで考える

ブログサービスを想定して、ユーザー情報を持つusersテーブルと、投稿を管理するpostsテーブルを使います。

CREATE TABLE users (
  id   INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100) NOT NULL
);

CREATE TABLE posts (
  id         INT PRIMARY KEY AUTO_INCREMENT,
  user_id    INT NOT NULL,
  title      VARCHAR(200) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_user_id (user_id),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

INSERT INTO users (name) VALUES ('山田太郎'), ('佐藤花子'), ('鈴木一郎');

INSERT INTO posts (user_id, title) VALUES
  (1, 'MySQLのインデックス入門'),
  (2, 'JOINの使い方まとめ'),
  (1, 'トランザクションを理解する'),
  (3, 'スロークエリの読み方'),
  (2, 'CASEで条件分岐を書く');

目標は「最新5件の投稿を、投稿者名つきで一覧表示する」です。このシナリオでN+1問題がどのように起きるかを順に確認します。

N+1問題の仕組み

多くのORMはデフォルトで「関連データが必要になった瞬間にSQLを発行する」lazy loading(遅延読み込み)を採用しています。コードはシンプルに書けますが、ループと組み合わさるとSQLが大量に発行されます。

まず投稿一覧を1クエリで取得します。

-- クエリ1: 投稿一覧を取得
SELECT id, user_id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 5;
+----+---------+-----------------------------+---------------------+
| id | user_id | title                       | created_at          |
+----+---------+-----------------------------+---------------------+
|  5 |       2 | CASEで条件分岐を書く         | 2026-05-29 10:05:00 |
|  4 |       3 | スロークエリの読み方         | 2026-05-29 10:04:00 |
|  3 |       1 | トランザクションを理解する   | 2026-05-29 10:03:00 |
|  2 |       2 | JOINの使い方まとめ          | 2026-05-29 10:02:00 |
|  1 |       1 | MySQLのインデックス入門      | 2026-05-29 10:01:00 |
+----+---------+-----------------------------+---------------------+

次に、ループで各行のuser_idを使って投稿者名を1件ずつ取得します。

-- クエリ2〜6: 各投稿の user_id に対して個別に実行
SELECT id, name FROM users WHERE id = 2;  -- post_id=5
SELECT id, name FROM users WHERE id = 3;  -- post_id=4
SELECT id, name FROM users WHERE id = 1;  -- post_id=3
SELECT id, name FROM users WHERE id = 2;  -- post_id=2(キャッシュなしなら再実行)
SELECT id, name FROM users WHERE id = 1;  -- post_id=1(同上)

5件の表示に対して合計6クエリが走ります。件数が100件になれば101クエリです。MySQLはインデックスで各クエリを高速に処理できても、クエリのラウンドトリップ(ネットワーク往復コスト)が100回分積み重なります。1往復あたり1msでも、100回で100ms以上になります。

N+1問題が特に厄介なのは「ローカル開発では気づきにくい」点です。開発環境はデータ件数が少ないため、10〜20クエリ程度では問題になりません。本番でデータが増えてから初めて遅延が顕在化するケースが多く、原因特定も難しくなります。

JOINで1クエリにまとめる方法

最もシンプルな解決策が、JOINで結合して1クエリにする方法です。

SELECT
  p.id,
  p.title,
  p.created_at,
  u.name AS author_name
FROM posts p
INNER JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT 5;
+----+-----------------------------+---------------------+-------------+
| id | title                       | created_at          | author_name |
+----+-----------------------------+---------------------+-------------+
|  5 | CASEで条件分岐を書く         | 2026-05-29 10:05:00 | 佐藤花子    |
|  4 | スロークエリの読み方         | 2026-05-29 10:04:00 | 鈴木一郎    |
|  3 | トランザクションを理解する   | 2026-05-29 10:03:00 | 山田太郎    |
|  2 | JOINの使い方まとめ          | 2026-05-29 10:02:00 | 佐藤花子    |
|  1 | MySQLのインデックス入門      | 2026-05-29 10:01:00 | 山田太郎    |
+----+-----------------------------+---------------------+-------------+

クエリが1回で完結します。posts.user_idにインデックスを貼っておけば、MySQLはNested Loop JoinでINDEXを活用し、件数が増えても効率よく処理します。EXPLAINtype列がeq_ref(PRIMARY KEYに対して1行確定のJOIN)になっていれば最適な状態です。

ただし、JOINに不向きなケースもあります。1対多の関連を複数同時にJOINする場合(例: 投稿+コメント+タグを一度に取得)は注意が必要です。各多側テーブルとJOINすると結果行が掛け合わさる「デカルト積」が発生します。3件の投稿に5コメントと3タグがある場合、JOIN後の行数は3 × 5 × 3 = 45行になります。GROUP BYで集約すれば件数は戻りますが、データ転送量が増え、集計ロジックも複雑になります。

IN句でバルクフェッチする方法

ORMのeager loading(事前読み込み)が内部で使うのが、IN句によるバルクフェッチです。処理は2段階に分かれます。

  1. 一覧クエリを実行して、関連ID(この例ではuser_id)を集める
  2. 重複を除いたIDをIN句にまとめて、関連データを1クエリで一括取得する
-- ステップ1: 投稿一覧と user_id を取得
SELECT id, user_id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 5;
-- → user_id 収集: 2, 3, 1, 2, 1
-- → 重複除去後 : 1, 2, 3

-- ステップ2: IN句で一括取得(クエリは1回だけ)
SELECT id, name
FROM users
WHERE id IN (1, 2, 3);
+----+-----------+
| id | name      |
+----+-----------+
|  1 | 山田太郎  |
|  2 | 佐藤花子  |
|  3 | 鈴木一郎  |
+----+-----------+

アプリ側でIDをキーにしたMapを作り、ステップ1の結果と突き合わせれば完成です。クエリ数は常に2回です。件数が1000件になっても2回のまま変わりません。

LaravelのEloquentならwith()、RailsのActiveRecordならincludes()、Djangoならselect_related()/prefetch_related()がこのパターンを自動化します。ORMを使う場合は、これらの指定を意識的に入れることで多くのN+1を防げます。

JOINとIN句バルクフェッチの使い分けを整理します。

状況 推奨アプローチ 理由
1対1・多対1の関連、常に使う情報 JOIN 1クエリで完結、シンプル
1対多の関連データを複数同時に取得 IN句バルクフェッチ デカルト積を避けられる
条件によって関連データが不要な行がある IN句バルクフェッチ 不要なJOINを省ける
ORMのeager loading機能を使う ORM任せ(内部でIN句) フレームワーク最適化済み

よくある落とし穴と注意点

IN句のリストが大きくなりすぎる問題

IN句に渡すIDが数万件を超えると、クエリ自体が重くなり、MySQLのメモリ使用量も増えます。ページング側でLIMITを絞るか、1000件ごとにチャンク分割してIN句を複数回に分けるのが現実的な対策です。

「解決した」つもりのJOINでデカルト積が起きる

1対多の関係をJOINすると意図せず行が増えます。投稿に対してコメントを複数JOINすると、コメント数分の行が生まれます。結果を集計する前にSELECT COUNT(*) FROM postsで期待値を確認し、JOIN後の件数と比較する習慣をつけましょう。

EXPLAINでインデックスを確認する

N+1を解消してJOINやIN句に変えた後も、インデックスが効いていなければ速くなりません。EXPLAINtype列がALL(フルスキャン)になっていないか確認します。posts.user_idにインデックスが貼ってあれば、INNER JOINのtypeは通常refeq_refになります。

EXPLAIN
SELECT p.id, p.title, u.name AS author_name
FROM posts p
INNER JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT 5;

スロークエリログで実際のクエリ数を把握する

N+1が起きているかどうかは、クエリ件数を数えるのが確実です。開発時はORMのクエリロガーを有効にする方法が手軽です。本番ではlong_query_time = 0でスロークエリログを全件記録し、同じクエリが大量に並んでいる箇所を探します。そこがN+1の発生箇所です。

まとめ

N+1問題は「1クエリ+N個の追加クエリ」というパターンで、データ量が増えるほど性能に直結します。JOINで1クエリにまとめる方法は1対1・多対1の関係に向いており、IN句バルクフェッチは1対多を複数同時に取得する場面に向いています。ORMを使う場合はeager loadingを明示的に指定し、EXPLAINでインデックスも確認することで、多くのN+1問題を根本から防げます。

参考リンク

アイキャッチ画像: Photo by Brett Jordan on Unsplash

タイトルとURLをコピーしました