「UUIDv4を主キーにするとインデックスが荒れる」。データベースの設計レビューで一度は聞く話です。私はPoCの作成から運用、DBの最適化までを一通りやる立場なのですが、この台詞を何度も口にしてきました。
ところが先日、2つの質問を続けて受けて詰まりました。「結局、なんでだめなんですか」「0→1のフェーズでも考慮する必要がありますか」。荒れることは知っているのに、仕組みを一段掘って説明できない。どの規模から効いてくるのかも数字で答えられない。知識としては持っているのに、判断材料としては持っていませんでした。
PostgreSQL 18で uuidv7() が標準関数になった今がいい機会です。UUIDv4とUUIDv7を同じ条件で500万行ずつ挿入して、性能差が出はじめる「崖」の位置ごと実測し、この2つの質問に数字で答えられるようにしました。結論を先に言うと、崖はきれいに観測できて、その位置は事前の理屈通りでした。
前提: UUIDv4が主キーに向かないと言われる理由
まず前提を揃えます。主キーを作ると、PostgreSQLはB-treeインデックスを自動で作ります。
B-treeインデックスとは
すべてのキーを常にソート済みの状態で保管する構造です。紙の辞書と同じで、「あ」で始まる単語は前の方のページ、「わ」で始まる単語は最後の方のページ、と値によって置き場所が最初から決まっています。
つまり、新しいキーを追加するとき、どこに置くかを選ぶことはできません。キーの値そのものが挿入先のページを決めます。連番のような増え続ける値なら、挿入先は常に「一番うしろのページ」で済みます。ところがUUIDv4は128bitのうち122bitが乱数です。挿入先が毎回、インデックス全体のどこかのページにランダムに飛びます。これが3つのコストを生みます。
- ページ分割が中間で起きる: 満杯のページの真ん中に割り込むと、ページは半分ずつに分割されます。分割後のページは半分空いたままになりやすく、インデックスがスカスカに膨らみます
- 触るページ数が多い: 挿入のたびに違うページを読み書きするので、インデックスのほぼ全ページが「作業中」になります。この作業領域がメモリ(shared_buffers)からあふれた瞬間、ディスク読みが始まります
- WALが増える: 汚すページが多いほど、チェックポイント後の全ページ書き出し(full page writes)も増えます
UUIDv7は先頭48bitがUnixタイムスタンプ(ミリ秒)です。値が時系列で単調に増えるので、挿入は常にB-treeの右端ページに集中します。
理屈はこれだけです。問題は、コスト2の「メモリからあふれた瞬間」がいつ来るかです。
崖の条件は行数ではない
ここが今回の実験設計の肝です。崖が来る条件は「行数が多いこと」ではなく、インデックスの作業領域がshared_buffersを超えることです。
だとすれば、崖を観測するのに1億行も要りません。shared_buffersを128MBに絞った「小さな水槽」を用意すれば、崖は数百万行の位置に前倒しで出現するはずです。手元のマシンで10分で再現できて、本番相当の規模には比率で換算できます。
実験環境
- PostgreSQL 18.4 (PGDG公式バイナリ)
- shared_buffers = 128MB、max_wal_size = 4GB(チェックポイントのノイズ低減)
- ローカルNVMe、Linux
テーブルは最小構成です。
CREATE TABLE bench_v4 (id uuid PRIMARY KEY, payload text);
CREATE TABLE bench_v7 (id uuid PRIMARY KEY, payload text);
10万行のバッチを50回、計500万行を各テーブルに挿入します。生成関数だけが違います。
-- v4側
INSERT INTO bench_v4
SELECT gen_random_uuid(), repeat('x', 100)
FROM generate_series(1, 100000);
-- v7側 (PostgreSQL 18の新標準関数)
INSERT INTO bench_v7
SELECT uuidv7(), repeat('x', 100)
FROM generate_series(1, 100000);
バッチごとに所要時間と、統計ビューからインデックスのキャッシュ状況を記録します。
SELECT pg_relation_size('bench_v4_pkey'),
idx_blks_hit, idx_blks_read
FROM pg_statio_user_indexes
WHERE indexrelname = 'bench_v4_pkey';
idx_blks_read が「shared_buffersの外から読んだブロック数」です。これが増えはじめる場所が崖です。
結果1: 崖は330万行で来た
左から、バッチ処理時間、shared_buffers外からの読みブロック数(対数)、キャッシュヒット率です。点線がv4インデックスの128MB到達点(約330万行)。
v4側の推移を抜き出すとこうなります。バッチあたりの読みブロック数(dRead)に注目してください。
| 総行数 | インデックス | dRead/バッチ | ヒット率 | 処理時間 |
|---|---|---|---|---|
| 200万 | 76.6MB | 0 | 100% | 355ms |
| 280万 | 110.7MB | 529 | 99.8% | 361ms |
| 300万 | 118.7MB | 2,901 | 99.0% | 375ms |
| 330万 | 129.5MB | 8,742 | 97.1% | 391ms |
| 400万 | 153.8MB | 21,538 | 92.8% | 446ms |
| 500万 | 195.7MB | 37,460 | 87.6% | 541ms |
280万行あたりから読みが立ち上がり、インデックスが128MBを超える330万行前後で指数的に増加。500万行時点ではバッチあたり3.7万ブロック(約300MB)をディスク側から読んでいます。10万行入れるだけなのにです。
一方のv7は、500万行までバッチあたりの読みブロック数が終始0〜3でした。インデックス自体は150MBまで育ってshared_buffersを超えていますが、触るのが右端の数ページだけなので、作業領域は永遠にキャッシュに収まり続けます。崖が来ないのではなく、崖に近づく理由がそもそもない、という結果です。
結果2: 崖の前からv4は4割遅い
意外だったのはこちらです。キャッシュに全部収まっている序盤(〜50万行)でも、v4は既に遅い。
| UUIDv4 | UUIDv7 | |
|---|---|---|
| 序盤5バッチ平均 | 273ms | 195ms |
| 終盤5バッチ平均 | 528ms | 194ms |
| 500万行の総投入時間 | 19秒 | 10秒 |
崖の手前でも4割遅く、崖を越えた終盤では2.7倍差。500万行の合計では約2倍です。ランダム挿入はページ分割とWAL増加という形で、崖のずっと手前から料金を取っていました。
(ちなみに投入自体は2系統合わせて29秒で終わっています。実験よりグラフの見た目を整える時間の方が長くかかりました)
インデックスの物理統計(pgstatindex)にもはっきり出ています。
| UUIDv4 | UUIDv7 | |
|---|---|---|
| インデックスサイズ | 195.7MB | 150.4MB |
| リーフページ充填率 | 69.2% | 90.0% |
| リーフ断片化 | 49.8% | 0% |
同じ500万行を格納して30%大きく、ページの3割が空き、リーフの半分が断片化。これがコスト1(中間ページ分割)の実物です。インデックスが大きい分だけキャッシュからあふれる日も早く来るので、コスト1はコスト2の崖を手前に引き寄せる関係にあります。
本番への換算: あなたの崖は何行先か
今回の崖は「128MBの水槽で330万行」でした。崖の位置はshared_buffersにほぼ比例するので、単純換算するとこうなります。
| shared_buffers | 崖の目安(uuid単独PK) |
|---|---|
| 128MB | 約330万行 |
| 1GB | 約2,600万行 |
| 8GB | 約2.1億行 |
ただし2つ、正直な注記があります。
1つ目。今回の実験で崖を越えた後も処理時間が「2倍止まり」だったのは、OSのページキャッシュが吸収していたからです。shared_buffersの外に出ても、まだRAM上のOSキャッシュに当たっていました。本番でデータ総量がRAMを超えると、この読みは本物のディスクI/Oになり、崖の傾斜は今回よりずっと急になります。
2つ目。shared_buffersはこのインデックスの専有物ではありません。テーブル本体、他のテーブル、他のインデックスと常に取り合っています。上の表は楽観値で、実際の崖はもっと手前に来ると見るべきです。
「うちのテーブルはまだ数百万行だから大丈夫」ではなく、「主要インデックスの合計サイズとshared_buffersの比率」で見るのが正しい距離感だと思います。
それでもuuidv7を選ばないケース
性能はv7の圧勝でしたが、1つだけ引き換えにしているものがあります。UUIDv7はIDに生成時刻が埋まっています。先頭48bitをデコードすればミリ秒精度のタイムスタンプが取り出せます。
ユーザーIDにv7を使うと、IDを見せただけで登録日時が漏れます。本人より先にIDが自己紹介を始めるようなものです。注文IDなら注文のタイミングと流量が推測できます。RFC 9562自体がこのトレードオフに言及しています。
使い分けはシンプルで、外部に露出するIDはv4、内部の主キーと結合キーはv7。露出面で匿名性を守り、B-treeが働く場所では局所性を効かせる。両方を1つのIDで済ませようとしない、が今回の実測を踏まえた私の結論です。
まとめ: 冒頭の2つの質問に答え直す
「結局、なんでだめなの?」 -- ランダム挿入がB-treeの全ページを作業領域にしてしまうからです。中間ページ分割でインデックスが30%膨らみ(充填率69%、断片化50%)、その作業領域がshared_buffersを超えた地点でディスク読みの崖が来ます。今回の実測では崖の位置は理論通り(128MBに対して330万行)、しかも崖の手前ですら4割遅い。だめな理由は1つではなく、常時払う分割コストと、ある日突然来るキャッシュあふれの二段構えでした。
「0→1フェーズでも考慮する必要ある?」 -- 崖だけ見れば「まだ先」です。数万行のプロダクトには崖は来ません。ただしPostgreSQL 18では、考慮のコスト自体がほぼゼロになりました。gen_random_uuid() を uuidv7() に書き換えるだけです。コストゼロで局所性の良い方を選べるなら、最初から選ばない理由がない。それが実測後の私の答えです。唯一の例外は外部に露出するIDで、そこだけはタイムスタンプの漏れないv4を残します。
実験スクリプトは合計50行ほどのシェルとSQLで、shared_buffersを絞れば手元のノートPCでも10分で追試できます。皆さんの本番テーブル、主要インデックスの合計サイズはshared_buffersに収まっていますか。pg_relation_size を眺めてみると、崖までの距離が意外と近いかもしれません。
参考
- PostgreSQL 18 Release Notes -- uuidv7()の追加
- RFC 9562: Universally Unique IDentifiers (UUIDs) -- UUIDv7の仕様とタイムスタンプ露出への言及
- PostgreSQL Documentation: pgstattuple -- インデックス物理統計の取得
