データモデル設計ガイド

外部キー・リレーション列のインデックス

FK 列に索引がないと親の DELETE / UPDATE が全件スキャン。MySQL は自動・PostgreSQL は手動、N:M の両方向も要注意。

外部キー・リレーション列のインデックス diagram

FK 列の子側インデックスは必須

外部キー制約を付けると、親テーブルの行を DELETE / UPDATE するときに子テーブルの該当行が存在しないかを確認する必要があります。子側の FK 列に索引がないと、この確認が子テーブル全件スキャンになります。

例: orders.user_id に FK があり索引がない状況で、ユーザー 1 件を削除しようとすると:

DELETE FROM users WHERE id = 42;
-- 内部的に実行: SELECT 1 FROM orders WHERE user_id = 42;
-- orders 全件スキャン(数千万行でもフルスキャン)

親テーブルの単純な削除が数秒〜数分で返らない、という事故の典型原因です。FK 列には例外なく索引を貼るのが鉄則。

RDBMSFK 列の索引備考
MySQL (InnoDB)自動作成FK 作成時に同名の索引がなければ自動で作る
PostgreSQL手動で作る必要ありFK 宣言だけでは索引は作られない。明示的に CREATE INDEX
SQL Server手動で作る必要ありPostgreSQL と同じ
Oracle手動で作る必要あり同上

ON DELETE CASCADE と索引

ON DELETE CASCADE は「親を消すと子も連鎖削除」する便利な機能ですが、内部的には親削除のたびに子を WHERE 句で探して DELETE するため、FK 列の索引必須度がさらに上がります。

索引がないままカスケード削除を実行すると:

  • 親 1 行の削除ごとに子テーブル全件スキャン
  • 親を 100 件まとめて削除 → 子テーブル全件スキャン × 100 回
  • 親が数百万件 × 子が数億件で、削除ジョブが何時間も終わらない典型パターン

CASCADE を使う FK は特に子側の索引を最初から用意し、EXPLAIN で DELETE の実行計画を確認する価値があります。ON DELETE RESTRICT / SET NULL でも同様の事情が発生します。

中間テーブル(N:M)の両方向インデックス

N:M リレーションを表す中間テーブル(交差表)は両方向から検索されます。片方向の索引しかないと、逆方向のクエリが遅くなります。

例: user_roles (user_id, role_id) の中間テーブル。

-- ① user から role を引く(ユーザーのロール一覧)
SELECT role_id FROM user_roles WHERE user_id = 42;

-- ② role から user を引く(ロールを持つユーザー一覧)
SELECT user_id FROM user_roles WHERE role_id = 3;

PK を (user_id, role_id) にすると ① は高速ですが、② は role_id が左端欠けで索引が使えません。対策は 2 つ。

  • 逆向きの索引を追加: CREATE INDEX idx_role_user ON user_roles (role_id, user_id)。2 本持つ形だが、カバリングになるので高速
  • 両方向の UNIQUE 制約でも可: UNIQUE (user_id, role_id) + UNIQUE (role_id, user_id)

中間テーブルは両方向の索引を最初から貼るのが定石。後から追加になるケースが非常に多いので、設計レビューでチェック項目にしましょう。

sql
-- N:M 中間テーブルの定番
CREATE TABLE user_roles (
  user_id BIGINT NOT NULL REFERENCES users(id),
  role_id BIGINT NOT NULL REFERENCES roles(id),
  PRIMARY KEY (user_id, role_id)    -- 順方向
);

-- 必ず逆方向の索引も貼る
CREATE INDEX idx_user_roles_role
  ON user_roles (role_id, user_id); -- 逆方向 + カバリング

「親から子一覧」クエリを支える列順

リレーション列に索引を貼るだけでは足りないケースが多くあります。典型は「親 ID で絞った子を新着順に並べる」パターン。

-- ブログ記事のコメント一覧(新着順)
SELECT * FROM comments
WHERE article_id = 123
ORDER BY created_at DESC
LIMIT 50;

ここで comments.article_id 単独の索引だと、article_id で絞ったあとに取得した全件を再ソートすることになります。50 件程度なら問題ありませんが、1 記事に数千コメントが付く人気記事ではソート処理が遅延の原因になります。

正解は (article_id, created_at DESC) の複合索引:

CREATE INDEX idx_comments_article_recency
  ON comments (article_id, created_at DESC);

こうすれば索引自体が「記事ごとに新着順」にソート済みなので、先頭 50 件を読むだけで LIMIT が完結します。FK 索引は単列で終わらせず、その FK と一緒に使われる絞り込み・ソート列を含めた複合索引で設計するのが実務の定石です。

設計レビュー — FK 索引チェックリスト

FK 周りの索引は「忘れる」ことによる事故が多い領域です。設計レビューでは次を機械的に確認しましょう。

  • [ ] 全 FK 列に索引があるか(PostgreSQL / SQL Server / Oracle は手動)
  • [ ] ON DELETE CASCADE / RESTRICT / SET NULL を使う FK は特に索引があるか
  • [ ] N:M 中間テーブルは両方向の索引があるか
  • [ ] 「親 → 子一覧 + ソート」のクエリを支える複合索引があるか
  • [ ] FK 列 + 論理削除列(deleted_at IS NULL)の組み合わせは部分インデックスで対応すべきか(部分インデックス

FK 制約そのものの使いどころは 外部キー制約の使いどころ を参照してください。