はじめに
最近XでUUIDを話題にしているポストをいくつか見かけました。
そういえばUUIDなんとなく使ってるなと思ったので、自分のために再調査と整理をしたいと思います。
「ライブラリを呼べばランダムな文字列が返ってくるもの」くらいの認識で長いこと使ってきましたが、実際に仕様を読んでみると、128ビットのうちどこに何が入るかがきっちり決まっていて、「全部ランダム」ではありませんでした。
この記事では、フォーマットの読み方(v4なら特定の桁が必ず4になる、という話も含めて)、各バージョンの中身、衝突確率、DBの主キーに使うときの影響、他のID方式との比較あたりまで見ていきます。
UUIDとは
UUID(Universally Unique Identifier)とは、128ビットの長さの一意識別子。
中央の採番サーバーに問い合わせることなく、各自が勝手に生成しても実用上ぶつからない、というのが最大の特徴です。分散システムやオフラインファーストなアプリで採番待ちをせずにIDを決められるのは、かなり強力な性質だと思います。
なお、Microsoft文化圏では同じものを GUID(Globally Unique Identifier)と呼びます。基本的には同じ128ビットの識別子ですが、後述するように表記や内部のバイト順で罠があります。
仕様はRFC 4122とRFC 9562によって定義されています。
| 項目 | RFC 4122 | RFC 9562 |
|---|---|---|
| 公開年 | 2005 | 2024 |
| 位置づけ | UUIDの初期標準 | UUIDの最新版仕様 |
| 状態 | 廃止 | 現行 |
| 定義 | v1,v2,v3,v4,v5 | v6,v7,v8 |
RFC 9562はRFC 4122を置き換える(obsolete)仕様なので、v1〜v5の定義も含めて現在はRFC 9562を読めば完結します。v6〜v8が「追加された」というより、全体が引き継がれた上で拡張された、という理解が正しいです。
| バージョン | 生成方式 | ソート可能 | 決定的 | 現在の位置づけ |
|---|---|---|---|---|
| v1 | 時刻 + MACアドレス | △ | × | レガシー |
| v2 | DCE Security | △ | × | 実質未使用 |
| v3 | 名前ベース(MD5) | × | ○ | v5があるので非推奨 |
| v4 | ランダム | × | × | 現役(最も普及) |
| v5 | 名前ベース(SHA-1) | × | ○ | 現役(名前ベースならこれ) |
| v6 | v1を並べ替えた時系列版 | ○ | × | v1からの移行用 |
| v7 | Unix Epoch + Random | ○ | × | 現役(新規採用の第一候補) |
| v8 | アプリ定義カスタム | 実装次第 | 実装次第 | 特殊用途 |
「決定的」は、同じ入力から必ず同じUUIDが得られるかどうかです。v3とv5だけがこの性質を持ちます。
RFC
フォーマットを読み解く
UUIDは以下の8-4-4-4-12の形式で表されます。(全体で36文字、ハイフン除くと32文字の16進数)
550e8400-e29b-41d4-a716-446655440000
| | | | | | | | | |
time_low mid hi clk node
このフィールド名(time_low、nodeなど)はv1由来のもので、v4やv7では意味を持ちません。ただしビットの区切り位置と文字数は全バージョン共通なので、「128ビットをこの並びで16進表記したもの」という形だけが共通の土台になります。
バージョンとバリアントの位置
ここが今回いちばん整理したかったところです。
128ビットのうち 6ビットは中身が固定されていて、ランダムでも時刻でもありません。バージョンを表す4ビットと、バリアントを表す2ビットです。
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
Mがバージョン、Nがバリアントです。位置を具体的に書くとこうなります。
550e8400-e29b-41d4-a716-446655440000
^ ^
| +--- バリアント(必ず 8 / 9 / a / b)
+-------- バージョン(1〜8)
| 対象 | ハイフン込みでの位置 | ハイフンを除いた位置 |
|---|---|---|
| バージョン | 15文字目 | 13文字目 |
| バリアント | 20文字目 | 17文字目 |
つまり、v4のUUIDは3ブロック目の先頭(15文字目)が必ず4になります。同様にv7なら必ず7です。
冒頭のサンプル550e8400-e29b-41d4-a716-446655440000も、41d4の4とa716のaを見れば「v4でRFC準拠のバリアント」だと目視で判別できます。
言われてみれば当たり前ですが、私はこれを「たまたま4が多いな」くらいにしか思っていませんでした。デバッグ中に受け取ったIDがv4なのかv7なのか、目grepで判断できるのは地味に便利です。
実際に確認してみます。
import { randomUUID } from "node:crypto";
const seen = new Set();
for (let i = 0; i < 100000; i++) {
const hex = randomUUID().replace(/-/g, "");
seen.add(`${hex[12]}:${hex[16]}`); // バージョン桁とバリアント桁
}
console.log([...seen].sort().join(" "));
10万回生成した結果がこちらです。
4:8 4:9 4:a 4:b
バージョン桁は4のみ、バリアント桁は8 9 a bの4種類しか出てきません。
なぜバリアントは8/9/a/bなのか
バージョンが4ビット丸ごと使うのに対して、バリアントは 上位2ビットだけ を使う、というのがポイントです。
バリアント桁の4ビット: 1 0 x x
└┬┘ └┬┘
│ └── ここは自由(0〜3の4通り)
└────── ここが 10 に固定
上位2ビットが10に固定されるので、残り2ビットの4通りと合わせて1000〜1011、つまり16進で8・9・a・bになります。
そして、なぜ2ビットしか使わないのかというと、バリアントは可変長のプレフィックスとして設計されているからです。
| 上位ビット | 16進の値 | バリアント |
|---|---|---|
0xx | 0〜7 | NCS(過去互換・未使用) |
10x | 8 9 a b | RFC 4122 / RFC 9562 |
110 | c d | Microsoft GUID(レガシー) |
111 | e f | 将来の予約 |
NCSは1ビット、RFCは2ビット、Microsoftは3ビットと、後から来た仕様ほど長いプレフィックスを使う形になっています。ハフマン符号のような構造ですね。
普段目にするUUIDはほぼすべて10xなので、実務では「4桁目のブロックの先頭が8/9/a/b以外だったら何かおかしい」と思っておけば十分です。
「ランダム」なのは122ビット
v4は「128ビットのランダム値」と説明されることがありますが、上記の通りバージョン4ビットとバリアント2ビットは固定なので、実際に自由なのは 122ビット です。
v4のビット構成
48bit: ランダム
4bit: バージョン(0100 固定)
12bit: ランダム
2bit: バリアント(10 固定)
62bit: ランダム
------------------
122bit がランダム
この6ビットの差は衝突確率の計算に効いてくるので、後述します。
Nil UUID と Max UUID
RFC 9562には、特殊な値として2つが定義されています。
Nil UUID: 00000000-0000-0000-0000-000000000000
Max UUID: ffffffff-ffff-ffff-ffff-ffffffffffff
Nilは「値なし」を表すためのもので、nullの代わりにDBのNOT NULLカラムへ入れる、といった使い方をされることがあります。Maxは範囲検索の上限として使えます。
どちらもバージョン/バリアントのビットルールに従っていない特別扱いの値なので、「バージョン桁を見てバリデーションする」ようなコードを書く場合はこの2つを例外として扱う必要があります。
表記ゆれの罠
同じUUIDでも、システムをまたぐと見た目が変わることがあります。
| 表記 | 出どころ |
|---|---|
550e8400-e29b-41d4-a716-446655440000 | RFC標準(小文字) |
550E8400-E29B-41D4-A716-446655440000 | .NETなどの大文字出力 |
{550e8400-e29b-41d4-a716-446655440000} | Windowsのレジストリ形式 |
550e8400e29b41d4a716446655440000 | ハイフン除去 |
urn:uuid:550e8400-e29b-41d4-a716-446655440000 | URN形式 |
RFCでは 出力は小文字、入力は大文字小文字どちらも受け付ける と定められています。文字列としてそのまま比較すると別物と判定されてしまうので、外部システムと連携するときは正規化を挟むのが安全です。
もうひとつ厄介なのが、MicrosoftのGUID構造体は最初の3フィールドをリトルエンディアンで持っているという点です。同じUUIDでも、.NETでバイト配列にした結果とRFC準拠の実装のバイト配列が一致しません。バイナリでやり取りするときにハマりやすいポイントです。
各バージョンの詳細
v1
- 構造
- 60bit:タイムスタンプ(100ns単位)
- 14bit:クロックシーケンス
- 48bit:ノードID(通常 MACアドレス)
- 特徴
- 時系列ソート可能
- 分散環境で衝突しにくい
- 問題点
- MACアドレス露出
タイムスタンプの起点が1970年ではなく 1582年10月15日(グレゴリオ暦の開始日)という、なかなか渋い仕様です。60ビットを100ナノ秒刻みで使うので、枯渇するのは西暦5000年代あたりになります。
クロックシーケンスは、システムの時刻が巻き戻ったときや、同じ100ns区間で複数生成したときに衝突を避けるためのカウンタです。
そして最大の問題が、時刻とMACアドレスがそのまま読めてしまうこと。「このIDはいつ、どのマシンで作られたか」が第三者に分かってしまうため、外部に出すIDとしては避けたほうが無難です。
なお、ソート可能とは書きましたが、v1はタイムスタンプの下位ビットが文字列の先頭側に来る配置なので、文字列としてソートしても時系列にはなりません。この並び順を直したのが後述のv6です。
v2
- 特徴
- v1 を拡張し UID/GID を含む
- DCE(分散計算環境)向け
- 問題点
- 実装・利用例ほぼゼロ
RFC 9562でも「歴史的経緯として記載するのみ」という扱いです。UID/GIDを埋め込むためにタイムスタンプの精度を犠牲にしており、同一マシンで7分あたり1個しか生成できないという制約もあります。新規で使う理由はまずありません。
v3
- 構造
- MD5 ハッシュ
- 特徴
- 同じ入力 → 同じUUID
- 問題点
- 一意ではない
- MD5使用のため新規用途では避けられる
v5
- 構造
- v3 と同様だが SHA-1 使用
- 特徴
- v3 の上位互換
- 問題点
- 一意ではない
v3とv5はまとめて「名前ベースUUID」と呼ばれます。名前空間UUID + 名前 をハッシュ化してUUIDを作るので、同じ入力からは何度でも同じUUIDが得られます。
名前空間としてRFCが定義済みの値がこちらです。
| 名前空間 | UUID |
|---|---|
| DNS | 6ba7b810-9dad-11d1-80b4-00c04fd430c8 |
| URL | 6ba7b811-9dad-11d1-80b4-00c04fd430c8 |
| OID | 6ba7b812-9dad-11d1-80b4-00c04fd430c8 |
| X.500 DN | 6ba7b814-9dad-11d1-80b4-00c04fd430c8 |
もちろん自前で好きなUUIDを名前空間として使うこともできます。
実装してみるとこうなります。
import { createHash } from "node:crypto";
const NS_DNS = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
function nameBased(namespace, name, algo, version) {
const nsBytes = Buffer.from(namespace.replace(/-/g, ""), "hex");
const digest = createHash(algo)
.update(Buffer.concat([nsBytes, Buffer.from(name, "utf8")]))
.digest();
const bytes = Buffer.from(digest.subarray(0, 16));
bytes[6] = (bytes[6] & 0x0f) | (version << 4);
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
console.log(nameBased(NS_DNS, "example.com", "md5", 3)); // v3
console.log(nameBased(NS_DNS, "example.com", "sha1", 5)); // v5
9073926b-929f-31c2-abc9-fad77ae3e8eb
cfbff0d1-9375-5685-968c-48ce8b15ae17
何度実行しても同じ値が返ります。「一意ではない」というのは欠点ではなく、むしろこれが目的の機能です。
用途としては、外部システムのキー(メールアドレス、URLなど)から自システムのIDを導出したいとき、対応表を持たずに済むのが便利です。同じ入力に対して常に同じIDになるので、冪等な登録処理を書きやすくなります。
注意点として、ハッシュ元の値が推測できる場合はUUIDも推測できてしまいます。メールアドレスからv5を作って公開IDにする、といった使い方は避けたほうがよいです。
v4
- 構造
- 122bit ランダム
- CSPRNG 前提
- 特徴
- 衝突確率が極小(ほぼない)
- 時系列の順序性を持たない
- 問題点
- DBインデックス効率が悪い
現状もっとも普及しているバージョンです。時刻もマシン情報も含まないので、IDから何かが漏れる心配がないのが強みです。
「CSPRNG前提」というのが地味に重要で、Math.random()のような擬似乱数で生成すると一意性も予測困難性も保証されません。詳しくは後述します。
v6
- 構造
- v1 のタイムスタンプを上位ビットに再配置
- 特徴
- v1 の後継
- v1 → v7 への橋渡し的存在
- 問題点
- v7があるため、現在は存在意義薄目
v1と保持している情報は同じで、フィールドの並び順だけを変えたものです。タイムスタンプの上位ビットが文字列の先頭に来るため、文字列としてもバイナリとしても時系列にソートできるようになりました。
すでにv1を大量に使っているシステムが、既存データと互換性を保ちつつ順序性を得たい場合の移行先、という位置づけです。ゼロから選ぶ理由はほぼありません。
v7
- 構造
- 48bit:Unix Epochタイムスタンプ(ms単位)
- 4bit:バージョン
- 12bit:ランダム(または連番カウンタ)
- 2bit:バリアント
- 62bit:ランダム
- 特徴
- 時系列ソート可能(B-Treeインデックスと相性が良い)
- MACアドレス不要でプライバシーの問題がない
- RFC 9562 で新規採用が推奨されている
- 問題点
- ミリ秒精度のため、同一ミリ秒内の順序はランダム部に依存
先頭48ビットがUnix時間(ミリ秒)なので、文字列のまま辞書順にソートすれば生成順に並びます。16進が小文字固定かつ桁数が揃っているため、バイナリ順と文字列順が一致するのがきれいなところです。
48ビットのミリ秒は西暦10889年ごろまで表現できるので、実質的に枯渇の心配はありません。
タイムスタンプが素直に入っているので、UUIDから生成時刻を取り出せます。
function timestampOf(uuidv7) {
const hex = uuidv7.replace(/-/g, "").slice(0, 12);
return new Date(parseInt(hex, 16));
}
console.log(timestampOf("019ff127-27cc-73e8-897c-d3bf465b00f0").toISOString());
// 2026-08-11T14:08:22.988Z
便利な反面、IDを見れば作成時刻が分かってしまう ということでもあります。これはv7を採用するかどうかの判断材料になります。
同一ミリ秒内の順序については、RFC 9562が単調増加を保つための方法をいくつか示しています。rand_aの12ビットをサブミリ秒精度に使う方法、単調増加カウンタとして使う方法などです。素朴に「タイムスタンプ以外を全部ランダム」で実装すると、同じミリ秒内では順序が保証されません。この挙動は後述の実装で実際に確認します。
v8
- 構造
- バージョンビット以外の122bitをアプリケーションが自由に定義
- 特徴
- 組織やシステム固有の要件に対応可能
- 独自のタイムスタンプ精度やエンコーディングを組み込める
- 問題点
- 相互運用性がない(自分たちだけのルール)
- 独自実装のため衝突管理も自己責任
「独自フォーマットのIDを作りたいが、UUIDとして扱える形にしておきたい」というときの逃げ道です。UUID型のカラムやライブラリのバリデーションをそのまま通せるのがメリットになります。
例えば「マイクロ秒精度のタイムスタンプ + テナントID + 連番」のようなIDを設計したい場合、v8として定義しておけば既存のUUID対応の仕組みに乗せられます。逆に言えば、そこまでの要件がなければv7で十分です。
v4の衝突確率をちゃんと考える
「衝突しない」と言われがちですが、実際どのくらいなのかを整理しておきます。
122ビットのランダムなので、取りうる値は約5.3 × 10^36通りです。
衝突は誕生日問題として計算します。50%の確率で1回でも衝突が起きるのは、およそ2.7 × 10^18個を生成したときです。
1秒あたり10億個のUUIDを生成し続けた場合
→ 約85年でようやく衝突確率が50%に到達
現実的な規模で考えてみます。
| 生成した個数 | 1回以上衝突する確率 |
|---|---|
| 10億(10^9) | 約 9.4 × 10^-20 |
| 1兆(10^12) | 約 9.4 × 10^-14 |
| 1000兆(10^15) | 約 9.4 × 10^-8 |
というわけで、まともなCSPRNGを使っている限り、アプリケーションの規模で衝突を心配する必要はありません。
ただしこれは 乱数源が本当にランダムな場合の話 です。実務で衝突が起きるときの原因は確率ではなく、乱数の実装ミスや、コンテナ/VMのクローンで乱数シードが共有されてしまうといった事故のほうが圧倒的に多いです。
自前実装
仕様を理解するため自分でも実装してみます。
Node.jsでv4
crypto.randomUUID()があるので実際こうすることはないと思いますが、crypto.randomBytesを使用してv4を生成することはできます。
import { randomBytes } from "node:crypto";
function uuidv4() {
const bytes = randomBytes(16);
// version 4: 上位4ビットを 0100 にする
bytes[6] = (bytes[6] & 0x0f) | 0x40;
// variant RFC4122: 上位2ビットを 10 にする
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
console.log(uuidv4());
ビット操作の部分がまさに前述のバージョン/バリアントの固定処理です。
bytes[6]は7バイト目、つまりハイフンを除いた13〜14文字目にあたります。& 0x0fで上位4ビットを消してから| 0x40(0100 0000)を立てるので、13文字目が必ず4になります。
bytes[8]も同様に& 0x3f(0011 1111)で上位2ビットを消し、| 0x80(1000 0000)で10を立てています。ここは上位2ビットだけを触りたいのでマスクが0x0fではなく0x3fになっている、というのがポイントです。
ブラウザJSでv4
ブラウザにはnode:cryptoのrandomBytesが存在しないため、ブラウザJSでやる場合はWeb Crypto APIのcrypto.getRandomValuesを使います。
function uuidv4() {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// version 4: 上位4ビットを 0100 にする
bytes[6] = (bytes[6] & 0x0f) | 0x40;
// variant RFC4122: 上位2ビットを 10 にする
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
console.log(uuidv4());
なお、現在のブラウザにはcrypto.randomUUID()も実装されているので、実際にはこれを呼ぶだけで済みます。
console.log(crypto.randomUUID());
ひとつ注意点があって、crypto.randomUUID()とcrypto.subtleは セキュアコンテキスト(HTTPSまたはlocalhost)でしか使えません。http://でアクセスする開発用サーバーやIPアドレス直打ちの検証環境ではundefinedになるので、そこだけ引っかかりやすいです。crypto.getRandomValues()はセキュアコンテキスト外でも使えます。
Node.jsでv7
v7は標準APIにないので(crypto.randomUUID()はv4固定)、自前で書くならこうなります。
import { randomBytes } from "node:crypto";
function uuidv7() {
const bytes = randomBytes(16);
const ts = Date.now(); // ミリ秒(48bitに収まる)
// 先頭48ビットにタイムスタンプをビッグエンディアンで詰める
bytes[0] = (ts / 2 ** 40) & 0xff;
bytes[1] = (ts / 2 ** 32) & 0xff;
bytes[2] = (ts / 2 ** 24) & 0xff;
bytes[3] = (ts / 2 ** 16) & 0xff;
bytes[4] = (ts / 2 ** 8) & 0xff;
bytes[5] = ts & 0xff;
// version 7: 上位4ビットを 0111 にする
bytes[6] = (bytes[6] & 0x0f) | 0x70;
// variant RFC4122: 上位2ビットを 10 にする
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
const list = Array.from({ length: 5 }, () => uuidv7());
console.log(list.join("\n"));
console.log("生成順に並んでいるか:", String([...list].sort()) === String(list));
実行結果です。
019ff127-27cc-73e8-897c-d3bf465b00f0
019ff127-27cc-7611-900b-2ee7935ed4e6
019ff127-27cc-7efd-a356-db68e44dbd62
019ff127-27cc-79b7-a9cd-a7f36784a1d9
019ff127-27cc-7da9-ae95-e09e7546d683
生成順に並んでいるか: false
先頭の019ff127-27ccまではタイムスタンプなので全部同じです。ループが1ミリ秒以内に終わっているためですね。
そしてfalse。これが先ほど触れた 同一ミリ秒内の順序保証がない という問題です。ミリ秒をまたげば必ず昇順になりますが、高速に連続生成すると同じミリ秒の中でランダム部の大小に従って前後します。
DBの主キーとして使う分にはミリ秒単位でまとまっていればインデックス的には十分なので実害は小さいですが、「生成順を厳密に復元したい」という要件があるなら、rand_aの12ビットをカウンタとして使う実装が必要になります。実運用では素直にライブラリを使うのが無難です。
バージョンとバリアントを読み取る
受け取ったUUIDが何者なのかを判定するコードです。
function inspect(uuid) {
const hex = uuid.replace(/-/g, "").toLowerCase();
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error("invalid uuid");
if (/^0{32}$/.test(hex)) return { kind: "nil" };
if (/^f{32}$/.test(hex)) return { kind: "max" };
const variantBits = parseInt(hex[16], 16).toString(2).padStart(4, "0");
const variant = variantBits.startsWith("10")
? "RFC 4122/9562"
: variantBits.startsWith("110")
? "Microsoft"
: variantBits.startsWith("111")
? "Reserved"
: "NCS";
return { kind: "uuid", version: parseInt(hex[12], 16), variant };
}
console.log(inspect("550e8400-e29b-41d4-a716-446655440000"));
// { kind: 'uuid', version: 4, variant: 'RFC 4122/9562' }
バリデーションとして「正規表現でバージョン桁を[1-8]に限定する」といったこともできますが、Nil UUIDやMax UUIDを弾いてしまう点には注意が必要です。
データベースとUUID
UUIDを主キーに使うかどうかは、たいていここが論点になります。
ストレージサイズ
まず単純にサイズの話です。
| 型 | サイズ |
|---|---|
BIGINT | 8バイト |
BINARY(16) / uuid | 16バイト |
CHAR(36)(latin1) | 36バイト |
CHAR(36)(utf8mb4) | 144バイト |
MySQLでうっかりCHAR(36)かつutf8mb4にしてしまうと、1文字あたり最大4バイトで領域が確保されるため 144バイト になります。BIGINTの18倍です。
しかもInnoDBは主キーがクラスタインデックスであり、すべてのセカンダリインデックスが主キーの値を内部に保持します。主キーが太るとセカンダリインデックスも全部太る、という形で効いてくるので、UUIDを使うならBINARY(16)で持つのが基本です。
PostgreSQLには専用のuuid型があり、これは16バイトで格納されます。素直にこれを使えばよいです。
B-Treeインデックスへの影響
v4は完全ランダムなため、B-Treeインデックスにおいてページ分割が頻発し、書き込みパフォーマンスが低下します。また、キャッシュヒット率も下がるため読み取りにも影響します。
挿入位置がインデックス全体にばらけるので、更新対象のページが毎回違う場所になります。テーブルが大きくなってインデックスがメモリに乗り切らなくなると、挿入のたびにディスクI/Oが発生する形になり、ここで一気に効いてきます。
v7はタイムスタンプが上位ビットに配置されるため、新しいレコードがインデックスの末尾に追加されやすくなり、B-Treeとの相性が良くなります。オートインクリメントに近い挙動になるので、ページ分割もキャッシュミスも大幅に減ります。
一方で、末尾に挿入が集中することで、書き込みが多いシステムでは末尾ページへのロック競合が起きやすくなる、というトレードオフもあります。オートインクリメントでも同じ話なので、UUID固有の問題ではありませんが。
各DBでの扱い
MySQL
-- 文字列 → バイナリ
SELECT UUID_TO_BIN('550e8400-e29b-41d4-a716-446655440000');
-- 第2引数(swap_flag)でタイムスタンプ部を並べ替える
SELECT UUID_TO_BIN('550e8400-e29b-41d4-a716-446655440000', 1);
UUID_TO_BIN()の第2引数は、v1のタイムスタンプ部を並べ替えてソート可能にするためのもの です。前述の通りv1は下位ビットが先頭に来るので、これを入れ替えることで時系列順にしています。
注意したいのは、v7に対してswap_flag = 1を使ってはいけないという点です。v7はもともと先頭がタイムスタンプなので、入れ替えると順序性が壊れます。v7を格納するならUUID_TO_BIN(uuid, 0)(デフォルト)です。
PostgreSQL
SELECT gen_random_uuid(); -- v4
SELECT uuidv7(); -- v7(PostgreSQL 18以降)
gen_random_uuid()はPostgreSQL 13以降で標準搭載されています(それ以前はpgcrypto拡張が必要)。v7の生成関数はPostgreSQL 18で追加されました。それより前のバージョンではアプリ側で生成するか、拡張を入れることになります。
SQL Server
uniqueidentifier型の比較順がバイト順と一致しない(内部のバイト順序が特殊)という独特の仕様があります。NEWSEQUENTIALID()という連番的なGUIDを生成する関数も用意されていますが、こちらはMACアドレスベースなので外部公開するIDには向きません。
結局UUIDにすべきか
ただし、UUIDは16バイトとBIGINT(8バイト)の2倍のサイズがあるため、大量のレコードを扱う場合はオートインクリメントの整数型のほうが効率的なケースもあります。UUIDを採用する主な理由は、分散環境での一意性の保証やIDの予測困難性にあるため、要件に応じて使い分けるのが良いと思います。
判断材料としてはこのあたりでしょうか。
| 状況 | 向いているもの |
|---|---|
| 単一DB、IDを外部に見せない | オートインクリメント |
| シャーディング・マルチマスター | UUID v7 |
| クライアント側でIDを採番したい | UUID v4 / v7 |
| URLに出るIDを推測されたくない | UUID v4 |
| 数十億行のログテーブル | BIGINT + 別途公開ID |
「内部の主キーはBIGINT、外部に見せるIDは別カラムでUUID」という二本立てにするのも、それぞれの利点を取れる現実的な折衷案です。
セキュリティ上の注意
UUIDは秘密情報ではない
前提として、UUIDは「一意な識別子」であって「推測不可能なトークン」ではありません。v1、v6、v7は時刻情報を含みますし、v3とv5は入力が分かれば再現できます。
「URLにUUIDが入っているから認可チェックは不要」という設計は、たとえv4であっても避けるべきです。UUIDはログやRefererヘッダー、ブラウザ履歴など、あちこちに漏れていきます。
一方で、CSPRNGから生成したv4の122ビットは、セッショントークンやパスワードリセットトークンとして十分な強度があるとされる128ビットに近い値です。用途として問題があるのは、あくまでv1/v6/v7やv3/v5のほうです。
乱数源に注意
一番やってはいけないのがこれです。
// 絶対にやらないこと
function badUuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
}
見た目は正しいv4が出てきますし、ネット上でもよく見かけるスニペットです。しかしMath.random()は暗号学的に安全な乱数ではなく、V8では内部状態を出力から復元できることが知られています。つまり、いくつかの値を観測すれば次のUUIDを予測できてしまいます。
また、Math.random()はプロセスごとの状態を持つため、シードが被る条件が揃うと一意性そのものも保証されなくなります。
必ずcrypto.randomUUID()、crypto.getRandomValues()、crypto.randomBytes()といったCSPRNGを使いましょう。
v7の時刻漏洩
v7を外部公開するIDに使うと、レコードの作成時刻が誰にでも分かります。
これが問題になるかはサービス次第です。ブログ記事のIDなら投稿日時は公開情報なので構いませんが、「ユーザーの登録日時」「注文の作成時刻」などを隠したいケースでは、v7を外に出さない設計にする必要があります。
内部の主キーはv7、外部公開用のIDはv4、と分けるのは有力な選択肢です。
他のID方式との比較
UUID以外にも、似た目的の識別子はいろいろあります。
ULID
ULID(Universally Unique Lexicographically Sortable Identifier)はUUID v7と似た特徴を持つ識別子です。
タイムスタンプ(ms)48bit + ランダム値80bitをCrockford Base32でエンコードします。
時系列順でソート可能で、26文字とUUIDと比べて短くなります。
01KZRJGABJQ8WHEBW0GZ8P5VFG
01KZRJGABK6NFJ8PDQHAJGRV6J
01KZRJGABMP30SFHKYBN1P3YGS
Crockford Base32はI L O Uを除いた32文字を使うので、1とI、0とOのような読み間違いが起きにくくなっています。大文字小文字を区別しない扱いなので、人間が口頭で伝えたり手入力したりする場面でも扱いやすいです。
中身は同じ128ビットなので、DB上はBINARY(16)やUUID型に格納できます。ただしバージョン/バリアントのビットを持たないため、RFC準拠のUUIDとしては不正な値 になります。UUID型のバリデーションが厳密な環境では弾かれることがあります。
構造がv7とほぼ同じであることを考えると、新規採用なら「標準化されている」という理由でv7を選ぶのが素直だと思います。表示上の短さを重視するならULID、という判断でしょうか。
その他
| 方式 | ビット数 | 文字数 | 特徴 |
|---|---|---|---|
| UUID v4 | 128 | 36 | 完全ランダム、標準 |
| UUID v7 | 128 | 36 | 時系列ソート可、標準 |
| ULID | 128 | 26 | 時系列ソート可、Base32で短い |
| Snowflake ID | 64 | 最大19 | Twitter発、64bitで軽いが採番機の管理が必要 |
| KSUID | 160 | 27 | 時系列ソート可、秒精度 |
| NanoID | 126程度 | 21 | URLセーフ、短さ重視、順序性なし |
| cuid2 | 可変 | 24程度 | 衝突耐性と予測困難性を重視 |
Snowflake IDは64ビットに収まるのでBIGINTで持てるのが大きな利点ですが、マシンIDを衝突なく割り当てる仕組みが別途必要になります。中央集権的な管理を許容できるならサイズ面では最も有利です。
NanoIDはURLに載せる短いIDが欲しいときの選択肢です。時系列の順序性はないので、v4の短い版という位置づけになります。
どれを使うのか
新規で採用するなら、基本的にv4かv7のどちらかになるかと思います。
長いこともあり、脳死で採用するようなものでもないと思います。
DBの設計によっては、オートインクリメントで済ます場合もあるかと思います。
v4を選ぶケース
- 時系列の順序性が不要な場合(例:APIキー、セッションID、トークンなど)
- 生成タイミングの情報を外部に漏らしたくない場合
- 既存システムがv4前提で組まれている場合
v7を選ぶケース
- DBの主キーとして使いたい場合
- 時系列ソートが必要な場合(例:イベントログ、メッセージIDなど)
- 大量の書き込みがあり、インデックスの効率を重視したい場合
v5を選ぶケース
- 外部システムのキーから決定的にIDを導出したい場合
- 対応表を持たずに冪等な処理を実現したい場合
迷ったら
DBの主キー用途ならv7、外部に露出するIDや秘匿性が要るものはv4。この2択で考えて、それでも要件に合わなければ他を検討する、くらいの温度感でよいと思います。
おわりに
v7までしか知らず、v8まであるの知らんかった。
改めて調べてみると、「ただのランダムな文字列」だと思っていたものに、バージョン・バリアントのビットからDBのインデックス特性、乱数源の話まで、意外と考えるべきことが詰まっていました。
特に「v4なら15文字目が必ず4」という話は、知ってしまえば当たり前なのに今まで意識していなかったところで、こういう小さい引っかかりを潰していくのが再入門の楽しさかなと思います。
