EXPLAIN ANALYZEの使い方:実行時間と実際の行数を読む

black laptop computer turned-on displaying source code on table SQL

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つの手順

出力が長くても、見る場所は決まっています。次の順に確かめると、ボトルネック(全体を遅くしている処理)を見つけやすくなります。

  1. 終了時間が大きい行を探す:actual time の後ろの数字を上から見ていきます
  2. 子との差を見る:親と子の時間がほぼ同じなら、原因は子の側にあります
  3. 推定と実際の行数を比べる:数十倍以上ずれていたら、統計情報が古いかもしれません
  4. 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と組み合わせて使ってみてください。

参考リンク

アイキャッチ画像: Photo by Jantine Doornbos on Unsplash

タイトルとURLをコピーしました