
この記事は every Tech Blog Advent Calendar 2025 の 16 日目の記事です。
はじめに
こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと申します。
現在、小売アプリ開発で Laravel 11 を利用しながら日々サービス開発に取り組んでいます。
先日、サービスのパフォーマンス改善を目的として、MySQL の SQL チューニングを行う機会がありました。 これまでも EXPLAIN を使って実行計画を確認することが多かったのですが、以前から「EXPLAIN の内容と実際の動作が一致しない」ケースをいくつか経験していました。今回のチューニングでも同じような状況があり、実際に SQL を実行しながら挙動を確かめる必要がありました。
しかし MySQL 8.0 系では、より深い分析が可能な EXPLAIN ANALYZE が導入され、実際の実行内容を踏まえた「リアルな実行計画」を確認できるようになっています。 私自身、SQL チューニングから少し遠ざかっていたこともあり、しっかり活用できていなかったのですが、意外とまだ使っていない方もいるのではと感じました。
そこで本記事では以下を中心にお話できればと思っています。
- EXPLAIN / EXPLAIN ANALYZE の違いについて
- 実行計画の読み方と注意点
- 推定と実測が大きく乖離するケース
- 使いどころと避けるべき点
- まとめ
1. EXPLAIN / EXPLAIN ANALYZE の今までの経緯
MySQL における実行計画確認は長らく EXPLAIN 一択でした。
- 「EXPLAIN」は初期から存在し、5.6 からは更新系も可能に
- ただし長年「推定計画のみ」で、実際の動作は把握しにくい
- 行数推定やコスト推定が外れるケースも度々あった
その後 MySQL 8.0 系でオプティマイザが大幅に改善され、
MySQL 8.0.18(2019 年)で EXPLAIN ANALYZE が追加されました。
特徴:
- 実際にクエリを実行する
- 実測行数(actual rows)
- 実行時間(actual time)
- 実際の JOIN 順
- 内部処理の詳細
が取得できるようになり、より精細な実行計画解析が可能になりました。
2. MySQL の EXPLAIN / EXPLAIN ANALYZE の違い
EXPLAIN と EXPLAIN ANALYZE でもっとも大きな違いは、「実際に SQL を実行するかどうか」にあります。
■ EXPLAIN(推定)
- SQL を実行せず、オプティマイザの「推定計画 」を表示
- 使用インデックス、JOIN 順序、読み込む推定行数などがわかる
- 統計情報に依存するため、実際と大きく異なる場合がある
■ EXPLAIN ANALYZE(実測)
- SQL を 実際に実行して 実行計画を取得
- 実際の行数、実際の実行時間、ループ回数などが表示される
- 推定と実測のギャップが明確にわかり、ボトルネック特定に非常に有効
ただし、DELETE / UPDATE(INSERT は未対応) の更新系クエリでは、 実際に実行されるため、トランザクション内による実行、ロールバックを行わないとデータに影響が出てしまいます。 また、通常と同様にロックが発生する点にも注意が必要です。
3. 実行計画の読み方
実際の実行計画の読み方を簡単ですがご紹介します。
次のようなシンプルなテーブル JOIN を例にします。
SELECT * FROM orders o JOIN order_items i ON o.id = i.order_id WHERE o.status = 'PAID' AND i.price > 1000;
実行計画は次のようになります。
EXPLAIN 結果
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | o | ALL | PRIMARY | NULL | NULL | NULL | 3 | 33.33 | Using where |
| 1 | SIMPLE | i | ref | order_id | order_id | 4 | retail-app.o.id | 1 | 33.33 | Using where |
補足
orders (o)テーブルは 全件走査(type = ALL) のため、条件列にインデックスがない状態。order_items (i)は ref 結合 で、order_idインデックスを使用。- 両方
Using whereのため、最終フィルタはクエリ条件で行われている。
さらに JOIN が増えると以下のような多段構造になっていきます。
SQL例
SELECT o.id AS order_id, c.name AS customer_name, i.product_id, i.price FROM orders o JOIN customers c ON o.customer_id = c.id JOIN order_items i ON o.id = i.order_id WHERE o.status = 'PAID' AND i.price > 1000;
EXPLAIN 結果(orders → customers → order_items)
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | o | ALL | PRIMARY, customer_id | NULL | NULL | NULL | 3 | 33.33 | Using where |
| 1 | SIMPLE | c | eq_ref | PRIMARY | PRIMARY | 4 | retail-app.o.customer_id | 1 | 100.00 | NULL |
| 1 | SIMPLE | i | ref | order_id | order_id | 4 | retail-app.o.id | 1 | 33.33 | Using where |
補足メモ
- orders(o): インデックス不使用 → 全件走査(ALL)
- customers(c): 主キーで eq_ref → 高速な1件特定
- order_items(i): ref 結合で order_id を利用
- orders の絞り込みが弱いと、JOIN 全体の効率が下がりやすい構造になる
多段 JOIN では特にデータ数がテーブルごとに極端に異なっていたりすると推定ミスが起きやすく、EXPLAIN ANALYZE の価値がより高まります。
4. EXPLAIN では見えない「ズレ」の例
極端にはなりますが、以下に2つの例を挙げてみました。
■ 例 1:統計情報が古い
SELECT * FROM users WHERE created_at >= CURDATE();
EXPLAIN(推定) の結果を抜粋
type=range, rows=10
→ MySQL は「現在のデータは 10 行くらい」と予測している。
EXPLAIN ANALYZE(実測)
-> Filter: (users.created_at >= curdate()) (cost=0.35 rows=10)
-> Table scan on users (cost=0.35 rows=1,204,293)
rows examined: 1,204,293
actual time=0.001..1200.226 rows=1,204,293 loops=1
→ 実際には 120万行を全件スキャンし、1.2 秒も時間がかかっている。
なぜ EXPLAIN では10行と予測していたのに、実際は120万件となり「ズレる」場合があるのか?
- 統計情報は自動更新されるが、更新タイミングは一定でない
- 大量 INSERT の直後は「古い推定」のままのことがある
- 古い統計のままだと「10行程度」など誤判断をしてしまう場合がある
■ 例 2:JOIN 順の推定が不適切
個人的にはこちらの方が特に厄介かなと感じています。
SELECT * FROM products p JOIN product_tags t ON p.id = t.product_id WHERE t.tag = 'SALE';
期待する動作:
product_tagsから SALE 行のみ抽出- その
product_idを使って products を引く
しかし EXPLAIN の推定(抜粋):
| table | type | key | rows | Extra |
|---|---|---|---|---|
| p | ALL | NULL | 1200000 | Using where |
| t | ref | tag | 10 | Using index |
→ products が 120 万行フルスキャンされるプランになっている。
EXPLAIN ANALYZE(実測)
-> Nested loop inner join (cost=0.90 rows=10)
-> Table scan on p (cost=0.35 rows=1,200,000)
rows examined: 1,200,000
actual time=0.004..5500.891 rows=1,200,000 loops=1
-> Filter: (t.tag = 'SALE') (cost=0.55 rows=10)
-> Index lookup on t using product_id (product_id=p.id)
actual time=0.05..0.10 rows=1 loops=1
→ 実際には 5.5 秒かけて 120 万行を読み切っている。
上記は、
- p テーブルを120万件フルスキャン
- p の各行に対して t テーブルを product_id でインデックス検索
- 見つかった t 行の中で tag = 'SALE' のものだけ採用
「120万行をひとつずつ見て、その都度 t テーブルを1件検索する」構造になっています。
この部分が重くなってしまう原因です。(Nested Loop JOIN)
考えられる原因:
- product_tags.tag のカーディナリティ推定が外れていた
- MySQL が「SALE は大量にあるだろう。フィルタにならない」と誤解
- そのため、「products を先に読んだほうが速い」という間違った結論になったりする
実際には SALE 件数が少なければ、 product_tags を起点に読む方が圧倒的に速い。 このケースが特に起きた時に一見問題ない SQL のつもりが、「突然クエリが数秒〜十数秒に悪化する」場合もありえます。 統計情報が最新でも起きる可能性があり、これがかなり厄介だったりします。
5. EXPLAIN ANALYZE が教えてくれる「実際の実行計画」
EXPLAIN ANALYZE では以下が明確になります。
■ 1. どこがボトルネックか
actual time / rows / loops により、処理の重い箇所を特定できる。
■ 2. 推定と実測のズレ
推定 rows と actual rows の差を見ることで、オプティマイザの誤判断を発見できる。
■ 3. 実際の実行時間
ミリ秒単位でどの処理が時間を使っているか把握できる。
■ 4. JOIN 順序が適切か
loops 値などから、JOIN の選択順が正しいか判断できる。
■ 5. インデックスが効いているか
行数の多さから、フルスキャンかどうか一目でわかる。
チューニングをする際、これをやれば解決といった方法は明確にないため、 ケースごとにインデックス見直し、結合方法の改善、条件指定の組み替えなどを行いながら解決する必要があります。 EXPLAIN ANALYZE の使用でそれが以前よりも実際の原因がわかりやすくなるのは大きな違いかなと思います。
6. EXPLAIN ANALYZE を本番で使う際の注意点
本番で使用する際には必ず以下を理解しておく必要があります。
更新系の EXPLAIN ANALYZE は特に注意する
先ほども少し触れましたが、更新系の場合、ANALYZE は実際に実行されてしまうので、 トランザクション内で確実に戻せることが確認できた上であれば実施は可能です。
しかし、
- クエリ自体は本当に実行される
- ロックは実際に取られる
- トリガーが動く可能性がある(環境による)
- ロックの影響で他の処理が待たされる
という点があり、実行には細心の注意が必要です。 特に「ロック中に他処理が待ち状態になるのは本番ではリスク」になります。
また、更新系ではキャンセル時の挙動も考慮する必要があります。 キャンセル時のロールバック、想定外のロックの遅延なども怖い要因かなと思います。
本番で更新系 ANALYZE は特別な理由がない限り実行しない
上記のことから、基本は以下のルールで行うのが良いのかなと思います。
- 本番で EXPLAIN ANALYZE を使うのは SELECT のみに限定
- UPDATE / DELETE (INSERT は未対応)の ANALYZE は 検証環境で行う
- 重いクエリの ANALYZE は本番で実行しない
ただ、本番でしか再現しないケースなどでは、本番で実行して試したいところですが、
上記ルールに従い、リスクを軽減できるようにしていきたいです。
7. ヒント句や FORCE INDEX は最終手段
また、EXPLAIN 機能の話とは少しずれますが、
チューニングを行なう際、想定通りの実行計画になかなかならず、
ヒント句やFORCE INDEX などを使用したいケースがあります。 (使うと解消できる状況)
ただ、ヒント句は便利ですが、以下の理由で「最終手段」とした方が良いのかなと個人的には思います。
- データ量・分布が変わると逆効果になる場合がある
- 計画が固定されるため柔軟性が落ちてしまう
- 長期メンテコストを考えると、逆効果の場合も
そのため、まず優先すべきは:
- 統計情報の更新
- 正しいインデックス設計
- JOIN 条件の見直し
- SQL の簡潔化
を行った上で、それでも必要であればという意識が必要かなと感じています。
8. まとめ
いかがでしたでしょうか。 最後にEXPLAIN ANALYZEについての比較を表にまとめました。
| 比較項目 | EXPLAIN | EXPLAIN ANALYZE |
|---|---|---|
| 実行されるか | 実行しない(推定) | 実行される(実測) |
| 出力 | 推定行数・推定コスト | 実行行数・実際の時間 |
| 統計情報の影響 | 大きい | 小さい |
| 本番への影響 | 小さい | ロックなど注意 |
| 更新系クエリ | 実行されない | 実行→ロールバック |
| 主な用途 | 計画確認 | 実挙動の把握 |
| 精度 | 誤差が大きい場合あり | 実測で高い精度 |
- 推定と実測は大きく異なることがあり、特に JOIN や統計情報が絡むケースでは差が顕著になる
- 多くのパフォーマンス問題は、統計情報の更新・インデックス改善・JOIN や SQL の見直しで解決可能
- ヒント句は強力だが、状況が変わると逆効果になる場合もあるため「最終手段として使った方が良い」
以上、MySQL の実行計画についてのお話でした。
今後はどちらも有効に活用して改善対応を進めていければと思っています。
少しだけでもSQLチューニング作業の参考になれば幸いです。
最後までお読みいただきありがとうございました。