インデックス — 読む行を減らすための索引
B-tree が何をしているかと、複合インデックスの列順がなぜ効くのか。張れば速くなるものではない。
先に読んでおくとよい記事
この記事の進み方
What — インデックスがなぜ速いのか
インデックスの本体は B-tree(正確には B+tree)という木構造です。値を辿って目的の行に到達することで、全件を読まずに済ませます。
- 1① ルートページ1〜1000 / 1001〜2000 / … のどこか軽い
- 2② 中間ページ1〜100 / 101〜200 / … と絞る軽い
- 3③ リーフページ実際の値と、行の位置軽い
- 4④ テーブルから行を取得求めた位置でテーブルを読む中
インデックスがない場合は、100万行を1行ずつ確認する(フルスキャン)。1000倍以上の差がつくのは、読むページ数がそれだけ違うから。
100万行のテーブルでも、木の深さは3〜4段程度。段数が対数で増えるため、行数が10倍になっても読むページはほとんど増えない。これが「速い」の正体。
複合インデックスは「左から順に」しか使えない
最も実務で効くのがこの性質です。(a, b, c) という複合インデックスは、電話帳の並び順と同じです。
左端から連続して条件が指定されている場合。
WHERE user_id = 1 -- ✅ 左端のみ
WHERE user_id = 1 AND status = 'paid' -- ✅ 左から2つ
WHERE user_id = 1 AND status = 'paid'
ORDER BY created_at DESC -- ✅ 3つ目でソートも解決
-- ソートまでインデックスで済むと、
-- ファイルソート(メモリ/ディスク上の並べ替え)が消える- 絞り込み
- インデックスで完結
- ソート
- 並び順を再利用できる
- 読む行数
- 必要な分だけ
- 列の順序
- —
- 設計の指針
- —
- –ORDER BY まで含めて設計できると、LIMIT が効いて上位20件だけ読めばよくなる。
- –ソートがインデックスで解決するかは、実行計画で filesort の有無を見れば分かる。
電話帳が「姓 → 名」で並んでいるのと同じ。姓が分かれば絞れるが、名だけ分かっても探せない。左から連続して使えるところまでしか効かない。
- 選択率(カーディナリティ)
- その列が持つ値の種類の多さ。種類が多いほど絞り込みの効果が高い。性別(2種類)より、メールアドレス(全件ユニーク)のほうが効く。
- カバリングインデックス
- 必要な列がすべてインデックスに含まれていて、テーブル本体を読まずに済む状態。非常に速い。
- フルスキャン
- インデックスを使わず全行を読むこと。件数が少ないテーブルなら、これが最速なこともある。
- filesort
- インデックスの並び順を使えず、別途ソートすること。件数が多いとディスクを使い、極端に遅くなる。
- クラスタ化インデックス
- 主キーの順にデータ本体が並んでいる構造(InnoDB の既定)。主キー検索が最速になる一方、ランダムな主キーは挿入が遅くなる。
(status, created_at) の複合インデックスがあります。WHERE created_at > ? だけの検索で使えますか?
Why — なぜ全部の列に張ってはいけないのか
インデックスは別の表です。テーブルとは別に、値が並び替えられたデータ構造がディスクに存在します。
そのため、次のコストが発生します。
書き込みコスト。 1行 INSERT するたびに、全インデックスに新しいエントリを追加し、B-tree のバランスを保つ処理が走ります。インデックスが6本あれば、書き込みは実質7回(テーブル + 6本)。冒頭の事故はこれです。
容量。 インデックスはディスクを使います。テーブルより大きくなることも珍しくありません。
選択肢が増えることによる判断コスト。 オプティマイザはインデックスが増えるほど、どれを使うかの検討に時間をかけます。また、誤った選択をする可能性も上がります。
効かないインデックスの典型
選択率が低い列。 status が3種類、is_deleted が2種類しかない場合、WHERE is_deleted = false で絞っても90%以上の行が残ります。インデックスを辿ってから大量の行を読むより、最初から全件読んだほうが速いので、オプティマイザはインデックスを使いません。
関数を適用している列。
-- インデックスが効かない
WHERE DATE(created_at) = '2026-09-15'
WHERE LOWER(email) = 'a@example.com'
-- 効く形に書き換える
WHERE created_at >= '2026-09-15' AND created_at < '2026-09-16'
WHERE email = 'a@example.com' -- 保存時に小文字に正規化しておく
インデックスは created_at の値で並んでいますが、DATE(created_at) の値では並んでいません。列を加工した瞬間、並び順が使えなくなります。
前方一致以外の LIKE。 LIKE 'abc%' は使えますが、LIKE '%abc' は使えません。並び順が先頭から決まる以上、末尾一致は辿れないためです。
インデックスを6本張ると、1行の INSERT で何が起きますか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この要件で、インデックスをどう設計しますか?
- orders テーブル、約 5000 万行。1日 10 万行ずつ増える
- 画面A: 特定ユーザーの注文履歴 — WHERE user_id = ? ORDER BY created_at DESC LIMIT 20
- 画面B: 管理者の未処理一覧 — WHERE status = 'pending' ORDER BY created_at ASC LIMIT 50
- 画面C: 期間指定の集計 — WHERE created_at BETWEEN ? AND ? GROUP BY status
- status は 'pending' / 'paid' / 'shipped' / 'cancelled' の4種類。うち 'paid' が 85%、'pending' は 0.1%
- 夜間バッチで 1 日分(10万行)をまとめて INSERT している
- 現在は主キー(id)のインデックスしかない
- · 各画面で、絞り込みに効く列はどれか(選択率を見る)
- · ORDER BY をインデックスで解決できるか
- · インデックスの本数と、夜間バッチの書き込みコストのバランス
「遅いから WHERE に出る列に全部インデックスを張った」という後輩に、何を考え直すべきかを説明してください
- 一覧は実際に少し速くなっている
- バッチの実行時間が3倍になった
- 闇雲に減らすのではなく、判断の仕方を渡したい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。