キーとインデックス
正しいキー設計はデータの整合性を、正しいインデックス設計はクエリの性能を支えます。 どちらもテーブル設計の初期段階で決めるべき重要項目であり、後から変更するコストは非常に高くなります。 このページでは、キーの体系的な理解からインデックスの実務的な設計指針までを網羅します。
キーの体系
RDB におけるキーは、行を一意に識別したりテーブル間の関係を表現するための仕組みです。 複数のキー概念がどのような包含関係にあるかを正確に理解しておくことが、設計の土台になります。
キーの種類と定義
| キーの種類 | 定義 | 例 |
|---|---|---|
| スーパーキー | 行を一意に識別できる列の組み合わせ(冗長な列を含んでもよい) | {社員ID}, {社員ID, 氏名}, {メールアドレス, 部署} |
| 候補キー | スーパーキーのうち、どの列を取り除いても一意性が失われる最小の組み合わせ | {社員ID}, {メールアドレス} |
| 主キー (PK) | 候補キーの中から1つを選んで「この列で行を識別する」と宣言したもの。NULL 不可 | {社員ID} を主キーに選択 |
| 代替キー (AK) | 主キーに選ばれなかった残りの候補キー。通常 UNIQUE 制約を付与する | {メールアドレス} に UNIQUE 制約 |
| 外部キー (FK) | 他テーブルの主キー(または候補キー)を参照する列。テーブル間のリレーションを表現する | 受注.顧客ID が 顧客.顧客ID を参照 |
キーの包含関係
外部キーの役割
外部キーは上記の「行を識別するキー」とは異なり、テーブル間の参照整合性を保証する仕組みです。 外部キー制約を定義すると、参照先に存在しない値を挿入・更新できなくなり、 また参照されている行を不用意に削除できなくなります。
-- 外部キー制約の定義例
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_date DATE NOT NULL,
customer_id BIGINT NOT NULL,
CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers (customer_id)
);
外部キー制約には ON DELETE / ON UPDATE のアクション(CASCADE, SET NULL, RESTRICT など)を指定できます。
安易に CASCADE を使うと意図しない大量削除が起きるため、まずは RESTRICT(デフォルト)で設計し、
必要な箇所だけ CASCADE を許可するのが安全です。
自然キーとサロゲートキーの詳細比較
主キーの選び方として、業務上意味のある値(自然キー)を使うか、 システムが自動採番する意味のない値(サロゲートキー)を使うかは、実務で最も議論になるテーマの一つです。
比較表
| 観点 | 自然キー | サロゲートキー |
|---|---|---|
| 不変性 | 業務変更で値が変わるリスクがある(例: 部門コード体系の変更) | 業務ロジックと無関係なので変わらない |
| 可読性 | 値を見れば意味がわかる(例: ISBN, 国コード) | 数値や UUID だけでは意味が読み取れない |
| サイズ | 文字列型が多く、複合になりやすい | 整数型なら 4〜8 バイトで済む |
| パフォーマンス | 文字列比較・複合キー JOIN はやや不利 | 整数型の単一列 JOIN は高速 |
| 外部キー設計 | 参照する側にも業務コードが伝搬し、列数が増えがち | 参照列は常に 1 列で済む |
| 一意性の保証 | 業務ルールで保証(ルール変更に弱い) | DB の採番機構で保証(堅牢) |
実務での判断基準: ISO 標準コード(国コード、通貨コード)のように国際的に不変性が保証されている値は自然キーとして使えます。 それ以外の業務コードは、主キーにはサロゲートキーを使い、業務コードには UNIQUE 制約を付与する設計が安全です。
UUID vs SERIAL/IDENTITY
サロゲートキーの実装方式にも選択肢があります。
| 観点 | SERIAL / IDENTITY(連番整数) | UUID (v4 / v7) |
|---|---|---|
| サイズ | 4 バイト (INT) / 8 バイト (BIGINT) | 16 バイト |
| インデックス効率 | 連番のため B-tree に順序よく挿入され、効率が良い | v4 はランダムなのでページ分割が多発。v7 は時刻順で改善 |
| 分散生成 | 単一 DB のシーケンスに依存。分散システムでは衝突リスク | 各ノードが独立に生成でき、衝突確率は実質ゼロ |
| 可読性 | 短い数値で扱いやすい | 長い16進文字列でデバッグしにくい |
| 推測可能性 | 連番なので次の ID が推測されやすい(URL に露出する場合は注意) | 推測困難 |
| 推奨用途 | 単一 DB の一般的なアプリケーション | マイクロサービス、分散環境、外部公開 ID |
-- IDENTITY(SQL標準準拠、推奨)
CREATE TABLE products (
product_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_name VARCHAR(200) NOT NULL
);
-- UUID v7(PostgreSQL 17+ では gen_random_uuid() の代わりに uuidv7 拡張も利用可能)
CREATE TABLE api_tokens (
token_id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id BIGINT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
UUID v4 をクラスタードインデックスの主キーに使うと、ランダム挿入によるページ分割でインデックスが断片化します。 大量 INSERT がある場合は UUID v7(時刻ベース)を検討するか、BIGINT IDENTITY を採用してください。
複合主キーの設計
複合主キー(2つ以上の列の組み合わせで行を識別する主キー)は、 特定の場面で自然かつ効果的な設計になります。一方で、デメリットも存在するため、適材適所の判断が必要です。
複合主キーが適する場面
- 明細テーブル: 親テーブルの ID + 明細番号(例: 受注明細)
- 関連テーブル(中間テーブル): 多対多を解決するテーブル(例: 学生ID + 科目ID)
- 履歴・バージョンテーブル: 本体 ID + バージョン番号 or 有効開始日
受注明細の具体例
-- 複合主キーの例: 受注明細
CREATE TABLE order_details (
order_id BIGINT NOT NULL,
detail_number INT NOT NULL, -- 受注内の連番(1, 2, 3...)
product_id BIGINT NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10, 2) NOT NULL,
PRIMARY KEY (order_id, detail_number),
FOREIGN KEY (order_id) REFERENCES orders (order_id),
FOREIGN KEY (product_id) REFERENCES products (product_id)
);
この設計により、(order_id = 1001, detail_number = 1) のように受注内の各明細行が一意に特定されます。
メリットとデメリット
| メリット | デメリット |
|---|---|
| 業務上の意味が明確(受注 1001 の 明細 1) | 外部キーとして参照する側に複数列が伝搬する |
| サロゲートキー列を追加する必要がない | ORM によっては複合主キーのサポートが弱い |
| 親キー + 明細番号で物理的にクラスタリングされ、親単位のまとめ読みが速い | JOIN の ON 句が長くなり、SQL がやや冗長になる |
判断の目安: そのテーブルが「さらに他テーブルから外部キーで頻繁に参照される」場合は、 サロゲートキー(単一列の IDENTITY)を主キーにした方が参照側の設計がシンプルになります。 参照されることが少ない末端の明細テーブルなら、複合主キーが自然です。
インデックスの仕組み
インデックスは、テーブルの特定列に対して検索を高速化するためのデータ構造です。 書籍の索引(index)と同じ発想で、目的のデータが格納されている場所をすばやく見つけます。
フルスキャン vs インデックススキャン
インデックスがない場合、DB は目的の行を見つけるためにテーブル全体を先頭から末尾まで走査します。 これをフルテーブルスキャン(Seq Scan)と呼びます。 100万行のテーブルから 1 行を探す場合でも、最悪 100万行すべてを読む必要があります。
インデックスがある場合、B-tree の木構造を辿ることで、数回のページ読み取りだけで目的の行に到達できます。 100万行でも、B-tree の高さはおよそ 3〜4 レベル程度であり、3〜4 回のアクセスで目的のデータを特定できます。
B-tree インデックスの概念図
B-tree(Balanced Tree)は、RDB で最も一般的に使われるインデックス構造です。 すべての葉ノードが同じ深さにあるため、どのキー値を検索しても一定のアクセス回数で到達できます。
なぜ速いのか: フルスキャンは O(n) の計算量ですが、B-tree 検索は O(log n) です。 100万行なら log(1,000,000) ≒ 20 ですが、1ノードに多数のキーを格納できるため、 実際のディスクアクセスは 3〜4 回で済みます。さらに範囲検索(BETWEEN, <, >)でも リーフノード間のリンクを辿るだけで連続したデータを効率よく取得できます。
インデックスの種類
B-tree が最も汎用的ですが、データの特性やクエリの種類に応じて最適なインデックスタイプは異なります。 以下は主要な RDBMS(PostgreSQL を中心に)でサポートされるインデックスの種類です。
| 種類 | 構造 | 得意な検索 | 代表的な用途 |
|---|---|---|---|
| B-tree | 平衡木 | 等価検索(=)、範囲検索(<, >, BETWEEN)、ORDER BY、前方一致 LIKE | 大多数の列。デフォルトのインデックスタイプ |
| Hash | ハッシュテーブル | 等価検索(=)のみ | 範囲検索が不要で等価検索だけの列(利用場面は限定的) |
| GIN | 転置インデックス | 配列の要素検索、全文検索、JSONB のキー・値検索 | タグ配列、JSONB 列、全文検索(tsvector) |
| GiST | 汎用検索木 | 幾何データの包含・近傍検索、範囲型の重なり検索 | PostGIS の空間検索、IP アドレス範囲、期間の重なり判定 |
| BRIN | ブロックレンジ | 自然順に並んだ大量データの範囲検索 | 時系列データ(タイムスタンプ列が物理的に整列しているログテーブルなど) |
-- B-tree(デフォルト。CREATE INDEX は B-tree になる)
CREATE INDEX idx_orders_customer ON orders (customer_id);
-- GIN(JSONB 列への検索を高速化)
CREATE INDEX idx_events_payload ON events USING gin (payload);
-- BRIN(時系列ログの created_at 列向け。非常に小さいインデックスサイズ)
CREATE INDEX idx_logs_created ON access_logs USING brin (created_at);
迷ったらまず B-tree を使ってください。Hash は PostgreSQL 10 以降でクラッシュセーフになりましたが、 B-tree で等価検索も十分に速いため、あえて選ぶ場面はほとんどありません。 GIN / GiST / BRIN は、対応するデータ型やクエリパターンが明確な場合に検討します。
複合インデックスと列順
複合インデックス(複数列で構成されるインデックス)では、列の定義順序が検索性能を決定的に左右します。 これは B-tree が「左端の列から順にソートする」構造であるためです。
左端一致の原則(Leftmost Prefix Rule)
複合インデックス (A, B, C) を定義した場合、B-tree 内部のデータは
まず A の昇順、A が同じなら B の昇順、B も同じなら C の昇順に並んでいます。
そのため、検索条件に A が含まれていなければ、このインデックスは使えません。
-- 複合インデックスの定義
CREATE INDEX idx_abc ON sales (region, category, sale_date);
-- A B C
どの検索パターンでインデックスが効くか
| WHERE 条件 | インデックス利用 | 理由 |
|---|---|---|
WHERE region = 'Tokyo' |
有効(A のみ) | 左端の列 A で絞り込める |
WHERE region = 'Tokyo' AND category = 'Food' |
有効(A, B) | 左端から連続する A, B で絞り込める |
WHERE region = 'Tokyo' AND category = 'Food' AND sale_date >= '2025-01-01' |
有効(A, B, C すべて) | 左端から全列が条件に含まれる |
WHERE region = 'Tokyo' AND sale_date >= '2025-01-01' |
部分的に有効(A のみ) | A で絞り込んだ後、B をスキップしているため C は活用できない |
WHERE category = 'Food' |
無効 | 左端の A がないため、インデックスを効率よく辿れない |
WHERE category = 'Food' AND sale_date >= '2025-01-01' |
無効 | 左端の A がない |
WHERE sale_date >= '2025-01-01' |
無効 | 左端の A がない |
列順の決め方: (1) 等価条件(=)で使う列を先に、(2) 範囲条件(BETWEEN, <, >)で使う列を後にするのが基本です。 範囲条件の列より後ろの列は、そのスキャンではインデックスを活用できなくなります。
-- 良い列順: 等価条件の列が先、範囲条件の列が後
CREATE INDEX idx_good ON orders (status, customer_id, order_date);
-- WHERE status = 'shipped' AND customer_id = 42 AND order_date >= '2025-01-01'
-- → 3 列すべてが有効に利用される
-- 悪い列順: 範囲条件の列が先
CREATE INDEX idx_bad ON orders (order_date, status, customer_id);
-- WHERE order_date >= '2025-01-01' AND status = 'shipped' AND customer_id = 42
-- → order_date の範囲スキャン後、status と customer_id はフィルタのみ
カバリングインデックス
通常のインデックス検索では、B-tree でキーを見つけた後、テーブル本体(ヒープ)に行データを取りに行きます。 この「テーブルへの戻り読み」をなくし、インデックスだけでクエリを完結させるのがカバリングインデックスです。
INCLUDE の使い方
PostgreSQL 11 以降では、INCLUDE 句を使って「検索キーではないが、SELECT で必要な列」を
インデックスに含めることができます。INCLUDE 列はソートには使われませんが、
インデックスのリーフノードに格納されるため、テーブルへの戻り読みが不要になります。
-- 通常のインデックス: customer_id で検索し、テーブルから order_date, total を取得
CREATE INDEX idx_orders_cust ON orders (customer_id);
-- カバリングインデックス: インデックスだけで SELECT を完結
CREATE INDEX idx_orders_cust_covering
ON orders (customer_id)
INCLUDE (order_date, total_amount);
-- このクエリはテーブルへの戻り読みが不要(Index Only Scan)
SELECT order_date, total_amount
FROM orders
WHERE customer_id = 42;
効果と注意点
| 効果 | 注意点 |
|---|---|
| テーブルへの Random I/O がなくなり、大幅に高速化 | INCLUDE 列の分だけインデックスサイズが増加する |
| EXPLAIN で「Index Only Scan」と表示される | PostgreSQL では Visibility Map が最新でないと結局ヒープを読む |
| 頻繁に実行される特定クエリに対して絶大な効果 | INCLUDE 列を増やしすぎるとインデックスがテーブルのコピーに近づく |
INCLUDE は PostgreSQL 11+、SQL Server 2005+ で利用できます。 MySQL (InnoDB) では、主キーの値がすべてのセカンダリインデックスに自動的に含まれるため、 主キー列に関しては暗黙的にカバリングされています。
インデックス設計の実務指針
選択度(カーディナリティ)の考え方
選択度(Selectivity)は、あるインデックスキー値でどれだけ行を絞り込めるかを示す指標です。 計算式は「ユニークな値の数 / 全行数」で、1 に近いほど選択度が高く、インデックスの効果が大きくなります。
| 列の例 | ユニーク値数 | 全行数 | 選択度 | インデックス効果 |
|---|---|---|---|---|
| user_id(ユーザー識別子) | 100,000 | 100,000 | 1.0 | 極めて高い |
| email(メールアドレス) | 99,500 | 100,000 | 0.995 | 高い |
| created_date(作成日) | 365 | 100,000 | 0.00365 | 中程度(範囲検索なら有効) |
| status(ステータス) | 5 | 100,000 | 0.00005 | 低い(単独では効果薄) |
| is_active(有効フラグ) | 2 | 100,000 | 0.00002 | 極めて低い |
インデックスを付けすぎるとどうなるか
インデックスはコストのないデータ構造ではありません。以下のデメリットが蓄積します。
- INSERT / UPDATE / DELETE が遅くなる: 行の変更のたびに、対象列を含むすべてのインデックスも更新が必要
- ストレージ消費の増大: 大きなテーブルでは、インデックスの合計サイズがテーブル本体を超えることもある
- オプティマイザの判断が複雑化: インデックスが多すぎると最適な実行計画の選択に時間がかかる
- VACUUM / メンテナンスの負荷増大: PostgreSQL では各インデックスに対して VACUUM が走る
目安: 1 テーブルあたりのインデックス数は 5〜10 個程度に収めるのが一般的です。 「このインデックスはどのクエリのために存在するのか」を説明できないインデックスは不要な可能性があります。
EXPLAIN の読み方の基本
インデックスが実際に使われているかどうかは、推測ではなく EXPLAIN で確認します。
-- 実行計画の確認(PostgreSQL)
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
-- 出力例:
-- Index Scan using idx_orders_customer on orders (cost=0.43..8.45 rows=5 width=64)
-- (actual time=0.023..0.031 rows=5 loops=1)
-- Index Cond: (customer_id = 42)
-- Planning Time: 0.085 ms
-- Execution Time: 0.052 ms
注目すべきポイント:
| 項目 | 確認すべきこと |
|---|---|
| Scan タイプ | Seq Scan(フルスキャン)か Index Scan / Index Only Scan かを確認 |
| rows(推定 vs 実績) | 推定行数 (rows=5) と実績行数 (rows=5) が大きく乖離していないか |
| actual time | 実際にかかった時間(ミリ秒) |
| Filter | Filter が表示される場合、インデックスで絞れずに行ごとのフィルタが発生している |
| Rows Removed by Filter | この値が大きい場合、インデックスの改善余地がある |
EXPLAIN だけでは推定値しか出ません。
EXPLAIN ANALYZE を使うと実際にクエリが実行され、実測値が得られます。
ただし本番データに対する更新系クエリには注意してください(トランザクション内で ROLLBACK するか、読み取り専用レプリカで実行します)。
アンチパターン
インデックス設計でよく見られる誤りを整理します。これらを避けるだけで、多くの性能問題を未然に防げます。
アンチパターン 1: 全列にインデックス
「検索が速くなるならすべての列にインデックスを付ければよい」という発想は、 更新性能とストレージの両方を犠牲にします。
-- アンチパターン: 全列にインデックス
CREATE INDEX idx_1 ON users (user_id); -- PK で自動作成されるので不要
CREATE INDEX idx_2 ON users (email); -- UNIQUE 制約で自動作成される場合は不要
CREATE INDEX idx_3 ON users (first_name);
CREATE INDEX idx_4 ON users (last_name);
CREATE INDEX idx_5 ON users (phone);
CREATE INDEX idx_6 ON users (address);
CREATE INDEX idx_7 ON users (created_at);
CREATE INDEX idx_8 ON users (updated_at);
CREATE INDEX idx_9 ON users (is_active); -- カーディナリティが低すぎて無意味
CREATE INDEX idx_10 ON users (role); -- 同上
INSERT 1 回で 10 個のインデックスが更新されます。バッチ処理で 10 万件を INSERT すると、 テーブルへの書き込みに加えて 100 万回のインデックス更新が発生します。 インデックスは「実際に使われるクエリ」を根拠に作成してください。
アンチパターン 2: 低カーディナリティ列への単独インデックス
is_active(TRUE / FALSE)や gender(M / F / Other)のように、
ユニーク値が極端に少ない列への単独インデックスは、ほとんど効果がありません。
インデックスで絞り込んでも全体の 50% や 33% の行がヒットするため、 オプティマイザはフルスキャンの方がコストが低いと判断し、インデックスを無視することが多いです。
-- ほぼ無意味なインデックス
CREATE INDEX idx_users_active ON users (is_active);
-- WHERE is_active = true → 全体の 95% がヒットする場合、Seq Scan の方が速い
-- 改善案: 高カーディナリティ列との複合インデックスにする
CREATE INDEX idx_users_active_created ON users (is_active, created_at);
-- WHERE is_active = false AND created_at < '2024-01-01'
-- → is_active = false の少数行をさらに created_at で絞り込む場合は有効
-- 改善案: 部分インデックス(該当行が少数の場合に特に有効)
CREATE INDEX idx_users_inactive ON users (created_at) WHERE is_active = false;
-- is_active = false が全体の 5% なら、インデックスサイズも 5% で済む
部分インデックス(Partial Index)は PostgreSQL の強力な機能です。
WHERE 条件を付けてインデックスの対象行を絞ることで、
サイズを小さく保ちつつ、特定のクエリパターンに対して高い効果を発揮します。
アンチパターン 3: 使われていないインデックスの放置
開発初期に作成したインデックスが、仕様変更やクエリ修正で使われなくなっているケースは頻繁に発生します。 定期的に未使用インデックスを確認し、不要なら削除しましょう。
-- PostgreSQL: 使われていないインデックスの確認
SELECT schemaname, relname AS table_name,
indexrelname AS index_name,
idx_scan AS times_used,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
pg_stat_user_indexes の統計はサーバ再起動やスタンバイ昇格でリセットされます。
十分な期間(少なくとも 1 か月以上)の運用データを蓄積してから判断してください。
月末処理や年次バッチでしか使われないインデックスを誤って削除しないよう注意が必要です。