データベースの主キー: AUTO_INCREMENT、UUID、UUIDv7
# 開発ノート主キーは auto increment にするべきか、UUID にするべきか。最近関連する議論を見ていて、この問いの答えはだんだん UUIDv7 に収束してきているように感じた。ただ、収束しているとはいえ、その背後にあるトレードオフや文脈はきちんと整理しておく価値がある。そうしないと、ただ流行に乗り換えているだけになってしまう。
TL;DR
UUIDv4はほとんど利点がない- 単一データベース構成で、ID を外部公開せず、将来的にも跨ぐデータベース統合の予定がないなら、Auto Increment が最もシンプル
- それ以外はすべて UUIDv7 を使う
UUID とは何か
UUID(Universally Unique Identifier)は 128 ビットの識別子で、標準的な形式は次のようになる。
550e8400-e29b-41d4-a716-446655440000
36 文字で、- により 5 つの区切りに分かれる(8-4-4-4-12)。この分割形式は最初期の UUIDv1 に由来し、それぞれの区切りが内部の異なるフィールドに対応していた。v4 や v7 では大半のフィールドが乱数で埋められているが、後方互換のため形式は維持されている。
UUID には複数のバージョンがあり、この記事で主に扱うのは次の 2 つだ。
- UUIDv41:122 ビットの純粋な乱数で、構造を持たない。現在もっとも広く使われているが、データベースにはあまり優しくない。
- UUIDv72:先頭 48 ビットにミリ秒単位の timestamp を置き、残り約 74 ビットに乱数を置く。時間順序とランダム性の両方を兼ね備え、現在の推奨選択肢だ。
見た目はどちらも同じで、違いは内部構造にある。見分けるには第 3 セグメントの先頭を見る。4xxx なら v4、7xxx なら v7 だ。
UUIDv4: 550e8400-e29b-41d4-a716-446655440000
^
UUIDv7: 018f3a3b-7a5d-7a3b-8b3a-3b7a5d7a3b8b
^
なぜ主鍵はそんなに重要なのか?
ユースケースによって、データ書き込みや検索の効率、将来の拡張性、移行時に地雷を踏むかどうかが変わる。下の benchmark を見ると、7 倍以上の差が出る可能性すらある。
Auto Increment vs UUID: 何が違うのか
まずは最も基本的な判断ポイントから始めよう。Primary Key を決める際には、次の観点で考えられる。
- 挿入性能
- INDEX の性能
- 検索性能
Auto Increment の利点は非常にわかりやすい。データベースが自動で増加させてくれる、8 bytes の整数で小さく速く、B-Tree インデックスに優しい。常に末尾へ追加されるので、INSERT 性能は基本的に最良だ。
しかし Auto Increment には 2 つの根本的な制約がある。
1. 分散アーキテクチャに向いていない
分散システムでは、全体で一意な ID をどう生成するかが難題になる。これは中央の発番器を実装することで解決でき、毎回グローバルに一意な ID を配ることができる。たとえば Twitter が公開した Snowflake アルゴリズムがそうだ。
しかし中央発番器には性能ボトルネックと単一障害点のリスクがある。単一障害点を避けるため、実運用では複数の worker を協調させて配置することになり、複雑さと保守対象のコンポーネントが増える。
2. 予測可能性
数字を 1024 から 1022 に変えるだけで、実装が甘ければ他人の注文が見えてしまうことがある。競合他社は ID の増加速度からユーザー数や取引量を推測できる。(逆にそれを騙しの材料にすることもできる)
UUID(代表として UUIDv4)はこの 2 つの問題を解決する。128 ビットの乱数空間により、どのノードでも独立して生成でき、中央調整は不要で、ほぼ推測不可能だ。
UUIDv4 は完全にランダムだが、B-Tree への INSERT では、すでに埋まっている page のあちこちに書き込むことになり、大量の page split を引き起こす。結果として書き込み性能が明確に落ちる。UUID を使ったら「なぜデータベースが遅くなったのか」と感じる人が多いのはこのためだ。
したがって、選択時に考えるべきなのは実際には次の点だ。
- システムは分散生成を必要とするか
- ID は外部に露出するか(URL、API Response など)
- データベース書き込み性能にどれだけ厳しい要求があるか
システムがモノリス構成で、ID を外部公開しないなら、auto increment でまったく問題ない。ただし分散化が絡む場合や、ID が URL に現れる場合は、UUID 系の方式がほぼ必須になる。
衝突したらどうするのか?
UUID の話をするたびに、ほぼ必ず聞かれるのがこの вопрос だ。
UUIDv4 は 122 ビットの乱数空間を持ち、組み合わせ数はおよそ 5.3 × 1036 になる。
衝突確率を 50% にするには、約 2.7 × 1018 個の UUID を生成する必要がある。この数字は「世界中で毎秒 10 億個の UUID を、85 年間連続生成する」くらいの規模だ。
UUIDv7 では乱数空間は「見かけ上」122 ビットから約 74 ビットに減るが、衝突が起こりうるのは同一ミリ秒内に限られる。つまり、同じミリ秒に生成された UUID だけが 74 ビットの乱数で衝突回避する必要がある。そして 74 ビットは約 1.8 × 1022 通りを提供するので、単一ミリ秒に対しては十分すぎる。
UUIDv7 vs created_at + index: 実測性能
UUIDv7 の最も魅力的な特性は、先頭 48 ビットにミリ秒単位の Unix timestamp が入るため、生来の時間順序を持つことだ。
UUIDv7 (128 bits)
┌──────────────────────┬────┬──────────────────────────────┐
│ Unix Timestamp (ms) │ ver│ Random │
│ 48 bits │ 4b │ ~74 bits │
└──────────────────────┴────┴──────────────────────────────┘
↑ 時間順に並ぶ ↑v7 ↑ 衝突防止の乱数
UUIDv7 を主鍵にすれば、ORDER BY id はそのまま ORDER BY created_at と等価になる。時間順ソートのために、別途 created_at カラムと対応するインデックスを用意する必要がなくなる。
悪くない話だが、実際にはどれくらい違うのか。僕は Docker で PostgreSQL 17 のインスタンスを立て、2000 万件のデータを入れて実測した。
テスト設定
3 つのテーブルは同じスキーマで、違いは主鍵戦略だけだ。
-- 方案 A:UUIDv7 主鍵(不需要額外的 created_at)
CREATE TABLE orders_uuidv7 (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id int NOT NULL,
amount numeric(10,2) NOT NULL,
status text NOT NULL
);
-- 方案 B:Serial 主鍵 + created_at 索引
CREATE TABLE orders_serial (
id bigserial PRIMARY KEY,
user_id int NOT NULL,
amount numeric(10,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX idx_orders_serial_created_at ON orders_serial (created_at);
-- 方案 C:UUIDv4 主鍵(対照組)
CREATE TABLE orders_uuidv4 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id int NOT NULL,
amount numeric(10,2) NOT NULL,
status text NOT NULL
);
テスト環境:
- Mac Mini M4
- Docker PostgreSQL 17
shared_buffers = 256MB
毎ラウンド 100 万件ずつ書き込み、全 20 ラウンドで累計 2000 万件にした。UUID は各ラウンド内でリアルタイム生成したため、生成関数のオーバーヘッドも含まれている。
テスト 1: INSERT 性能の推移(100 万件ごと、単位 ms)
| 累計行数 | Serial | UUIDv7 | UUIDv4 |
|---|---|---|---|
| 1M | 1,132 | 3,185 | 2,998 |
| 5M | 1,474 | 2,661 | 2,972 |
| 10M | 1,165 | 2,560 | 6,497 |
| 13M | 1,123 | 2,745 | 9,668 |
| 15M | 1,411 | 2,630 | 9,188 |
| 16M | 1,133 | 2,552 | 15,857 |
| 20M | 1,017 | 2,539 | 20,845 |
重要なのは絶対値ではなく、傾向だ。
UUIDv4 は 3 秒から 21 秒まで一気に悪化した。
B-Tree インデックスが大きくなるほど、ランダム挿入がすでに disk に flush 済みの page に当たる確率が高くなる。INSERT のたびに、古い page を disk から buffer pool に読み戻す → page split → disk へ書き戻す、という流れが発生しうる。
100 万件ごとの INSERT では、WAL 量がデータベースの実際のディスク書き込み負荷をそのまま反映する。
| 累計行数 | Serial WAL | UUIDv7 WAL | UUIDv4 WAL |
|---|---|---|---|
| 1M | 146 MB | 171 MB | 744 MB |
| 5M | 147 MB | 171 MB | 1,227 MB |
| 8M | 148 MB | 171 MB | 1,715 MB |
| 10M | 147 MB | 171 MB | 2,418 MB |
Serial と UUIDv7 の WAL 量は最初から最後までほぼ一定(~147 vs ~171 MB)だった。この差(24 MB)は、UUID が bigint より 16 bytes 分大きいことによる純粋なデータ量差だ。
一方、UUIDv4 の WAL は 744 MB から 2,418 MB へ膨らんだ。同じく 100 万件を書き込んでいるのに、round 10 では WAL 書き込み量が UUIDv7 の 14 倍 になっている。
これが high write amplification3 だ。
UUIDv7 は最初から最後まで 2.5~2.8 秒で安定し、劣化しなかった。
time-ordered の性質により、新しい key ほぼすべてが B-Tree の最右端 leaf page に append されるため、auto-increment と同じ挙動になる。DB は sequentially write でき、page split は極めて少ない。
Serial は 1.0~1.5 秒で安定していた。
Serial が最速なのは疑いようがない。ただし UUIDv7 と Serial の差は主に次の 2 点による。
- UUID 自体が 16 bytes で、bigint の 8 bytes の 2 倍あるため、インデックス page に載る entry 数が少ない
- 実運用では UUID を application layer でネイティブ関数により生成することが多く、その差はさらに縮まる(UUID 生成時間を application 側に移すことになる)
Postgres のネイティブ関数で UUID を生成すべきかについては、以前の僕は「データベースができることは全部データベースに任せる。省ける手間は省く」という考え方を好んでいた。
しかし、業務ロジックの一部をデータベース内に書いてしまうと、将来それに気づきにくくなる。今の僕はむしろUUID の生成は application layer に任せるべきだと考えている。
テスト 2: ストレージ容量(20M rows)
| 方案 | Heap | Index | 総サイズ |
|---|---|---|---|
| Serial | 1,149 MB | 428 MB | 1,578 MB |
| UUIDv7 | 1,302 MB | 749 MB | 2,051 MB |
| UUIDv4 | 1,302 MB | 770 MB | 2,072 MB |
UUID 系の heap は Serial より 153 MB 大きい(各行の PK が 8 bytes から 16 bytes に増えるため)。index は約 1.75 倍大きくなったが、ストレージ容量としては大きな差とはいえない。
テスト 3: Cursor-Based Pagination
Cursor-based pagination の仕組みは、前ページの最後の値を「cursor」として使い、次ページでは WHERE > cursor ORDER BY ... LIMIT N で取得するというものだ。OFFSET と比べて、どこまでページを進めても安定した性能を保てる。
110 万件規模で、3 つの条件をそれぞれ 100 回ずつ実行した。cursor の位置は 90 万件目に置き、深いページネーションを模擬した。
| クエリ方式 | 100 回の総時間 |
|---|---|
| Serial + OFFSET 900000(対照組) | 7,012 ms |
| Serial + cursor on created_at index | 1.9 ms |
| UUIDv7 + cursor on PK | 1.6 ms |
いくつか観察がある。
- UUIDv7 の cursor pagination は主鍵だけで完結する。 追加のインデックスは不要だ。Serial で時間順 cursor pagination をやるなら
created_atインデックスを使う必要があるが、これは secondary index なので、検索時にまずインデックスを引き、その後 heap を取りに行く必要がある(index scan → heap fetch)。 - UUIDv7 は created_at インデックスよりわずかに速い。 UUIDv7 の cursor は primary key を使うのに対し、created_at は secondary index だからだ。
- 深いページネーションで OFFSET は破滅的だ。 7 秒対 2 ミリ秒未満で、3,600 倍の差がある。OFFSET は前方の行をすべて走査して捨てる必要がある。この結論はこの記事の主題とは少しずれるが、ページネーションを作るなら
OFFSETは使わないほうがよい。性能を簡単に壊してしまう。
小結
UUIDv7 を主鍵にする利点はいくつかある。
- 書き込み性能が劣化しない: auto-increment と同じく sequential append なので、データ量が増えても遅くなりにくい。UUIDv4 は 2000 万件時点ですでに 7 倍遅く、今後さらに悪化する。
- 1 つのインデックスで 2 つの役割を担える: 主鍵制約であると同時に、時間順ソートの根拠にもなる。
created_atカラムとそのインデックスを省け、cursor-based pagination もWHERE id > :last_id ORDER BY idで済む(実務上はなお必要な場合もある)。
クエリパターンが時間順ソートに強く依存するなら、たとえば feed、timeline、注文一覧などでは、UUIDv7 によって schema を簡潔にし、インデックスを減らせる。
ただし、マイクロ秒単位の作成時刻が必要な場合や、created_at に業務上の意味がある場合(たとえば時間範囲で検索したい場合)には、created_at カラムは残すべきだ。UUIDv7 の timestamp 精度はミリ秒であり、同一ミリ秒内の順序は乱数で決まるので、厳密な挿入順は保証されない。
外部 URL はどうするのか?
UUIDv7 を内部主鍵として使うのは問題ないが、そのまま URL に露出させると欠点がある。長いこと(36 文字)、そして timestamp 部分によって作成時刻が漏れることだ。
よくある方法はいくつかある。
- 16 bytes の UUID を Base62 または Base58 でエンコードして、21~22 文字に圧縮する。データの本質は変わらず、より短い表現に変えるだけだ。Base58 は
0/O、I/lのような紛らわしい文字を除外しているので、手入力の場面では扱いやすい。 - 外部用に別の ID を持つ: NanoID を使って、内部 ID と完全に無関係な外部識別子を別生成する。NanoID は純粋な乱数で時間構造を持たず、デフォルトの 21 文字で約 126 ビットのエントロピーがあり、衝突耐性は UUIDv4 と同等だ。
僕は 2 の方法を好む。Base62/58 で短くするやり方は、文字列が短くなるだけで本質的には同じ UUID だからだ。意図を持つ人ならデコードして timestamp を取り出せてしまう。NanoID なら内部 ID と完全に切り離されるので、外部 ID を手に入れてもデータベース内の情報を逆算できない。代わりに外部 ID を保存するためのカラムとインデックスが 1 つずつ増えるが、このトレードオフはたいてい十分に価値がある。
それでも「絶対に重複しない」必要がある場合は?
UUID(どのバージョンでも)の一意性は確率に基づく。衝突確率は極めて低いが、理論上はゼロではない。ほとんどのシステムではこれで十分だが、金融取引番号、請求書番号、医療記録 ID など、100% の保証が必要な場面も確かにある。
その場合は「中央集権」モデルに戻る必要がある。つまり、単一の権威ある発番元が ID を配る方式だ。よくある方法は次のとおり。
- データベースシーケンス + 分散ロック。 最も直接的な方法で、PostgreSQL の
SEQUENCEや Redis のINCRに分散ロックを組み合わせて全体一意性を担保する。欠点は単一障害点のリスクがあり、高並行時にはロック競合がボトルネックになることだ。
Twitter Snowflake とその変種。 先に述べた Snowflake も本質的には中央集権だ。Worker ID の割り当てには中央協調(ZooKeeper / etcd)が必要になる。百度の UidGenerator、Sony の Sonyflake も同様の発想で、bit 配分や Worker ID 管理だけを調整している。
データベース組み込みの分散 ID。 CockroachDB や TiDB のような NewSQL データベースは、分散一意 ID 生成機構を内蔵しており、データベース層でこの問題を解決する。application layer 側で追加処理は不要だ。
どの方式を選んでも、本質的には可用性や運用複雑性と引き換えに、絶対的一意性を得ることになる。業務要件として法規制やコンプライアンス上、厳密な一意性が求められないなら、UUIDv7 の確率的一意性で十分だ。
そのほかの小ネタ
00000000-0000-0000-0000-000000000000は有効な UUID で、Nil UUID と呼ばれる4FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFFも有効な UUID で、Max UUID と呼ばれる- UUID 生成器を自分で書かないこと
事故現場を見る限り、問題の多くは衝突そのものではなく、アプリケーション側の実装不備だ。ああ、この点まで含めるなら、いっそ一番安全なのは Serial なのかもしれない。
Footnotes
-
https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-4 ↩
-
https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-7 ↩
-
単回の挿入で複数の page の書き換えが発生しうること。書き込んだデータ量を大きく上回る I/O が発生する ↩