1. 思考停止の「プライマリキー=UUID v4」が本番DBを絞め殺す
現代のWebアプリケーションやモバイルバックエンドの新規開発において、非常によく見かけるスキーマ設計があります。それが「とりあえず推測されにくいように、主キーを UUID v4 にしておく」というアプローチです。
採用の動機を聞けば、どの現場でもだいたい以下のような理由が並びます。
- 推測防止:
/users/1024や/orders/4096のような単調増加の連番ID(BIGINT auto_increment)だと、競合他社や悪意あるユーザーに「登録ユーザー数」や「1日の受注件数」が丸見えになってしまう。 - クライアント側での先行採番: オフラインファーストのモバイルアプリやSPA側で、DBへの問い合わせを待たずにレコードIDを発行したい。
- シャーディング・分散採番の容易さ: 複数DBノードやマイクロサービス間でID採番の排他制御・ID生成サービス(Snowflake等)の管理をしたくない。
これらは一見、極めて論理的でモダンな設計判断のように思えます。しかし、断言します。リレーショナルデータベース(特にMySQL InnoDBやPostgreSQL)のクラスタ化インデックス(主キー)に「完全ランダムなUUID v4」を採用することは、将来の物理I/O破綻を約束された時限爆弾です。
「アプリケーション層の利便性だけを見てストレージエンジンの物理データ構造を無視した設計は、トラフィックが急増した一番クリティカルな局面で、確実にハードウェアリソースを食い破る。」
テーブルのレコード数が数万件のステージでは、すべてのデータとインデックスがメモリ(RAM)に収まっているため、この問題は顕在化しません。しかし、レコード数が数十万件を超え、数百〜数千万件に達した瞬間、DBサーバーのCPUは遊んでいるのにディスクI/Oだけが100%に張り付き、単純な1行の INSERT や SELECT に数百ミリ秒を要する「謎の重力」に捕らわれることになります。
2. B+Treeインデックスの物理解剖: なぜランダム書き込みはDBを殺すのか?
なぜUUID v4はそれほどまでにRDBを苦しめるのでしょうか。その根本原因は、ほぼすべてのリレーショナルデータベースが採用している「B+Treeインデックスの格納メカニズム」と「OS/ストレージのキャッシュ局所性(Cache Locality)」の決定的なミスマッチにあります。
MySQL InnoDBのクラスタ化インデックス(Clustered Index)の特性
特にMySQL(InnoDBストレージエンジン)において、主キーは単なるインデックスではありません。テーブルそのものが主キーの昇順に並んだB+Tree構造(Index-Organized Table)になっています。リーフノード(末端のページ、デフォルト16KB)そのものの中に、各行の全カラム実データが格納されているのです。
単調増加ID(BIGINT)の場合: 平穏な末尾アペンド
BIGINT auto_increment やシーケンスによる単調増加IDを使っている場合、新しいレコードは「B+Treeの常に一番右端のリーフページ」に順番に書き込まれます。
- 現在のリーフページが満杯になると、新しい空ページが末尾に割り当てられ、そこへ順次追記されます。
- 既存のページをいじる必要がないため、ページの充填率は約90%〜94%(InnoDBでは後続の挿入マージンとして約15/16)という極めて高密度な状態を維持します。
- 書き込み対象となるページは常にメモリ上の「最新の数ページ(ホットスポット)」に集中するため、OSやDBのバッファプール(Buffer Pool)に100%ヒットします。ディスクへの書き込みはWAL(Redo Log)によるシーケンシャルI/Oで一括フラッシュされるため、極めて高速です。
[単調増加ID (BIGINT): 末尾アペンドの美しい世界]
Page A [1, 2, 3, 4] (満杯)
↓
Page B [5, 6, 7, 8] (満杯)
↓
Page C [9, 10, .. ] ← 新規INSERTは常に右端のメモリ上ページへ追記!
(ディスクI/Oは最小、ページスプリット皆無)
UUID v4(完全ランダム)の場合: ページスプリットの地獄
一方、完全ランダムなUUID v4を主キーにした場合、次に挿入される値がB+Treeの「どの位置」に来るかは神のみぞ知る状態です。過去に作成された数百万行のツリーの、あらゆるリーフノードの隙間に無作為にねじ込まれます。
[UUID v4 (完全ランダム): ページスプリットと断片化の地獄]
Page X [1a.., 2f.., 4c.., 5e..] (すでに16KB満杯)
↑
ここに "3b..." (ランダム) を挿入したい!
↓
【ページスプリット (Page Split) 発生】
1. 新しい空ページ Y を確保
2. Page X のデータを半分 (50%) Page Y へ強制移動
3. 親ノードのポインタを書き換え
4. 充填率が約50%に急落(断片化の発生)
5. ディスク容量とインデックスサイズが約2倍に膨張!
このランダム挿入がもたらす致命的な被害は、主に以下の2点です。
1. ページスプリット(Page Split)によるデータ断片化と肥大化
満杯のページの中央に新しい行を割り込ませるため、DBエンジンはページを2つに分割し、データを半分ずつに再配置します。その結果、全リーフページの充填率(Fill Factor)は約50%〜60%にまで急落します。
つまり、同じ1,000万件のデータを保持するのに、単調増加IDなら10GBで済むテーブルが、UUID v4では20GB近くのディスク容量を食いつぶすことになります。二次インデックス(Secondary Index)もリーフに主キーの値を持つため、すべてのセカンダリインデックスのサイズまで激増します。
2. キャッシュ局所性(Cache Locality)の完全喪失とI/Oストーム
これが最も致命的です。テーブルとインデックスの総サイズがDBのバッファプール(メモリ)を超えた瞬間、地獄の蓋が開きます。
次に挿入されるレコードの挿入先ページがメモリ上に載っている確率は、データの肥大化に伴って激減します。結果として、たった1行の INSERT を実行するために、DBはディスク(SSD)からランダムな16KBページをメモリへ読み込み(Random Read)、ページスプリットを起こし、ダーティページをディスクへ書き戻す(Random Write)という物理I/Oを毎回強いられることになります。
シーケンシャル追記であればSSDのSLCキャッシュやコントローラの書き込みバッファが綺麗に効きますが、ギガバイト単位で散らばるランダムなブロックへの読み書きは、どんなに高速なNVMe SSDであってもIOPSの上限を瞬時に食い尽くします。
3. 「BIGINT vs UUID v4」の不毛な二者択一を終わらせた「UUID v7(RFC 9562)」
長年、エンジニアはこのジレンマに頭を抱えてきました。「推測されたくない・分散生成したい」という要件を取ればUUID v4でI/Oが死ぬ。「性能とコンパクトさ」を取ればBIGINTの連番漏洩リスクを甘受しなければならない。
この不毛な争いに終止符を打つべく、2024年5月、IETF(Internet Engineering Task Force)から歴史的な標準仕様が正式に発行されました。それが「RFC 9562: Universally Unique IDentifiers (UUIDs)」です。
RFC 9562は、古色蒼然とした2005年のRFC 4122を全面的に改定し、現代の分散システムとデータベースの特性に最適化した新しいUUIDバージョン(v6, v7, v8)を正式規格化しました。その中でも、現代のプライマリキーとして圧倒的な本命となるのが「UUID v7」です。
UUID v7のビットレイアウト構造
UUID v7は、「先頭48ビットのミリ秒精度タイムスタンプ」+「バージョン情報」+「暗号学的なランダム値(またはサブミリ秒カウンタ)」で構成される128ビットの値です。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms | (32 bit)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| unix_ts_ms | ver | rand_a | (16 + 4 + 12 bit)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var| rand_b | (2 + 62 bit)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| rand_b | (32 bit)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- unix_ts_ms: Unix Epoch (1970-01-01T00:00:00Z) からの経過ミリ秒 (48 bit)
- ver: バージョンビット '0111' (4 bit)
- rand_a: 擬似乱数 または ミリ秒内単調増加カウンタ (12 bit)
- var: バリアントビット '10' (RFC 9562 / RFC 4122準拠) (2 bit)
- rand_b: 暗号学的擬似乱数 (62 bit)
なぜUUID v7はB+Treeを救うのか?
UUID v7の最大の特徴は、「文字列表現またはバイナリ表現でソートしたとき、自然に時系列順(Time-ordered)に並ぶ」という点にあります。
- 末尾アペンドの復活: 新しく生成されたIDは、直前のIDよりも必ず数値的に大きくなります。そのため、B+Treeインデックスの右端リーフノードに順次追記され、ページスプリットが原理的に発生しません。充填率は90%以上を維持します。
- 完璧なキャッシュ局所性: 挿入対象ページは常にメモリ上のアクティブなページに局所化されるため、ディスクI/Oが激減します。
- 高い衝突耐性: 後半に最低でも74ビット(12+62ビット)以上のエントロピーを有しており、同一ミリ秒内で世界中の何十億台ものマシンが同時に採番しても衝突確率は天文学的にゼロに近似します。
- 標準UUID互換: ハイフン区切りの36文字形式(
018e65a3-7b4c-7a91-8d23-0123456789ab)であり、既存のUUIDパーサー、DBのUUID型(PostgreSQLのuuid型など)、外部APIスキーマと100%の互換性を持ちます。
4. 実践:Go と SQL で見る UUID v7 の生成とスキーマ設計
それでは、実際のコードでUUID v7をどのように扱い、DBスキーマをどのように設計すべきかを見ていきましょう。
GoによるUUID v7の生成(クリーンルーム実装)
外部ライブラリを追加しなくても、Goの標準ライブラリ(crypto/rand と time)だけでRFC 9562に完全準拠したUUID v7を美しく生成できます。依存関係を増やしたくないミニマルなプロジェクトにも最適です。
package idgen
import (
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"time"
)
// NewUUIDv7 は RFC 9562 に準拠した UUID v7 (16 bytes) を生成します。
func NewUUIDv7() ([16]byte, error) {
var uuid [16]byte
// 1. 現在のUnixタイムスタンプ (ミリ秒) を取得
ms := time.Now().UnixMilli()
// 2. 先頭 48-bit (6 bytes) にタイムスタンプをビッグエンディアンで格納
uuid[0] = byte(ms >> 40)
uuid[1] = byte(ms >> 32)
uuid[2] = byte(ms >> 24)
uuid[3] = byte(ms >> 16)
uuid[4] = byte(ms >> 8)
uuid[5] = byte(ms)
// 3. 残りの 10 bytes (80-bit) を暗号乱数で満たす
if _, err := io.ReadFull(rand.Reader, uuid[6:]); err != nil {
return [16]byte{}, fmt.Errorf("failed to read secure random: %w", err)
}
// 4. バージョン 7 (ビットパターン: 0111) を設定 (第7バイトの上位4ビット)
uuid[6] = (uuid[6] & 0x0f) | 0x70
// 5. バリアント 1 (ビットパターン: 10) を設定 (第9バイトの上位2ビット)
uuid[8] = (uuid[8] & 0x3f) | 0x80
return uuid, nil
}
// FormatUUID は 16バイトのUUIDを標準ハイフン付き文字列表現に変換します。
func FormatUUID(u [16]byte) string {
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
}
PostgreSQLでのスキーマ定義
PostgreSQLではネイティブの UUID 型(16バイト固定長バイナリ)が用意されているため、極めて親和性が高いです。PostgreSQL 17以降では uuid_generate_v7() がネイティブサポートされ、標準関数だけで主キーのデフォルト採番が可能になりました。
-- PostgreSQL 17+ の場合: ネイティブ関数を直接デフォルト値に指定
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- アプリケーション側(Go等)でIDを先行生成して INSERT する場合も
-- id カラムにそのまま文字列または16バイトバイナリを流し込むだけで完全なB-Tree局所性を発揮します。
MySQL (InnoDB) でのスキーマ定義
MySQLには専用のUUID型が存在しないため、VARCHAR(36) にしてしまう現場が多いですが、それは絶対に避けてください。36文字の文字列を主キーにすると、インデックスサイズが36バイト(+オーバーヘッド)に肥大化し、メモリ効率が悪化します。必ず BINARY(16) を使用します。
-- MySQL 8.0+ の場合
CREATE TABLE orders (
-- 16バイトバイナリとして格納し、メモリを極限まで節約する
id BINARY(16) NOT NULL,
user_id BINARY(16) NOT NULL,
total_amount INT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- アプリケーション側で生成した UUID v7 のハイフンを除去した16進数を UNHEX() で挿入
-- INSERT INTO orders (id, user_id, total_amount) VALUES (UNHEX(REPLACE(?, '-', '')), ...);
-- 参照時は HEX() で復元
-- SELECT HEX(id) AS id, total_amount FROM orders WHERE id = UNHEX(REPLACE(?, '-', ''));
5. ベンチマークで見る劇的な物理差
1,000万件のレコードを新規に挿入し続けるベンチマーク試験を行った場合、UUID v4とUUID v7、そしてBIGINTの間には、以下のような生々しい性能差が記録されます。
| 指標 (10,000,000 件挿入) | BIGINT (auto_inc) | UUID v4 (完全ランダム) | UUID v7 (RFC 9562) |
|---|---|---|---|
| 挿入スループット推移 | 最後まで一定 (高速) | 件数増加に伴い 1/10 以下に激減 | 最後まで一定 (BIGINTと同等) |
| B+Tree ページ充填率 | 約 93% | 約 55% (断片化) | 約 92% |
| 主キーインデックスサイズ | 約 240 MB | 約 1,050 MB (4倍以上) | 約 480 MB (キー長16B相当) |
| バッファプール溢れ後のディスクI/O | 極小 (シーケンシャル) | 天井張り付き (激しいRandom I/O) | 極小 (末尾ページのみ局所化) |
この結果が物語る通り、UUID v7は「UUIDの利点(クライアント採番・分散ユニーク・推測困難性)」を維持したまま、物理I/Oとインデックス効率においてはBIGINTとほぼ同等の最高性能を発揮します。
6. 銀の弾丸ではない: UUID v7が抱える3つの落とし穴と現実解
どんな技術にもトレードオフが存在します。UUID v7を盲目的に崇拝する前に、現場で必ず直面する「3つの落とし穴」と、その具体的な対策を把握しておかなければなりません。
落とし穴1: 作成時刻の情報漏洩(Information Disclosure)
UUID v7の先頭48ビットには、ミリ秒精度のUNIXタイムスタンプがビッグエンディアンでそのまま格納されています。つまり、IDを見れば、そのレコードが「西暦何年何月何日何時何分何秒何ミリ秒」に作られたのかが、誰でも即座に逆算できるということです。
- リスクシナリオ: 競合サービスが自社ECサイトの注文完了画面のURL(
/orders/018e65a3-...)を定期的に監視した場合、「注文の正確な発生頻度」や「1日のトランザクション数推移」が完全に筒抜けになります。 - 現実解: 「内部プライマリキー」と「外部公開用ID(Public ID)」の分離です。DB内部のリレーションや結合には高速なUUID v7を使い、URLや公開APIのパラメータには暗号学的に安全なランダム文字列(例:
order_9xK2mP1q...などのNanoIDやランダムトークン)をセカンダリユニークキーとして発行します。
落とし穴2: 末尾ノードのロック競合(Hot Spot Insert)
すべての書き込みが「末尾のページ」に局所化されるということは、超高トラフィック環境(秒間数万件のINSERTが単一テーブルに集中するケース)において、B+Treeの最右端ページに対する排他ロック(ページラッチ)の競合が発生しやすくなることを意味します。
皮肉なことに、UUID v4が持っていた唯一の副次的メリットは「書き込みがツリー全体に分散するため、末尾ページのラッチ競合が起きない」という点でした(その代償としてディスクI/Oが爆発するのですが)。秒間数十万件のログ収集のような特殊なワークロードでは、パーティショニングやバッファリングキューを前段に挟む設計が必要です。
落とし穴3: クロックロールバック(NTPの時刻逆行)
サーバーマシンのシステム時刻がNTP同期などで「過去」に戻された場合、新しく生成されたUUID v7のタイムスタンプが直前のレコードより小さくなり、局所性が一時的に乱れたり、同一ミリ秒内の重複リスクが理論上生じ得ます。
RFC 9562では、時刻逆行を検知した場合に「前回のタイムスタンプを維持し、カウンタビット(rand_a)をインクリメントする」アルゴリズムが推奨されています。信頼性の高いライブラリを使用するか、自前実装時にもこのフォールバックを考慮しておく必要があります。
7. まとめ: 主キー設計の意思決定マトリクス
これからの時代のRDB主キー設計は、以下の明確な基準で判断すべきです。
- 社内向け業務システム・小規模ツール:
迷わずBIGINT GENERATED ALWAYS AS IDENTITY(auto_increment)を採用する。最小のストレージ(8バイト)、最速の結合速度、最も枯れた運用性。外部に推測されて困るデータがないなら、これが今でも最高峰の選択肢です。 - 一般的なWebサービス・SaaS・BtoCプロダクト(現代の標準解):
内部主キーにUUID v7(16バイト)を採用する。クライアント採番や分散環境に対応しつつ、B+Treeの断片化を完全に防ぐ。外部に晒すURLやAPIキーには、必要に応じて「プレフィックス付きPublic ID」をセカンダリカラムとして付与する。 - 使い捨てトークン・セッションID・パスワードリセット:
完全ランダムなUUID v4または 256bitの暗号乱数を採用する。これらは時系列順に並べる必要がなく、推測不可能性が最優先されるため、UUID v4本来の独壇場です(ただしテーブルのクラスタ化インデックスにしてはいけません)。
「なんとなく連番は恥ずかしいからUUID v4にしておこう」という時代は終わりました。物理ハードウェアの振る舞いと数学的なデータ構造に敬意を払い、RFC 9562が切り拓いたUUID v7という強力な武器を正しく使いこなしていきましょう。