EXPLAIN の読み方と実行計画の改善 ハンズオン

概要

「一覧画面が重い」「夜間バッチが終わらない」。この種の相談の多くは、特定の SQL が想定外のアクセス経路(実行計画)で動いていることが原因です。インデックスを追加すれば直ることもありますが、まず必要なのは「DB が今どう動いているか」を正確に読むことです。

本記事では EXPLAIN の出力の読み方と、そこから実行計画を改善する手順を、再現可能な形でまとめます。対象は MySQL 8.0 と PostgreSQL 14 以降。公開されている一般的な挙動のみを扱います。

検証環境

  • MySQL 8.0 / PostgreSQL 14 以降
  • 本番相当のデータ量(最低でも数万〜数十万行)で計測すること。少量データだと DB がフルスキャンを選び、計画の善し悪しが見えません
CREATE TABLE orders (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id     BIGINT      NOT NULL,
  status      VARCHAR(20) NOT NULL,
  total_price INT         NOT NULL,
  created_at  DATETIME    NOT NULL
);
-- 擬似データを大量投入してから計測する

発生した問題

よくあるのは「特定ユーザーの最新注文 20 件」を取るクエリです。

SELECT *
FROM orders
WHERE user_id = 1001
  AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

データが増えると、これが数百ミリ秒〜数秒かかるようになります。

原因 — EXPLAIN で読む

MySQL の主要カラム

EXPLAIN
SELECT * FROM orders
WHERE user_id = 1001 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;

まず見るべきは次の 4 つです。

カラム 意味 危険サイン
type アクセス方式 ALL(フルスキャン)
key 使われたインデックス NULL
rows 走査見込み行数 返す件数より桁違いに大きい
Extra 補足情報 Using filesort / Using temporary

悪い出力の例:

type: ALL
key:  NULL
rows: 480000
Extra: Using where; Using filesort

type: ALL と key: NULL はインデックス未使用の全走査、Using filesort はソート処理の発生を意味します。欲しいのは 20 件なのに 48 万行を読んでいます。

MySQL 8.0.18 以降は EXPLAIN ANALYZE で実測値が取れます。

EXPLAIN ANALYZE
SELECT * FROM orders
WHERE user_id = 1001 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;
-> Limit: 20 row(s)  (actual time=312..312 rows=20 loops=1)
    -> Sort: orders.created_at DESC  (actual time=312..312 ...)
        -> Filter: ...  (actual time=0.1..290 rows=1800 loops=1)
            -> Table scan on orders  (rows=480000)

PostgreSQL の読み方

PostgreSQL は最初から EXPLAIN (ANALYZE, BUFFERS) が定石です。

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE user_id = 1001 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;
Limit  (actual time=305.1..305.2 rows=20 loops=1)
  ->  Sort  (actual time=305.1..305.1 rows=20 loops=1)
        Sort Method: top-N heapsort  Memory: 30kB
        ->  Seq Scan on orders
              (actual time=0.2..280.4 rows=1800 loops=1)
              Filter: ((user_id = 1001) AND (status = 'paid'))
              Rows Removed by Filter: 478200
Execution Time: 305.4 ms

読み方のコツ:

  • ノードは 内側(下)から外側(上) に実行される。一番内側の重いノードが律速
  • Rows Removed by Filter が大きい = ムダに読んでいる
  • 見積り rows と actual rows が乖離していたら 統計情報が古い可能性 → ANALYZE で更新

対応方法

1. 複合インデックスを「等値 → ソート」順で張る

条件は「user_id, status で等値絞り込み → created_at でソート」です。複合インデックスの列順は 等値条件を先に、ソート/範囲を後に が原則です。

CREATE INDEX idx_orders_user_status_created
  ON orders (user_id, status, created_at);

この順序なら (user_id, status) で範囲を特定でき、その範囲内はすでに created_at 順に並ぶため、ソート自体を省略できます。

2. 改善後を EXPLAIN で確認

MySQL:

type: range
key:  idx_orders_user_status_created
rows: 1800
Extra: Using index condition

PostgreSQL:

Limit  (actual time=0.05..0.12 rows=20 loops=1)
  ->  Index Scan using idx_orders_user_status_created on orders
        Index Cond: ((user_id = 1001) AND (status = 'paid'))
Execution Time: 0.2 ms

Seq Scan → Index Scan、Sort ノード消滅。305ms が 0.2ms になりました。

実装例 — インデックスが効かなくなる典型パターン

-- ❌ 列に関数 → 効かない
WHERE DATE(created_at) = '2026-10-17'
-- ⭕ 範囲条件に
WHERE created_at >= '2026-10-17 00:00:00'
  AND created_at <  '2026-10-18 00:00:00'
-- ❌ 中間一致 LIKE → 効かない
WHERE name LIKE '%田中%'
-- ⭕ 前方一致
WHERE name LIKE '田中%'
-- ❌ 暗黙の型変換(VARCHAR カラムに数値)
WHERE phone = 09012345678
-- ⭕ 型を合わせる
WHERE phone = '09012345678'

確認方法

  1. EXPLAIN ANALYZE(MySQL 8.0.18+)/ EXPLAIN (ANALYZE, BUFFERS)(PostgreSQL)で Execution Time を改善前後で比較
  2. アクセス方式(type / Index Scan)が変わったか確認
  3. Using filesort / Sort ノードが消えたか確認
  4. 本番相当のデータ量で確認

注意点

  • インデックスは書き込みコストとストレージを消費する。使われないものは削除候補
  • 見積りがおかしいときは統計更新(MySQL ANALYZE TABLE / PostgreSQL ANALYZE)を先に
  • EXPLAIN ANALYZE は 実際にクエリを実行する。UPDATE/DELETE では本当に更新が走るため本番では要注意(トランザクションで囲みロールバックする等)
  • 複合インデックスの列順は「等値 → 範囲/ソート」が基本

まとめ

  • 見積りではなく EXPLAIN ANALYZE の実測を見る
  • MySQL は type/key/rows/Extra、PostgreSQL は Seq Scan / Rows Removed / 見積りとのズレ に注目
  • 第一手は多くの場合「等値 → ソート」順の複合インデックス。filesort/Sort が消えれば成功
  • 必ず本番相当のデータ量で、改善前後の数値を比較して裏を取る

EXPLAIN は「遅い」を「どこがどう遅い」に翻訳する道具です。読めるようになると、当て推量のインデックス追加が、根拠のあるチューニングに変わります。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。