仕組みから学ぶ Web
応用読了目安 16#インデックス#B-tree#複合インデックス#性能

インデックス — 読む行を減らすための索引

B-tree が何をしているかと、複合インデックスの列順がなぜ効くのか。張れば速くなるものではない。

先に読んでおくとよい記事

この記事の進み方

What — インデックスがなぜ速いのか

インデックスの本体は B-tree(正確には B+tree)という木構造です。値を辿って目的の行に到達することで、全件を読まずに済ませます。

Figureuser_id = 500 を探す
  1. 1① ルートページ1〜1000 / 1001〜2000 / … のどこか軽い
  2. 2② 中間ページ1〜100 / 101〜200 / … と絞る軽い
  3. 3③ リーフページ実際の値と、行の位置軽い
  4. 4④ テーブルから行を取得求めた位置でテーブルを読む

インデックスがない場合は、100万行を1行ずつ確認する(フルスキャン)。1000倍以上の差がつくのは、読むページ数がそれだけ違うから。

1/4
① ルートページ木の頂点。値の範囲だけが書かれており、実データはない。500 は最初の範囲なので、そちらへ降りる。ここは常にメモリに載っている。

100万行のテーブルでも、木の深さは3〜4段程度。段数が対数で増えるため、行数が10倍になっても読むページはほとんど増えない。これが「速い」の正体。

複合インデックスは「左から順に」しか使えない

最も実務で効くのがこの性質です。(a, b, c) という複合インデックスは、電話帳の並び順と同じです。

CompareINDEX (user_id, status, created_at) が効く条件

左端から連続して条件が指定されている場合。

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 > ? だけの検索で使えますか?

まず選ぶ(解答例は a〜d の記号で説明します)

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 で何が起きますか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この要件で、インデックスをどう設計しますか?

与えられた条件
  • 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 をインデックスで解決できるか
  • · インデックスの本数と、夜間バッチの書き込みコストのバランス
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「遅いから WHERE に出る列に全部インデックスを張った」という後輩に、何を考え直すべきかを説明してください

与えられた条件
  • 一覧は実際に少し速くなっている
  • バッチの実行時間が3倍になった
  • 闇雲に減らすのではなく、判断の仕方を渡したい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この記事を前提にしている記事