UUID v1・v4・v7の比較とDB主キー設計
要約 (TL;DR)
少し物議を醸す言い方をすると、高頻度INSERTテーブルの主キーにv4 UUIDを使うのは、自分の足を撃ち抜くのに近い。あるPostgreSQL 16のワークロードを見たら、events テーブルの主キーを uuid v4(gen_random_uuid())にしていて、INSERTのたびにB-treeのランダムな葉ページに落ち、shared_buffers を冷やし、インデックスの断片化が一桁後半パーセントで蓄積していた。カラム型をv7に変えただけで——同じ16バイトの uuid 型、同じインデックス——平均INSERT遅延が約3分の1まで落ち、pg_stat_user_indexes の断片化指標も安定した。UUIDのバージョン選択は「暗号学的判断」ではなくデータベース設計の判断だ。UUIDは128ビット値で、8-4-4-4-12 の16進表記のうち4ビットはバージョン、2–3ビットはvariantに固定で割り当てられる。v1は時刻とノード識別子(歴史的にはMACアドレス)を刻み込み、おおよそ時系列でソートされるがホストを露出する。v4は122ビットの乱数で強い予測不可能性を得る代わりにソートされない。v7はRFC 9562(2024)で標準化されたバージョンで、上位48ビットにUnixミリ秒のタイムスタンプを置き、残りを乱数で埋めることでv4の安全性とv1のインデックス局所性を組み合わせる。公開API IDのように「いつ作られたかを隠す」ことが本当に重要な場所では、v4が依然として既定。それ以外のほぼすべての場所ではv7の方が良い選択で、v1はレガシーとして——DBが受け入れたとしても新規設計の既定にはしない方が良い。
背景・コンセプト
UUIDは128ビット。慣例的にはハイフンで区切った32桁の16進数で書かれる。xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx。M の位置にバージョン(1、4、7など)が入り、N の上位ビットがvariantを示す。残りはバージョンごとに意味が違う。
バージョン1はマシンと時刻をまたぐ一意性を狙って設計された。60ビットのタイムスタンプは1582-10-15以来の100ナノ秒間隔を、クロックシーケンスフィールドは時計の巻き戻しを、48ビットのノードIDは元々ネットワークMACアドレスを格納していた。プライバシー面の帰結はストレートだ。ノートPCで発行されたv1 UUIDにはそのMACがエンコードされており、uuid のCLI1行で逆算できる。現代のライブラリは漏洩を避けるためにノードIDをランダム化することもあるが、依然として元の規則に従う実装も多い。
バージョン4は122ビットの乱数にバージョン・variant用の6ビットを固定した形式だ。強力なRNG(ブラウザの crypto.randomUUID() やPostgreSQLの gen_random_uuid())を前提とする。現実的な規模で衝突は事実上不可能だ——誕生日限界で約10億個生成しても衝突確率は1兆分の1のオーダー。欠点は、連続して発行された2つのv4が互いに無関係であることで、そのためインデックス挿入時にページがランダムに触られ、キャッシュ局所性と書き込み増幅(WAL・full-page write)の両方に悪い。
バージョン7は2024年に発行されたRFC 9562の一部で、このRFCがRFC 4122を廃止しv6・v7・v8を追加した。v7は上位に48ビットUnixミリ秒タイムスタンプを置き、バージョンタグ、小さな rand_a、variantタグ、62ビットの rand_b の尾を続ける。実用的な効果として、同じミリ秒に発行されたv7はソート上隣接し、ミリ秒をまたぐと時系列でソートされる。乱数尾だけでも同一ミリ秒内の一意性を確保するエントロピーには十分だ。PostgreSQL 18にはネイティブの uuidv7() が入り、それ以前のバージョンでも pg_uuidv7 拡張やアプリレベルライブラリ(Node uuid 9.x、Python uuid6)で同じ結果を得られる。
variantビットが重要なのは、RFC 9562/4122系列とレガシーのMicrosoft・Apollo UUIDを区別するからだ。この記事ではRFC variant(上記 N グループの先頭16進が 8、9、a、b のいずれか)を前提とする。
保存形式は別問題だ。PostgreSQL 16のネイティブ uuid 型は値を16バイトで保存する。MySQLは普通 BINARY(16) か CHAR(36) を使うが、後者は保存容量を倍にし比較を文字単位にする。バージョン選択と保存形式は絡み合う。v7をバイナリでソートすれば安く正しく、16進文字列でソートしても正しいが遅く、v4はどう保存してもソートに意味がない。
比較・データ
| 属性 | v1 | v4 | v7 |
|---|---|---|---|
| 生成入力 | タイムスタンプ + クロックシーケンス + ノードID | 122ビット乱数 | 48ビットUnix ms + 乱数尾 |
| プライバシー | ノードID(よくMAC)を露出 | ホスト・時刻情報なし | 生成時刻(ms)は露出、ホストはなし |
| 時系列ソート | 可能(ただしバイト順 ≠ 時間順、並び替え必要) | 不可 | 可能——辞書順が時系列と一致 |
| インデックス局所性 | 中 | 悪い(B-tree全体にランダム挿入) | 良い(ほぼ単調増加) |
| 代表用途 | レガシーシステム、一部のCOM/Windows ID | 公開API ID、セッショントークン、ソルト | イベントログ、高頻度INSERT、時間ページネーション |
| エントロピー | 低い(大半が時刻・ノード) | 高い(約122ビット) | 尾が高い(約74ビット)、ms内の衝突は稀 |
おおまかなメンタルモデルはこうだ。v4は予測不可能性を最大化する代わりにインデックス性能を犠牲にし、v7は多くの応用で十分な予測不可能性を保ったままDBが好む「末尾追加」パターンを復元し、v1は歴史的遺産として知っておくべきだが選んではいけない側に近い。
実践シナリオ
シナリオ1 — 追加中心のイベントログ。 冒頭で触れたワークロードがまさにこの形だった。1日に数百万行入り、普段は「直近24時間、時系列で」照会されるテーブルではv7が直接効く。新しい行が主キーインデックスの末尾に付くのでホットページが温かいまま保たれ、時間範囲クエリは隣接するインデックスセグメントにマップされる。v4からv7にカラムのデフォルトを変えるだけで、クエリコードは同じなのに書き込み遅延とインデックス断片化が下がるケースは普通によくある。
シナリオ2 — 公開ユーザー向けID。 /orders/{id} のような共有リンクは他ユーザーの注文を列挙されないように予測不可能であるべき。v4が安全な既定だ。v7の利点も欲しいなら、v7がミリ秒単位の生成時刻を露出することを覚えておく。注文には許容できるかもしれないが、もっと機微な文脈(分間決済件数のようなビジネスシグナル)では漏洩になる。あるチームに勧めた折衷案は、v7を内部主キーにし、外部には別のv4か短いランダムスラグを露出する二重ID パターンだった——インデックス性能を失わず、外部に時刻情報を漏らさない。
シナリオ3 — マルチリージョン・シャードシステム。 v7のタイムスタンプ接頭は、異なるリージョンが同じミリ秒にUUIDを発行しても時系列で自然に混ざるようにしてくれるが、同一ミリ秒内のリージョン間順序保証はない。リージョン間でより厳密な順序が必要ならULID(48ビットタイムスタンプ + 80ビット乱数をCrockford Base32で表現)がほぼ同じ特性をより短い26文字テキストで提供する。それより厳密な保証が必要なら、明示的なマシンIDを含むSnowflake型ID(Twitter原典・Discord変形のいずれも64ビット)もあるが、マシンIDを割り当てる**調整(coordination)**を追加で背負う必要がある。
よくある誤解
「UUIDはDBで常に遅い」。 4バイト int よりは遅いが、実コストの大半はB-treeインデックスのランダム挿入断片化で、これはv7がほぼ取り除く。36文字の文字列ではなく16バイトで保存すればインデックスサイズは半分になり比較も速くなる。「UUIDは遅い」というベンチマークの多くは実際は「MySQLで CHAR(36) で保存したv4が遅い」に近い。
「v4だけが安全」。 v7の乱数尾も十分大きなエントロピープールなので、ほとんどの応用において——セッション参照、API ID——攻撃者が列挙するのは非現実的だ。問題は予測可能性ではなくタイムスタンプ漏洩で、v7は行がいつ作られたかを露出する。その漏洩が許容できるなら(たいていそうだ)、外部IDにもv7は妥当な選択だ。
「UUIDは文字列で保存すべき」。 文字列形式は36文字(16進32文字 + ハイフン4文字)だがバイナリは16バイトで、バイト順がそのまま正しいソート順になる。v1の非単調なバイト順やv7のバイト順が時間と一致するべき点を考えると、バイナリ保存が有利だ。PostgreSQLの uuid 型はすでに16バイトで保存するので、別途バイナリ変換を悩む必要はない。
「v1のMAC漏洩は誰も見ない」。 v1からMACを逆算するのは公開された変換で、フォレンジックツール(uuid -d など)は既定でこれを抜く。UUIDがURL・サポートチケット・外部に共有されるログに含まれるなら、これは実際の情報露出だ。
チェックリスト
- 頻繁に挿入されるインデックスのキーに使うか? v7が既定。生成時刻の予測不可能性が本当に必要なときだけv4。
- UUIDがユーザー・パートナーに見えるか? どちらでも動く。v7のタイムスタンプ漏洩を許容できるかだけ確認。
- Postgresか? ネイティブ
uuid(16バイト)で保存。MySQLでは文字列互換が本当に必要でなければBINARY(16)。 - 複数生成器間の順序保証が必要か? v7だけでは不十分。ULID(同じ時間接頭)か、明示的マシンIDを含むSnowflake型を。
- コードベースにv1が残っているか? MAC漏洩を文書化し、スキーマが許す時点で移行計画を立てる。
- クライアント側で生成するか? 暗号学的に強いRNGを呼ぶライブラリを使う(現代ブラウザのv4は
crypto.randomUUID()、v7は普通同じRNGを包む)。
関連ツール
Patrache Studioの UUID生成ツール はv4とv7をローカルで生成するため、生成された値が第三者サービスのログに残らない。UUIDはほぼ常にJSONペイロードに乗って動くが、JSONのフォーマット・構文検証・JSON Schemaの違いと実務での使い分け では、それらのIDをサービス間で型付きのまま運ぶスキーマパターンを扱う。16バイトUUIDから派生した22文字の短いIDのような簡潔なテキスト表現が必要なときは、Base64・URLエンコーディング:なぜ必要で、いつ誤用されるか で、標準Base64ではなくBase64URLを選ぶべき理由を説明している。
参考文献
- IETF RFC 9562, “Universally Unique IDentifiers (UUIDs)” — https://datatracker.ietf.org/doc/html/rfc9562
- IETF RFC 4122, “A Universally Unique IDentifier (UUID) URN Namespace” (RFC 9562で廃止) — https://datatracker.ietf.org/doc/html/rfc4122
- PostgreSQL公式ドキュメント, “UUID Type” — https://www.postgresql.org/docs/current/datatype-uuid.html
- ULID仕様 — https://github.com/ulid/spec