データベース

MySQL EXPLAINの読み方|複合インデックスの列順を実測で比べる

MySQL 8.4の10万行で、金額を先にする索引と日付を先にする索引を比較。EXPLAINのtype・key・rows・Extraと実測を読み、絞り込みとORDER BY LIMITのどちらを優先するか判断します。

この記事の目次
  1. 同じSELECTに、二つのインデックスを試す
  2. 検証用データを作る
  3. 追加前・A・Bの計画を順に記録する
  4. Aでは、金額で絞れた後にも並べ替えが必要
  5. Bでは、日付順に読みながら金額を調べる
  6. 条件に合う注文が少ないと、Bは多く読む
  7. EXPLAINの列を、今回の判断に結びつける
  8. TREEは下の走査から、Filter、Limitへ追う
  9. 列順を決める前に、他の使い方と追加コストも見る

EXPLAINにUsing filesortが残っている。インデックスの列順を変えると消えたけれど、それで速くなったと言えるでしょうか。

複合インデックスは、条件で読む範囲を狭める案と、必要な順に読んで早く止める案を比べて選びます。EXPLAINのkeyだけで決めず、同じ結果が返ることを確かめ、EXPLAIN ANALYZEで実際に通った行数を見ます。

この記事ではMySQL 8.4.11の隔離環境で、10万行の注文データに二つの索引を別々に作りました。遅いSELECTが特定できた後の比較が対象です。対象SQLを探す段階なら、先にスロークエリログの取り方を確認してください。

同じSELECTに、二つのインデックスを試す

調べるのは「テナント3・ステータス1・金額9,000以上の注文を、新しい順に20件」です。金額で絞ることと日付順に読むことを、どちらも一つの索引で済ませたい場面です。

SELECT id, created_at, amount
FROM orders_demo
WHERE tenant_id = 3 AND status = 1 AND amount >= 9000
ORDER BY created_at DESC
LIMIT 20;
候補 列順 狙い
A:ix_filter tenant_id, status, amount, created_at 金額の範囲まで絞ってから並べる
B:ix_order tenant_id, status, created_at, amount 日付の新しい順に読み、金額の条件を満たす20件で止める

どちらにも使いどころがあります。「等価条件、範囲条件、ORDER BYの順」と覚えて終わると、Bの選択肢を見落とします。

検証用データを作る

以下は新しく用意した検証用MySQLで実行してください。既存の業務DBへ流す手順ではありません。explain_labが既にあれば、CREATE DATABASEで止まります。金額と作成日時が単純に連動しない合成データを10万行作ります。

-- 新しく作った検証用MySQLだけで実行する。既存DBがあればCREATEで停止する。
CREATE DATABASE explain_lab;
USE explain_lab;
CREATE TABLE orders_demo (
  id BIGINT UNSIGNED NOT NULL,
  tenant_id INT NOT NULL,
  status TINYINT NOT NULL,
  amount INT NOT NULL,
  created_at DATETIME NOT NULL,
  memo VARCHAR(180) NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

INSERT INTO orders_demo
WITH digits AS (
  SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3
  UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
  UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9
), numbers AS (
  SELECT a.d + b.d * 10 + c.d * 100 + d.d * 1000 + e.d * 10000 AS n
  FROM digits a CROSS JOIN digits b CROSS JOIN digits c
  CROSS JOIN digits d CROSS JOIN digits e
)
SELECT n + 1, MOD(n, 10) + 1, MOD(FLOOR(n / 10), 4),
       MOD(FLOOR(n / 10) * 7919 + 13, 10000),
       DATE_ADD('2025-01-01 00:00:00', INTERVAL FLOOR(n / 10) HOUR),
       REPEAT('x', 160)
FROM numbers;
ANALYZE TABLE orders_demo;
SELECT COUNT(*) AS total_rows FROM orders_demo;

同じmysql接続で続ければ、USEで選んだDBが維持されます。別の接続から最初のSELECTだけを実行するときは、先にUSE explain_lab;を実行してください。今回の条件内ではcreated_atが一意なので、同じ日時の行の順序に左右されず結果を比較できます。

スポンサーリンク

追加前・A・Bの計画を順に記録する

まず追加前に、次の二つを実行します。通常のEXPLAINは計画を示し、EXPLAIN ANALYZEはSELECTそのものを実行して実測を返します。重いSQLを本番で試す前に、実行範囲や負荷を判断してください。

USE explain_lab;
EXPLAIN FORMAT=TRADITIONAL
SELECT id, created_at, amount
FROM orders_demo
WHERE tenant_id = 3 AND status = 1 AND amount >= 9000
ORDER BY created_at DESC
LIMIT 20;

-- ANALYZEは対象SELECTを実行する。今回の隔離データだけで比較する。
EXPLAIN ANALYZE
SELECT id, created_at, amount
FROM orders_demo
WHERE tenant_id = 3 AND status = 1 AND amount >= 9000
ORDER BY created_at DESC
LIMIT 20;

次にAを作り、同じEXPLAINとSELECTを再実行します。

USE explain_lab;
CREATE INDEX ix_filter
  ON orders_demo (tenant_id, status, amount, created_at);
ANALYZE TABLE orders_demo;

Aの記録を保存したらAを削除し、Bだけを作って再実行します。二つを同時に置くと、比較したい方が選ばれるとは限りません。ここでは索引を強制するヒントも使っていません。

USE explain_lab;
-- AとBを同時に置かず、同じデータで別々に比較する検証用の手順。
DROP INDEX ix_filter ON orders_demo;
CREATE INDEX ix_order
  ON orders_demo (tenant_id, status, created_at, amount);
ANALYZE TABLE orders_demo;
amount >= 9000 追加前 A:金額を先にする B:日付を先にする
type / key ALL / NULL range / ix_filter ref / ix_order
key_len NULL 9 5
EXPLAINのrows(推定) 104,315 250 2,500
末端の走査処理が返した行数(実測) 100,000 250 190
SELECTの結果 20行 同じ20行 同じ20行
Using filesort あり あり なし

この実測では、Bは190行を調べたところで金額条件に合う20行がそろい、LIMITで止まりました。推定rowsの2,500を、そのまま実際に読んだ行数として扱わないのがポイントです。

Aでは、金額で絞れた後にも並べ替えが必要

Aの先頭二列は等価条件で固定され、amountの範囲へ進めます。ただし、その中では金額ごとにcreated_atが並んでいます。金額が異なる注文全体を新しい順に返すには、別途並べ替える必要があります。

今回のAは250行まで狭めた後に並べ替えました。Using filesortは、この追加の並べ替えを表します。名前だけで「ディスクに書き出した」「索引が役に立たなかった」とは判断できません。

Bでは、日付順に読みながら金額を調べる

Bもtenant_idとstatusを固定します。その範囲をcreated_atの逆順に読めるので、ExtraにはBackward index scanが現れました。amountは各行を読みながら条件に合うか確認します。

Bのkey_lenは5で、今回のNOT NULLなINTとTINYINTの計4+1バイトに対応します。しかしcreated_atが無意味なわけではありません。検索開始位置を絞る部分と、並び順に使う部分は違います。key_lenが短い=残りの列は一切使われないとは読めません。

条件に合う注文が少ないと、Bは多く読む

次に、同じSELECTの金額条件だけをamount >= 9995へ変えます。AとBをそれぞれ単独で作った状態で比べると、結果はどちらも同じ1行でした。

金額の下限 Aの走査行数 Bの走査行数 返した行数
9,000 250 190 20
9,995 1 2,500 1

Aは金額の範囲を直接狭めるため、対象の1行だけを読みました。Bは日付順に調べても20件そろわず、tenant_idとstatusが一致する2,500行を最後まで読みます。並べ替えがないことより、条件に合う行へどれだけ少ない読み取りで届くかが効く場面です。

記録したルート処理の終了時刻は、9,000の条件でAが約0.062ms、Bが約0.067msでした。9,995ではAが約0.016ms、Bが約0.341msです。単発の小さな合成データの測定なので、速度差の保証には使えません。読み取り量と、その差が生まれる理由を比較材料にしてください。

本番に近い検証では、よく使う条件だけでなく「ほとんど該当しない値」「件数の多いテナント」「LIMITの変更」も候補に入れます。一つの条件で速くなっても、同じ画面の別条件で負担が増えることがあります。

EXPLAINの列を、今回の判断に結びつける

列・表示 何を見るか 今回の読み方
type 行へのアクセス方法 ALLは全表走査、rangeは範囲、refは同じキー値の候補を読む。名前の順位だけで採用しない
possible_keys / key 探索の候補 / 実際に選んだ索引 keyが付いた後も行数を見る。候補一覧にない索引がカバリング目的で選ばれる場合もある
key_len 探索に用いるキー部分の長さ Aは9、Bは5。型やNULL許容も関係するので列数そのものではない
rows / filtered 調べる行数の推定 / 条件を通る割合の推定 残る行数の目安はrows × filtered ÷ 100。filteredは捨てる割合ではない
Using index 必要な値を索引で賄える 単に「索引を使った」という意味ではない。今回はAもBも表示された
Using filesort 索引の順序だけでは済まない並べ替え 有無と、並べ替えへ渡る行数を合わせて見る

各列の詳細はMySQL公式のEXPLAIN出力説明でも確認できます。今回のInnoDBではセカンダリインデックスに主キー値も含まれるため、SELECTのidを賄えます。InnoDBの索引構造を踏まえると、列定義にidを重ねて足す必要がない理由が分かります。

TREEは下の走査から、Filter、Limitへ追う

Bで金額9,995を指定した記録から、実測部分を抜き出すと次のようになります。

Limit: 20 row(s)                  actual rows=1    loops=1
  Filter: amount >= 9995          actual rows=1    loops=1
    Covering index lookup (逆順) actual rows=2500 loops=1

下から2,500行が渡され、金額の条件を通ったのは1行です。LIMITの20は上限なので、結果が1行でも矛盾しません。TREEのrowsはその処理が返した行数で、上のLimitのrowsだけを見ても走査量は分かりません。

actual time=a..bは最初の行までと処理を終えるまでの時間をミリ秒で示します。子の処理を含むので、親子の時間を足し合わせないでください。loopsが複数なら、時間は1ループあたりの平均です。詳しくはEXPLAIN ANALYZEの公式説明を参照してください。今回の記録はすべてloops=1です。

列順を決める前に、他の使い方と追加コストも見る

複合インデックスは、通常、左端からの組み合わせを手掛かりに探索します。たとえば(tenant_id, status, amount)なら、tenant_idだけ、tenant_idとstatus、さらにamountという使い方を考えられます。WHERE句に条件を書く順番と、インデックスの列順は別物です。

先頭列の条件がないSQLは、同じように狭い範囲を探索できると決めつけず、実際の計画を確認します。例外的なアクセス方法もあるため「先頭条件がなければ索引は絶対使われない」とまで覚える必要はありません。複合インデックスの左端からの利用と、ORDER BYに索引を使う条件を分けて考えると整理できます。

範囲条件より後ろの列も、今回のAのcreated_atのように、取得する値を索引内で賄うためには役立ちます。探索範囲を狭められないことと、その列に役割がないことは同じではありません。

採用前には、次の四点を記録しておきます。

  • 同じ条件で、返る値と必要な順序が変わっていないか。
  • 代表的な条件と、該当件数が少ない条件の両方で、実測の走査行数がどう変わったか。
  • 既存索引と先頭列が重複していないか。他のSELECTが必要とする列順ではないか。
  • 読み取りの改善に対し、INSERT・UPDATE時の索引更新と保存容量の負担が見合うか。

小さい表や大部分を返すSQLなら、全表走査が選ばれること自体を不具合と決める必要はありません。索引を増やすことが目的にならないようにします。本番へ追加するDDLと実行時の注意点は、MySQLのDDLと安全なスキーマ変更で確認してください。

手元のSQLで迷ったら、まず「範囲を狭める案」と「順序を使って早く止める案」を一つずつ書き出してみてください。同じ結果を返す二案の、走査行数と条件ごとの変化を比べるところから選べます。

スポンサーリンク