複合インデックスの設計とカーディナリティ入門

はじめに

テーブルにインデックスを張ったのにクエリが速くならない。あるいはインデックスはあるのにスキャン行数が減らない。こうした状況の多くは、「複合インデックスの列順」と「カーディナリティ」への理解不足から生まれます。

この記事では入門者向けに、次の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 > ? だけ

列順を決める基本指針

  1. 等価条件(=)で使う列を先に、範囲条件(>, <, BETWEEN)で使う列を後ろに置く。範囲条件の列より後ろの列は絞り込みに使われなくなる。
  2. ORDER BY / GROUP BY で使う列をインデックス順と揃える。並び順が一致すればソート(filesort)を省ける。
  3. カーディナリティだけで単純に決めない。「高カーディナリティを先頭に」という定石より、クエリの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からインデックスを逆算してみてください。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。