はじめに
DB設計で主キーをどう決めるかは、よく議論になるテーマです。大きく分けると2つの方針があります。
- ナチュラルキー派:ビジネス上の識別子をそのまま主キーにする
-
サロゲートキー派:全テーブルに
Id列を置き、ビジネス上の一意性はユニーク制約で表現する
私はサロゲートキー派です。理由は2つあります。
- ビジネスルールの変更でキー構成が変わったときの影響範囲を小さくしたい
- ORMとの相性がよい(特にナチュラルキーが複合キーになる場合)
この記事では、なぜ私がサロゲートキーを選ぶのか、具体例を交えて整理します。
ナチュラルキーとサロゲートキーの違い
注文(Orders)と注文明細(OrderItems)を例に、SQL Serverの構文で見てみます。
ナチュラルキー方式
OrderItemsの主キーを (OrderId, LineNumber) とする設計です。
CREATE TABLE OrderItems (
OrderId INT NOT NULL,
LineNumber INT NOT NULL,
ProductId INT NOT NULL,
Quantity INT NOT NULL,
PRIMARY KEY (OrderId, LineNumber),
FOREIGN KEY (OrderId) REFERENCES Orders(Id)
);
ビジネス上の一意性がそのまま主キーになるので、テーブル定義を見ただけで「何がこのレコードを一意に識別するか」がわかります。
サロゲートキー方式
全テーブルに Id 列を追加し、ビジネス上の一意性はユニーク制約で表現します。
CREATE TABLE OrderItems (
Id INT IDENTITY PRIMARY KEY,
OrderId INT NOT NULL,
LineNumber INT NOT NULL,
ProductId INT NOT NULL,
Quantity INT NOT NULL,
CONSTRAINT UQ_OrderItems_OrderId_LineNumber
UNIQUE (OrderId, LineNumber),
CONSTRAINT FK_OrderItems_Orders
FOREIGN KEY (OrderId) REFERENCES Orders(Id)
);
キー構成の変更への強さ
業務システムを長く運用していると、ビジネスルールの変更は避けられません。ナチュラルキーを主キーにしていると、ビジネスルールの変更が主キーの変更に直結します。
たとえば「OrderIdとLineNumberで一意だった明細に、倉庫コード(WarehouseCode)が加わって3列で一意になった」というケースを考えます。
ナチュラルキー方式の場合
主キーの構成変更は破壊的です。
- OrderItemsの主キーを
(OrderId, LineNumber)→(OrderId, LineNumber, WarehouseCode)に変更 - OrderItemsを外部キーで参照しているすべてのテーブルに
WarehouseCode列を追加 - 関連するインデックス、外部キー制約をすべて再作成
- アプリケーション側のFindやJOINも全箇所修正
影響が連鎖的に広がります。
サロゲートキー方式の場合
- OrderItemsのユニーク制約を変更
-- 旧
CONSTRAINT UQ_OrderItems_OrderId_LineNumber
UNIQUE (OrderId, LineNumber)
-- 新
CONSTRAINT UQ_OrderItems_OrderId_LineNumber_WarehouseCode
UNIQUE (OrderId, LineNumber, WarehouseCode)
これだけです。Id で参照している関連テーブルには一切手を入れる必要がありません。
ORMとの相性
多くのORMは、単一の整数型・UUID型の主キーを前提としたAPIを提供しています。ナチュラルキーが単一列であればまだ問題は少ないですが、複合キーになると途端に扱いが煩雑になります。
私が普段使っているC# + EF Coreでも、この傾向は顕著です。
Findメソッドが不便
サロゲートキーなら直感的に書けます。
var item = await context.OrderItems.FindAsync(orderItemId);
複合キーだとこうなります。
var item = await context.OrderItems.FindAsync(orderId, lineNumber);
FindAsync の引数は params object[] なので、型も個数もコンパイル時にはチェックされません。キーの定義は OnModelCreating にあり、呼び出し側とは離れた場所にあるため、引数の過不足や順序の間違いがあっても実行時まで気づけません。
モデル定義が冗長になる
複合キーはFluent APIでの設定が必須です。
modelBuilder.Entity<OrderItem>()
.HasKey(x => new { x.OrderId, x.LineNumber });
サロゲートキーなら [Key] 属性をつけるだけ、あるいは Id という命名規則に従えば設定すら不要です。テーブル数が増えるほど、この差が積み重なります。
リレーションの設定も複雑になる
複合キーを外部キーとして参照する側のテーブルでは、複数列をセットで指定する必要があります。関連テーブルが増えるたびに列が増え、Fluent APIの設定も膨れていきます。
サロゲートキーへのよくある懸念
ユニーク制約だとわかりづらくないか
サロゲートキー方式に対してよく挙がる懸念です。ナチュラルキーなら「このテーブルはこの列の組み合わせで一意」というのが主キー定義から一目瞭然ですが、ユニーク制約だと見落とされるのではないか、と。
もっともな懸念です。ただ、以下の運用でカバーできています。
制約に意味のある名前をつける
CONSTRAINT UQ_OrderItems_OrderId_LineNumber UNIQUE (OrderId, LineNumber)
UQ_{テーブル名}_{カラム名} という命名規則を統一しておけば、DDLを見たときにビジネスキーがすぐわかります。
テーブル定義書に明記する
「ビジネスキーは OrderId + LineNumber の組み合わせ」と定義書やカラムコメントに書いておきます。
レビューでチェックする
設計規約として「サロゲートキーを置いたテーブルには必ずビジネスキーのユニーク制約をつける」を明文化し、レビュー観点に入れています。ユニーク制約の付け忘れは、ビジネス上ありえない重複データの登録を許してしまうので、ここは手を抜けないポイントです。
正規化に反しないか
「Id列を追加するのは正規化のルールに反するのでは」と聞かれることがありますが、反しません。
正規化が求めるのは「候補キーが存在し、非キー属性がキーに完全に関数従属していること」です。サロゲートキーを追加しても、ビジネスキーにユニーク制約をつけていれば候補キーは消えていないので、正規化の条件はそのまま満たされます。
Id も (OrderId, LineNumber) もどちらも候補キーであり、Id を主キーに選んでいるだけ。候補キーが2つあるテーブルというだけの話です。
まとめ
| 観点 | ナチュラルキー | サロゲートキー + ユニーク制約 |
|---|---|---|
| ビジネス上の意味の明示性 | ◎ 主キーがそのまま意味を持つ | ○ 命名・規約でカバーが必要 |
| キー構成変更時の影響範囲 | △ 関連テーブルに波及 | ◎ ユニーク制約の修正だけ |
| ORMでの扱いやすさ | △ 複合キーになると煩雑 | ◎ 規約ベースでシンプル |
| 正規化との整合性 | ◎ | ◎(ユニーク制約があれば問題なし) |
ナチュラルキーの「意味的なわかりやすさ」は確かに魅力です。ただ、変更への強さとORMとの相性を天秤にかけると、私はサロゲートキーを選びます。
ユニーク制約のわかりづらさは命名規約・ドキュメント・レビューで十分カバーできます。