EXPLAINで実行計画を見ても、なぜ遅いのか分からないことがあります。EXPLAINが出すのはオプティマイザ(実行方法を決める仕組み)の「見積もり」だけで、実際にかかった時間は分からないからです。MySQL 8.0.18から使えるEXPLAIN ANALYZEは、クエリを本当に実行して、処理ごとの実行時間と実際の行数を表示します。この記事では、出力の読み方、遅い場所の探し方、JOINでのloopsの読み方を、手元で試せるサンプルで順に説明します。最後に、本番で使うときの注意点もまとめます。
このシナリオで考える
ECサイトの注文テーブル orders を使います。顧客1,000人、注文10万件の規模です。再帰CTE(WITH RECURSIVE)で10万行をまとめて作ります。
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
amount INT NOT NULL,
created_at DATETIME NOT NULL
);
SET SESSION cte_max_recursion_depth = 100000;
INSERT INTO orders (customer_id, status, amount, created_at)
WITH RECURSIVE seq (n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 100000
)
SELECT n % 1000 + 1,
ELT(n % 3 + 1, 'paid', 'shipped', 'cancelled'),
(n % 50 + 1) * 100,
DATE '2026-01-01' + INTERVAL (n % 300) DAY
FROM seq;
調べたいのは「顧客42番の支払い済み注文を新しい順に10件」取るクエリです。マイページの注文履歴でよく見る形でしょう。
SELECT id, amount, created_at
FROM orders
WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 10;
EXPLAINとEXPLAIN ANALYZEの違い
一番の違いは、クエリを実際に実行するかどうかです。EXPLAINは計画を立てるだけで、すぐに結果が返ります。EXPLAIN ANALYZEは最後まで実行してから、計画と実測値を並べて返します。
| 項目 | EXPLAIN | EXPLAIN ANALYZE |
|---|---|---|
| クエリの実行 | しない | する |
| 分かること | 推定コスト・推定行数 | 推定値+実際の時間・行数・ループ回数 |
| 出力形式 | 表・TREE・JSON | TREEのみ |
| 使えるバージョン | 昔から | MySQL 8.0.18以降 |
| かかる時間 | 一瞬 | クエリの実行時間と同じ |
使い方は、調べたいSELECTの前に EXPLAIN ANALYZE を付けるだけです。クエリの結果行は表示されず、実行計画だけが返ります。
EXPLAIN ANALYZE
SELECT id, amount, created_at
FROM orders
WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 10;
出力の読み方:actual timeとrows
インデックスがない状態で実行すると、次のような出力になります(数値は環境によって変わります)。
-> Limit: 10 row(s) (cost=10069 rows=10) (actual time=38.2..38.2 rows=10 loops=1)
-> Sort: orders.created_at DESC, limit input to 10 row(s) per chunk (cost=10069 rows=99650) (actual time=38.2..38.2 rows=10 loops=1)
-> Filter: ((orders.status = 'paid') and (orders.customer_id = 42)) (cost=10069 rows=997) (actual time=0.35..38.1 rows=33 loops=1)
-> Table scan on orders (cost=10069 rows=99650) (actual time=0.05..31.6 rows=100000 loops=1)
出力は木の形をしていて、インデントが深いほど先に動く処理です。下から上へ読むと、流れがつかみやすくなります。この例では「全件を読む → 条件で絞る → 並べ替える → 10件に切る」の順に動いています。
各行のカッコ内の意味は次のとおりです。前半のカッコが見積もり、後半の actual が実測値になります。
| 表示 | 意味 |
|---|---|
| cost=10069 | オプティマイザが見積もったコスト(単位のない相対値) |
| rows=997(前半) | 返すと見積もった行数 |
| actual time=0.35..38.1 | 最初の1行を返すまでの時間 .. この処理が終わるまでの時間(ミリ秒) |
| rows=33(後半) | 実際に返した行数 |
| loops=1 | この処理が繰り返された回数 |
時間は子の処理を含んだ累計です。Filterの38.1ミリ秒のうち31.6ミリ秒は、子のTable scanの時間と分かります。原因は10万行の全件スキャンです。条件に合うのは33行だけなのに、毎回すべての行を読んでいます。
遅い場所を探す4つの手順
出力が長くても、見る場所は決まっています。次の順に確かめると、ボトルネック(全体を遅くしている処理)を見つけやすくなります。
- 終了時間が大きい行を探す:
actual timeの後ろの数字を上から見ていきます - 子との差を見る:親と子の時間がほぼ同じなら、原因は子の側にあります
- 推定と実際の行数を比べる:数十倍以上ずれていたら、統計情報が古いかもしれません
- loopsを掛ける:2回以上なら、時間と行数は1回あたりの平均です
今回は手順1と2で、Table scanが原因と分かりました。WHEREとORDER BYの列で複合インデックスを作り、もう一度実行します。
CREATE INDEX idx_customer_status_created
ON orders (customer_id, status, created_at);
-> Limit: 10 row(s) (cost=11.9 rows=10) (actual time=0.055..0.075 rows=10 loops=1)
-> Index lookup on orders using idx_customer_status_created (customer_id=42, status='paid') (reverse) (cost=11.9 rows=33) (actual time=0.052..0.072 rows=10 loops=1)
Table scanとSortが消えて、インデックスを逆順にたどるだけになりました。10件そろった時点で止まるので、読む行も10行で済みます。
| 比較 | インデックスなし | インデックスあり |
|---|---|---|
| 読んだ行数 | 100,000行 | 10行 |
| 並べ替え | あり(Sort) | なし |
| 全体の時間 | 約38ミリ秒 | 約0.08ミリ秒 |
JOINではloopsに注目する
JOINを含むクエリでは、loopsの読み方が大切です。顧客テーブル customers(id, name, prefecture の3列、1,000行)と結合する例で見てみます。
EXPLAIN ANALYZE
SELECT c.name, o.amount
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE c.prefecture = '東京都';
-> Nested loop inner join (cost=1351 rows=10000) (actual time=0.12..4.85 rows=5000 loops=1)
-> Filter: (c.prefecture = '東京都') (cost=101 rows=100) (actual time=0.05..0.42 rows=50 loops=1)
-> Table scan on c (cost=101 rows=1000) (actual time=0.04..0.33 rows=1000 loops=1)
-> Index lookup on o using idx_customer_status_created (customer_id=c.id) (cost=25.1 rows=100) (actual time=0.02..0.08 rows=100 loops=50)
ネステッドループ結合(外側の1行ごとに内側を探す結合方法)では、内側の処理が何度も動きます。最後の行は loops=50 なので、東京都の顧客50人分だけ繰り返されました。
- 時間:0.08ミリ秒 × 50回 = 約4ミリ秒
- 行数:100行 × 50回 = 5,000行
表示された数字をそのまま見ると、軽い処理に見えてしまいます。loopsが大きいときは、掛け算した値で判断すると覚えてください。外側の行数が数万になると、内側がわずか0.1ミリ秒でも全体は数秒かかります。
よくある落とし穴と注意点
EXPLAIN ANALYZEは本当にクエリを動かします。この性質から、次の点に気をつけてください。
- 重いクエリはそのまま重い:10分かかるクエリは、EXPLAIN ANALYZEでも10分かかります。本番環境では負荷の少ない時間帯か、検証環境で試しましょう。8.0.20以降は
KILL QUERYやCtrl+Cで途中で止められます - UPDATE・DELETEはデータが変わる:複数テーブルのUPDATE/DELETEにも使えますが、実際に更新されます。
START TRANSACTION;の中で実行し、最後にROLLBACK;すると安全です - 2回目は速く出やすい:1回目に読んだデータがメモリ(バッファプール)に残るためです。改善前後を比べるときは、同じ条件で何回か実行して見比べてください
- 推定行数が大きくずれる:統計情報が古いと、オプティマイザが悪い計画を選びがちです。
ANALYZE TABLE orders;で統計を取り直してから再確認しましょう - JSON形式では出せない:MySQL 8.0の出力はTREE形式だけです。
FORMAT=JSONを付けるとエラーになります
なお、PostgreSQLにも同じ名前の EXPLAIN ANALYZE があります。こちらも実際に実行する点は同じで、BUFFERS オプションを付けると読み込んだブロック数まで分かります。
まとめ
EXPLAIN ANALYZEは、EXPLAINの見積もりに実測値を足して見せる機能です。出力は下から上へ読み、actual time の大きい処理と、推定・実際の行数のずれを探します。loopsが2以上なら掛け算して考えることも大切です。実際に実行される点だけ注意すれば、チューニングの前後比較にとても役立ちます。基本のEXPLAINと組み合わせて使ってみてください。
参考リンク
- MySQL 8.0 リファレンスマニュアル: EXPLAIN Statement
- MySQL 8.0 リファレンスマニュアル: Optimizing Queries with EXPLAIN
- PostgreSQL Documentation: Using EXPLAIN
- EXPLAINで実行計画を読む方法(当サイト)
- インデックスが効かない7つのパターンと直し方(当サイト)
アイキャッチ画像: Photo by Jantine Doornbos on Unsplash

