はじめに
CREATE INDEX ... ON cities(country, city) のような複合インデックスを貼ったのに、思ったほどクエリが速くならない。あるいは EXPLAIN で見たら Seq Scan になっていた ── そんな経験はないでしょうか。
複合インデックスは「並べたカラムの順序」が効くかどうかを決めます。この記事では、なぜ順序が重要なのかを「辞書のように並んでいる」というイメージで整理し、実践的な設計指針までまとめます。
以下のサイトでは複合インデックスやその他のインデックスも含め、図解で解説しています。
https://taitech.dev/rdb-index/composite
複合インデックスは辞書順に並ぶ
複合インデックス (A, B) は、まず A で並び替え、A が同じ値の中で B の順に並ぶという構造です。
紙の辞書を思い浮かべると分かりやすいです。「アイウエオ」で 1 文字目 → 2 文字目 の順に並んでいるので、1 文字目が「か」の言葉は連続した範囲に固まっています。逆に、2 文字目だけを条件に指定して「〇い」で終わる言葉を探そうとすると、辞書全体を頭からめくる必要があります。
インデックス (country, city) の中身は次のようなイメージです。
country | city | 行ID
--------|-----------|-------
Japan | Osaka | (1,0)
Japan | Tokyo | (1,1)
Japan | Yokohama | (1,2)
USA | Boston | (1,3)
USA | Tokyo | (2,0)
USA | Seattle | (2,1)
Japan の都市は連続した 3 行、USA の都市はその後にまとまって並ぶ、というふうに country ごとに固まっています。「日本の中の東京」「アメリカの中のボストン」という包含関係が、そのまま並び順として保持されているイメージです。この構造を踏まえると、どのクエリで効くかが説明できます。
効くパターンと効かないパターン
INDEX (country, city) に対して、代表的なクエリを見ていきます。
先頭カラムだけを条件にする(効く)
SELECT * FROM cities
WHERE country = 'Japan';
辞書で「さ」を引くのと同じで、country = 'Japan' の連続範囲が一発で特定できます。B-tree でその範囲の先頭に降りて、あとは葉ノードを順にたどるだけです。
先頭 + 2 番目を条件にする(もっとも効く)
SELECT * FROM cities
WHERE country = 'Japan' AND city = 'Tokyo';
country = 'Japan' の範囲の中で、さらに city = 'Tokyo' の位置に直接降りられます。1 件をピンポイントで取れるパターンで、複合インデックスの一番の得意技です。
2 番目のカラムだけを条件にする(効かない)
SELECT * FROM cities
WHERE city = 'Tokyo';
先頭カラム country が条件に含まれていないと、インデックスの木構造をたどれません。city = 'Tokyo' は Japan の下にも USA の下にも散らばっているためです(日本の東京もアメリカの Tokyo, Michigan もどちらもインデックス上に存在する)。
結果、オプティマイザはフルテーブルスキャンを選びます。(country, city) というインデックスがあっても、このクエリではまったく使われません。これは複合インデックス全般に共通する原則で、2番目以降のカラムだけを条件に指定してもインデックスは効きません。
範囲検索を挟むと以降のカラムが効きにくい
SELECT * FROM cities
WHERE country >= 'S' AND city = 'Tokyo';
country を範囲で絞った時点で、city は各 country の中で並んでいるだけで、範囲全体を横断すると連続にはなりません。この場合、インデックスの city 部分は絞り込みには効かず、country >= 'S' に該当する範囲全体を読んでからフィルタする形になります。
カラム順を決める 3 つの原則
複合インデックスを設計するときに迷ったら、次の順番で考えると外れにくいです。
1. 等価 → 範囲 → ソートの順に並べる
= で絞るカラムを先頭に、> や BETWEEN などの範囲比較を後ろに、最後に ORDER BY に使うカラムを置くと、範囲以降のカラムもインデックスから得られる情報を活かしやすくなります。
-- 例: 会員の中で特定の期間を範囲検索し、日時順で返す
CREATE INDEX idx_events_user_created
ON events (user_id, created_at);
SELECT * FROM events
WHERE user_id = 123
AND created_at >= '2026-01-01'
ORDER BY created_at DESC;
user_id(等価) → created_at(範囲 + ソート)という並びで、インデックスの範囲スキャンとソート省略の両方が効きます。
2. 単独で検索されるカラムを先頭に
(A, B) は「A 単独の検索」でも「A + B の検索」でも効きますが、「B 単独」では効きません。単独で使われる可能性があるカラムを先頭に置いておくと、1 本のインデックスで 2 つのクエリパターンを吸収できます。
3. カーディナリティが高いカラムを先頭に(等価前提)
同じ「等価で絞る」なら、値の種類が多い(絞り込みが強い)カラムを先頭にすると、範囲スキャンの読み取り量が減ります。ただし「単独で検索されるかどうか」が優先条件です。
まとめ
- 複合インデックス
(A, B)は「A → B の辞書順」で並ぶ - 先頭カラム A を条件に含めないとインデックスは効かない(
WHERE B = ?は Seq Scan) - カラム順は「等価 → 範囲 → ソート」を基本に、単独検索するカラムを先頭に置く
- 範囲検索を挟むと、それ以降のカラムはインデックスからの絞り込みには使えない
複合インデックスは「貼ればとりあえず速くなる」というよりも、クエリのカラム順に合わせて設計するというマインドセットが重要です。
インデックスの内部構造・ページアクセス・カラム順の効き方を動く図で確認できるサイトを作っています。気になる方は覗いてみてください。