EXPLAIN の読み方と実行計画の改善 ハンズオン
概要
「一覧画面が重い」「夜間バッチが終わらない」。この種の相談の多くは、特定の SQL が想定外のアクセス経路(実行計画)で動いていることが原因です。インデックスを追加すれば直ることもありますが、まず必要なのは「DB が今どう動いているか」を正確に読むことです。
本記事では EXPLAIN の出力の読み方と、そこから実行計画を改善する手順を、再現可能な形でまとめます。対象は MySQL 8.0 と PostgreSQL 14 以降。公開されている一般的な挙動のみを扱います。
検証環境
- MySQL 8.0 / PostgreSQL 14 以降
- 本番相当のデータ量(最低でも数万〜数十万行)で計測すること。少量データだと DB がフルスキャンを選び、計画の善し悪しが見えません
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL, total_price INT NOT NULL, created_at DATETIME NOT NULL ); -- 擬似データを大量投入してから計測する
発生した問題
よくあるのは「特定ユーザーの最新注文 20 件」を取るクエリです。
SELECT * FROM orders WHERE user_id = 1001 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
データが増えると、これが数百ミリ秒〜数秒かかるようになります。
原因 — EXPLAIN で読む
MySQL の主要カラム
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
まず見るべきは次の 4 つです。
| カラム | 意味 | 危険サイン |
|---|---|---|
type |
アクセス方式 | ALL(フルスキャン) |
key |
使われたインデックス | NULL |
rows |
走査見込み行数 | 返す件数より桁違いに大きい |
Extra |
補足情報 | Using filesort / Using temporary |
悪い出力の例:
type: ALL key: NULL rows: 480000 Extra: Using where; Using filesort
type: ALL と key: NULL はインデックス未使用の全走査、Using filesort はソート処理の発生を意味します。欲しいのは 20 件なのに 48 万行を読んでいます。
MySQL 8.0.18 以降は EXPLAIN ANALYZE で実測値が取れます。
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1001 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
-> Limit: 20 row(s) (actual time=312..312 rows=20 loops=1)
-> Sort: orders.created_at DESC (actual time=312..312 ...)
-> Filter: ... (actual time=0.1..290 rows=1800 loops=1)
-> Table scan on orders (rows=480000)
PostgreSQL の読み方
PostgreSQL は最初から EXPLAIN (ANALYZE, BUFFERS) が定石です。
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 1001 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
Limit (actual time=305.1..305.2 rows=20 loops=1)
-> Sort (actual time=305.1..305.1 rows=20 loops=1)
Sort Method: top-N heapsort Memory: 30kB
-> Seq Scan on orders
(actual time=0.2..280.4 rows=1800 loops=1)
Filter: ((user_id = 1001) AND (status = 'paid'))
Rows Removed by Filter: 478200
Execution Time: 305.4 ms
読み方のコツ:
- ノードは 内側(下)から外側(上) に実行される。一番内側の重いノードが律速
Rows Removed by Filterが大きい = ムダに読んでいる- 見積り
rowsとactual rowsが乖離していたら 統計情報が古い可能性 →ANALYZEで更新
対応方法
1. 複合インデックスを「等値 → ソート」順で張る
条件は「user_id, status で等値絞り込み → created_at でソート」です。複合インデックスの列順は 等値条件を先に、ソート/範囲を後に が原則です。
CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at);
この順序なら (user_id, status) で範囲を特定でき、その範囲内はすでに created_at 順に並ぶため、ソート自体を省略できます。
2. 改善後を EXPLAIN で確認
MySQL:
type: range key: idx_orders_user_status_created rows: 1800 Extra: Using index condition
PostgreSQL:
Limit (actual time=0.05..0.12 rows=20 loops=1)
-> Index Scan using idx_orders_user_status_created on orders
Index Cond: ((user_id = 1001) AND (status = 'paid'))
Execution Time: 0.2 ms
Seq Scan → Index Scan、Sort ノード消滅。305ms が 0.2ms になりました。
実装例 — インデックスが効かなくなる典型パターン
-- ❌ 列に関数 → 効かない WHERE DATE(created_at) = '2026-10-17' -- ⭕ 範囲条件に WHERE created_at >= '2026-10-17 00:00:00' AND created_at < '2026-10-18 00:00:00'
-- ❌ 中間一致 LIKE → 効かない WHERE name LIKE '%田中%' -- ⭕ 前方一致 WHERE name LIKE '田中%'
-- ❌ 暗黙の型変換(VARCHAR カラムに数値) WHERE phone = 09012345678 -- ⭕ 型を合わせる WHERE phone = '09012345678'
確認方法
EXPLAIN ANALYZE(MySQL 8.0.18+)/EXPLAIN (ANALYZE, BUFFERS)(PostgreSQL)で Execution Time を改善前後で比較- アクセス方式(
type/Index Scan)が変わったか確認 Using filesort/Sortノードが消えたか確認- 本番相当のデータ量で確認
注意点
- インデックスは書き込みコストとストレージを消費する。使われないものは削除候補
- 見積りがおかしいときは統計更新(MySQL
ANALYZE TABLE/ PostgreSQLANALYZE)を先に EXPLAIN ANALYZEは 実際にクエリを実行する。UPDATE/DELETEでは本当に更新が走るため本番では要注意(トランザクションで囲みロールバックする等)- 複合インデックスの列順は「等値 → 範囲/ソート」が基本
まとめ
- 見積りではなく
EXPLAIN ANALYZEの実測を見る - MySQL は
type/key/rows/Extra、PostgreSQL はSeq Scan / Rows Removed / 見積りとのズレに注目 - 第一手は多くの場合「等値 → ソート」順の複合インデックス。
filesort/Sortが消えれば成功 - 必ず本番相当のデータ量で、改善前後の数値を比較して裏を取る
EXPLAIN は「遅い」を「どこがどう遅い」に翻訳する道具です。読めるようになると、当て推量のインデックス追加が、根拠のあるチューニングに変わります。
