実行せずに計画だけを見る
EXPLAINをSELECT文の前に置くと、そのクエリを実行せずに、オプティマイザが選んだ実行計画だけを返します。結果は1行が1テーブルへのアクセスに対応し、JOINが絡むクエリでは複数行になります。列数が多いので、\Gを付けて縦に展開すると読みやすくなります。
EXPLAIN SELECT * FROM orders WHERE user_id = 'alice01'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: ref
possible_keys: idx_user_created
key: idx_user_created
key_len: 66
ref: const
rows: 2000
filtered: 100.00
Extra: NULL1テーブルだけのアクセスなので1行で済んでいます。以下、この出力の列を1つずつ見ていきます。
typeは行の探し方
typeは、そのテーブルの行にどうやってたどり着いたかを表します。よく登場する値を、速い側から並べます。
constは、主キーやUNIQUEキーを定数で指定し、一致する行が高々1件に決まるときの値です。
EXPLAIN SELECT * FROM orders WHERE id = 12345\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: const
rows: 1
filtered: 100.00
Extra: NULLconstよりさらに上位にsystemという値もありますが、これはMyISAMやMEMORYなど、行数が正確に1件と分かるテーブル向けの特殊ケースです。InnoDBは行数を正確に管理しないため、1行だけのテーブルでもsystemにはならずconstと表示されます。
eq_refはconstとよく似ています。どちらも一致する行が高々1件に決まりますが、constは列を定数と比較するのに対し、eq_refはJOINの結合条件で、外側のテーブルから来る値と、内側のテーブルの主キーやUNIQUEキー全体を比較します。外側テーブルの行ごとに、内側テーブルの1行が主キーで一意に決まる、という状態です。
EXPLAIN SELECT a.id, b.status FROM orders a JOIN orders b ON a.id = b.id
WHERE a.user_id = 'alice01'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: a
partitions: NULL
type: ref
possible_keys: PRIMARY,idx_user_created
key: idx_user_created
key_len: 66
ref: const
rows: 2000
filtered: 100.00
Extra: Using index
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: b
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: innodb_verify.a.id
rows: 1
filtered: 100.00
Extra: NULLb側のtypeがeq_refです。keyはPRIMARYで、refにはinnodb_verify.a.idとあり、定数ではなくa.idの値と結合していることが分かります。aから来る行1件ごとに、bの主キーで1行だけを直接引き当てられるため、rowsは常に1です。
refは、主キー・UNIQUEキー以外のインデックス、または複合インデックスの一部を使い、値の一致で複数件がヒットし得るときの値です。最初のuser_id = 'alice01'の例がこれにあたります。
rangeは、不等号やBETWEEN、前方一致のLIKEのように、インデックス上の連続した範囲を読むときの値です。
EXPLAIN SELECT id FROM orders WHERE order_no LIKE 'A-0000%'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: range
possible_keys: idx_order_no
key: idx_order_no
key_len: 82
ref: NULL
rows: 99
filtered: 100.00
Extra: Using where; Using indexindexは、インデックスの葉を先頭から末尾まで、あるいは末尾から先頭まで、すべてたどる値です。ALLと同じ全件アクセスですが、テーブル本体ではなくインデックスの木構造をたどる点が違います。テーブル本体を読まずに済むのは、ExtraにUsing indexがあるとき、つまり必要な列がすべてインデックスの中にある場合に限ります。Using indexがなければ、インデックスの並び順でテーブル本体の行を1件ずつ取得しています。
EXPLAIN SELECT id FROM orders WHERE order_no = 1\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: index
possible_keys: idx_order_no
key: idx_order_no
key_len: 82
ref: NULL
rows: 96849
filtered: 10.00
Extra: Using where; Using indexorder_noはVARCHARですが数値の1と比較しているため、格納されている値を1件ずつ変換して比較するしかなく、refのような一発の位置決めができずにindexまで落ちています。この例ではExtraにUsing indexがあるため、テーブル本体は読んでいません。
ALLは、インデックスをまったく使わず、テーブルを先頭から順に読む値です。
EXPLAIN SELECT * FROM orders WHERE DATE(created_at) = '2024-06-15'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 96849
filtered: 100.00
Extra: Using wherepossible_keysとkey
possible_keysは候補になり得るインデックスの一覧、keyは実際に選ばれたインデックスです。possible_keysに名前があってもkeyがNULLなら、そのインデックスは候補にはなったが最終的には使われなかったことを意味します。
EXPLAIN SELECT * FROM orders WHERE status <> 'canceled'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: ALL
possible_keys: idx_status
key: NULL
key_len: NULL
ref: NULL
rows: 96849
filtered: 50.00
Extra: Using whereidx_statusは候補になりましたが、否定条件は残りの大半にヒットすると見積もられ、テーブル全体を読む方が安いと判断されて捨てられています。possible_keysにあるのにkeyがNULLになっていたら、データの偏りや統計を疑います。
key_lenは複合インデックスのどこまで使われたかを示す
key_lenは、検索条件として実際に使われたインデックス列のバイト数です。同じkeyでも、先頭から何列分使われたかで値が変わります。
EXPLAIN SELECT * FROM orders FORCE INDEX (idx_shop_user_created)
WHERE shop_id = 3 AND created_at >= '2024-06-10 00:00:00'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: ref
possible_keys: idx_shop_user_created
key: idx_shop_user_created
key_len: 4
ref: const
rows: 5520
filtered: 33.33
Extra: Using index conditionidx_shop_user_createdは(shop_id, user_id, created_at)の3列ですが、key_lenはINT型のshop_id1列分、4バイトしかありません。user_idを飛ばしてcreated_atを条件にしているため、created_atは検索範囲の開始・終了位置には使われず、shop_idで絞った行を読みながらUsing index conditionで判定する側に回っています。keyだけを見ると3列とも効いているように見えますが、key_lenを見れば実際には1列分しか位置決めに使われていないと分かります。
rowsとfilteredは見積もりであって実測ではない
rowsは、typeに示された方法で読むと見積もられる行数です。filteredは、そのrows件のうち、さらに条件を満たすと見積もられる行の割合です。ここでの条件とは、keyによる絞り込みでは判定されなかった、WHERE句の残りの条件のことです。直前のkey_lenの例で言えば、rowsの5520件はshop_id = 3だけで絞り込んだ件数の見積もりで、filteredの33.33%はそのうちcreated_at >= '2024-06-10 00:00:00'も満たす行の割合の見積もりです。どちらも統計情報からの見積もりであり、実際に読んだ行数ではありません。統計が古い、または値の分布が偏っている場合、見積もりと実態はずれます。傾向をつかむために見るものであり、正確な件数として扱わないようにします。
Extraにある主な値
Using whereは、ストレージエンジンから返った行に対し、サーバー側でさらにWHERE条件を評価していることを示します。上のALLの例、DATE(created_at) = '2024-06-15'がこれにあたります。possible_keysがNULLで絞り込みが一切行われていないため、テーブルの全行がいったんそのまま返され、1件ごとにサーバー側でWHERE条件を判定しています。
Using indexは、クエリに必要な列がすべてインデックスの中にあり、テーブル本体を読まずに済んでいることを示します。上のindexの例、order_no = 1がこれにあたります。SELECT idで取得するidは主キーとしてどのインデックスにも含まれ、絞り込みに使うorder_noもidx_order_noに含まれているため、インデックスの葉だけで完結し、テーブル本体へのアクセスが発生していません。
Using index conditionは、テーブル本体の行を読みに行く前に、インデックスに含まれている列だけで条件の一部を判定していることを示します。インデックスの葉をたどりながら判定し、条件に合わない行はテーブル本体を読まずに捨てるため、無駄な行読み込みが減ります。上のkey_lenの例がこれにあたります。
Using filesortは、ORDER BYを満たすために、インデックスの並び順とは別に、読み出した行をソートし直していることを示します。
EXPLAIN SELECT * FROM orders WHERE user_id = 'alice01' ORDER BY name\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: ref
possible_keys: idx_user_created
key: idx_user_created
key_len: 66
ref: const
rows: 2000
filtered: 100.00
Extra: Using filesortuser_idの絞り込みにはidx_user_createdが使われていますが、ORDER BY nameはこのインデックスの並び順と関係ないため、絞り込んだあとに別途ソートが発生しています。
Using index for skip scanは、複合インデックスの先頭列の条件を省いても、先頭列の取り得る値ごとに区切って読むことでそのインデックスを使えているときに出ます。
EXPLAIN SELECT id, user_id FROM t_user_created
WHERE created_at BETWEEN '2024-06-10 00:00:00' AND '2024-06-12 00:00:00'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t_user_created
partitions: NULL
type: range
possible_keys: idx
key: idx
key_len: 71
ref: NULL
rows: 11777
filtered: 100.00
Extra: Using where; Using index for skip scant_user_createdのインデックスidxは(user_id, created_at)の順で、本来は先頭列user_idの条件がないと読み始める位置が決まりません。ですがuser_idの取り得る値がこのテーブルでは50種類しかないため、その値ごとに区切って範囲を読むSkip Scanという最適化が働き、created_atだけの条件でもこのインデックスが使われています。先頭列の値の種類が少ないときに有効な最適化で、常に選ばれるわけではありません。
Using join buffer (hash join)は、結合相手に使えそうなインデックスがない、またはハッシュ結合の方が安いとオプティマイザが判断したときに出ます。片方のテーブルをメモリ上にハッシュ化してから突き合わせる方式で、インデックスを使わない結合です。
EXPLAIN SELECT o.id FROM report_days d JOIN t_func_only o
ON (CAST(o.created_at AS DATE)) = d.d\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: d
partitions: NULL
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 3
ref: NULL
rows: 3
filtered: 100.00
Extra: Using index
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: o
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 99750
filtered: 100.00
Extra: Using where; Using join buffer (hash join)o側はt_func_onlyが持つ関数インデックスidx_funcを結合条件には使えないため、typeがALLになっています。dから読んだ3件それぞれについてoを全件スキャンし直すのではなく、行数の少ないdの3件を先にメモリ上のハッシュに載せ、oを1回だけ走査しながらそのハッシュと突き合わせています。ExtraのUsing join buffer (hash join)は、先に読んだテーブルの行をハッシュに載せ、このテーブルの行と突き合わせているという意味で、この例ではdより後に読むo側に表示されます。
読む順番の目安
上から順に読むよりも、次の順で確認すると原因を切り分けやすくなります。
typeがALLか、それに近いindexになっていないかpossible_keysはあるのにkeyがNULLになっていないかkeyはあるのにkey_lenが想定より短くないかExtraにUsing filesortやUsing join bufferのような追加コストの兆候がないか
EXPLAIN ANALYZEで実測を見る
EXPLAINは見積もりだけですが、EXPLAIN ANALYZEは実際にクエリを実行し、見積もりに加えてactual time・実際の行数・loopsといった実測値も出します。同じ結果を返す2つの実行計画のどちらが実際に速いかを比較したいときに使います。
EXPLAIN ANALYZE SELECT * FROM orders FORCE INDEX (idx_created_user)
WHERE created_at >= '2024-06-10 00:00:00' AND user_id = 'alice01'\G
-> Index range scan on orders using idx_created_user over ('2024-06-10 00:00:00' <= created_at AND 'alice01' <= user_id), with index condition: ((orders.user_id = 'alice01') and (orders.created_at >= TIMESTAMP'2024-06-10 00:00:00')) (cost=0.71 rows=1) (actual time=0.801..17.6 rows=1428 loops=1)見積もりではrows=1ですが、実際に返った行数はactual ... rows=1428です。見積もりと実測が大きくずれることがあると分かるのは、EXPLAIN ANALYZEを使ったときだけです。loopsは、そのアクセスが実行された回数です。今回はJOINを伴わない単独アクセスなのでloops=1ですが、JOINで内側のテーブルにアクセスする場合は、外側から来る行数だけ繰り返し実行されるためloopsが1より大きくなります。costは内部的な相対値なので絶対値としては扱わず、同じクエリの実行計画同士を比べる用途に留めます。