- 設計
主キー設計の考え方:サロゲートキーとナチュラルキーの違いと選び方
主キー設計の基本は、「行を特定する役目」と「業務上の重複を防ぐ役目」を分けて考えることです。 行を特定する役目は意味を持たないサロゲートキー(代理キー)に任せ、受注番号や顧客コードのようなナチュラルキー(自然キー)には UNIQUE 制約を付けます。こうしておくと、番号の体系が変わっても、参照している側のテーブルに手を入れずに済みます。
この記事では、同じ受注テーブルを3つの方式で書いた CREATE TABLE と、キーの値を変えたとき・外部キーで参照したときに実際に出るエラー文を、PostgreSQL と MySQL の両方で示します。
この記事の結論
- 主キーはサロゲートキー(連番または UUID)にします。 値に意味を持たせないので、変更する理由が生まれません。
- ナチュラルキーには NOT NULL と UNIQUE 制約を付けます。 サロゲートキーだけでは同じ受注番号を2回登録できてしまいます。
- 交差テーブル(多対多をつなぐテーブル)だけは複合主キーも選べます。 2つの外部キーの組み合わせがそのまま「重複させたくない単位」だからです。
- 採番方式は、1つのデータベースで完結するなら連番、複数の場所で採番する・ID を外に見せるなら UUIDv7 か UUIDv4 を選びます。
確認環境:PostgreSQL 16.15、MySQL 8.0.46(InnoDB)、Ubuntu 24.04。確認日 2026-10-05。掲載した SQL と実行結果は、すべてこの環境で流したものです。データベース名は appdb に置き換えています。
主キー設計の早見表(テーブルの種類別)
| テーブルの種類 | 例 | 主キー | あわせて付ける制約 |
|---|---|---|---|
| マスタ | 顧客、商品 | サロゲートキー(customer_id) | 顧客コードに NOT NULL と UNIQUE |
| トランザクション | 受注、受注明細 | サロゲートキー(order_id) | 受注番号に UNIQUE、明細は(order_id, line_no)に UNIQUE |
| 交差テーブル | 受注とタグの対応 | 複合主キー(order_id, tag_id)も可 | 逆向きの検索用に tag_id の索引 |
| 履歴 | 顧客名の変更履歴 | サロゲートキー(history_id) | (customer_id, valid_from)に UNIQUE |
| コード表(数件〜数十件で値が変わらない) | 状態区分、単位 | コードそのもの(ナチュラルキー)も可 | 表示名に NOT NULL |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「主キー設計の考え方:サロゲートキーとナチュラルキーの違いと選び方」
サロゲートキー・ナチュラルキー・複合主キーの違い
| 用語 | 意味 | 受注テーブルでの例 | 値が変わる可能性 |
|---|---|---|---|
| 主キー | 行を1つに特定する列(の組)。NULL を持てず、重複しない | order_id | 変えない前提で使う |
| ナチュラルキー(自然キー) | 業務の中でもともと使われている識別子 | 受注番号 SO-2026-000001 | 番号体系の見直しや入力の訂正で変わりうる |
| サロゲートキー(代理キー) | 行を特定するためだけに付ける、意味を持たない値 | 連番の order_id、UUID | 変える理由がない |
| 複合主キー | 2つ以上の列を組み合わせた主キー | (顧客コード, 受注日, 連番) | 構成する列のどれかが変わると全体が変わる |
| 候補キー | 主キーになれる資格がある列(の組)。主キーに選ばなかったものは UNIQUE 制約で守る | 受注番号 | ― |
「サロゲートキーかナチュラルキーか」は、どちらか一方を捨てる話ではありません。ナチュラルキーは残したまま、主キーの役目だけをサロゲートキーに移す、というのが実際の設計です。
同じ受注テーブルを3方式で書く
題材は架空の受注管理です。顧客、受注、受注明細の3つのテーブルを使います。
方式A:ナチュラルキーを主キーにする
CREATE TABLE customers_a (
customer_code varchar(10) PRIMARY KEY,
customer_name text NOT NULL
);
CREATE TABLE orders_a (
order_no varchar(14) PRIMARY KEY,
customer_code varchar(10) NOT NULL REFERENCES customers_a (customer_code),
ordered_on date NOT NULL,
total_amount integer NOT NULL
);
CREATE TABLE order_items_a (
order_no varchar(14) NOT NULL REFERENCES orders_a (order_no),
line_no integer NOT NULL,
item_code varchar(10) NOT NULL,
quantity integer NOT NULL,
PRIMARY KEY (order_no, line_no)
);
方式B:複合主キーにする
CREATE TABLE orders_b (
customer_code varchar(10) NOT NULL REFERENCES customers_a (customer_code),
ordered_on date NOT NULL,
seq_no integer NOT NULL,
total_amount integer NOT NULL,
PRIMARY KEY (customer_code, ordered_on, seq_no)
);
CREATE TABLE order_items_b (
customer_code varchar(10) NOT NULL,
ordered_on date NOT NULL,
seq_no integer NOT NULL,
line_no integer NOT NULL,
item_code varchar(10) NOT NULL,
quantity integer NOT NULL,
PRIMARY KEY (customer_code, ordered_on, seq_no, line_no),
FOREIGN KEY (customer_code, ordered_on, seq_no)
REFERENCES orders_b (customer_code, ordered_on, seq_no)
);
親の主キーが3列なので、明細テーブルにも同じ3列を持たせることになります。 明細のさらに下に子テーブルを作ると、4列を持ち回ります。
方式C:サロゲートキーを主キーにする(PostgreSQL)
CREATE TABLE customers_c (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_code varchar(10) NOT NULL UNIQUE,
customer_name text NOT NULL
);
CREATE TABLE orders_c (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no varchar(14) NOT NULL UNIQUE,
customer_id bigint NOT NULL REFERENCES customers_c (customer_id),
ordered_on date NOT NULL,
total_amount integer NOT NULL
);
CREATE TABLE order_items_c (
order_item_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders_c (order_id),
line_no integer NOT NULL,
item_code varchar(10) NOT NULL,
quantity integer NOT NULL,
UNIQUE (order_id, line_no)
);
方式C:サロゲートキーを主キーにする(MySQL)
CREATE TABLE customers_c (
customer_id bigint AUTO_INCREMENT PRIMARY KEY,
customer_code varchar(10) NOT NULL UNIQUE,
customer_name varchar(100) NOT NULL
);
CREATE TABLE orders_c (
order_id bigint AUTO_INCREMENT PRIMARY KEY,
order_no varchar(14) NOT NULL UNIQUE,
customer_id bigint NOT NULL,
ordered_on date NOT NULL,
total_amount int NOT NULL,
CONSTRAINT fk_orders_c_customer FOREIGN KEY (customer_id) REFERENCES customers_c (customer_id)
);
CREATE TABLE order_items_c (
order_item_id bigint AUTO_INCREMENT PRIMARY KEY,
order_id bigint NOT NULL,
line_no int NOT NULL,
item_code varchar(10) NOT NULL,
quantity int NOT NULL,
UNIQUE (order_id, line_no),
CONSTRAINT fk_items_c_order FOREIGN KEY (order_id) REFERENCES orders_c (order_id)
);
確認用のデータ
INSERT INTO customers_a VALUES ('C0001','青空商店'),('C0002','みどり文具');
INSERT INTO orders_a VALUES ('SO-2026-000001','C0001','2026-10-01',5400),
('SO-2026-000002','C0002','2026-10-01',1200);
INSERT INTO order_items_a VALUES ('SO-2026-000001',1,'ITEM-01',2),
('SO-2026-000001',2,'ITEM-02',1),
('SO-2026-000002',1,'ITEM-03',4);
INSERT INTO orders_b VALUES ('C0001','2026-10-01',1,5400),('C0002','2026-10-01',1,1200);
INSERT INTO order_items_b VALUES ('C0001','2026-10-01',1,1,'ITEM-01',2),
('C0001','2026-10-01',1,2,'ITEM-02',1);
INSERT INTO customers_c (customer_code, customer_name) VALUES ('C0001','青空商店'),('C0002','みどり文具');
INSERT INTO orders_c (order_no, customer_id, ordered_on, total_amount)
VALUES ('SO-2026-000001',1,'2026-10-01',5400),('SO-2026-000002',2,'2026-10-01',1200);
INSERT INTO order_items_c (order_id, line_no, item_code, quantity)
VALUES (1,1,'ITEM-01',2),(1,2,'ITEM-02',1),(2,1,'ITEM-03',4);
3方式の比較
| 観点 | A:ナチュラルキー | B:複合主キー | C:サロゲートキー |
|---|---|---|---|
| 主キーの列数 | 1 | 3 | 1 |
| 子テーブルが持つ外部キーの列数 | 1 | 3 | 1 |
| 番号体系が変わったとき | 参照している全テーブルの値を書き換える | 同じ | 1行の UPDATE で済む |
| 結合条件 | 1列 | 3列を AND でつなぐ | 1列 |
| 業務上の重複の防止 | 主キーが兼ねる | 主キーが兼ねる | UNIQUE 制約を別に付ける |
| データを見たときの分かりやすさ | 子テーブルだけ見ても受注番号が分かる | 同じ | 親と結合して受注番号を得る |
ナチュラルキーの値が変わったときに起きること
顧客コードの体系を C0001 から K0001 に切り替える、という変更を考えます。
方式A:外部キーに止められる
PostgreSQL 16 の結果です。
UPDATE customers_a SET customer_code = 'K0001' WHERE customer_code = 'C0001';
ERROR: update or delete on table "customers_a" violates foreign key constraint "orders_a_customer_code_fkey" on table "orders_a"
DETAIL: Key (customer_code)=(C0001) is still referenced from table "orders_a".
受注番号を直そうとしても同じです。
UPDATE orders_a SET order_no = 'SO-2026-900001' WHERE order_no = 'SO-2026-000001';
ERROR: update or delete on table "orders_a" violates foreign key constraint "order_items_a_order_no_fkey" on table "order_items_a"
DETAIL: Key (order_no)=(SO-2026-000001) is still referenced from table "order_items_a".
MySQL 8.0 では、同じ2つの UPDATE が次のエラーになります。
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`appdb`.`orders_a`, CONSTRAINT `fk_orders_a_customer` FOREIGN KEY (`customer_code`) REFERENCES `customers_a` (`customer_code`))
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`appdb`.`order_items_a`, CONSTRAINT `fk_items_a_order` FOREIGN KEY (`order_no`) REFERENCES `orders_a` (`order_no`))
外部キーは「参照されている値は勝手に変えさせない」ように働くので、主キーにした値は事実上変えにくくなります。 これは外部キーが正しく働いている証拠で、設計の側で「変わりうる値を主キーにしない」ことで避けられます。
方式C:1行の UPDATE で終わる
UPDATE customers_c SET customer_code = 'K0001' WHERE customer_code = 'C0001';
UPDATE orders_c SET order_no = 'SO-2026-900001' WHERE order_no = 'SO-2026-000001';
SELECT o.order_id, o.order_no, c.customer_code, count(i.*) AS items
FROM orders_c o JOIN customers_c c USING (customer_id)
LEFT JOIN order_items_c i USING (order_id)
GROUP BY 1,2,3 ORDER BY 1;
order_id | order_no | customer_code | items
----------+----------------+---------------+-------
1 | SO-2026-900001 | K0001 | 2
2 | SO-2026-000002 | C0002 | 1
(2 rows)
子テーブルは order_id と customer_id でつながっているので、コードや番号を変えても明細2件とのつながりはそのままです。MySQL でも同じ2つの UPDATE がエラーなく通りました。
方式A・Bのまま通すなら ON UPDATE CASCADE
ナチュラルキーを主キーにしたまま値を変えたい場合は、外部キーに ON UPDATE CASCADE を付けると、親の変更が子へ自動で伝わります。PostgreSQL で、顧客を参照している2つの外部キーを付け替えてから UPDATE しました。
ALTER TABLE orders_a DROP CONSTRAINT orders_a_customer_code_fkey,
ADD CONSTRAINT orders_a_customer_code_fkey FOREIGN KEY (customer_code)
REFERENCES customers_a (customer_code) ON UPDATE CASCADE;
ALTER TABLE orders_b DROP CONSTRAINT orders_b_customer_code_fkey,
ADD CONSTRAINT orders_b_customer_code_fkey FOREIGN KEY (customer_code)
REFERENCES customers_a (customer_code) ON UPDATE CASCADE;
UPDATE customers_a SET customer_code = 'K0001' WHERE customer_code = 'C0001';
ERROR: update or delete on table "orders_b" violates foreign key constraint "order_items_b_customer_code_ordered_on_seq_no_fkey" on table "order_items_b"
DETAIL: Key (customer_code, ordered_on, seq_no)=(C0001, 2026-10-01, 1) is still referenced from table "order_items_b".
今度は孫にあたる order_items_b で止まりました。 顧客コードは orders_b の複合主キーの一部で、それをさらに明細が参照しているためです。明細側の外部キーにも ON UPDATE CASCADE を付けると通ります。
ALTER TABLE order_items_b DROP CONSTRAINT order_items_b_customer_code_ordered_on_seq_no_fkey,
ADD CONSTRAINT order_items_b_customer_code_ordered_on_seq_no_fkey
FOREIGN KEY (customer_code, ordered_on, seq_no)
REFERENCES orders_b (customer_code, ordered_on, seq_no) ON UPDATE CASCADE;
UPDATE customers_a SET customer_code = 'K0001' WHERE customer_code = 'C0001';
SELECT 'orders_a' AS tbl, count(*) FROM orders_a WHERE customer_code = 'K0001'
UNION ALL SELECT 'orders_b', count(*) FROM orders_b WHERE customer_code = 'K0001'
UNION ALL SELECT 'order_items_b', count(*) FROM order_items_b WHERE customer_code = 'K0001';
tbl | count
---------------+-------
orders_a | 1
orders_b | 1
order_items_b | 2
(3 rows)
顧客1件のコード変更で、3つのテーブルの計4行が書き換わりました。行数が多いテーブルでは、この書き換えがそのまま更新量とロックの範囲になります。キーが伝わる経路のすべてに CASCADE を付ける必要がある点が、複合主キーとナチュラルキーの組み合わせで見落としやすいところです。
複合主キーを外部キーで参照するときの決まり
複合主キーのテーブルを参照する側は、主キーの全列をそろえて参照します。一部の列だけで参照しようとすると、PostgreSQL ではテーブルを作る時点でエラーになります。
CREATE TABLE shipments_b (
shipment_no varchar(14) PRIMARY KEY,
customer_code varchar(10) NOT NULL,
seq_no integer NOT NULL,
FOREIGN KEY (customer_code, seq_no) REFERENCES orders_b (customer_code, seq_no)
);
ERROR: there is no unique constraint matching given keys for referenced table "orders_b"
MySQL 8.0 で同じ文を流すと、次のエラーです。
ERROR 1822 (HY000): Failed to add the foreign key constraint. Missing index for constraint 'shipments_b_ibfk_1' in the referenced table 'orders_b'
ただし MySQL(InnoDB)は、参照先の索引の先頭から順に並んだ列であれば、一意でなくても外部キーにできます。主キー(customer_code, ordered_on, seq_no)の先頭2列を参照する次のテーブルは、エラーなく作成できました。
CREATE TABLE shipments_b2 (
shipment_no varchar(14) PRIMARY KEY,
customer_code varchar(10) NOT NULL,
ordered_on date NOT NULL,
FOREIGN KEY (customer_code, ordered_on) REFERENCES orders_b (customer_code, ordered_on)
);
MySQL 8.0 リファレンスマニュアルは、これを標準 SQL に対する InnoDB の拡張だと説明しています。この形では「どの1件の受注を指しているか」が決まらないので、MySQL でも外部キーは主キーか UNIQUE 制約の全列に向けると、PostgreSQL と同じ意味になります。
サロゲートキーだけでは重複を防げない
サロゲートキーにしたときに忘れやすいのが、ナチュラルキーの UNIQUE 制約です。制約なしのテーブルで確かめます。
CREATE TABLE orders_c2 (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no varchar(14) NOT NULL
);
INSERT INTO orders_c2 (order_no) VALUES ('SO-2026-000001');
INSERT INTO orders_c2 (order_no) VALUES ('SO-2026-000001');
SELECT * FROM orders_c2;
order_id | order_no
----------+----------------
1 | SO-2026-000001
2 | SO-2026-000001
(2 rows)
同じ受注番号が2行入りました。UNIQUE 制約を付けた orders_c では、登録の時点で止まります。
INSERT INTO orders_c (order_no, customer_id, ordered_on, total_amount)
VALUES ('SO-2026-000002',1,'2026-10-02',800);
PostgreSQL 16:
ERROR: duplicate key value violates unique constraint "orders_c_order_no_key"
DETAIL: Key (order_no)=(SO-2026-000002) already exists.
MySQL 8.0:
ERROR 1062 (23000): Duplicate entry 'SO-2026-000002' for key 'orders_c.order_no'
交差テーブルと履歴テーブルの主キー
交差テーブル:複合主キーが素直
受注とタグの対応のように、2つの外部キーだけでできているテーブルは、その組み合わせを主キーにできます。
CREATE TABLE tags (
tag_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tag_name varchar(30) NOT NULL UNIQUE
);
CREATE TABLE order_tags (
order_id bigint NOT NULL REFERENCES orders_c (order_id),
tag_id bigint NOT NULL REFERENCES tags (tag_id),
PRIMARY KEY (order_id, tag_id)
);
CREATE INDEX order_tags_tag_id_idx ON order_tags (tag_id);
INSERT INTO tags (tag_name) VALUES ('急ぎ'),('贈答用');
INSERT INTO order_tags VALUES (1,1),(1,2);
INSERT INTO order_tags VALUES (1,1);
ERROR: duplicate key value violates unique constraint "order_tags_pkey"
DETAIL: Key (order_id, tag_id)=(1, 1) already exists.
MySQL 8.0 では ERROR 1062 (23000): Duplicate entry '1-1' for key 'order_tags.PRIMARY' です。ここでの複合主キーは、構成する列がどちらも変わらないサロゲートキーなので、前の章のような書き換えの連鎖は起きません。
交差テーブルにサロゲートキーを足すのは、次のどちらかに当てはまるときです。
| 状況 | 選ぶ形 |
|---|---|
| 2つの外部キーだけで、ほかのテーブルから参照されない | 複合主キー(order_id, tag_id) |
| 交差テーブル自体がほかのテーブルから参照される、または数量・期間などの属性が増えて1件ずつ扱う | サロゲートキー+(order_id, tag_id)に UNIQUE |
履歴テーブル:サロゲートキー+UNIQUE
CREATE TABLE customer_name_history (
history_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers_c (customer_id),
valid_from date NOT NULL,
customer_name text NOT NULL,
UNIQUE (customer_id, valid_from)
);
INSERT INTO customer_name_history (customer_id, valid_from, customer_name)
VALUES (1,'2026-04-01','青空商店'),(1,'2026-10-01','青空商店本店');
INSERT INTO customer_name_history (customer_id, valid_from, customer_name)
VALUES (1,'2026-10-01','あおぞら商店');
ERROR: duplicate key value violates unique constraint "customer_name_history_customer_id_valid_from_key"
DETAIL: Key (customer_id, valid_from)=(1, 2026-10-01) already exists.
履歴は「適用開始日を訂正する」ことがあるため、日付を主キーに含めずに UNIQUE 制約で守る形にしておくと、訂正が1行の UPDATE で済みます。
連番・UUIDv4・UUIDv7のどれを主キーにするか
サロゲートキーの値の作り方は、大きく3つあります。
| 観点 | 連番(IDENTITY / AUTO_INCREMENT) | UUIDv4 | UUIDv7 |
|---|---|---|---|
| サイズ | bigint で8バイト | 16バイト(uuid 型・BINARY(16)) | 16バイト |
| 並び順 | 採番した順に大きくなる | 乱数なので作成順と無関係 | 先頭48ビットがミリ秒の時刻で、ほぼ作成順に並ぶ |
| 推測されにくさ | 隣の番号が分かる(1001 の次は 1002) | 122ビットが乱数 | 作成時刻は値から読み取れる。残りは乱数 |
| 採番する場所 | データベース | アプリケーションでもデータベースでも | 同じ |
| データベースをまたぐ統合 | 番号がぶつかるので振り直しが要る | そのまま持ち寄れる | そのまま持ち寄れる |
| 人が読む・口頭で伝える | 伝えやすい | 36文字で長い | 36文字で長い |
UUIDv4 と UUIDv7 のビット構成は RFC 9562 の定義です。PostgreSQL は 18 から uuidv7() が組み込み関数になりました(公式ドキュメントの UUID 関数の章に掲載)。今回の確認環境は 16 なので、下の測定では同じビット構成の値を返す関数を用意して使っています。
サイズを測る
SELECT pg_column_size(1::bigint) AS bigint_bytes,
pg_column_size(gen_random_uuid()) AS uuid_bytes,
pg_column_size(gen_random_uuid()::text) AS uuid_text_bytes,
pg_column_size('SO-2026-000001'::varchar) AS order_no_bytes;
bigint_bytes | uuid_bytes | uuid_text_bytes | order_no_bytes
--------------+------------+-----------------+----------------
8 | 16 | 40 | 18
(1 row)
UUID を文字列型の列に入れると、uuid 型の2倍以上の大きさになります。 30万行を入れたときの主キー索引の大きさも測りました。
-- PostgreSQL 16 向けの UUIDv7 生成関数(18 以降は組み込みの uuidv7() を使う)
CREATE FUNCTION uuid_v7() RETURNS uuid LANGUAGE sql VOLATILE AS $$
SELECT encode(
set_bit(set_bit(
overlay(uuid_send(gen_random_uuid())
PLACING substring(int8send((extract(epoch FROM clock_timestamp()) * 1000)::bigint) FROM 3)
FROM 1 FOR 6),
52, 1), 53, 1), 'hex')::uuid
$$;
CREATE TABLE k_seq (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, memo text);
CREATE TABLE k_v4 (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, memo text);
CREATE TABLE k_v7 (id uuid DEFAULT uuid_v7() PRIMARY KEY, memo text);
CREATE TABLE k_txt (id varchar(36) DEFAULT gen_random_uuid()::text PRIMARY KEY, memo text);
INSERT INTO k_seq (memo) SELECT 'x' FROM generate_series(1,300000);
INSERT INTO k_v4 (memo) SELECT 'x' FROM generate_series(1,300000);
INSERT INTO k_v7 (memo) SELECT 'x' FROM generate_series(1,300000);
INSERT INTO k_txt (memo) SELECT 'x' FROM generate_series(1,300000);
SELECT c.relname AS index_name, pg_size_pretty(pg_relation_size(c.oid)) AS index_size,
pg_size_pretty(pg_relation_size(i.indrelid)) AS table_size
FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid
WHERE c.relname IN ('k_seq_pkey','k_v4_pkey','k_v7_pkey','k_txt_pkey') ORDER BY 1;
index_name | index_size | table_size
------------+------------+------------
k_seq_pkey | 6600 kB | 13 MB
k_txt_pkey | 21 MB | 20 MB
k_v4_pkey | 12 MB | 15 MB
k_v7_pkey | 11 MB | 15 MB
(4 rows)
MySQL(InnoDB)では、主キーの長さが主キー以外の索引にも効きます。主キーの型だけを変え、grp という整数の列に索引を1つ付けたテーブルに30万行ずつ入れて比べました。
CREATE TABLE k_seq (id bigint AUTO_INCREMENT PRIMARY KEY, memo varchar(10), grp int, KEY (grp));
CREATE TABLE k_bin (id binary(16) PRIMARY KEY, memo varchar(10), grp int, KEY (grp));
CREATE TABLE k_txt (id char(36) PRIMARY KEY, memo varchar(10), grp int, KEY (grp));
SET SESSION cte_max_recursion_depth = 400000;
INSERT INTO k_seq (memo, grp)
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 300000) SELECT 'x', n FROM s;
INSERT INTO k_bin
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 300000) SELECT UUID_TO_BIN(UUID()), 'x', n FROM s;
INSERT INTO k_txt
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 300000) SELECT UUID(), 'x', n FROM s;
ANALYZE TABLE k_seq, k_bin, k_txt;
SELECT table_name, ROUND(data_length/1024/1024,1) AS data_mb,
ROUND(index_length/1024/1024,1) AS secondary_index_mb
FROM information_schema.tables
WHERE table_schema='appdb' AND table_name LIKE 'k\_%' ORDER BY 1;
+------------+---------+--------------------+
| TABLE_NAME | data_mb | secondary_index_mb |
+------------+---------+--------------------+
| k_bin | 13.5 | 8.5 |
| k_seq | 10.5 | 5.5 |
| k_txt | 19.6 | 14.6 |
+------------+---------+--------------------+
grp の索引は3つとも同じ整数の列に張ったものですが、大きさは 5.5MB、8.5MB、14.6MB と主キーの長さに応じて増えています。MySQL 8.0 リファレンスマニュアルの「Clustered and Secondary Indexes」には、InnoDB のセカンダリ索引の各レコードが主キーの列を含むこと、主キーが長いとセカンダリ索引が場所を使うので短い主キーが有利であることが書かれています。MySQL で UUID を主キーにするなら CHAR(36) ではなく BINARY(16) に入れると、この差を小さくできます(数値は information_schema の統計値です)。
並び順と、値から読み取れる情報
5行を10ミリ秒おきに登録し、UUIDv4 と UUIDv7 のそれぞれで並べ替えた順番を比べます。
CREATE TABLE t_order (n integer, v4 uuid DEFAULT gen_random_uuid(), v7 uuid DEFAULT uuid_v7());
INSERT INTO t_order (n) VALUES (1); SELECT pg_sleep(0.01);
INSERT INTO t_order (n) VALUES (2); SELECT pg_sleep(0.01);
INSERT INTO t_order (n) VALUES (3); SELECT pg_sleep(0.01);
INSERT INTO t_order (n) VALUES (4); SELECT pg_sleep(0.01);
INSERT INTO t_order (n) VALUES (5);
SELECT string_agg(n::text, ',' ORDER BY v4) AS order_by_v4,
string_agg(n::text, ',' ORDER BY v7) AS order_by_v7 FROM t_order;
order_by_v4 | order_by_v7
-------------+-------------
2,3,5,1,4 | 1,2,3,4,5
(1 row)
UUIDv7 は登録した順に並び、UUIDv4 は無関係な順になりました(v4 の並びは実行のたびに変わります)。新しい行が索引の末尾付近にまとまって入るので、RFC 9562 も時刻順の UUID は索引の局所性がよくなると説明しています。
一方で、UUIDv7 の先頭12桁からは作成時刻が取り出せます。
SELECT to_timestamp(('x' || replace(left(v7::text, 13), '-', ''))::bit(48)::bigint / 1000.0) AS created_at_in_v7, v7
FROM t_order WHERE n = 1;
created_at_in_v7 | v7
----------------------------+--------------------------------------
2026-10-05 14:49:48.778+00 | 01a10c8a-d9ea-78da-9fd7-7b12923beb8f
(1 row)
ID を URL や画面に出す場合、UUIDv7 では「いつ作られたか」が相手に伝わります。 それを見せたくないデータでは UUIDv4 を選ぶか、外に見せる識別子を主キーとは別の列にします。どの方式でも、ID を知っていることを権限の代わりにせず、参照のたびに権限を確かめる作りにします。
採番方式の選び方
| 条件 | 選ぶもの |
|---|---|
| 1つのデータベースで完結し、ID を外部に見せない | 連番(bigint の IDENTITY / AUTO_INCREMENT) |
| 複数のサービスや端末で、登録前に ID を決めたい | UUIDv7 |
| 複数のデータベースのデータを後で1つにまとめる予定がある | UUIDv7 または UUIDv4 |
| ID を URL に出し、件数や作成時刻を推測されたくない | UUIDv4。または主キーは連番にして、公開用に UUIDv4 の列を UNIQUE 付きで足す |
| 行数が多く、索引を小さく保ちたい | 連番 |
| 問い合わせで ID を口頭で伝える | 連番、または人が読むための番号(受注番号)を別の列で持つ |
主キーを決めるときの判断基準
| 確認すること | 当てはまる場合 | 選ぶもの |
|---|---|---|
| その値は、将来も絶対に変わらないと言い切れるか | 言い切れない(体系変更、入力の訂正、再発行がありうる) | サロゲートキー+UNIQUE |
| その値は、自分たちで採番しているか | 外部から受け取る番号である | サロゲートキー+UNIQUE |
| その値は、登録の時点で必ず決まっているか | 後から決まる・空のことがある | サロゲートキー(主キーは NULL にできない) |
| ほかのテーブルから参照されるか | 参照される | 1列のサロゲートキー |
| 2つの外部キーだけの対応表か | はい、かつ参照されない | 複合主キー |
| 数件〜数十件の区分値で、コードが仕様として固定か | はい | コードを主キーにしてよい |
確かめて分かったこと
- ON UPDATE CASCADE は、キーが伝わる経路の全部に付けて初めて通りました。 顧客→受注に付けただけでは、受注の複合主キーを参照する明細の外部キーで止まります。
- MySQL は複合主キーの先頭2列だけを参照する外部キーを受け付けました。 PostgreSQL では同じ形が作成時にエラーになります。両方で同じ意味にしたいなら、全列を参照します。
- 採番列に値を直接入れたときの挙動が違いました。 PostgreSQL の GENERATED ALWAYS AS IDENTITY は
cannot insert a non-DEFAULT value into column "order_id"というエラーで止め、MySQL の AUTO_INCREMENT は order_id に 100 を指定した INSERT をそのまま受け付け、次の行は 101 になりました。 - UUID を文字列で持つと、主キー索引は uuid 型の約1.8倍になりました(PostgreSQL、30万行で 21MB と 12MB)。連番の bigint は 6600kB で、uuid 型のおよそ半分です。
- MySQL では主キーの型を変えただけで、別の列の索引の大きさが 5.5MB から 14.6MB まで変わりました。
- MySQL の重複エラーは、複合キーの値をハイフンでつないで表示します(
Duplicate entry '1-2026-10-01')。値にハイフンを含む列では、どこが区切りかをキーの定義と見比べて読みます。
よくある質問
Q. サロゲートキーとナチュラルキーはどっちを主キーにすべきですか? A. 迷ったらサロゲートキーを主キーにして、ナチュラルキーに UNIQUE 制約を付けます。番号体系の変更や訂正があっても、参照している側のテーブルを書き換えずに済むからです。値が仕様として固定された小さなコード表は、コードを主キーにしても困りません。
Q. 複合主キーのデメリットは何ですか? A. 参照する側のテーブルに同じ列をすべて持たせることになり、結合条件と外部キーが長くなる点です。構成する列のどれかが変わると、参照している全テーブルの書き換えが必要になります。2つのサロゲートキーだけでできた交差テーブルでは、この点が問題になりません。
Q. サロゲートキーにすれば UNIQUE 制約は要りませんか? A. 要ります。サロゲートキーは行を区別するだけなので、制約がなければ同じ受注番号を何行でも登録できます。業務上「重複しては困る単位」ごとに UNIQUE 制約を付けます。
Q. 主キーを連番にすると、欠番ができるのは問題ですか? A. 主キーとしては問題ありません。主キーの役目は行を1つに特定することで、連続していることは求められていません。欠番のない番号が業務で必要なら、主キーとは別の列で管理します。
まとめ
- 主キーは「変わらない・意味を持たない・1列」のサロゲートキーにすると、参照する側が楽になります。
- ナチュラルキーは捨てずに NOT NULL と UNIQUE 制約で守ります。
- ナチュラルキーを主キーにすると、値の変更は外部キーに止められます。 CASCADE で通す場合は、経路の全部に設定します。
- 交差テーブルは複合主キー、履歴テーブルはサロゲートキー+UNIQUE が扱いやすい形です。
- 採番は、単独のデータベースなら連番、複数の場所で採番するなら UUIDv7、作成時刻を見せたくないなら UUIDv4 を選びます。
- UUID は uuid 型か BINARY(16) に入れます。 文字列で持つと索引が大きくなります。
参考
- PostgreSQL Documentation「UUID Functions」(PostgreSQL 18 の uuidv7() の記載)https://www.postgresql.org/docs/current/functions-uuid.html
- MySQL 8.0 Reference Manual「FOREIGN KEY Constraints」https://dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html
- MySQL 8.0 Reference Manual「Clustered and Secondary Indexes」https://dev.mysql.com/doc/refman/8.0/en/innodb-index-types.html
- RFC 9562「Universally Unique IDentifiers (UUIDs)」https://www.rfc-editor.org/rfc/rfc9562.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「主キー設計の考え方:サロゲートキーとナチュラルキーの違いと選び方」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。