主キーひとつがスキーマ全体に広がる
主キー(Primary Key)はテーブルひとつだけの問題ではありません。そのキーを参照するすべての外部キー列が同じ型になり、インデックス・結合・API の URL・ログまでその値を持ち回ります。そのため主キーの型は、後から変えるのが最も難しい設計判断のひとつです。
実務でよく選ばれる候補は次の 3 つです。
- bigint — データベースが 1、2、3… と順番に振る整数
- UUID v4 — 128 ビットのうち 122 ビットがランダムな識別子
- UUID v7 — 先頭に生成時刻(ミリ秒)を持ち、時刻順に並ぶ UUID
2025 年 9 月 25 日に正式リリースされた PostgreSQL 18 に uuidv7() 関数が入り、3 つ目の選択肢を拡張モジュールなしで使えるようになりました。それぞれの長所・短所と選び方を整理します。
bigint:小さく速いが、番号を振るのはデータベース
PostgreSQL では GENERATED ALWAYS AS IDENTITY で宣言します。従来の serial より SQL 標準に沿った書き方です。
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);
長所
- 8 バイトで UUID(16 バイト)の半分です。主キーのインデックスも、それを参照する外部キーのインデックスも小さくなります。
- 新しい値は常にインデックスの右端に追加されるため、書き込みが効率的です。
- 人が読んだり口にしたりしやすい(「注文 1042 番」)。
短所
- 番号はデータベースが振るので、保存するまで ID がわかりません。オフラインアプリや、複数のサービスがそれぞれレコードを作って後でまとめる構成では不便です。
- URL にそのまま出すと次の番号を推測しやすく、総件数や増え方も見えてしまいます。
- 複数のデータベースのデータを統合すると番号が衝突します。
UUID v4:どこでも作れるが、インデックスに散らばる
CREATE TABLE customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- PostgreSQL 18 の uuidv4() と同じ関数
email text NOT NULL UNIQUE
);
長所
- アプリケーション・クライアント・別のサービスなど、どこでも事前に作れ、事実上衝突しません。
- 推測できないため、URL に出しても他のレコードを推し量られにくいです。
短所
- 値がランダムなので、新しい行が B-tree インデックスの任意の位置に入ります。書き込みの多いテーブルではインデックスページの分割が頻繁に起き、最近書いたデータがメモリキャッシュにまとまらないためディスク I/O が増えます。
- 16 バイトなので bigint よりインデックスが大きくなります。外部キーが多いほど差は広がります。
MySQL(InnoDB)のように、テーブル自体を主キー順に格納する(クラスタ化インデックス)エンジンでは、このランダム挿入の影響がテーブル全体に及びます。
UUID v7:時刻順に並ぶ UUID
UUID v7 は 2024 年 5 月に公開された RFC 9562 で定義された形式です。128 ビットのうち先頭 48 ビットに Unix 時刻(ミリ秒)を入れ、バージョン・バリアントのビットを除く残りをランダム値で埋めます。RFC 9562 は、可能であれば UUIDv1・v6 の代わりに UUIDv7 を使うべき(SHOULD)としています。
たとえば 019535d9-3df7-79fb-b466-fa907fa17f9e では、先頭の 16 進数 12 桁(019535d9-3df7)が生成時刻で、その次の 7 がバージョンです。
先頭が時刻なので、後で作った値ほどおおむね大きくなります。そのため新しい行は bigint と同じようにインデックスの右端に集まり、UUID v4 のランダム挿入の問題は大きく減ります。どこでも作れて、ランダム部分のおかげで推測しにくいという UUID の長所はそのままです。
注意点
- ID を見るだけで 生成時刻がわかります。 RFC 9562 もセキュリティ上の考慮事項として、タイムスタンプはごく小さな攻撃面になると書いています。登録日時のように知られると困る情報なら、外部に見せる ID を別に用意してください。
- サイズは v4 と同じ 16 バイトです。
PostgreSQL 18 での使い方
PostgreSQL 18 からは拡張なしですぐに使えます。
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer_id uuid NOT NULL REFERENCES customers(id),
total_amount numeric(12, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ID に含まれる生成時刻とバージョンを確認
SELECT id,
uuid_extract_timestamp(id) AS id_created_at,
uuid_extract_version(id) AS version
FROM orders
ORDER BY id DESC
LIMIT 10;
PostgreSQL 18 の UUID 関連の関数は次のとおりです。
| 関数 | 説明 |
|---|---|
uuidv7() | 時刻順の UUID v7 を生成。uuidv7(interval) で記録する時刻をその分ずらせる |
uuidv4() | gen_random_uuid() と同じランダムな v4 を生成(バージョンを明示した別名) |
uuid_extract_timestamp(uuid) | v1・v7 から生成時刻を取り出す(他のバージョンは NULL) |
uuid_extract_version(uuid) | バージョン番号を確認 |
uuid_extract_timestamp() があるからといって created_at 列を消さないでください。ID はアプリケーションで事前に作ることも、時刻をずらして作ることもできるため、業務で使う作成日時と常に一致するとは限りません。
MySQL の場合
MySQL 8.4 のドキュメントによると、UUID() は バージョン 1 の UUID を生成し、UUID v7 を作る組み込み関数はありません(ネイティブ対応の要望が MySQL のバグトラッカーに登録されています)。現実的な方法は 2 つです。
- アプリケーションで UUID v7 を生成し、
BINARY(16)列に保存する UUID()を使うならUUID_TO_BIN(UUID(), 1)で保存する — 第 2 引数の1は時刻部分の並びを入れ替え、値が連番に近い順で入るようにします
どちらの場合も、文字列(CHAR(36))で保存すると 2 倍以上の大きさになるため、バイナリで保存するのがよいでしょう。
状況別の選び方
| 状況 | おすすめ |
|---|---|
| データベースはひとつ、ID を外部に見せない | bigint identity |
| クライアントや複数のサービスが ID を事前に作る必要がある、データを統合する | UUID v7 |
| URL・API に ID が出るが、生成時刻が知られても問題ない | UUID v7 |
| 生成時刻まで隠すべき値(招待リンク、パスワード再設定トークンなど) | UUID v4 または別のランダムトークン |
すでに UUID v4 で運用しているなら、新しいテーブルから v7 を使うだけで十分なことが多いです。既存のキーを変えると参照しているすべての外部キーも変える必要があるため、効果とコストを先に比べてください。
ERD を描くときに確認すること
- 外部キーの型は参照先の主キーと同じにします。
uuidとtext、bigintとintegerが混ざると、結合のたびに型変換が起きたり、制約を作れなかったりします。 - 主キーの方針はテーブルごとに決めるより、スキーマ全体でひとつのルールにするほうがミスが減ります。例外(外部公開用のトークンなど)は別の列に分けてください。
- 主キーのインデックスに加え、よく検索する外部キーにもインデックスを付けてください。キーが 16 バイトなら、インデックスのサイズも考慮が必要です。
先にキーの型を決めてからリレーションを描くと、外部キー列の型の不一致を早めに見つけられます。詳しくは外部キーとテーブルのリレーションシップを完全理解とデータベースインデックス完全ガイドで解説しています。