データベースの主キーで UUIDv7 が UUIDv4 に勝る理由
約 10 年にわたる定説は「主キーには auto-increment の整数を使え。ランダムな UUID はデータベース性能を破壊する」でした。これは正しかったのですが、「UUID は遅い」という結論は一般化しすぎでした。真犯人は UUIDv4 の完全な乱数性であって、UUID というカテゴリそのものではありません。UUIDv7(2024 年 5 月に RFC 9562 として標準化され、旧 RFC 4122 を置き換え)は、上位ビットに Unix ミリ秒のタイムスタンプを置き、その下に乱数ビットを配置します。1 ms 離れて生成された 2 つの値はタイムスタンプ順に整列し、同じミリ秒内に生成された 2 つの値はそのミリ秒内でランダムに並びます。挿入のパターンが完全に変わるのです。
これが重要な理由は B-tree にあります。PostgreSQL、MySQL/InnoDB、SQL Server — いずれも主キーをキーとした B-tree(または B+-tree)の順序で行を格納します。既存のどのキーよりも大きい主キーを持つ値を挿入すると、常に最も右端のリーフに着地します。そのページはバッファプール内でホットな状態にあり、次の挿入も同じページに入るため、データベースはほとんど IO を必要としません。UUIDv4 では、すべての挿入がランダムなリーフに着地します — 挿入のたびにディスクから新しいページを引き込み、ダーティにし、別のページを追い出し、ダーティページを書き戻すことになります。インデックスが RAM に収まらないほど大きなテーブルでは、UUIDv4 の挿入は auto-increment より 10〜50 倍遅くなることがあり、ランダムな挿入がツリーのあちこちでページ分割を引き起こすため、B-tree はひどく断片化します。
UUIDv7 はこれを解消します。挿入が時間順であるため、インデックスの小さなホット領域に収まります — 常に文字どおり右端のリーフというわけではありません(ランダムな下位ビットが局所的な揺らぎを生みます)が、キャッシュに留まり続ける小さなワーキングセットです。PlanetScale、AWS、Percona のベンチマークはいずれも、最新の InnoDB と Postgres において UUIDv7 の INSERT スループットが bigint auto-increment の 10〜20% 以内に収まることを示しています。UUIDv4 を苦しめていた断片化も、新しいキーが常に時間的に近接して並ぶため消滅します。
実務上の注意点が 1 つあります。InnoDB では UUID を 16 バイトの BINARY としてネイティブに格納します — ダッシュ付き 36 文字の hex 文字列ではなく BINARY(16) で保存してください。hex 形式は 36 バイト(ダッシュなしなら 32)で、ストレージもインデックスのフットプリントも 2 倍以上になり、それはページあたりのキー数の減少と IO の増加に直結します。Postgres には 16 バイトで格納するネイティブの uuid 型があるので、それを使いましょう。人間によるデバッグのしやすさとストレージのトレードオフに対する標準的な答えは、バイナリで保存し、API 境界でのみ hex にレンダリングすることです。
// UUIDv4 layout: 122 random bits, 6 fixed bits
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
^^^^ ^^^^
version 4 variant bits
// UUIDv7 layout: 48-bit Unix ms timestamp + 74 random bits
ttttttttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx
^^^^^^^^^^^^^ ^^^^ ^^^^
unix_ts_ms rand-A rand-B
+ version + variant
// Postgres
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- PG18+
...
);
// MySQL — store as BINARY(16), not VARCHAR(36)
CREATE TABLE orders (
id BINARY(16) PRIMARY KEY,
...
);
// JS — generate UUIDv7 (no deps)
function uuidv7() {
const ts = Date.now();
const tsHex = ts.toString(16).padStart(12, '0');
const rand = crypto.getRandomValues(new Uint8Array(10));
rand[0] = (rand[0] & 0x0f) | 0x70; // version 7
rand[2] = (rand[2] & 0x3f) | 0x80; // variant
const r = [...rand].map(b => b.toString(16).padStart(2, '0')).join('');
return `${tsHex.slice(0,8)}-${tsHex.slice(8)}-${r.slice(0,4)}-${r.slice(4,8)}-${r.slice(8)}`;
}UUID の衝突確率 — 誕生日のパラドックスを数字で見る
「UUID が衝突する確率はどれくらいですか?」は、どのチームも一度は口にする質問です。答えの根拠は誕生日のパラドックスにあります。UUIDv4 は 122 の乱数ビット(残りの 6 ビットは version と variant 用に固定)を持つため、名前空間は 2^122 ≈ 5.3 × 10^36 通りの値になります。素朴な直感は「これだけスロットがあれば衝突などあり得ない」と言います。しかし誕生日のパラドックスによれば、衝突リスクは N ではなく sqrt(N) に比例します — 大まかな法則として、最初に衝突確率が 50% に達するには約 sqrt(2^122) ≈ 2^61 ≈ 2.3 × 10^18 個の値を生成する必要があります。
具体的には、毎秒 10 億個の UUIDv4 を 100 年間生成し続けても、衝突確率は依然として 10^-9 のオーダーです。合計 1 兆個生成した場合、そのなかで何らかの衝突が起きる確率はおよそ 10^-15 — 今年隕石に当たる確率よりも約 100 万倍低い値です。単一のアプリケーションにおいて、衝突リスクは事実上ゼロです。数学が面白くなるのは、極端な規模で生成する多数の独立したシステム(数十年にわたる世界規模の IoT フリートを想像してください)からの UUID を寄せ集めた場合だけで、その場合でも実務上の安全な答えは「戦略を 1 つ選んで、二度と心配しない」です。
現代の UUID ファミリーのバージョン表はおおよそ次のとおりです。
v1 (1988): タイムスタンプ + MAC アドレス。ホストごとに一意だが、ホストの MAC と時刻が漏れる。ほぼ引退済み。
v3 / v5: namespace + name の決定的ハッシュ。同じ入力は常に同じ UUID を生成 — 派生 ID に有用。
v4: 122 の乱数ビット。2010〜2024 年の支配的な選択肢。
v6: v1 に似ているがソート可能なバイト順。ニッチ。
v7 (2024 RFC 9562): 48 ビットの Unix ミリ秒タイムスタンプ + 74 の乱数ビット。新規システムに強く推奨。
v8: カスタム。ビットを自分で埋める。v7 で要件を満たせない場合にのみ使用。
実務上の教訓: 独自の UUID フォーマットを自作しないでください。6 つの予約ビット(version + variant)のおかげで、ツールやデータベースは UUID を識別できます。version ビットを誤って設定した v4 は別のバージョンとして解釈され、比較・ソート・ドライバレベルの最適化が壊れます。常に検証済みのライブラリか、上記の標準的な式を使ってください。
// Birthday paradox: probability of ANY collision among n values
// drawn from a space of size N is ≈ 1 - exp(-n^2 / (2N))
// For UUIDv4: N = 2^122
// n = 1 billion → P ≈ 4.7 × 10^-20
// n = 1 trillion (10^12) → P ≈ 4.7 × 10^-14
// 50% collision threshold: n ≈ 2.3 × 10^18
// Detect the version of a UUID
function uuidVersion(uuid) {
// version is the first hex digit of the third group
return parseInt(uuid[14], 16);
}
uuidVersion('018fb1c2-...-7abc-...'); // 7
uuidVersion('a1b2c3d4-...-4abc-...'); // 4
// Deterministic UUIDv5 (SHA-1 of namespace + name)
// Same name → same UUID, useful for migration mapping
import { v5 as uuidv5 } from 'uuid';
const NS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // DNS namespace
uuidv5('example.com', NS); // always "cfbff0d1-9375-5685-968a-48ce8b50e3e0"ULID vs UUIDv7 — どちらか 1 つ選んで先へ進む
UUIDv7 が標準化される前から、コミュニティはすでに複数の時間順 ID フォーマットを生み出していました。最も人気なのが 2016 年に仕様化された ULID(Universally Unique Lexicographically Sortable Identifier)です。ULID も 128 ビット — 48 ビットのミリ秒タイムスタンプに 80 ビットの乱数が続く構成 — ですが、ダッシュ付き hex ではなく Crockford Base32 でエンコードされます。見た目は 01HX1Y9ZQM7TWBKE6JR5VN8YGB のような 26 文字です。
機能面では ULID と UUIDv7 は非常によく似ています。どちらも時間順に並び、ミリ秒タイムスタンプのプレフィックスを持ち、乱数ビットも十分にあり、テキストとして保存すれば辞書順でソートされます。違いは美観とエコシステムです。
エンコーディング: ULID は Crockford Base32(26 文字)。UUIDv7 はダッシュ付き hex(36 文字)。ULID の文字列はより短く、紛らわしい文字(I、L、O、U なし)を意図的に避けています。URL と人間の読みやすさでは ULID の勝ちです。
データベースサポート: UUIDv7 は UUID そのものなので、uuid 型を持つすべてのデータベースが 16 バイトのバイナリとして効率的に格納します。ULID にはネイティブ型がありません — CHAR(26) か BINARY(16) で保存し、境界で変換することになります。
ツール類: UUID は共通語です。あらゆる言語、あらゆる ORM、あらゆるデータベースに数十年分の UUID ツールがあります。ULID には良いライブラリがありますが、ツール統合はまばらです。
仕様の成熟度: UUIDv7 は RFC 9562(2024 年 5 月、IETF 標準)。ULID は GitHub 上のコミュニティ仕様です。
2026 年のデフォルトは UUIDv7 です — URL の見た目という明確な理由で ULID を選びたい場合を除いて。UUIDv7 には本物の RFC であるという制度的な勢いがあり、ネイティブなデータベースサポートも現実に広がりつつあり(Postgres 18+、MySQL 9+、主要ドライバすべて)、「とにかく UUID を使え」という判断は、すべてのコードレビュアーと DBA がすでに理解している常識と一致します。
エンドユーザーの目に触れるものに URL フレンドリーな短い ID(YouTube の /watch?v= や Stripe の cus_ を思い浮かべてください)が必要なら、UUIDv7 も ULID も理想的ではありません — そこで使われるのは 21 文字・純粋な乱数の NanoID スタイルの英数字トークンです。仕事が違えば道具も違います。ソート可能な ID は内部の主キーとして、NanoID は顧客に見せる外部識別子として使い分けましょう。
// Side-by-side
UUIDv7: 018fb1c2-7abc-7d3e-8f4a-1b2c3d4e5f60 (36 chars, hex)
ULID: 01HX1Y9ZQM7TWBKE6JR5VN8YGB (26 chars, base32)
NanoID: V1StGXR8_Z5jdHi6B-myT (21 chars, alphanumeric)
// Both UUIDv7 and ULID sort by time when stored as strings:
SELECT id FROM orders ORDER BY id; -- chronological without an extra index!
// UUIDv7 Postgres setup
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- for older versions
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- v4
-- or in PG18+: DEFAULT uuidv7()
user_id uuid REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
// Generating ULID
import { ulid } from 'ulid';
ulid(); // "01HX1Y9ZQM7TWBKE6JR5VN8YGB"
ulid(1714560000000); // ULID for a specific timestamp