【完全ガイド】MySQL EXPLAIN の見方|type/key/rows/Extraの読み方とインデックス最適化を徹底解説
- 作成日 2026.07.07
- その他
MySQL のクエリが遅い時、真っ先に打つべきコマンドが EXPLAIN:
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com';
出力例:
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
| 1 | SIMPLE | users | ref | idx_email | idx_email | 767 | const | 1 | Using index |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
わずか10列の情報の中に、MySQL がどうやってクエリを処理するかの全てが詰まっている。しかしそれを正しく読み解けなければ、EXPLAIN は「よく分からないおまじない」で終わってしまいます。
現場では:
typeがALLになってる… これって全表スキャン?Using filesortって何?なぜ出る?Using temporaryは避けたい?rows: 1000000って本当に100万行読む?- インデックス張ったのに
key: NULLになっている - 複合インデックスの
key_lenの見方は? EXPLAIN ANALYZEって何が違う?- Rails のクエリを EXPLAIN する方法は?
など、EXPLAIN は情報が凝縮されすぎていて、読み方を体系的に理解しないと判断を誤ります。
本記事では、MySQL EXPLAIN の完全な読み方と使い方を、実務に直結する形でリファレンスとして整理します。基本構文、12カラムの完全解説、type ランキング、Extra の重要値、EXPLAIN ANALYZE(MySQL 8.0+)、FORMAT オプション、Rails ActiveRecord との連携、インデックス設計、実践パターン、トラブル対応、FAQまで完全網羅。この1本でMySQL クエリ最適化のスキルが飛躍的に向上します。
- 1. 結論:EXPLAIN 見るなら7項目に集中
- 2. EXPLAIN の基本
- 3. 【カラム①】id:実行順序
- 4. 【カラム②】select_type:SELECT の種類
- 5. 【カラム③】table:アクセス中のテーブル
- 6. 【カラム④】partitions:パーティション
- 7. 【カラム⑤】type:アクセス方法(最重要)
- 8. 【カラム⑥】possible_keys:候補インデックス
- 9. 【カラム⑦】key:実際に使ったインデックス
- 10. 【カラム⑧】key_len:使用したインデックスのバイト数
- 11. 【カラム⑨】ref:比較対象
- 12. 【カラム⑩】rows:検査見積もり行数
- 13. 【カラム⑪】filtered:フィルタ後の割合(%)
- 14. 【カラム⑫】Extra:追加情報(超重要)
- 15. type と Extra の重要な組み合わせ
- 16. EXPLAIN ANALYZE(MySQL 8.0.18+)
- 17. EXPLAIN FORMAT オプション
- 18. インデックス設計の実践
- 19. Rails ActiveRecord での EXPLAIN
- 20. 実践的なチューニング例
- 21. スロークエリログとの併用
- 22. 実行時のシステムリソース確認
- 23. トラブルシューティング
- 24. よくある質問(FAQ)
- 24.1. Q1. EXPLAIN と EXPLAIN ANALYZE どっちを使う?
- 24.2. Q2. type: ALL でも問題ない場合
- 24.3. Q3. インデックスの上限
- 24.4. Q4. 複合インデックスの列順
- 24.5. Q5. UNIQUE INDEX vs INDEX
- 24.6. Q6. PostgreSQL の EXPLAIN との違い
- 24.7. Q7. Rails での EXPLAIN 実行
- 24.8. Q8. EXPLAIN の結果をログに残す
- 24.9. Q9. 本番でクエリ実行前に EXPLAIN で確認
- 24.10. Q10. 開発と本番で EXPLAIN の結果が違う
- 24.11. Q11. Using where; Using index の意味
- 24.12. Q12. Handler 統計との組み合わせ
- 25. 参考リンク・関連資料
- 26. まとめ
結論:EXPLAIN 見るなら7項目に集中
時間がない方向けに、最重要ポイントを先に示します。
見るべき7項目
| 列 | 見るポイント | 危険サイン |
|---|---|---|
| type | アクセス方法 | ALL(全表スキャン) |
| key | 使われているインデックス | NULL(未使用) |
| rows | 検査する行数(見積もり) | 数万〜数百万 |
| Extra | 追加情報 | Using filesort / Using temporary |
| filtered | フィルタ後の割合 | 低い数値(1%等) |
| key_len | 使ってるインデックス長 | 想定より短い |
| id | 実行順序(同じidは同じSELECT) | – |
良い EXPLAIN の例
type: const / eq_ref / ref / range
key: 適切なインデックス(NULL でない)
rows: 少ない(数〜数百)
Extra: Using index(カバリング)または空
悪い EXPLAIN の例
type: ALL ← 全表スキャン
key: NULL ← インデックス未使用
rows: 数万〜数百万 ← 大量スキャン
Extra: Using filesort ← ソートで遅い
Using temporary← 一時テーブル作成
詳細は以下で解説します。
EXPLAIN の基本
3種類の EXPLAIN
-- ① 基本の EXPLAIN(実行計画を表示、実行はしない)
EXPLAIN SELECT * FROM users WHERE id = 1;
-- ② FORMAT=JSON / TREE で詳細
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE id = 1;
EXPLAIN FORMAT=TREE SELECT * FROM users WHERE id = 1; -- 8.0.16+
-- ③ EXPLAIN ANALYZE(実際に実行して、実際のコスト・時間を表示)※ 8.0.18+
EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1;
DELETE / UPDATE / INSERT にも使える
EXPLAIN UPDATE users SET name = 'Alice' WHERE id = 1;
EXPLAIN DELETE FROM users WHERE created_at < '2020-01-01';
EXPLAIN INSERT INTO logs SELECT * FROM events WHERE ...;
書き込みクエリのパフォーマンス確認に必須。
実行はされない(EXPLAIN の場合)
EXPLAIN SELECT COUNT(*) FROM users;
-- カウント自体は実行されない、実行計画のみ表示
ただし EXPLAIN ANALYZE は実際に実行するので注意。本番大量データでは要注意。
見やすい表示(\G)
EXPLAIN SELECT * FROM users WHERE id = 1\G
出力:
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: users
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: const
rows: 1
filtered: 100.00
Extra: NULL
縦表示で列名と値がペアで並ぶ。カラム数が多い時に必須。
【カラム①】id:実行順序
各 SELECT ステートメントの識別子:
EXPLAIN SELECT * FROM users u JOIN posts p ON p.user_id = u.id;
+----+-------------+-------+------+---------------+
| id | select_type | table | ...
+----+-------------+-------+------+---------------+
| 1 | SIMPLE | u | ...
| 1 | SIMPLE | p | ...
+----+-------------+-------+------+---------------+
同じidは同じ SELECT、上から順に処理。
サブクエリ・UNION の場合
EXPLAIN SELECT * FROM (SELECT * FROM users WHERE age > 30) AS u;
+----+-------------+------------+------+
| id | select_type | table | ...
+----+-------------+------------+
| 1 | PRIMARY | <derived2> | ...
| 2 | DERIVED | users | ...
+----+-------------+------------+
id: 2が先に実行されるid: 1が後で<derived2>(サブクエリ結果)を使う
大きい id ほど先に実行される(部分的な例外あり)。
【カラム②】select_type:SELECT の種類
| 値 | 意味 |
|---|---|
| SIMPLE | サブクエリ・UNION なし |
| PRIMARY | 最外側の SELECT |
| SUBQUERY | SELECT 内のサブクエリ |
| DERIVED | FROM 内のサブクエリ(派生テーブル) |
| UNION | UNION の2番目以降 |
| UNION RESULT | UNION の結果セット |
| DEPENDENT SUBQUERY | 外側の値に依存するサブクエリ |
| DEPENDENT UNION | 依存する UNION |
| UNCACHEABLE SUBQUERY | キャッシュ不可のサブクエリ |
| MATERIALIZED | マテリアライズド サブクエリ |
危険サイン
- DEPENDENT SUBQUERY: 相関サブクエリで遅い可能性大(JOIN に書き換え検討)
- UNCACHEABLE: RAND() 等でキャッシュされない、要注意
【カラム③】table:アクセス中のテーブル
通常はテーブル名、または <derived N> <subquery N> <unionM,N> 等。
table: users -- テーブル名
table: <derived2> -- id=2 の派生テーブル
table: <union1,2> -- id=1,2 の UNION 結果
エイリアスがあれば alias が表示:
table: u -- users AS u
【カラム④】partitions:パーティション
パーティションテーブルで、どのパーティションにアクセスするかを表示。
EXPLAIN SELECT * FROM sales_2024 PARTITION (p2024_q1);
-- partitions: p2024_q1
パーティションプルーニングが効いているか確認。通常テーブルは NULL。
【カラム⑤】type:アクセス方法(最重要)
EXPLAIN で最も重要な列。テーブルへのアクセス方法を示す。
効率順(良い → 悪い)
| type | 意味 | 効率 |
|---|---|---|
| system | 1行しかないテーブル | 最速 |
| const | PRIMARY / UNIQUE でヒット、1行確定 | 最速 |
| eq_ref | JOIN で PRIMARY / UNIQUE 使用、1行 | 非常に高速 |
| ref | 非UNIQUEインデックス使用、複数行の可能性 | 高速 |
| fulltext | FULLTEXT インデックス | – |
| ref_or_null | ref + NULL 対応 | 高速 |
| index_merge | 複数インデックスをマージ | 中程度 |
| unique_subquery | UNIQUE サブクエリ | 高速 |
| index_subquery | サブクエリ | 中程度 |
| range | インデックス範囲スキャン | 中程度 |
| index | インデックス全スキャン | 遅い |
| ALL | 全表スキャン | 最悪 |
各 type の詳細
const(最速)
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const(PRIMARY KEY で1行)
PRIMARY KEY / UNIQUE KEY で1行確定。定数として扱われる。
eq_ref(JOIN で最速)
EXPLAIN SELECT * FROM posts p JOIN users u ON u.id = p.user_id;
-- p: type: ALL(全posts)
-- u: type: eq_ref(PRIMARY で1行)
JOIN 対象で PRIMARY / UNIQUE により1行に絞られる。
ref(高速)
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com';
-- type: ref(idx_email、複数該当あり得るが実際は1件)
非UNIQUE のインデックスヒット。
range(範囲スキャン)
EXPLAIN SELECT * FROM users WHERE id BETWEEN 100 AND 200;
-- type: range
EXPLAIN SELECT * FROM users WHERE created_at > '2024-01-01';
-- type: range(idx_created_at)
BETWEEN, <, >, IN(...), LIKE 'prefix%' 等。
index(インデックス全スキャン)
EXPLAIN SELECT id FROM users;
-- type: index(PRIMARY 全スキャン)
全表スキャンよりマシだが、大きいテーブルでは遅い。
ALL(全表スキャン)
EXPLAIN SELECT * FROM users WHERE age = 30;
-- age にインデックスがない → type: ALL
インデックス無視、テーブル全体を読む。大きいテーブルでは致命的。
type ALL は必ずしも悪くない
- 小さいテーブル(数十行)ではむしろ効率的
- 全体の何割も読むクエリでは ALL が最適
- 判断は
rowsと組み合わせて
【カラム⑥】possible_keys:候補インデックス
MySQL オプティマイザが使うことを検討したインデックスのリスト。
possible_keys: idx_email, idx_status
- NULL: 使える候補なし
- 複数リスト: どれかを検討したが、実際に使われるのは
keyの1つ
possible_keys があるのに key: NULL → オプティマイザが「使わない方が速い」と判断(統計情報の問題かも)。
【カラム⑦】key:実際に使ったインデックス
key: idx_email
- NULL: インデックス未使用(全表スキャン)
- PRIMARY: PRIMARY KEY
- インデックス名: 使ったインデックス
key: NULL の理由
- インデックスがそもそもない
- インデックス設計に問題
- データが偏っていて全表スキャンの方が速い
LIKE '%foo%'(前方一致でない)- 関数を適用(
WHERE UPPER(name) = 'ALICE') - 型不一致(暗黙変換)
インデックスを強制
-- 使うインデックスを指定
SELECT * FROM users USE INDEX (idx_email) WHERE email = '...';
-- 特定のインデックスを避ける
SELECT * FROM users IGNORE INDEX (idx_status) WHERE ...;
-- 強制
SELECT * FROM users FORCE INDEX (idx_email) WHERE ...;
⚠️ 通常はオプティマイザに任せる。強制はほぼ最終手段。
【カラム⑧】key_len:使用したインデックスのバイト数
複合インデックスで、どこまで使われたかを判断する重要指標。
計算方法
INT: 4 bytesBIGINT: 8 bytesVARCHAR(N) utf8mb4: N × 4 + 2 (可変長)NULLABLE: +1 byte
例
CREATE INDEX idx_user_status ON posts (user_id, status);
-- user_id INT (4 bytes), status VARCHAR(10) utf8mb4 NULLABLE (10*4+2+1 = 43 bytes)
-- インデックス総長: 47 bytes
EXPLAIN SELECT * FROM posts WHERE user_id = 1;
-- key_len: 4 → user_id だけ使用
EXPLAIN SELECT * FROM posts WHERE user_id = 1 AND status = 'draft';
-- key_len: 47 → 両方使用
期待する key_len より短い→ 複合インデックスの後半が使われていない可能性。
【カラム⑨】ref:比較対象
インデックスに対して、何と比較しているか。
ref: const -- 定数
ref: users.id -- テーブルのカラム
ref: NULL -- 定数と直接比較しない
例
EXPLAIN SELECT * FROM posts WHERE user_id = 5;
-- ref: const
EXPLAIN SELECT * FROM posts p JOIN users u ON u.id = p.user_id;
-- p の ref: myapp.u.id
【カラム⑩】rows:検査見積もり行数
MySQL オプティマイザの推定値。実際の値ではない。
rows: 1 -- 1行だけ検査
rows: 100 -- 100行検査
rows: 1000000 -- 100万行検査 ← 大問題
推定 vs 実際
EXPLAIN ANALYZE で実際の値と比較:
EXPLAIN ANALYZE SELECT ...;
-- rows=1000 loops=1 → 実際は1000行
-- 推定と大きく違うなら統計情報の更新推奨
統計情報の更新
ANALYZE TABLE users;
-- インデックスの統計情報を更新
推定精度が上がる。
【カラム⑪】filtered:フィルタ後の割合(%)
rows の中で、WHERE 条件を通過する割合の推定。
rows: 1000
filtered: 10.00
-- 1000 × 10% = 100行が結果として返る見込み
判断基準
- 100.00: すべて通過(絞れていない)
- 50.00: 半分が通過
- 1.00: ほぼ絞られている
filtered が低い(1% 等)+ rows が大きい → インデックスで絞れていない可能性。
【カラム⑫】Extra:追加情報(超重要)
EXPLAIN で type と並ぶ最重要列。多くの重要情報が含まれる。
良い Extra
| 値 | 意味 |
|---|---|
| Using index | カバリングインデックス(超高速) |
| Using where | WHERE で絞り込み(通常) |
| Using index condition | Index Condition Pushdown(8.0+ で高速化) |
悪い Extra(警告サイン)
| 値 | 意味 | 対処 |
|---|---|---|
| Using filesort | ファイルソートが発生 | ORDER BY にインデックス |
| Using temporary | 一時テーブル作成 | GROUP BY / DISTINCT 見直し |
| Using join buffer | JOIN でバッファ使用 | インデックス追加 |
| Using where; Using filesort | 両方 | 深刻 |
| Range checked for each record | JOIN での動的インデックス選択 | 統計情報更新 |
Using filesort とは
EXPLAIN SELECT * FROM users ORDER BY created_at;
-- Extra: Using filesort
created_at にインデックスがないため、メモリ or ファイルでソート。
対策:
CREATE INDEX idx_created_at ON users (created_at);
-- Extra から Using filesort が消える
Using temporary とは
EXPLAIN SELECT COUNT(*) FROM users GROUP BY status ORDER BY status;
-- Extra: Using temporary; Using filesort
一時テーブルを作成 → メモリ or ディスク上で処理。
対策:
- GROUP BY と ORDER BY を同じ列で
- インデックスの追加
- クエリの見直し
Using index(カバリングインデックス)
CREATE INDEX idx_email_status ON users (email, status);
EXPLAIN SELECT email, status FROM users WHERE email = 'alice@example.com';
-- Extra: Using index
必要な列がすべてインデックス内にある → テーブル本体を読まなくて良い、超高速。
type と Extra の重要な組み合わせ
理想
type: const / eq_ref / ref / range
Extra: Using index(カバリング)or 空
妥協点
type: ref / range
Extra: Using where
要改善
type: index / ALL
Extra: Using filesort / Using temporary
最悪
type: ALL
Extra: Using where; Using join buffer; Using filesort; Using temporary
rows: 数百万
これは完全にインデックス設計を見直すべき。
EXPLAIN ANALYZE(MySQL 8.0.18+)
通常の EXPLAIN との違い
-- EXPLAIN: 実行計画を表示(実行しない)
EXPLAIN SELECT * FROM users WHERE ...;
-- EXPLAIN ANALYZE: 実際に実行して、推定と実測を比較
EXPLAIN ANALYZE SELECT * FROM users WHERE ...;
出力例
-> Index lookup on users using idx_email (email='alice@example.com')
(cost=0.35 rows=1) (actual time=0.031..0.032 rows=1 loops=1)
- cost: 推定コスト
- rows: 推定行数
- actual time: 実際の時間(ms)
- actual rows: 実際の行数
- loops: 実行回数
推定 vs 実測の比較
(cost=1000 rows=1000) -- 推定
(actual time=... rows=50000 loops=1) -- 実測
-- 推定1000行、実測50000行 → 統計情報が古い
-- ANALYZE TABLE で更新推奨
推定と実測が大きく違う = オプティマイザの誤判断の可能性。
注意点
⚠️ EXPLAIN ANALYZE は実際にクエリを実行する:
- 大量データでは時間がかかる
- 本番環境では慎重に
SELECTは問題ないが、DELETE/UPDATEは実際にデータが変わる(実験環境で)
EXPLAIN FORMAT オプション
FORMAT=TRADITIONAL(デフォルト)
上記までの表形式。
FORMAT=JSON
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'alice@example.com';
出力例:
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.20"
},
"table": {
"table_name": "users",
"access_type": "ref",
"key": "idx_email",
"used_key_parts": ["email"],
"key_length": "767",
"ref": ["const"],
"rows_examined_per_scan": 1,
"rows_produced_per_join": 1,
"filtered": "100.00",
"cost_info": {
"read_cost": "0.25",
"eval_cost": "0.10",
"prefix_cost": "0.35",
"data_read_per_join": "1K"
},
"used_columns": ["id", "email", "name"]
}
}
}
より詳細な情報。プログラマティックな解析に。
FORMAT=TREE(8.0.16+)
EXPLAIN FORMAT=TREE SELECT * FROM users u JOIN posts p ON p.user_id = u.id WHERE u.email = 'alice@example.com';
出力例:
-> Nested loop inner join (cost=1.20 rows=1)
-> Index lookup on u using idx_email (email='alice@example.com') (cost=0.35 rows=1)
-> Index lookup on p using idx_user_id (user_id=u.id) (cost=0.85 rows=1)
JOIN の入れ子構造が視覚化され、非常に読みやすい。
インデックス設計の実践
基本原則
- WHERE で使う列 → インデックス候補
- JOIN の外部キー → 必須
- ORDER BY / GROUP BY → 効果的
- SELECT する列 → カバリング考慮
複合インデックスの設計
-- WHERE user_id = ? AND status = ? ORDER BY created_at
CREATE INDEX idx_composite ON posts (user_id, status, created_at);
左端一致の原則:
- ✅
WHERE user_id = ?(1列目) - ✅
WHERE user_id = ? AND status = ?(1,2列目) - ✅
WHERE user_id = ? AND status = ? ORDER BY created_at - ❌
WHERE status = ?(2列目だけ)→ 使えない
カバリングインデックスを狙う
-- 必要な列を全てインデックスに含める
CREATE INDEX idx_covering ON users (email, name, status);
EXPLAIN SELECT name, status FROM users WHERE email = ?;
-- Extra: Using index
テーブル本体を読まないので超高速。
インデックス多すぎ問題
- INSERT / UPDATE / DELETE が遅くなる
- ストレージ増加
- オプティマイザの判断が難しくなる
必要最小限に。
統計情報の更新
ANALYZE TABLE users;
-- インデックスの統計情報を更新
-- オプティマイザの判断精度向上
定期的に実行(大量更新後、性能が悪化した時)。
Rails ActiveRecord での EXPLAIN
基本
User.where(email: 'alice@example.com').explain
出力:
EXPLAIN for: SELECT `users`.* FROM `users` WHERE `users`.`email` = 'alice@example.com'
+----+-------------+-------+------+---------------+
| id | select_type | table | type | ...
+----+-------------+-------+------+---------------+
| 1 | SIMPLE | users | ref | ...
+----+-------------+-------+------+---------------+
includes / joins との組み合わせ
User.includes(:posts).where(active: true).explain
複数の EXPLAIN 結果が表示される。
JSON フォーマット
User.where(email: 'alice@example.com').explain(:json)
# Ruby 2.7+ / Rails 7.1+
実行時間も測る
# development.log で
config.active_record.query_log_tags_enabled = true
または:
ActiveRecord::Base.logger.level = 0 # DEBUG
# クエリ発行
User.where(email: '...').first
# → SQLとその実行時間がログに
詳細はfind vs find_by vs where 違いの記事、includes vs preload vs eager_load 違いの記事も参照。
bullet gem との併用
# Gemfile
gem 'bullet', group: :development
N+1 の自動検知 → EXPLAIN で確認 → includes で修正。
詳細はhas_many :through 使い方の記事も参照。
実践的なチューニング例
例1:全表スキャン → インデックス追加
Before:
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com';
+------+---------------+------+---------+
| type | possible_keys | key | rows |
+------+---------------+------+---------+
| ALL | NULL | NULL | 1000000 |
+------+---------------+------+---------+
100万行スキャン、致命的。
対処:
CREATE INDEX idx_email ON users (email);
After:
+------+---------------+-----------+------+
| type | possible_keys | key | rows |
+------+---------------+-----------+------+
| ref | idx_email | idx_email | 1 |
+------+---------------+-----------+------+
100万倍高速化。
例2:Using filesort → ORDER BY にインデックス
Before:
EXPLAIN SELECT * FROM posts WHERE user_id = 5 ORDER BY created_at;
+------+---------------+------+------+---------------------------+
| type | key | rows | Extra |
+------+---------------+------+------+---------------------------+
| ref | idx_user_id | 100 | Using where; Using filesort |
+------+---------------+------+------+---------------------------+
100行のソートが filesort に。
対処:
CREATE INDEX idx_user_created ON posts (user_id, created_at);
DROP INDEX idx_user_id ON posts;
After:
+------+-------------------+------+------+-------------+
| type | key | rows | Extra |
+------+-------------------+------+------+-------------+
| ref | idx_user_created | 100 | Using where |
+------+-------------------+------+------+-------------+
filesort 消滅。
例3:カバリングインデックス化
Before:
EXPLAIN SELECT email FROM users WHERE status = 'active';
+------+--------------+------+---------------+
| type | key | rows | Extra |
+------+--------------+------+---------------+
| ref | idx_status | 1000 | NULL |
+------+--------------+------+---------------+
インデックス使ってるが、テーブル本体も読んでいる。
対処:
CREATE INDEX idx_status_email ON users (status, email);
DROP INDEX idx_status ON users;
After:
+------+--------------------+------+-------------+
| type | key | rows | Extra |
+------+--------------------+------+-------------+
| ref | idx_status_email | 1000 | Using index |
+------+--------------------+------+-------------+
Using index = カバリングインデックス、テーブル本体不要。
例4:JOIN の最適化
Before:
EXPLAIN SELECT u.name, p.title
FROM users u JOIN posts p ON p.user_id = u.id
WHERE u.status = 'active';
+----+---+------+------+--------+---------------------+
| id | t | type | key | rows | Extra |
+----+---+------+------+--------+---------------------+
| 1 | u | ALL | NULL | 100000 | Using where |
| 1 | p | ALL | NULL | 500000 | Using join buffer |
+----+---+------+------+--------+---------------------+
対処:
CREATE INDEX idx_status ON users (status);
CREATE INDEX idx_user_id ON posts (user_id);
After:
+----+---+--------+---------------+------+----------+
| id | t | type | key | rows | Extra |
+----+---+--------+---------------+------+----------+
| 1 | u | ref | idx_status | 1000 | Where |
| 1 | p | ref | idx_user_id | 5 | NULL |
+----+---+--------+---------------+------+----------+
500億件 → 5000件 に激減。
スロークエリログとの併用
有効化
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
または my.cnf:
slow_query_log = 1
long_query_time = 1
slow_query_log_file = /var/log/mysql/slow.log
log_queries_not_using_indexes = 1
遅いクエリを EXPLAIN
# slow log を確認
sudo tail -f /var/log/mysql/slow.log
# 該当クエリを取り出して EXPLAIN
pt-query-digest(Percona Toolkit)
pt-query-digest /var/log/mysql/slow.log > report.txt
# 遅いクエリの統計・ランキング
詳細はLinux find オプション一覧の記事、Linux grep オプション一覧の記事も参照。
実行時のシステムリソース確認
EXPLAIN で「なぜ遅いか」は分かるが、実際の I/O は別で確認:
# I/O 確認
iostat -xm 1
# ネットワーク確認
ss -tn state established
# プロセス
top
詳細はiostat 見方 使い方の記事、ss コマンドの使い方の記事も参照。
トラブルシューティング
インデックス作ったのに使われない
原因1: 型不一致(暗黙変換)
CREATE INDEX idx_id ON users (id); -- INT
-- ❌ 文字列で検索 → インデックス無視
SELECT * FROM users WHERE id = '1';
-- ✅ 数値で検索
SELECT * FROM users WHERE id = 1;
原因2: 関数適用
-- ❌ 関数 → インデックス無視
SELECT * FROM users WHERE UPPER(email) = 'ALICE@EXAMPLE.COM';
-- ✅ 生の列で
SELECT * FROM users WHERE email = 'alice@example.com';
-- または関数インデックス(MySQL 8.0+)
CREATE INDEX idx_email_upper ON users ((UPPER(email)));
原因3: LIKE で先頭 %
-- ❌ 前方一致でない
SELECT * FROM users WHERE name LIKE '%alice%';
-- ✅ 前方一致
SELECT * FROM users WHERE name LIKE 'alice%';
原因4: 統計情報が古い
ANALYZE TABLE users;
原因5: データが偏っている
全体の80%が同じ値 → インデックス使うより全表スキャンの方が速い、と判断。
rows の推定が実際と大きく違う
ANALYZE TABLE users;
-- 統計情報を更新
または optimizer_use_condition_fanout_filter の設定。
EXPLAIN ANALYZE が終わらない
大量データで時間がかかる。LIMIT を追加して部分確認、またはテスト環境で。
FORCE INDEX が効かない
インデックス名が違う、または本当にオプティマイザが使えないケース:
SHOW INDEX FROM users;
-- 正しいインデックス名確認
よくある質問(FAQ)
Q1. EXPLAIN と EXPLAIN ANALYZE どっちを使う?
| 状況 | 推奨 |
|---|---|
| 実行前の見積もり | EXPLAIN |
| 実際のコスト測定 | EXPLAIN ANALYZE |
| 本番大量データ | EXPLAIN のみ(ANALYZE は避ける) |
| 開発環境で検証 | ANALYZE |
Q2. type: ALL でも問題ない場合
- 小テーブル(数十行)
- 全体の 30% 以上を取得
- 統計情報が正確な結果としての判断
Q3. インデックスの上限
MySQL では 1テーブルあたり 64個まで(InnoDB)。ただし現実的には5〜10個以下推奨。
Q4. 複合インデックスの列順
選択性の高い(ユニーク性の高い)列を左にが原則、ただし WHERE の使用パターンに合わせる:
-- WHERE user_id AND status AND date_range
CREATE INDEX idx (user_id, status, created_at);
-- user_id が最も絞り込みが強いのが理想
Q5. UNIQUE INDEX vs INDEX
- UNIQUE: 重複禁止 + インデックス
- INDEX: インデックスのみ
UNIQUE の方が最適化しやすい(type: const, eq_ref になりやすい)。
Q6. PostgreSQL の EXPLAIN との違い
PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
より詳細なコスト表示。MySQL は 8.0 で追いついてきた。
Q7. Rails での EXPLAIN 実行
User.where(...).explain
# コンソールで確認
Q8. EXPLAIN の結果をログに残す
Rails.logger.info User.where(...).explain
または SQL クエリログの機能で。
Q9. 本番でクエリ実行前に EXPLAIN で確認
-- 実行前
EXPLAIN UPDATE users SET status = 'inactive' WHERE last_login < '2020-01-01';
-- rows を確認して、影響行数の想定と一致するか
大規模 UPDATE / DELETE では必須。
Q10. 開発と本番で EXPLAIN の結果が違う
- データ量が違う: オプティマイザの判断が変わる
- 統計情報の差:
ANALYZE TABLEで近づける - インデックス設定の差: schema.rb 等で管理
Q11. Using where; Using index の意味
- Using where: WHERE で絞り込み
- Using index: カバリング
両方: WHERE 条件がインデックスで完結し、テーブル本体不要。最高。
Q12. Handler 統計との組み合わせ
FLUSH STATUS;
SELECT * FROM users WHERE ...;
SHOW STATUS LIKE 'Handler%';
-- Handler_read_key: インデックスで読んだ回数
-- Handler_read_next: シーケンシャル読み込み
より詳細な実測情報。
参考リンク・関連資料
公式
- MySQL EXPLAIN Output Format(公式) – MySQL 8.4 EXPLAIN
- Optimizing Queries with EXPLAIN – クエリ最適化
- Optimization Overview – 最適化全般
関連ツール
- pt-query-digest – スロークエリ分析
- Bullet gem – N+1 検知
関連記事(本サイト)
- find vs find_by vs where 違い – Rails クエリ基礎
- includes vs preload vs eager_load 違い – N+1 対策
- has_many :through 使い方 – 関連付け
- save vs save! 違い – 保存
- MySQL Error 1045: Access denied – 認証エラー
- MySQL Error 1146: Table doesn’t exist – テーブル問題
- MySQL Error 28: No space left on device – ディスク
- rails db:migrate 使い方 – マイグレーション
- rails console 使い方 – コンソール
- iostat 見方 使い方 – I/O 監視
- ss コマンドの使い方 – 接続確認
- systemctl vs service – MySQL サービス管理
- Rails 8 アップグレードガイド – Rails 全般
- Solid Queue 使い方 – バックグラウンド
- Solid Cache 使い方 – キャッシュ
まとめ
MySQL EXPLAIN の読み方、要点を再整理します。
見るべき7項目
type → const / eq_ref / ref / range が理想
key → NULL でなく、期待するインデックス
key_len → 想定通りの長さ(複合の使用状況)
rows → 少ないほど良い
filtered → 高いほど絞り込めている
Extra → Using index が最高、filesort / temporary は要改善
id → 実行順序(同じidは同じSELECT)
type の目指すべき順
system (1行)
> const (PK/UK ヒット)
> eq_ref (JOIN で1行)
> ref (INDEX ヒット)
> range (範囲)
> index (INDEX 全スキャン)
> ALL (全表スキャン) ← 避けたい
Extra の重要な値
✅ Using index (カバリング、最高)
✅ Using where (WHERE で絞り込み、通常)
✅ Using index condition (ICP、高速化)
❌ Using filesort (ソートで遅い)
❌ Using temporary (一時テーブル)
❌ Using join buffer (JOIN 非効率)
❌ Range checked ... (オプティマイザ迷い)
基本の使い方
-- 実行計画のみ
EXPLAIN SELECT ...;
-- 詳細(JSON)
EXPLAIN FORMAT=JSON SELECT ...;
-- 実行と実測(8.0.18+)
EXPLAIN ANALYZE SELECT ...;
-- 縦表示
EXPLAIN SELECT ...\G
インデックス設計の指針
- WHERE 条件の列 → インデックス
- JOIN の外部キー → 必須
- ORDER BY の列 → インデックスで filesort 回避
- カバリングインデックス → 必要な列を全て含める
- 左端一致の原則 → 複合インデックスの列順
- 統計情報の更新 →
ANALYZE TABLE
Rails との連携
User.where(email: '...').explain
User.includes(:posts).where(active: true).explain
チューニングフロー
1. スロークエリログで遅いクエリ特定
2. EXPLAIN で実行計画確認
3. type / key / rows / Extra チェック
4. インデックス追加 / クエリ書き換え
5. 再 EXPLAIN で改善確認
6. EXPLAIN ANALYZE で実測
これらの知識は、Web アプリのパフォーマンス改善・データベース運用・Rails 開発・DBA 業務など、あらゆる場面で活用できます。本記事をブックマークしておけば、遅いクエリに出会った時に確実に原因を特定・改善できるようになります。
本記事は2026年6月時点の情報をもとに、MySQL 8.0/8.4、MariaDB 10.11+、Rails 7.x/8.x での動作確認・公式ドキュメントに基づき作成しています。MySQL のバージョンによって出力形式や利用可能な機能が異なる場合があるため、最新の情報はMySQL公式ドキュメントもあわせてご確認ください。
-
前の記事
【完全ガイド】Kamal 2 で Rails 8 をデプロイする方法|設定・SSL・アクセサリー・トラブル対応を徹底解説 2026.07.06
-
次の記事
【完全ガイド】git pull / merge conflict の解決方法|3-way merge の仕組みからoursとtheirsまで徹底解説 2026.07.07
コメントを書く