遅いクエリを見つけたら、次は「なぜ遅いのか」を調べます。そのときに使うのが EXPLAIN です。この記事では MySQL 8.0 / 8.4 を前提に、EXPLAIN の出力 (実行計画) の読み方を説明します。列の意味を並べるだけでなく、1 つのクエリにインデックスを足しながら、出力がどう変わるかを追っていきます。
実行計画と EXPLAIN とは
MySQL は SQL を受け取ると、オプティマイザと呼ばれる部分が「どのインデックスを使うか」「どのテーブルから読むか」「並べ替えをどうするか」を決めてから実行します。この段取りが実行計画です。同じ SQL でも、インデックスの有無やデータの量によって実行計画は変わり、速さも何桁も変わります。
EXPLAIN を SQL の前につけると、実際には実行せずに、この実行計画を表示します。
EXPLAIN SELECT id, total FROM orders WHERE shop_id = 42;
EXPLAIN は SELECT のほか、INSERT、UPDATE、DELETE、REPLACE、TABLE にも使えます。実行しないので、更新系の SQL にかけてもデータは変わりません。
一方、EXPLAIN ANALYZE は実際に SQL を実行して、かかった時間と行数を計測します。こちらは後半で説明します。
EXPLAIN の出力の列
標準の表形式では、1 行が 1 つのテーブル (または派生テーブル) に対応し、次の列が並びます。
| 列 | 意味 | 最初に見るか |
|---|---|---|
id |
クエリの中の SELECT の番号 | 結合やサブクエリのとき |
select_type |
SELECT の種類 (SIMPLE、SUBQUERY、DERIVED など) | 結合やサブクエリのとき |
table |
対象のテーブル | 見る |
type |
テーブルの読み方 (アクセス方式) | 必ず見る |
possible_keys |
使える候補のインデックス | 見る |
key |
実際に選ばれたインデックス | 必ず見る |
key_len |
使ったインデックスの長さ (バイト) | 複合インデックスのとき |
ref |
インデックスと比べている値や列 | 結合のとき |
rows |
読む必要があると見積もった行数 | 必ず見る |
filtered |
読んだ行のうち条件に合うと見積もった割合 (%) | 見る |
Extra |
追加の情報 | 必ず見る |
全部を一度に覚える必要はありません。まずは type、key、rows、Extra の 4 つを見て、気になるところがあれば残りの列で詳しく確かめる、という順番で十分です。
type (アクセス方式) の読み方
type は、そのテーブルをどう読むかを表します。公式ドキュメントでは良いものから順に並んでいます。よく見るものを抜き出すと次のとおりです。
| type | 意味 | 目安 |
|---|---|---|
const / system |
主キーやユニークキーを定数で指定し、最大 1 行だけ読む | 速い |
eq_ref |
結合で、相手の 1 行につき主キーやユニークキーで 1 行だけ読む | 速い |
ref |
ユニークでないインデックスで、一致する行を読む | 多くの場合は問題ない |
range |
インデックスを使って範囲 (BETWEEN、>、IN など) を読む |
範囲の広さ次第 |
index |
インデックスを最初から最後まで全部読む | 件数が多いと遅い |
ALL |
テーブルを最初から最後まで全部読む (フルスキャン) | 件数が多いと遅い |
ALL だから必ず悪いわけではありません。数十行のマスタテーブルなら全部読んでも一瞬です。問題になるのは、大きなテーブルで ALL になっているときです。rows とあわせて判断してください。
index は名前に反して、インデックスを全部なめる読み方です。Extra に Using index が出ている場合は、インデックスだけで答えが出ているので、テーブル本体を全部読む ALL よりは軽く済みます。
例で読む: インデックスを足すと実行計画はこう変わる
ここからは、次のテーブルとクエリで実行計画の変化を追います。ネットショップの注文テーブルで、ある店舗の未発送の注文を古い順に 50 件取り出すクエリです。値は説明のための例です。
CREATE TABLE orders (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
shop_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
total INT NOT NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 約 200 万行が入っているとする
SELECT id, total, created_at
FROM orders
WHERE shop_id = 42 AND status = 'pending'
ORDER BY created_at
LIMIT 50;
段階 1: インデックスがない
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-----------------------------+
| 1 | SIMPLE | orders | ALL | NULL | NULL | NULL | NULL | 1985412 | 1.00 | Using where; Using filesort |
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-----------------------------+
type が ALL、key が NULL なので、インデックスを使わずに全行を読んでいます。rows は約 199 万行です。
filtered は、読んだ行のうち WHERE の条件に合うと見積もった割合です。rows × filtered / 100 で、条件を通る行数の見積もりが出ます。ここでは 1,985,412 × 1% で約 2 万行です。50 件を返すために 199 万行を読むことになり、読む量と返す量の差が非常に大きい状態です。
Extra の Using where は、読んだ行を WHERE の条件でふるい落としていることを示します。Using filesort はインデックスの順番を使えず、取り出した行を別に並べ替えていることを示します。
段階 2: shop_id だけのインデックスを足す
ALTER TABLE orders ADD INDEX idx_shop (shop_id);
| type | possible_keys | key | key_len | ref | rows | filtered | Extra |
| ref | idx_shop | idx_shop | 4 | const | 41230 | 10.00 | Using where; Using filesort |
type が ref になり、key に idx_shop が入りました。ref が const なのは、インデックスと定数 (42) を比べているという意味です。rows は約 4 万行に減りました。
ただし、Extra には Using where; Using filesort が残っています。status の条件はインデックスに含まれないので、4 万行を読んでから status でふるい落とし、残った行を created_at で並べ替えています。
段階 3: 条件と並び順に合わせた複合インデックスにする
ALTER TABLE orders ADD INDEX idx_shop_status_created (shop_id, status, created_at);
| type | possible_keys | key | key_len | ref | rows | filtered | Extra |
| ref | idx_shop,idx_shop_status_created | idx_shop_status_created | 86 | const,const | 312 | 100.00 | NULL |
rows が 312 行になり、filtered は 100% になりました。インデックスの段階で条件に合う行だけを選べているので、読んだ行をふるい落とす必要がありません。Using filesort も消えています。shop_id と status を等号で指定しているので、インデックスの中で行がすでに created_at の順に並んでおり、そのまま先頭から 50 件を取れば済むからです。
複合インデックスの列の順番は、このように「等号で絞る列を先に、並べ替えに使う列を後ろに」が基本です。範囲条件 (>= や BETWEEN) を使った列より後ろの列では、インデックスの順番を並べ替えに使えません。たとえば (shop_id, created_at, total) のインデックスで WHERE shop_id = 42 AND created_at >= '2026-10-01' ORDER BY total とすると、created_at の範囲の中で total の順番はそろっていないので、Using filesort が出ます。
idx_shop は idx_shop_status_created の先頭の列と重なるので、ほかに使うクエリがなければ削除を検討できます。インデックスは書き込みのたびに更新されるので、使わないものは減らしたほうが更新の負担が減ります。
key_len で複合インデックスの使われ方を読む
段階 3 の key_len は 86 でした。これはインデックスのうち何バイト分を使ったかを表す数字です。複合インデックスではこの値から「何列目まで使えているか」を読み取れます。
数え方の目安は次のとおりです。
| 列の型 | key_len でのバイト数 |
|---|---|
INT |
4 |
BIGINT |
8 |
DATETIME (秒の小数なし) |
5 |
DATE |
3 |
VARCHAR(n) (utf8mb4) |
n × 4 + 2 |
| NULL を許す列 | 上の値 + 1 |
今回は shop_id (INT、4 バイト) と status (VARCHAR(20)、20 × 4 + 2 = 82 バイト) で 86 です。3 列目の created_at は条件の絞り込みには使われていない (並び順に使われている) ことが分かります。
もし key_len が 4 なら、shop_id しか使えていません。status の条件の書き方 (型が合っていない、関数をかけているなど) を疑います。
Extra の主な値
Extra には多くの値がありますが、よく見るものは限られます。
| Extra の値 | 意味 | 気にするべきか |
|---|---|---|
Using where |
読んだ行を WHERE の条件でふるい落としている | rows が大きいときは要確認 |
Using index |
インデックスだけで答えが出て、テーブル本体を読んでいない (カバリングインデックス) | 良い状態 |
Using index condition |
インデックスの段階で条件の一部を先に判定している (Index Condition Pushdown) | 多くの場合は良い状態 |
Using filesort |
インデックスの順番を使えず、別に並べ替えている | 件数が多いと遅い |
Using temporary |
結果を一時テーブルに入れている。GROUP BY と ORDER BY の列が違うときなど | 件数が多いと遅い |
Using join buffer (hash join) |
結合でハッシュ結合を使っている。結合の列にインデックスがないことが多い | 結合の列のインデックスを確認 |
Impossible WHERE |
条件が常に偽で、1 行も返らない | 条件の書き間違いを疑う |
公式ドキュメントでも、クエリをできるだけ速くしたいなら Using filesort と Using temporary に注意するよう書かれています。ただし、Using filesort は「ディスクで並べ替えている」という意味ではありません。並べ替える行が少なければメモリ上で終わり、問題になりません。並べ替える前の行数 (rows × filtered) が大きいかどうかで判断してください。
結合したクエリの読み方
複数のテーブルを結合すると、出力は複数の行になります。上の行から順に読まれ、上のテーブルの 1 行ごとに下のテーブルを探す、という流れが基本です。
| id | table | type | key | ref | rows | filtered | Extra |
| 1 | o | ref | idx_... | const,const | 312 | 100.00 | NULL |
| 1 | c | eq_ref | PRIMARY | shop.o.customer_id | 1 | 100.00 | NULL |
この例では o (orders) から 312 行を読み、その 1 行ごとに c (customers) を主キーで 1 行ずつ引いています。読む行数のおおよその合計は、各行の rows の積で見積もれます。ここでは 312 × 1 です。
下のテーブルの type が ALL で rows も大きいと、積が一気に大きくなります。たとえば 312 × 50,000 なら 1,560 万行です。結合の条件に使う列にインデックスがあるかを確かめてください。
EXPLAIN ANALYZE で実際の時間と行数を見る
EXPLAIN の rows や filtered は見積もりで、統計情報がずれていると実際とは大きく違うことがあります。MySQL 8.0.18 以降では EXPLAIN ANALYZE で実際に実行し、見積もりと実際の値を並べて確かめられます。
EXPLAIN ANALYZE
SELECT id, total, created_at
FROM orders
WHERE shop_id = 42 AND status = 'pending'
ORDER BY created_at
LIMIT 50;
段階 1 (インデックスなし) の状態だと、出力はたとえば次のようになります。
-> Limit: 50 row(s) (cost=201234 rows=50) (actual time=1523..1523 rows=50 loops=1)
-> Sort: orders.created_at, limit input to 50 row(s) per chunk (cost=201234 rows=1.99e+6) (actual time=1523..1523 rows=50 loops=1)
-> Filter: ((orders.status = 'pending') and (orders.shop_id = 42)) (cost=201234 rows=19854) (actual time=1.12..1519 rows=318 loops=1)
-> Table scan on orders (cost=201234 rows=1.99e+6) (actual time=0.85..1402 rows=1.99e+6 loops=1)
TREE 形式の読み方
EXPLAIN ANALYZE の出力は常に TREE 形式です。-> で始まる 1 行が 1 つの処理で、字下げの深いものが先に動き、その結果を 1 段浅い処理が受け取ります。下から上へ読むと流れが分かります。この例ではテーブルを全部読み、条件でふるい落とし、並べ替えて、先頭 50 件を返しています。
各行のかっこの中は次のとおりです。
cost=とrows=(1 つ目のかっこ): オプティマイザの見積もりactual time=A..B: 最初の 1 行を返すまでの時間と、その処理が終わるまでの時間 (ミリ秒)rows=(2 つ目のかっこ): 実際に返した行数loops=: その処理が繰り返された回数
loops が 2 以上のとき、actual time は 1 回あたりの平均です。結合の内側の処理で loops=10000 のようになっていれば、合計の時間はおおよそ 1 回分の 1 万倍です。
どこを見るか
まず、時間の大半をどの処理が使っているかを見ます。この例では Table scan on orders だけで 1,402 ミリ秒かかっています。
次に、見積もりと実際の行数のずれを見ます。Filter の見積もりは 19,854 行ですが、実際は 318 行でした。ずれが大きいと、オプティマイザが誤った前提で計画を選んでいる可能性があります。ANALYZE TABLE orders; で統計情報を更新し、計画が変わるかを確かめてください。
EXPLAIN ANALYZE を使うときの注意
EXPLAIN ANALYZE はクエリを実際に実行します。数分かかるクエリなら、結果が出るまで数分かかり、その間は本番と同じ負荷がかかります。途中で止めるときは、ほかの接続から KILL QUERY <接続 ID> を実行します。
また、複数のテーブルを対象にした UPDATE や DELETE にも使えますが、実際に実行されます。更新系のクエリで試すときは、検証環境か、トランザクションの中で実行して ROLLBACK するなどの手当てをしてください。
本番でまず計画だけを見たい場合は、実行しない EXPLAIN FORMAT=TREE が使えます (MySQL 8.0.16 以降)。見積もりだけですが、TREE 形式で処理の順番を確かめられます。
本番で実行中のクエリの計画を見る
本番で長く動いているクエリがあるときは、そのクエリを手元で再現しなくても、実行中の計画を見られます。
-- 実行中の接続の ID を調べる
SELECT id, time, state, LEFT(info, 100) AS query
FROM information_schema.processlist
WHERE command = 'Query' AND time > 10;
-- その接続で動いているクエリの実行計画
EXPLAIN FOR CONNECTION 18342;
EXPLAIN FOR CONNECTION は実行中の計画を表示するだけで、クエリを止めたり実行し直したりはしません。ほかのユーザーの接続を見るには PROCESS 権限が必要です。
実行計画を読むときのチェックの順番
最後に、実行計画を読む順番をまとめます。
typeがALLやindexで、rowsが大きいテーブルはないか。keyがNULLになっていないか。possible_keysに候補があるのに使われていないなら、条件の書き方 (列に関数をかけている、型が合っていない) を疑う。rows × filtered / 100が、実際に返す行数よりずっと大きくないか。ExtraにUsing filesortやUsing temporaryがあり、しかも対象の行数が多くないか。- 複合インデックスなら、
key_lenで何列目まで使われているか。 - 見積もりが怪しいときは
EXPLAIN ANALYZEで実際の行数と比べる。
遅いクエリそのものの見つけ方は MySQL のスロークエリの記事 で、1 回は速いのに回数が多いクエリの問題は N+1 問題の記事 で説明しています。PostgreSQL を使っている場合は PostgreSQL の EXPLAIN の記事 をご覧ください。
この確認を続けるには
実行計画はデータの量や分布が変わると入れ替わります。インデックスを追加したときに速くなったクエリが、数か月後に別の計画を選んで遅くなることもあります。手作業で続けるなら、スロークエリログや sys.statement_analysis の上位を定期的に確かめ、新しく上がってきたクエリだけ EXPLAIN にかけるのが現実的です。監視ツールでクエリの応答時間にアラートをかけておけば、悪化に早く気づけます。
AI に任せる方法もあります。Scandock は AI がデータベースを監視するサービスで、MySQL や MariaDB の接続文字列を登録すると、AI がテーブルの構成と実データを読んで監視する項目を決めます。注文数やジョブの滞留のような事業の数字の変化を、固定の閾値ではなく推移や過去の経緯を踏まえて判断し、異常があれば読み取り専用のクエリで原因を調べて、根拠と対処案をつけて通知します。
参考
- MySQL 8.4 Reference Manual: EXPLAIN Output Format
- MySQL 8.4 Reference Manual: EXPLAIN Statement
- MySQL 8.4 Reference Manual: Obtaining Execution Plan Information for a Named Connection
- MySQL 8.4 Reference Manual: ORDER BY Optimization
- MySQL 8.4 Reference Manual: Multiple-Column Indexes
- MySQL 8.4 Reference Manual: Data Type Storage Requirements