インデックス — 検索を速くする代償
100 万件のテーブルから 1 件を探すとき、インデックスが無ければ全行を見ます。あれば 20 回程度の比較で届きます。
なぜそんなに違うのか。そしてなぜ全部の列にインデックスを張らないのか。この 2 つが分かると、どこに張るべきかの判断ができます。
この章で学ぶこと
- インデックスが検索を速くする仕組み
- 複合インデックスとカラム順序の関係
- インデックスを張ることで何を失うか
- インデックスが効かなくなる書き方
- シャーディングやハッシングとの区別
この章で扱わないこと
具体的な設計手順、EXPLAIN の読み方、部分インデックス、UUID 主キーとの相性は既存のガイドが詳しく扱います。
クエリそのものの書き方は第 12 部が扱います。結合と集計は 結合と集計、サブクエリとウィンドウ関数は サブクエリとウィンドウ関数 です。
本章はなぜそうなるかの説明に絞ります。
なぜ速くなるのか
インデックスが無ければ、データベースは条件に合う行を探すために全行を順に読みます (フルスキャン)。100 万行なら 100 万回です。計算量でいえば O(n) です。
インデックスは、指定した列の値をあらかじめ並べ替えて保持した別の構造です。多くのデータベースは B-Tree という木を使います。
各ノードが「この範囲はこの子へ」という案内を持つので、探すときに枝を選んで降りるだけで済みます。1 段降りるごとに候補が大きく減るので、計算量は O(log n) です。
これは二分探索と同じ発想です。違うのは、配列ではなくノードの連結で実現している点と、1 ノードに多くのキーを詰めてディスクの読み取り回数を減らしている点です。
| 行数 | フルスキャン | B-Tree |
|---|---|---|
| 1,000 | 1,000 | 約 10 |
| 100 万 | 100 万 | 約 20 |
| 10 億 | 10 億 | 約 30 |
**データが増えるほど差が開きます。**開発環境の数百件では体感できず、本番の数百万件で顕在化するのはこのためです。
**文字列の列では、「並べ替えた」の順序を決めているのは照合順序です。**照合順序を変えると並びも変わるので、インデックスは作り直しになります (文字コード)。
複合インデックス
検索条件が複数の列にまたがることがあります。
SELECT * FROM orders WHERE user_id = 42 AND status = 'shipped';
このとき、user_id と status に別々のインデックスを張るより、2 列をまとめた複合インデックスのほうが効きます。
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
別々だと、片方のインデックスで絞ってから残りを検査する形になります。複合なら 1 つの構造で両方の条件を使い切れます。
順序が結果を変える
複合インデックスで最も重要なのがカラムの順序です。
複合インデックスは、指定した順に電話帳のように並んでいると考えてください。「姓 → 名」の順に並んだ電話帳では、次のようになります。
| 検索条件 | 使えるか |
|---|---|
| 姓で探す | 使える。姓で並んでいるので範囲が絞れる |
| 姓 + 名で探す | 使える。姓で絞ってから名で絞れる |
| 名だけで探す | 使えない。名は姓の中でしか並んでいない |
(user_id, status) の複合インデックスも同じです。
-- 効く: 左端から使っている
WHERE user_id = 42
WHERE user_id = 42 AND status = 'shipped'
-- 効かない: 左端を飛ばしている
WHERE status = 'shipped'
**左端から連続して使う条件にしか効きません。**これを左端接頭辞の原則と呼びます。順序を決めるときの目安は次のとおりです。
- 等価比較 (
=) で使う列を先に、範囲比較 (>、BETWEEN) を後に - 等価比較どうしなら、値の種類が多い列 (絞り込みが効く列) を先に
範囲比較を先に置くと、そこから後ろの列は並び順が保証されなくなり使えません。
何を失うのか
インデックスはただの得ではありません。張るほど更新が遅くなります。
理由は単純で、インデックスもデータなので更新が要るからです。
| 操作 | インデックスへの影響 |
|---|---|
INSERT | すべてのインデックスに新しいエントリを追加する |
UPDATE | 対象列を含むインデックスを更新する。木の再編成が起きることもある |
DELETE | すべてのインデックスからエントリを削除する |
1 テーブルに 10 個のインデックスがあれば、1 行の挿入で 11 箇所 (本体 + 10 個) の書き込みが発生します。
失うものは他にもあります。
| 代償 | 内容 |
|---|---|
| 更新時の負荷 | 挿入・更新・削除のたびにインデックスも書き換える |
| ディスク容量 | インデックスは実データとは別に領域を使う |
| メモリ | よく使うインデックスはメモリに載せたい。載らなければ効果が落ちる |
| プランナーの迷い | 選択肢が増えすぎると、最適でない実行計画が選ばれることがある |
したがって設計は読み取りと書き込みの比率で決まります。書き込みが増えれば、ロックと分離レベルで見た待ち時間にも跳ね返ります。
- 検索が多く更新が少ないテーブル (商品マスタ、記事) → インデックスを厚めに
- 書き込みが多いテーブル (ログ、イベント) → 最小限に
「とりあえず全部の列に張る」は、読み取りをわずかに速くする代わりに書き込みを確実に遅くします。
効かなくなる書き方
インデックスを張っていても、クエリの書き方で使われないことがあります。
-- 効かない: 列に関数を適用している
WHERE DATE(created_at) = '2026-08-17'
-- 効く: 列はそのまま、範囲で指定する (境界のタイムゾーンを明示。例は PostgreSQL)
WHERE created_at >= '2026-08-17 00:00:00+09:00'
AND created_at < '2026-08-18 00:00:00+09:00'
-- 効かない: 前方にワイルドカードがある
WHERE name LIKE '%田'
-- 効く: 前方一致なら並び順を使える
WHERE name LIKE '田%'
-- 効かない: 列を計算している
WHERE price * 1.1 > 1000
-- 効く: 計算を右辺へ移す
WHERE price > 1000 / 1.1
共通する原則は、インデックスを張った列をそのままの形で条件に置くことです。列に関数や計算を適用すると、並べ替えられた値と照合できなくなります。
最初の例で境界に +09:00 を付けてあるのは、タイムゾーンを書かない '2026-08-17' がどの地点の 0 時を指すか、列の型と接続先の設定で変わるからです。PostgreSQL の timestamp with time zone は値を UTC で保存し、タイムゾーンを含まない入力文字列をセッションの TimeZone 設定が示すタイムゾーンとして解釈します (PostgreSQL — Date/Time Types)。接続が UTC のまま日本時間の 1 日を数えると、9 時間ずれた範囲を集計することになります。タイムゾーンを持たない型 (MySQL の DATETIME など) は、保存された値にどの地方時かの情報が残りません。**解釈の規則はデータベースと列の型で変わるので、使っている環境で確かめてください。**どちらにしても、境界値の側でタイムゾーンを明示しておけば、クエリを読む人に意図が伝わります。日時の表し方そのものは 日時とタイムゾーン が扱います。
**オフセットを書く場所は境界値の側で、列ではありません。**列を AT TIME ZONE で変換してから日付にすると、最初の例と同じ理由でインデックスが効かなくなります。
前方のワイルドカードが効かないのは、電話帳で「最後が『田』の人」を探すのと同じだからです。並び順が使えず、全部見るしかありません。この用途が本当に必要なら、全文検索の仕組みを別途使います。
同じ原則は結合条件にも効きます。ON orders.customer_id = customers.id の列を関数や計算で包むと、その列に張ったインデックスは使えなくなります。結合の相手を索引で引く実行計画が選べなくなるので、行数が増えたときの負荷が変わります。どの実行計画が選ばれるかはデータの分布次第なので、影響は EXPLAIN で確かめます。結合が行数をどう変えるかは 結合と集計 が扱います。
似た用語との区別
インデックスと混同されやすい用語があります。目的も層も違うので整理しておきます。
| 用語 | 何をするもの | 主な目的 |
|---|---|---|
| インデックス | 特定列の値を並べ替えた検索用の構造を作る | 検索を速くする |
| 複合インデックス | 複数列をまとめた 1 つのインデックス | 複数条件の検索を速くする |
| ハッシング | キーをハッシュ関数で位置へ変換する | 等価検索を速くする。範囲検索には使えない |
| シャーディング | データを複数のサーバーへ分割して配置する | 1 台の限界を超える。容量と負荷の分散 |
| パーティショニング | 1 つのテーブルを内部で複数に分割する | 巨大テーブルの管理と検索範囲の限定 |
**この 3 つはインデックスの代わりにはなりません。**シャーディングが解くのは「1 台に収まらない」という規模の問題で、検索そのものを速くする手法ではありません。パーティショニングは検索対象を特定の区画へ絞り込めますが、効くのは分割の基準になった列で絞り込むときだけで、任意の列の検索には効きません。ハッシングはハッシュインデックスという形で使えますが、範囲検索や並べ替えができないため汎用の第一選択にはなりません。
検索を速くしたいときの出発点はインデックスで、その代償は更新時の負荷です。ここがトレードオフの中心になります。分割の設計で問題になる「偏り」(特定のノードへアクセスが集中すること) とは、対処すべき対象が別です。偏りが起きるキーの見分け方と、分割によって何が難しくなるかは データの分散 が扱います。
よくある誤解
「インデックスは張れば張るほど速くなる」 — 検索は速くなりますが、更新は確実に遅くなります。ディスクとメモリも消費します。読み書きの比率で決めます。
「複合インデックスはカラムの順序を気にしなくてよい」 — 順序が結果を変えます。左端から連続して使う条件にしか効きません。
「インデックスを張っていれば必ず使われる」 — 列に関数を適用したり、前方ワイルドカードを使ったりすると使われません。
「シャーディングは検索を速くする手法」 — データを複数サーバーへ分散させる手法で、目的は 1 台の限界を超えることです。単一テーブルの検索を速くするのはインデックスです。
「ハッシュインデックスのほうが B-Tree より常に速い」 — 等価検索では速いことがありますが、範囲検索と並べ替えに使えません。汎用性で B-Tree が既定になっています。
確認問題
問 1. 数千万行の注文テーブルで、特定の顧客の注文を絞り込む検索が遅くなりました。同僚から 3 つの案が出ています。どれを採るべきですか。
案 A: customer_id にインデックスを張る
案 B: テーブルを顧客 ID のハッシュ値で 4 台のサーバーへ分割する
案 C: 全カラムにインデックスを張って、どんな検索にも備える
答え: 案 A。案 B と案 C は目的に合いません。
案 A が正しい理由 — 遅い原因は、条件に合う行を探すためにテーブル全体を走査していることです。customer_id にインデックスを張れば O(n) から O(log n) に落ちます。この列で絞り込みたい、という課題にまっすぐ対応します。
案 B が合わない理由 — 分割 (シャーディング) は、1 台に収まらない容量や負荷を複数台へ散らす手法です。目的が違います。しかも顧客 ID で分割すると、注文の多い大口顧客が特定の 1 台に偏り、そのサーバーだけが重くなります。分割の設計で問題になるのはこの偏りで、インデックス設計の論点とは別です。
案 C が合わない理由 — 検索はわずかに速くなるかもしれませんが、INSERT / UPDATE / DELETE のたびに全インデックスを書き換えるので更新が確実に遅くなります。注文テーブルは書き込みも多いので、割に合いません。
なお、検索条件が customer_id と ordered_at の組で使われるなら、2 つを 1 つにまとめた複合インデックスのほうが効きます。インデックス設計で払う代償は一貫して更新時の負荷なので、実際に使われる条件に絞って張ります。
問 2. (user_id, created_at) の複合インデックスがあります。次のクエリのうちインデックスが効くのはどれですか。
A: WHERE user_id = 42
B: WHERE created_at > '2026-01-01'
C: WHERE user_id = 42 AND created_at > '2026-01-01'
D: WHERE created_at > '2026-01-01' AND user_id = 42
答え: A・C・D が効き、B は効かない
判断の基準は「左端の列から連続して使っているか」です (境界値のタイムゾーンはこの設問の論点ではないので省いてあります。実務では前述のとおり明示します)。
- A —
user_idは左端。効きます - B —
created_atだけでは左端を飛ばしています。created_atはuser_idごとにしか並んでいないので使えません - C — 左端から 2 列とも使っています。最も効きます
- D — C と同じです。
WHERE句に書く順序は関係ありません。データベースが条件を解釈して使える形に組み替えます
D が引っかかりやすい点です。重要なのはインデックス定義のカラム順序であって、SQL に書く順序ではありません。
B の形の検索が頻繁なら、(created_at) の単独インデックスか (created_at, user_id) の複合インデックスを別途検討します。
問 3. ログテーブルに検索用のインデックスを 8 個張ったところ、書き込みが遅くなりました。どう対処しますか。
答え: 実際に使われているインデックスだけを残し、それ以外を削除します。
ログテーブルは書き込みが圧倒的に多く、読み取りは調査時に限られるという性質を持ちます。インデックスが 8 個あると、1 行の挿入で 9 箇所への書き込みが発生します。
対処の順序は次のとおりです。
- 使用状況を実測する — 多くのデータベースはインデックスごとの利用回数を記録しています。まったく使われていないものを特定します
- 重複を整理する —
(a)と(a, b)があるなら、(a)は(a, b)の左端接頭辞で代替できることが多く、削除の候補になります - 残すものを絞る — 調査で実際に使う条件 (時刻の範囲、ログレベルなど) に対応するものだけ残します
設計そのものを見直す選択肢もあります。
- 時刻でパーティショニングして、検索範囲を限定する
- 古いログを別テーブルへ移し、書き込み対象を小さく保つ
- 全文検索が要るなら、専用の検索基盤へ流す
**「検索も速く、書き込みも速く」は同時には成り立ちません。**そのテーブルが何を優先すべきかを決めるのが先です。
まとめ
- インデックスは値を並べ替えた別構造を持つことで、検索を
O(n)からO(log n)へ落とします - 複数条件には複合インデックスが効きます。左端から連続して使う条件にしか効きません
- カラム順序は、等価比較を先に、範囲比較を後に。等価どうしなら絞り込みが効く列を先に置きます
WHERE句に書く順序は関係ありません。効くかどうかを決めるのはインデックス定義の順序です- 代償は更新時の負荷です。インデックスの数だけ書き込みが増えます
- 列に関数や計算を適用すると効きません。前方ワイルドカードも効きません
- シャーディングは分散、ハッシングは等価検索、インデックスは検索の高速化。目的が違います
- データベース設計ガイド — インデックス設計入門 — 設計手順、
EXPLAINの読み方、部分インデックス、UUID 主キーとの相性 - Laravel API 開発ガイド — 一覧 API — N+1 問題とページネーション
次に読む
- シェルの基本 — ここから実行環境の層へ移ります