EXPLAIN とは、MySQL が SQL を「どのテーブルを、どのインデックスで、何行くらい読んで」実行するつもりかを表示する命令です。 SELECT の前に EXPLAIN と書くだけで使え、まず見る列は type(読み方)・key(使うインデックス)・rows(読む行数の見積もり)・Extra(補足)の4つです。

この記事では、MySQL 8.0.46 で実際に実行した出力を使い、10万行のサンプルテーブルを「フルスキャン → インデックス追加 → カバリングインデックス」と改善していく過程を、各段階の EXPLAIN と EXPLAIN ANALYZE で並べます。type と Extra の早見表、rows × filtered の読み方、FORMAT=TREE と JSON の違い、MySQL 8.4 との差、上から順に見るチェックリストまでを1ページにまとめました。

EXPLAIN の早見表(type・key・rows・Extra)

まず見る4つの列

列 意味 見るポイント
type テーブルの読み方(アクセス方法) ALL(全件)や index(インデックス全体)なら、絞り込みにインデックスを使えていない
key 実際に使うインデックス NULL ならインデックスを使っていない
rows 読む行数の見積もり 返したい行数に比べて大きすぎないか
Extra 補足情報 Using filesort、Using temporary が出ていないか。Using index なら良い兆候

type の早見表(速い順)

公式リファレンス(8.4)の並びに沿って、上ほど読む行が少ない順です。

type 読み方 出る場面の例
system 1行だけのテーブル(const の特殊な形) 1行の派生テーブル
const 主キーやユニークキーの等価検索で、最大1行 WHERE id = 42
eq_ref 結合で、前のテーブルの1行につき1行を主キー・ユニークキーで読む JOIN customers c ON c.id = o.customer_id
ref ユニークでないインデックスの等価検索で、複数行 WHERE customer_id = 42
fulltext FULLTEXT インデックスを使う MATCH ... AGAINST
ref_or_null ref に加えて NULL の行も探す WHERE col = 1 OR col IS NULL
index_merge 複数のインデックスを組み合わせる WHERE customer_id = 42 OR id = 7
unique_subquery IN サブクエリを主キーで引く IN (SELECT 主キー ...)
index_subquery IN サブクエリをユニークでないインデックスで引く IN (SELECT インデックス列 ...)
range インデックスの範囲検索 BETWEEN、>=、IN (...)
index インデックス全体を端から読む インデックスだけで足りる全件集計
ALL テーブル全体を読む(フルスキャン) 条件の列にインデックスがない

このうち今回実行して確認したのは system・const・eq_ref・ref・index_merge・range・index・ALL です。fulltext・ref_or_null・unique_subquery・index_subquery は未実行で、リファレンスの説明に沿って載せています。

目安として、行数の多いテーブルでは range より上(const・eq_ref・ref・range)になっているかを確認します。 数百行ほどの小さなテーブルなら ALL でも十分速いので、rows とあわせて判断します。

Extra の早見表(良い兆候/要注意)

区分 Extra 意味
良い兆候 Using index インデックスだけで結果を返せる(カバリングインデックス)。テーブル本体を読まない
良い兆候 Using index condition インデックスの段階で条件を先に評価してから行を読む(Index Condition Pushdown)
良い兆候 Backward index scan インデックスを逆順に読んで並び替えを省いている
良い兆候 Using index for group-by インデックスだけで GROUP BY や DISTINCT を処理できる
良い兆候 Select tables optimized away 実行前に答えが決まった(インデックス列の MAX など)
状況しだい Using where 読んだ行を WHERE でさらに絞っている。type が ALL のときは全件を読んで絞っている
状況しだい Using index for skip scan 複合インデックスの先頭列を飛ばして使っている
状況しだい Using union(...) index_merge で複数インデックスの結果を合わせている
状況しだい Using join buffer (hash join) 結合の列にインデックスがなく、ハッシュ結合を使っている
要注意 Using filesort インデックスの順番を使えず、別に並び替えをしている
要注意 Using temporary 一時テーブルを作っている(GROUP BY や DISTINCT など)
確認 Impossible WHERE WHERE が常に偽で、1行も返らない
確認 no matching row in const table 主キーで探した行が存在しない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MySQL EXPLAINの見方|type・key・rows・Extra早見表」

検証した環境とサンプルテーブル

項目 内容
MySQL 8.0.46(Ubuntu 24.04 のパッケージ、InnoDB)
クライアント mysql コマンド
仕様の確認 MySQL 8.4 リファレンスマニュアルの「EXPLAIN Output Format」「EXPLAIN Statement」
MySQL 8.4 今回は未実行。8.4 との差はリファレンスの記述と 8.0.46 の実行結果を比べたもの

顧客1,000件、注文10万件のテーブルを作ります。次の SQL をそのまま流すと同じデータになります。

SET SESSION cte_max_recursion_depth = 200000;

CREATE TABLE customers (
  id   INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(50) NOT NULL,
  pref VARCHAR(20) NOT NULL
);

CREATE TABLE orders (
  id          INT PRIMARY KEY AUTO_INCREMENT,
  customer_id INT NOT NULL,
  status      VARCHAR(10) NOT NULL,
  amount      INT NOT NULL,
  ordered_at  DATETIME NOT NULL
);

INSERT INTO customers (name, pref)
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000)
SELECT CONCAT('customer', n),
       ELT(1 + n % 5, 'Tokyo', 'Osaka', 'Aichi', 'Fukuoka', 'Hokkaido')
FROM seq;

INSERT INTO orders (customer_id, status, amount, ordered_at)
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 100000)
SELECT 1 + (n * 7919) % 1000,
       ELT(1 + (n DIV 1000) % 10, 'paid','paid','paid','paid','paid','paid','paid',
                                  'shipped','shipped','cancelled'),
       1000 + (n * 37) % 9000,
       TIMESTAMPADD(MINUTE, n * 5, '2026-01-01 00:00:00')
FROM seq;

ANALYZE TABLE customers, orders;

顧客1人あたりの注文は100件で、customer_id = 42 の注文のうち status が paid のものは70件です。最初の時点では、orders には主キー以外のインデックスがありません。

EXPLAIN の基本の使い方と12列の意味

SQL の前に EXPLAIN を付けます。末尾を \G にすると縦に表示され、読みやすくなります。

EXPLAIN SELECT * FROM orders WHERE id = 42;
+----+-------------+--------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table  | partitions | type  | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
+----+-------------+--------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | orders | NULL       | const | PRIMARY       | PRIMARY | 4       | const |    1 |   100.00 | NULL  |
+----+-------------+--------+------------+-------+---------------+---------+---------+-------+------+----------+-------+

主キーの等価検索なので type は const、rows は 1 です。EXPLAIN は SQL を実行せず、計画だけを表示します。

列 意味
id SELECT の番号。サブクエリや UNION があると複数になる
select_type SELECT の種類(SIMPLE、PRIMARY、SUBQUERY、DERIVED、UNION など)
table 対象のテーブル(別名があれば別名)
partitions 対象のパーティション(パーティションなしなら NULL)
type テーブルの読み方。早見表を参照
possible_keys 使える候補のインデックス
key 実際に選ばれたインデックス
key_len 使うインデックスの長さ(バイト)。複合インデックスの何列目まで使ったかの手がかり
ref インデックスと比べる相手(const は定数、t.c.id は別テーブルの列)
rows 読む行数の見積もり
filtered 読んだ行のうち、条件で残る割合の見積もり(%)
Extra 補足情報。早見表を参照

possible_keys に候補があるのに key が NULL のときは、「インデックスはあるが、使わないほうが速いと判断された」状態です。possible_keys も NULL のときは、条件に合うインデックスがそもそもありません。

EXPLAIN は UPDATE や DELETE にも使えます。実行はされないので、更新前に読み方を確かめられます。

EXPLAIN UPDATE orders SET amount = amount + 1 WHERE customer_id = 42;

インデックス追加後に実行したところ、select_type は UPDATE、type は range、key は idx_customer、rows は 100 と表示されました。

rows × filtered の読み方

rows は「読む行数」、filtered は「読んだあと条件で残る割合」 の見積もりです。2つを掛けると、そのテーブルから次の処理へ渡る行数の見積もりになります。

次へ渡る行数の見積もり = rows × filtered ÷ 100

インデックスのない状態で顧客42の注文を探すと、こう表示されます。

EXPLAIN SELECT * FROM orders WHERE customer_id = 42\G
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 101284
     filtered: 10.00
        Extra: Using where
読み取れること 根拠
テーブルを全件読む type が ALL、key が NULL
約10万行を読む見積もり rows: 101284(InnoDB の統計による概算。実際は 100,000 行)
残るのは約1万行という見積もり 101284 × 10.00% = 約 10,128 行
実際に残るのは 100 行 EXPLAIN ANALYZE で確認(後述)

rows も filtered も見積もりです。インデックスやヒストグラムのない列では、filtered に一律の値が入ることがあり、実際とは離れます。顧客テーブルの pref 列で確かめました。Tokyo の顧客は実際には 1,000 件中 200 件(20%)です。

EXPLAIN SELECT * FROM customers WHERE pref = 'Tokyo';          -- filtered: 10.00
ANALYZE TABLE customers UPDATE HISTOGRAM ON pref;              -- ヒストグラムを作成
EXPLAIN SELECT * FROM customers WHERE pref = 'Tokyo';          -- filtered: 20.00

ヒストグラムを作ると、filtered が 10.00 から 20.00 に変わり、実際の割合と一致しました。見積もりが実際と大きく違うときは、ANALYZE TABLE で統計を更新するのが最初の一手です。

結合では掛け算で見る

複数のテーブルを結合する場合、各行の rows × filtered を上から掛けていくと、全体で調べる行数の規模がわかります。インデックスをすべて追加したあと(ヒストグラム作成前)の結合の例です。possible_keys 列は横に長いため省いています。

EXPLAIN SELECT c.name, o.amount
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.pref = 'Tokyo';
+----+-------------+-------+------+------------------------+---------+--------+------+----------+-------------+
| id | select_type | table | type | key                    | key_len | ref    | rows | filtered | Extra       |
+----+-------------+-------+------+------------------------+---------+--------+------+----------+-------------+
|  1 | SIMPLE      | c     | ALL  | NULL                   | NULL    | NULL   | 1000 |    10.00 | Using where |
|  1 | SIMPLE      | o     | ref  | idx_cust_status_amount | 4       | t.c.id |   99 |   100.00 | Using index |
+----+-------------+-------+------+------------------------+---------+--------+------+----------+-------------+
手順 計算 見積もり
c から残る行 1000 × 10% 100 行
c の1行につき o から読む行 99 × 100% 99 行
全体 100 × 99 約 9,900 行

EXPLAIN FORMAT=TREE でも見積もりは rows=9989 と表示され、この掛け算とほぼ一致します。同じ id の行は上から順に結合されるので、上の行のテーブルが「外側(先に読む側)」です。ref 列の t.c.id は、「o のインデックスを c.id の値で引く」という意味です。

改善の実例1:フルスキャンの状態を確認する

ここから、同じ SQL を3段階で改善していきます。最初はインデックスのない状態です。EXPLAIN の結果は前の節のとおり type: ALL、rows: 101284 でした。実際に実行して時間と行数を測る EXPLAIN ANALYZE も見てみます。

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42\G
-> Filter: (orders.customer_id = 42)  (cost=10201 rows=10128) (actual time=0.384..25.4 rows=100 loops=1)
    -> Table scan on orders  (cost=10201 rows=101284) (actual time=0.289..20.3 rows=100000 loops=1)

100 行を返すために 100,000 行を読んでいることが、Table scan の actual rows=100000 からわかります。

改善の実例2:インデックスを追加する

絞り込みの列にインデックスを作ります。

CREATE INDEX idx_customer ON orders (customer_id);

EXPLAIN SELECT * FROM orders WHERE customer_id = 42\G
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_customer
          key: idx_customer
      key_len: 4
          ref: const
         rows: 100
     filtered: 100.00
        Extra: NULL
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42\G
-> Index lookup on orders using idx_customer (customer_id=42)  (cost=35 rows=100) (actual time=0.227..0.304 rows=100 loops=1)
項目 追加前 追加後
type ALL ref
key NULL idx_customer
rows(見積もり) 101284 100
filtered 10.00 100.00
Extra Using where NULL
実際に読んだ行(ANALYZE) 100,000 100
actual time の終わり 25.4 ミリ秒 0.304 ミリ秒

時間は手元の1回の計測値で、環境や実行のたびに変わります。注目するのは「読む行数が 100,000 から 100 になった」 という点です。Extra が NULL なのは「特に補足なし」という意味で、問題ではありません。

条件が増えると、テーブル本体で絞る行が出てくる

同じインデックスのまま、status の条件を足し、必要な列だけを SELECT します。

EXPLAIN SELECT customer_id, status, amount
FROM orders
WHERE customer_id = 42 AND status = 'paid'\G
         type: ref
possible_keys: idx_customer
          key: idx_customer
      key_len: 4
          ref: const
         rows: 100
     filtered: 10.00
        Extra: Using where
-> Filter: (orders.`status` = 'paid')  (cost=26 rows=10) (actual time=0.171..0.257 rows=70 loops=1)
    -> Index lookup on orders using idx_customer (customer_id=42)  (cost=26 rows=100) (actual time=0.169..0.241 rows=100 loops=1)

インデックスで 100 行を見つけ、テーブル本体を読んでから status で 70 行に絞っています。filtered が 100 でなく Extra に Using where が出ているときは、「インデックスで絞ったあと、さらにテーブルの行を見て絞っている」 と読めます。

改善の実例3:カバリングインデックスにする

WHERE の列(customer_id、status)と SELECT で使う列(amount)をすべて含む複合インデックスを作ります。

CREATE INDEX idx_cust_status_amount ON orders (customer_id, status, amount);

EXPLAIN SELECT customer_id, status, amount
FROM orders
WHERE customer_id = 42 AND status = 'paid'\G
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_customer,idx_cust_status_amount
          key: idx_cust_status_amount
      key_len: 46
          ref: const,const
         rows: 70
     filtered: 100.00
        Extra: Using index
-> Covering index lookup on orders using idx_cust_status_amount (customer_id=42, status='paid')  (cost=8.45 rows=70) (actual time=0.0211..0.028 rows=70 loops=1)
項目 単一インデックス カバリングインデックス
key idx_customer idx_cust_status_amount
key_len 4 46
ref const const,const
rows 100 70
filtered 10.00 100.00
Extra Using where Using index
EXPLAIN ANALYZE の表示 Index lookup + Filter Covering index lookup
cost 26 8.45

Extra の Using index は「インデックスだけで結果を返せた」しるしです。テーブル本体を読みに行かないぶん、読み取りが少なくなります。

key_len が 4 から 46 に増えたのは、customer_id(INT で 4 バイト)に加えて status(VARCHAR(10)、utf8mb4 で 10 × 4 + 長さ 2 バイト = 42 バイト)まで使っているからです。key_len を見ると、複合インデックスの何列目まで絞り込みに使えたかがわかります。

SELECT する列が増えると Using index は消える

同じ条件で SELECT * にすると、インデックスにない列(id 以外では ordered_at)が必要になります。

EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';

結果は type: ref、key: idx_cust_status_amount、rows: 70、Extra: NULL でした。絞り込みにはインデックスが使われますが、Using index は付きません。カバリングインデックスを生かすには、SELECT に必要な列だけを書きます。

並び替えもインデックスで済ませる

ORDER BY があると、Extra に Using filesort が出ることがあります。

EXPLAIN SELECT * FROM orders
WHERE customer_id = 42
ORDER BY ordered_at DESC LIMIT 10;
状態 key rows Extra
idx_customer だけ idx_customer 100 Using filesort
CREATE INDEX idx_cust_ordered ON orders (customer_id, ordered_at) の追加後 idx_cust_ordered 100 Backward index scan

「絞り込みの列、並び替えの列」の順で複合インデックスを作ると、インデックスの順番をそのまま使えるので、並び替えの処理がなくなります。DESC の指定なので、インデックスを逆から読む Backward index scan になりました。

type ごとの実例(range・index・index_merge・eq_ref)

インデックスを3つ追加した状態で、ほかの type も確かめました。表は type・key・rows・Extra を抜き出したものです。

SQL(orders に対して) type key rows Extra
WHERE id = 42 const PRIMARY 1 NULL
WHERE customer_id = 42 ref idx_customer 100 NULL
WHERE customer_id BETWEEN 10 AND 12 range idx_customer 300 Using index condition
WHERE customer_id = 42 AND ordered_at >= '2026-06-01' range idx_cust_ordered 57 Using index condition
WHERE customer_id = 42 OR id = 7 index_merge idx_customer,PRIMARY 101 Using union(idx_customer,PRIMARY); Using where
SELECT customer_id, COUNT(*) ... GROUP BY customer_id index idx_customer 101284 Using index
SELECT status, COUNT(*) ... GROUP BY status index idx_cust_status_amount 101284 Using index; Using temporary
上に ORDER BY COUNT(*) DESC を追加 index idx_cust_status_amount 101284 Using index; Using temporary; Using filesort
WHERE customer_id + 0 = 42 ALL NULL 101284 Using where
WHERE ordered_at >= '2026-06-01' AND ordered_at < '2026-06-02' ALL NULL 99516 Using where

この表から読み取れるポイントです。

ポイント 説明
index は「インデックスの全件読み」 type: index は rows が全行数のまま。Using index が付いていればテーブル本体は読まないが、行数が多いと時間はかかる
GROUP BY の列がインデックスの先頭でないと一時テーブル status は複合インデックスの2列目なので Using temporary。customer_id は先頭列なので付かない
列に計算を加えるとインデックスを使えない customer_id + 0 = 42 は ALL。列はそのまま、計算は右辺に寄せる
インデックスのない列の範囲検索は ALL ordered_at 単独のインデックスがないため。該当は 288 行なので、必要ならインデックスを追加する

結合での eq_ref も確認しました。注文から顧客を主キーで引く形です。

EXPLAIN SELECT c.name, o.amount
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.pref = 'Tokyo';

orders にインデックスがない最初の状態では、次のようになりました(possible_keys 列は省略)。

+----+-------------+-------+--------+---------+---------+-----------------+--------+----------+-------------+
| id | select_type | table | type   | key     | key_len | ref             | rows   | filtered | Extra       |
+----+-------------+-------+--------+---------+---------+-----------------+--------+----------+-------------+
|  1 | SIMPLE      | o     | ALL    | NULL    | NULL    | NULL            | 101284 |   100.00 | NULL        |
|  1 | SIMPLE      | c     | eq_ref | PRIMARY | 4       | t.o.customer_id |      1 |    10.00 | Using where |
+----+-------------+-------+--------+---------+---------+-----------------+--------+----------+-------------+

orders を全件読み、1行ごとに customers を主キーで1行引いています(eq_ref)。インデックス追加後は、前の節で見たとおり customers を先に読み、orders を ref で引く順番に変わりました。インデックスの有無で、結合の順番そのものが変わる例です。

先頭列を飛ばしたときの skip scan

複合インデックス (customer_id, status, amount) に対して、2列目の status だけで絞った場合です。

EXPLAIN SELECT customer_id, status, amount FROM orders WHERE status = 'paid';

結果は type: range、key: idx_cust_status_amount、rows: 33761、Extra: Using where; Using index for skip scan でした。先頭列の値ごとにインデックスを飛びながら読む方法です。使えてはいますが、rows は 33,761 と多めです。status で頻繁に絞るなら、status を先頭にしたインデックスを別に用意するほうが読む行数を減らせます。

そのほかの Extra

EXPLAIN SELECT * FROM orders WHERE id = -1;      -- no matching row in const table
EXPLAIN SELECT * FROM orders WHERE 1 = 0;        -- Impossible WHERE
EXPLAIN SELECT MAX(id) FROM orders;              -- Select tables optimized away
EXPLAIN SELECT * FROM (SELECT 1 AS x) t;         -- type: system(2行目は No tables used)

右のコメントは、それぞれの Extra(4つ目は type)に実際に表示された内容です。

EXPLAIN ANALYZE の読み方(actual time・rows・loops)

EXPLAIN は見積もりだけ、EXPLAIN ANALYZE は SQL を実際に実行して、見積もりと実測を並べて表示します。 出力はツリー形式で、字下げの深い行(内側)から先に実行されます。

EXPLAIN ANALYZE
SELECT c.name, o.amount
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.pref = 'Tokyo'
ORDER BY o.amount DESC LIMIT 5\G
-> Limit: 5 row(s)  (actual time=10.7..10.7 rows=5 loops=1)
    -> Sort: o.amount DESC, limit input to 5 row(s) per chunk  (actual time=10.7..10.7 rows=5 loops=1)
        -> Stream results  (cost=1264 rows=9989) (actual time=0.0475..8.96 rows=20000 loops=1)
            -> Nested loop inner join  (cost=1264 rows=9989) (actual time=0.0453..5.95 rows=20000 loops=1)
                -> Filter: (c.pref = 'Tokyo')  (cost=101 rows=100) (actual time=0.0288..0.371 rows=200 loops=1)
                    -> Table scan on c  (cost=101 rows=1000) (actual time=0.0256..0.271 rows=1000 loops=1)
                -> Covering index lookup on o using idx_cust_status_amount (customer_id=c.id)  (cost=1.74 rows=99.9) (actual time=0.0113..0.0214 rows=100 loops=200)
表示 意味
cost=1264 rows=9989 見積もり。コストと、返す行数の予測
actual time=0.0453..5.95 実測(ミリ秒)。左が最初の1行を返すまで、右がすべての行を返すまで
rows=20000 実際に返した行数
loops=200 その処理を繰り返した回数

読むときのポイントは3つです。

ポイント この例での読み方
loops が2以上のとき、time と rows は1回あたりの平均 最下行は「1回 0.0214 ミリ秒・100 行」を 200 回。合計は約 4.3 ミリ秒・20,000 行
見積もりの rows と実測の rows を比べる Filter は見積もり 100 行に対して実測 200 行。結合全体も 9,989 に対して 20,000。pref の見積もりが半分になっている
時間が大きく増えた行を探す 結合が 5.95 ミリ秒、結果の受け渡しまでで 8.96、並び替えを終えて 10.7。20,000 行を並べて 5 行だけ返している

見積もりと実測の差は、前の節のヒストグラムで縮められます。並び替えの対象が多い点は、条件を足して行数を減らすか、並び替えに合うインデックスを検討します。

EXPLAIN ANALYZE で気をつけることをまとめます。

項目 内容
実際に実行される 時間のかかる SQL は、そのぶん待つ。途中で止めるときは KILL QUERY か Ctrl+C(リファレンスの記述)
対応する文 SELECT、複数テーブルの UPDATE・DELETE、TABLE 文(リファレンスの記述)
形式 TREE 形式のみ。8.0.46 で FORMAT=JSON を付けるとエラーになった
EXPLAIN ANALYZE FORMAT=JSON SELECT 1;
ERROR 1235 (42000): This version of MySQL doesn't yet support 'EXPLAIN ANALYZE with JSON format'

FORMAT=TREE と FORMAT=JSON の違い

EXPLAIN の出力形式は3つあります。

形式 書き方 特徴 向いている場面
TRADITIONAL(表) EXPLAIN SELECT ... 1テーブル1行。type・key・rows・Extra を一覧できる まず全体をつかむ
TREE EXPLAIN FORMAT=TREE SELECT ... 実行の流れを字下げで表示。結合の方式(Nested loop、hash join)や Filter、Sort が行として見える 処理の順番を追う
JSON EXPLAIN FORMAT=JSON SELECT ... コストの内訳や、使った列・インデックスの部分を詳しく表示 数値を細かく調べる、ツールで処理する

カバリングインデックスの SQL を JSON 形式で出した結果(一部)です。

EXPLAIN FORMAT=JSON
SELECT customer_id, status, amount FROM orders
WHERE customer_id = 42 AND status = 'paid'\G
{
  "query_block": {
    "select_id": 1,
    "cost_info": { "query_cost": "8.45" },
    "table": {
      "table_name": "orders",
      "access_type": "ref",
      "key": "idx_cust_status_amount",
      "used_key_parts": ["customer_id", "status"],
      "key_length": "46",
      "rows_examined_per_scan": 70,
      "rows_produced_per_join": 70,
      "filtered": "100.00",
      "using_index": true,
      "cost_info": {
        "read_cost": "1.45",
        "eval_cost": "7.00",
        "prefix_cost": "8.45",
        "data_read_per_join": "4K"
      },
      "used_columns": ["customer_id", "status", "amount"]
    }
  }
}

(possible_keys と ref の配列は省き、配列を1行に詰めて載せています。)

表形式の列 JSON でのキー JSON だけでわかること
type access_type —
key_len key_length used_key_parts:複合インデックスのどの列まで使ったかが列名でわかる
rows rows_examined_per_scan rows_produced_per_join:絞り込み後の行数
Extra: Using index using_index: true —
(なし) cost_info 読み取りと評価のコストの内訳
(なし) used_columns SQL が実際に使う列の一覧

key_len の数字から列数を逆算する代わりに、JSON の used_key_parts を見ると、使われた列が名前で確認できます。

TREE 形式は、ハッシュ結合が使われているかどうかを確かめるのにも使えます。インデックスのない列どうしで結合した例です。

EXPLAIN FORMAT=TREE
SELECT c.name, o.amount
FROM customers c JOIN orders o ON o.amount = c.id + 1000
WHERE c.pref = 'Tokyo'\G
-> Inner hash join (o.amount = (c.id + 1000))  (cost=1.01e+6 rows=1.01e+6)
    -> Index scan on o using idx_cust_status_amount  (cost=10.9 rows=101284)
    -> Hash
        -> Filter: (c.pref = 'Tokyo')  (cost=101 rows=100)
            -> Table scan on c  (cost=101 rows=1000)

同じ SQL を表形式で出すと、o の行の Extra に Using where; Using index; Using join buffer (hash join) と表示されました。

MySQL 8.0 と 8.4 での違い

8.0.46 での実行結果と、MySQL 8.4 リファレンスマニュアルの記述を比べた表です。8.4 での実行は今回行っていません。

項目 8.0.46(実行して確認) 8.4(リファレンスの記述)
explain_format 変数 あり。既定は TRADITIONAL。SET @@explain_format = TREE で既定の形式が変わることを確認 あり。値は TRADITIONAL・JSON・TREE・DEFAULT
explain_json_format_version 変数 SHOW VARIABLES LIKE 'explain%' に表示されなかった あり。既定は 1(従来の形式)。2 にするとアクセスパスに基づく新しい形式
EXPLAIN FORMAT=JSON INTO @変数 構文エラー(ERROR 1064)になった 構文に INTO variable があり、JSON の出力をユーザー変数に保存できる
EXPLAIN ANALYZE の形式 TREE のみ。FORMAT=JSON は ERROR 1235 「EXPLAIN ANALYZE は TREE 形式のみ対応」と記載。explain_format が JSON のときは FORMAT=TREE を明示する
表形式の12列 id〜Extra の12列 同じ列構成で説明されている

表形式の基本の読み方(type・key・rows・filtered・Extra)は共通です。8.4 では、JSON 出力を変数に入れて JSON 関数で調べる、といった使い方が加わっています。ツリーに表示される文言や見積もりの数値はバージョンで変わる場合があるので、出力を比べるときは同じバージョンどうしで行います。

上から順に見るチェックリスト

EXPLAIN の結果を見るときの順番です。

順番 見る項目 確認すること 次の一手
1 rows 返したい行数に比べて、桁が大きくないか 大きければ 2 へ
2 type ALL や index になっていないか 条件の列にインデックスを検討する
3 key と possible_keys key が NULL でないか。期待したインデックスか 候補があるのに使われないなら、条件の書き方と統計を確認する
4 key_len 複合インデックスの想定した列まで使えているか 列の順番(等価の列を先、範囲の列を後)を見直す
5 filtered 100 より大きく下回っていないか テーブルで絞っている条件の列をインデックスに含める
6 Extra Using filesort、Using temporary がないか ORDER BY・GROUP BY に合うインデックスを検討する
7 Extra Using index にできそうか SELECT の列を絞り、カバリングインデックスを検討する
8 結合の行の順番 上の行(先に読むテーブル)の rows × filtered が小さいか 絞り込みの効く側から読まれるよう、条件とインデックスを確認する
9 EXPLAIN ANALYZE 見積もりの rows と実測の rows が離れていないか ANALYZE TABLE、必要ならヒストグラムを作る

インデックスは、追加するほど INSERT や UPDATE の処理が増えます。よく実行される SQL、rows の大きい SQL から順に対応すると、少ないインデックスで効果が出ます。

よくある質問

Q. type が ALL なら、必ず直したほうがよいですか? A. 行数しだいです。数百行ほどのテーブルや、もともと大半の行を返す SQL なら、全件読みが妥当なこともあります。rows が大きく、返す行が少ない SQL から優先して見直します。

Q. rows の値が実際の行数と違うのはなぜですか? A. rows は統計情報をもとにした見積もりだからです。今回も 100,000 行のテーブルで 101284 や 99516 と表示されました。大きくずれているときは ANALYZE TABLE で統計を更新します。

Q. EXPLAIN と EXPLAIN ANALYZE はどう使い分けますか? A. EXPLAIN は SQL を実行しないので、まず計画を確かめるのに向いています。EXPLAIN ANALYZE は実際に実行して実測の時間と行数を出すので、見積もりと実際の差を確かめたいときに使います。時間のかかる SQL では、実行時間ぶん待つ点を考えて使います。

Q. Using where は良くない表示ですか? A. いいえ。WHERE で行を絞っているという意味で、多くの SQL に出ます。type が ALL で rows が大きいときにだけ、「全件読んでから絞っている」という意味で見直しの対象になります。

まとめ

  • EXPLAIN は SQL の実行計画を表示する命令。まず type・key・rows・Extra の4列を見る
  • type は const・eq_ref・ref・range が絞り込めている状態、index と ALL は全体を読んでいる状態
  • rows × filtered ÷ 100 が次へ渡る行数の見積もり。結合では上の行から掛け算で規模をつかむ
  • インデックス追加で type は ALL から ref に、読む行は 100,000 から 100 に。さらに SELECT の列まで含めると Extra が Using index になる
  • Using filesort と Using temporary は、ORDER BY・GROUP BY に合うインデックスで減らせる
  • EXPLAIN ANALYZE は実測つき。loops が2以上なら time と rows は1回あたりの値で、見積もりとの差を見る
  • FORMAT=TREE は処理の流れ、FORMAT=JSON は used_key_parts やコストの内訳がわかる。8.4 では JSON を変数に保存する INTO も使える(8.4 は未実行、リファレンスで確認)

参考・出典

  • MySQL 8.4 Reference Manual「EXPLAIN Output Format」(type の並び、rows と filtered、Extra の意味を確認)https://dev.mysql.com/doc/refman/8.4/en/explain-output.html
  • MySQL 8.4 Reference Manual「EXPLAIN Statement」(FORMAT、INTO、explain_format、explain_json_format_version、EXPLAIN ANALYZE の説明を確認)https://dev.mysql.com/doc/refman/8.4/en/explain.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MySQL EXPLAINの見方|type・key・rows・Extra早見表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?