主キーを何にするか。設計レビューのたびに少しだけ揉める論点ですが、正直なところ、私はこれまで「外に出すなら UUIDv4、出さないなら連番」で済ませてきました。
先週、社内で新しく立ち上げる受付系 API の設計レビューがありました。注文や問い合わせの ID を URL に載せる画面があるので、連番はやめようという話になります。/orders/1043 のような URL だと、日をまたいで2回注文すれば、差分から1日の注文数がおおよそ読めてしまうからです。以前担当した案件でも、件数の伸びを外から推測されたくないという理由で、運用中に連番から UUID へ移したケースがありました。
そこで v4 で、と言いかけたところで、若手から「PostgreSQL 18 なら uuidv7() が標準で入っているので、v7 のほうが索引に優しいらしいです」と返ってきました。話としては聞いていましたが、自分の手で数字を見たことはありません。
決める前に、一度測ることにしました。
測り方:中身が同じテーブルを並べる
手元の Docker で postgres:18(18.3)を立て、設定は既定のまま(shared_buffers 128MB)にしました。比べたのは bigint の連番、UUIDv4(gen_random_uuid())、UUIDv7(uuidv7())の3つと、もう1つ、v7 の主キーに外に出す用の v4 の列を足したテーブルです。最後の1つは後で出てきます。
それぞれに100万行を一括で入れたあと、CHECKPOINT を挟んで「1000行ずつコミットを100回」足しました。後半は、運用中のテーブルに行が増えていく状況のつもりです。一括投入だけを見ると、ページが新しく作られていくだけなので差が出にくいんですよね。
-- 中身は同じで、主キーの作り方だけ変える
CREATE TABLE t_bigint (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, body text NOT NULL);
CREATE TABLE t_v4 (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), body text NOT NULL);
CREATE TABLE t_v7 (id uuid PRIMARY KEY DEFAULT uuidv7(), body text NOT NULL);
-- 運用中を想定した追加: per 行ずつ、batches 回コミットする
CREATE PROCEDURE add_rows(tbl text, batches int, per int) LANGUAGE plpgsql AS $$
BEGIN
FOR i IN 1..batches LOOP
EXECUTE format('INSERT INTO %I(body) SELECT md5(random()::text) FROM generate_series(1,%s)', tbl, per);
COMMIT;
END LOOP;
END $$;
-- 計測: CHECKPOINT のあとの WAL 位置を覚えておき、追加後との差を取る
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS l0 \gset
CALL add_rows('t_v4', 100, 1000);
SELECT round(pg_wal_lsn_diff(pg_current_wal_insert_lsn(), :'l0'::pg_lsn) / 1048576.0, 1) AS wal_mb;
索引の詰まり具合は、pgstattuple 拡張の pgstatindex() で葉ページの充填率(avg_leaf_density)と断片化(leaf_fragmentation)を見ました。全体を2回流した結果がこちらです。
| 主キー | 主キー索引(110万行) | 葉ページの充填率 | 追加10万行の WAL | 追加10万行の時間 |
|---|---|---|---|---|
| bigint の連番 | 23.6MB | 90.0% | 15.8MB | 254〜269ms |
| UUIDv4 | 40.7〜41.4MB | 72〜73% | 45.5〜45.6MB | 405〜433ms |
| UUIDv7 | 33.1MB | 90.0% | 17.2MB | 284ms |
| UUIDv7 の主キー+外に出す用の v4 列 | 33.1MB+40.3〜41.7MB | 90.0%/71〜74% | 54.0〜54.3MB | 540〜564ms |
一括投入の100万行の時間は表から外しました。1回目と2回目で倍近くぶれた(bigint で 2,810ms と 1,394ms)ので、比べる材料になりません。WAL は2回の差が 0.4MB 以内、索引サイズも 1.4MB 以内に収まったので、こちらを判断の軸にしています。
v4 の索引は v7 より約2割大きく(41.4MB と 33.1MB)、充填率は72%前後、断片化は約50%でした。ランダムな値は索引のあちこちに入るので、満杯のページに割り込むたびにページが半分ずつに分かれ、隙間の多いページが積み上がっていきます。v7 と bigint は右端に足していくだけなので、B-tree の既定の詰め具合である90%にきれいに揃いました。
差がいちばん大きく出たのは、運用中の追加の WAL です。v4 は 45.5MB で、v7 の 17.2MB の約2.6倍でした。チェックポイントのあとに最初に書き換えるページは丸ごと WAL に書かれるので、触るページが散るほど WAL が膨らみます。WAL が増えれば、レプリカへの転送量もバックアップの容量もそのまま増えます。
とはいえ、v7 が bigint と同じになるわけではありません。uuid は16バイトなので、索引は bigint の1.4倍(33.1MB と 23.6MB)です。ここは好みが分かれるところですが、私は推測されにくさの代わりに払う額として許容範囲だと判断しました。ULID も候補に挙がりましたが、PostgreSQL には標準の型も関数も無く、アプリ側で作って文字列か uuid に詰め直すことになるので外しています。uuid 型にそのまま入る v7 なら、既存の uuid 列のデフォルトを替えるだけで済みます。
想定外だったこと:ID を見れば作成時刻が分かる
測っている途中で、最後に入れた行の ID を眺めていて気づきました。v7 は先頭48ビットがミリ秒単位の UNIX 時刻なので、ID がそのまま作成時刻を持っています。ドキュメントにはそう書いてあるけど、自分のテーブルの ID から時刻が出てくると、受け取る印象がまるで違いました。
-- 最新の行の ID から作成時刻を取り出す
SELECT id, uuid_extract_timestamp(id) FROM t_v7 ORDER BY id DESC LIMIT 1;
-- 01a10a07-d9d8-774e-b0aa-560b8cc0ea3a | 2026-10-05 03:07:29.112+00
-- v4 は時刻を持たないので NULL になる
SELECT uuid_extract_timestamp(id) FROM t_v4 LIMIT 1;
-- (NULL)
-- 余談: 最初は max(id) と書いて怒られた。uuid には max() が無い
SELECT max(id) FROM t_v7;
-- ERROR: function max(uuid) does not exist
しかも、データベースに入らなくても読めます。URL に載った ID を見た人が、ブラウザのコンソールで次の数行を打てば終わりです。
// ハイフンを除いた先頭12桁(48ビット)が、ミリ秒の UNIX 時刻
const id = "01a10a07-d9d8-774e-b0aa-560b8cc0ea3a";
const createdAt = new Date(parseInt(id.replaceAll("-", "").slice(0, 12), 16));
console.log(createdAt.toISOString()); // 2026-10-05T03:07:29.112Z
注文 ID なら注文した時刻、会員 ID なら登録した時刻がミリ秒で分かります。問い合わせの受付番号なら、深夜に送ったことまで読めてしまう。連番をやめた理由が「件数を読まれたくない」だったのに、今度は別の情報を渡していることになります。
uuidv7() には時刻をずらす引数もあり、uuidv7(interval '-30 days') と書くと、ちょうど30日前の時刻が入りました。ただ、全部の行を同じだけずらすなら、行どうしの間隔と順番はそのまま残ります。隠す手段にはならないと判断しました。
ここで少し脱線します。同じミリ秒の中の並びが乱数だと、v4 と同じことが小さな範囲で起きるのでは、と気になったんです。1セッションで10万個作ったところ49ミリ秒に散り、生成順と ID 順の逆転は0件でした。続けて300万個を一気に作っても、最後の ID の時刻は現在時刻より250ミリ秒手前で、時刻が先走ることもありませんでした。ただ、複数の接続から同時に作ったときの順番までは測っていません。ID の並びを「作られた順」として当てにする使い方はしない、という扱いにしています。
最初のアプローチ:外に出す ID を別の列にする
時刻が漏れるなら、外に出す ID を別に持てばいい。最初はそう考えて、主キーは v7 のまま、URL 用に v4 の列を足しました。
-- 最初のアプローチ(改善の余地あり)
-- 主キーは v7 のまま、URL やメールには public_id(v4)を出す
CREATE TABLE t_v7pub (
id uuid PRIMARY KEY DEFAULT uuidv7(),
public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
body text NOT NULL
);
-- 注: UNIQUE 制約の索引は v4 のランダムな値で育つので、
-- v4 を主キーにしたときと同じ断片化をそのまま抱える。
-- 運用中の追加10万行の WAL は 54.0MB で、v4 単独(45.5MB)より重かった。
表の最後の行がこれです。主キー側の索引は v7 と同じ 33.1MB で充填率も90%ですが、public_id の索引は40MBを超え、充填率は7割前後でした。WAL は v7 の約3倍に戻り、v4 だけのテーブルよりも重くなっています。索引を2本持つのだから当然と言えば当然です。それでも、v7 にしたから軽いという前提が列を1本足しただけで崩れたのは、今思えば完全に見落としでした。
改善後のアプローチ:テーブルの性格で分ける
全部のテーブルに同じ答えを当てはめるのをやめました。判断に使ったのは、1日に増える行数、ID を外に出すかどうか、作成時刻を知られて困るかどうか、の3つです。
| テーブルの例 | 1日に増える行数の目安 | ID を外に出すか | 作成時刻を知られて困るか | 選んだ主キー |
|---|---|---|---|---|
| 操作ログ、連携ジョブの履歴 | 数万〜数十万行 | 出さない | 困らない | bigint の連番(複数の場所で採番するなら v7) |
| お知らせ、記事、商品 | 数件〜数十行 | URL に出す | 困らない(公開日をもともと表示している) | UUIDv7 |
| 注文、会員、問い合わせ | 数十〜数百行 | URL やメールに出す | 困る(個人の行動の時刻になる) | 主キーは v7、外に出す用に v4 の列 |
並べてみると、行数が多いテーブルほど ID を外に出さず、ID を外に出すテーブルほど行数が少ない、という傾向がありました。1日数百行なら、WAL が3倍になっても絶対量はわずかです。重さが効いてくるテーブルでは、そもそも v4 の列を足す必要がない。最初のアプローチは捨てたのではなく、効く場所を限ったというのが落としどころです。
既存のテーブルでも1つ試しました。v4 で100万行入っているテーブルのデフォルトだけ uuidv7() に替え、同じように10万行を足すと、WAL は 19.3MB で、最初から v7 のテーブル(17.2MB)とほぼ同じまで下がりました。ところが索引の増え方は 5.4MB で、最初から v7 のテーブルの 3.0MB の約1.8倍です。新しい v7 の値は索引の右端ではなく既存の v4 の値の間に入っていくので、右端に足すときの詰め込みが効かず、ページが半分ずつ割れているようです。下の棚卸しクエリで見ると、切り替えたテーブルの索引は充填率が69.1%で、同じ10万行を v4 のまま足したテーブル(72.06%)より低くなっていました。値が増える一方なので、半分に割れたページの左側には二度と新しい行が入らないのだと思います。切り替えれば WAL はすぐ軽くなるけれど、索引の太りやすさはしばらく残る、ということになります。
既存案件の棚卸しには、次のクエリを使っています。pgstatindex() は索引を頭から全部読むので、大きなテーブルでは夜間に流すようにしています。
-- uuid の主キー索引を、サイズの大きい順に充填率と断片化つきで並べる(pgstattuple 拡張が必要)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT i.indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size,
s.avg_leaf_density,
s.leaf_fragmentation
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
CROSS JOIN LATERAL pgstatindex(i.indexrelid::regclass) s
WHERE i.indisprimary AND a.atttypid = 'uuid'::regtype
ORDER BY pg_relation_size(i.indexrelid) DESC;
-- 新しい行から v7 にする(既存の値は書き換えない)
ALTER TABLE t_v4 ALTER COLUMN id SET DEFAULT uuidv7();
おわりに
測る前の私の理解は「v7 は v4 の上位互換」でした。実際には、索引と WAL では確かに軽くなる一方で、ID が時計を兼ねるようになり、それを隠そうとすると軽さの一部を返すことになります。どこで払うかを決めるのは、結局テーブルごとの事情でした。
今後は、手元の既存案件の v4 テーブルを上のクエリで順に棚卸しして、充填率の低いものからデフォルトの切り替えを検討していきたいと考えています。外に出す ID についても、v4 の列を足す以外に、時刻の部分を暗号化して外に出す方式があるので、索引を増やさずに済むかを次に試すつもりです。複数の接続から同時に作ったときの並び順も、いずれ測っておきたいところです。