複合インデックスの設計とカーディナリティ入門
はじめに
テーブルにインデックスを張ったのにクエリが速くならない。あるいはインデックスはあるのにスキャン行数が減らない。こうした状況の多くは、「複合インデックスの列順」と「カーディナリティ」への理解不足から生まれます。
この記事では入門者向けに、次の3点を整理します。
- カーディナリティとは何か、なぜインデックス設計で重要なのか
- 複合インデックスの列順をどう決めるか(左端一致の原則)
- 実行計画で効き目を確認する方法
対象は一般的なB-Treeインデックスで、MySQL(InnoDB)とPostgreSQLを想定しています。
カーディナリティとは
カーディナリティとは、その列に含まれる値の種類の多さ(ユニーク度)を指します。
- 高カーディナリティ:
user_id、email、order_idなど → 値がほぼ一意 - 低カーディナリティ:
status、gender、is_deletedなど → 値の種類が少ない
インデックスは対象行を絞り込むために使われます。高カーディナリティの列ほど1つの値で一気に行数を絞れるため、インデックスが効きやすくなります。逆に is_deleted(0/1だけ)のような列に単独でインデックスを張っても、全体の半分を指すだけなら絞り込み効果は小さく、オプティマイザがフルスキャンを選ぶこともあります。
単一インデックスと複合インデックス
複数の列で絞り込みをする場合、列ごとに単一インデックスを張るより、1本の複合インデックスを張った方が効くケースが多くあります。
SELECT * FROM orders WHERE tenant_id = 10 AND status = 'shipped' ORDER BY created_at DESC LIMIT 20;
多くのDBは原則1クエリで1本のインデックスしか使えません。そこで複合インデックスの出番です。
複合インデックスの列順:左端一致の原則
複合インデックスで最も重要なのが列の順番です。B-Treeインデックスは、定義した列を左から順に連結したキーでソートされています。
CREATE INDEX idx_orders_lookup ON orders (tenant_id, status, created_at);
これは電話帳の「姓→名」のような並びです。姓で引けば速いし、姓+名でも速い。しかし「名だけ」で引こうとすると全ページをめくることになります。これが左端一致(leftmost prefix)の原則です。
効くパターン:
tenant_id = ?tenant_id = ? AND status = ?tenant_id = ? AND status = ? AND created_at > ?
フルには活かせないパターン:
status = ?だけ(先頭のtenant_idを飛ばしている)created_at > ?だけ
列順を決める基本指針
- 等価条件(=)で使う列を先に、範囲条件(>, <, BETWEEN)で使う列を後ろに置く。範囲条件の列より後ろの列は絞り込みに使われなくなる。
- ORDER BY / GROUP BY で使う列をインデックス順と揃える。並び順が一致すればソート(filesort)を省ける。
- カーディナリティだけで単純に決めない。「高カーディナリティを先頭に」という定石より、クエリのWHERE条件の形が優先。必ず等価で絞る列を先頭に置くのが実践的です。
カバリングインデックス
SELECTする列までインデックスに含めると、テーブル本体を読みに行かずインデックスだけで完結します。これをカバリングインデックスと呼びます。
-- PostgreSQL は INCLUDE で絞り込みに使わない列を付加できる CREATE INDEX idx_orders_cover ON orders (tenant_id, status) INCLUDE (created_at, id);
ただしインデックスが太るほど更新コストとストレージが増えるため、乱用は禁物です。
確認方法:実行計画を見る
設計が効いているかは必ず実行計画(EXPLAIN)で確認します。
-- MySQL EXPLAIN SELECT * FROM orders WHERE tenant_id = 10 AND status = 'shipped' ORDER BY created_at DESC LIMIT 20;
見るべきポイント:
typeがref/rangeになっているか(ALLはフルスキャン)keyに意図したインデックスが出ているかrows(推定スキャン行数)が十分小さいかExtraにUsing filesortが出ていないか
-- PostgreSQL EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE tenant_id = 10 AND status = 'shipped' ORDER BY created_at DESC LIMIT 20;
Seq Scan でなく Index Scan / Index Only Scan になっているか、推定行数と実測行数が乖離していないかを確認します。統計情報が古いと推定を誤るため、必要に応じて ANALYZE(PostgreSQL)/ ANALYZE TABLE(MySQL)で統計を更新します。
注意点
(a, b)と(b, a)は別物。効くクエリが異なる。- 先頭列をWHEREに含めないクエリでは効かない。
- 範囲条件の列はなるべく末尾へ。
- 低カーディナリティ列の単独インデックスは過信しない。
- インデックスは書き込みコスト。読み取り最適化と更新負荷のトレードオフを意識する。
- 本番相当のデータ量・分布で検証する。少量データでは差が出ない。
まとめ
- カーディナリティは「値の種類の多さ」。高いほど絞り込みに効く。
- 複合インデックスは左端一致が原則。先頭列を飛ばすと効かない。
- 列順はカーディナリティよりまずWHEREの形。等価条件を先に、範囲条件を後ろに。
- ORDER BYと順序を揃えるとソートを省ける。
- 最後は必ずEXPLAINで、スキャン行数とfilesortの有無を確認する。
インデックスは「張れば速くなる魔法」ではなく、クエリの形に合わせて設計する道具です。まずは代表的なクエリを1本選び、そのWHEREとORDER BYからインデックスを逆算してみてください。
