【完全ガイド】MySQL EXPLAIN の見方|type/key/rows/Extraの読み方とインデックス最適化を徹底解説

【完全ガイド】MySQL EXPLAIN の見方|type/key/rows/Extraの読み方とインデックス最適化を徹底解説

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 は「よく分からないおまじない」で終わってしまいます。

現場では:

  • typeALL になってる… これって全表スキャン?
  • 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 クエリ最適化のスキルが飛躍的に向上します。


目次

結論: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
SUBQUERYSELECT 内のサブクエリ
DERIVEDFROM 内のサブクエリ(派生テーブル)
UNIONUNION の2番目以降
UNION RESULTUNION の結果セット
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意味効率
system1行しかないテーブル最速
constPRIMARY / UNIQUE でヒット、1行確定最速
eq_refJOIN で PRIMARY / UNIQUE 使用、1行非常に高速
ref非UNIQUEインデックス使用、複数行の可能性高速
fulltextFULLTEXT インデックス
ref_or_nullref + NULL 対応高速
index_merge複数インデックスをマージ中程度
unique_subqueryUNIQUE サブクエリ高速
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 の理由

  1. インデックスがそもそもない
  2. インデックス設計に問題
  3. データが偏っていて全表スキャンの方が速い
  4. LIKE '%foo%'(前方一致でない)
  5. 関数を適用(WHERE UPPER(name) = 'ALICE'
  6. 型不一致(暗黙変換)

インデックスを強制

-- 使うインデックスを指定
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 bytes
  • BIGINT: 8 bytes
  • VARCHAR(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 whereWHERE で絞り込み(通常)
Using index conditionIndex Condition Pushdown(8.0+ で高速化)

悪い Extra(警告サイン)

意味対処
Using filesortファイルソートが発生ORDER BY にインデックス
Using temporary一時テーブル作成GROUP BY / DISTINCT 見直し
Using join bufferJOIN でバッファ使用インデックス追加
Using where; Using filesort両方深刻
Range checked for each recordJOIN での動的インデックス選択統計情報更新

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 の入れ子構造が視覚化され、非常に読みやすい。


インデックス設計の実践

基本原則

  1. WHERE で使う列 → インデックス候補
  2. JOIN の外部キー → 必須
  3. ORDER BY / GROUP BY → 効果的
  4. 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 の読み方、要点を再整理します。

見るべき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公式ドキュメントもあわせてご確認ください。