Tech Learning Daily

2026-10-25 (Sun) — 第 100 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
DB 🌿 基礎 ⏱ 約 7 分

同じ UUID の主キーなのに、なぜ v4 だとテーブルが育つほど INSERT が重くなるのか?

Rails で create_table :orders, id: :uuid と書けば、ID の採番を DB に問い合わせずに済み、URL から件数も推測されにくい。ところが「UUID の主キーはインデックスに悪い」「v7 にしろ」という話もよく聞く。同じ 128 ビットの値なのに、生成方法を変えるだけで DB の負荷が変わるのはなぜだろうか。

🎯 3 行まとめ
  • B+Tree はキーの順に並ぶので、完全ランダムな UUIDv4 を入れると挿入先がインデックス全体に散り、ページ分割と「全ページをメモリに置いておく必要」が生じる。
  • UUIDv7 は先頭 48 ビットがミリ秒単位の Unix 時刻なので、新しい値はほぼ常にインデックスの右端に入り、連番 bigint に近い書き込みパターンになる。
  • PostgreSQL 18 で uuidv7() が組み込まれた。ただし v7 は生成時刻を外に漏らすので、推測されてはいけないトークンには v4 を使う。

UUID のバージョンは「128 ビットのどこに何を詰めるか」の違い

UUID(Universally Unique Identifier)は、中央の採番係なしにどこでも重複しにくい ID を作るための 128 ビットの値で、現行の仕様は RFC 9562 である。どの版も長さは同じで、違いは中身のビットの割り当てにある。版番号は 4 ビットの ver フィールドに書かれ、文字列表記では 3 つ目のブロックの先頭 1 文字(xxxxxxxx-xxxx-7xxx-...)として目で見える。

v4 は版と variant の固定ビットを除いた 122 ビットがすべて乱数である。Rails の PostgreSQL アダプタが id: :uuid で既定に使う gen_random_uuid() もこの v4 を返す。

v7 は先頭 48 ビットにミリ秒単位の Unix 時刻(unix_ts_ms)を入れ、残りの 12 ビット(rand_a)と 62 ビット(rand_b)を乱数かカウンタで埋める。時刻が上位にあるので、v7 はバイト列のまま比較するだけで生成順に並ぶ。

UUIDv4: 122 ビットがほぼ全部乱数
┌────────────┬───┬──────┬───┬──────┐
│ random     │ver│random│var│random│
│ 48 bit     │ 4 │ 12   │ 2 │ 62   │
└────────────┴───┴──────┴───┴──────┘

UUIDv7: 上位 48 ビットが時刻
┌────────────┬───┬──────┬───┬──────┐
│ unix_ts_ms │ver│rand_a│var│rand_b│
│ 48 bit     │ 4 │ 12   │ 2 │ 62   │
└────────────┴───┴──────┴───┴──────┘
  ▲ ここが大きいほど新しい
    ▶ B+Tree の右端に入る

B+Tree の「どこに入れるか」はキーの値だけで決まる

PostgreSQL でも InnoDB でも、主キーには B+Tree のインデックスが付く。B+Tree は、キーを昇順に並べた固定サイズのページ(InnoDB の既定は 16KB)を木構造でつないだもので、新しいキーを入れる葉ページはキーの大小だけで一意に決まる。連番 bigint なら新しい値は常に最大なので、書き込みは毎回いちばん右の葉ページに集まる。

v4 だとこの性質が裏返る。乱数のキーは既存のどのキーとの間にも同じ確率で入りうるので、100 万行目の INSERT が 1 行目の近くのページに入ることもある。満杯のページに入れるときは、そのページを 2 つに割って半分ずつ移すページ分割が起き、親ページへのポインタ追加も伴う。

分割された直後のページは半分ほど空いたまま残る。MySQL のマニュアルは、InnoDB のクラスタ化インデックスに昇順や降順で挿入するとページは約 15/16 まで埋まるが、ランダムな順で挿入すると 1/2〜15/16 になると書いている。同じ行数でもインデックスがかさばるのはこのためだ。

🍱 たとえるなら

受付番号順に綴じるファイルなら、新しい書類は常に最後のページの後ろに足すだけで済む。ところが書類を「名前のランダムな暗号」順に綴じると決めた途端、1 枚届くたびに棚のどこかのファイルを開き、満杯なら 2 冊に分け直す羽目になる。しかも次にどの棚に行くか分からないので、手元に全部のファイルを広げておかないと速く作業できない。

本当に効いてくるのは「メモリに載り切らなくなった後」

ページ分割の回数だけが問題に見える。しかし実際に INSERT を重くするのは、どのページに触るかが読めないことのほうだ。DB はディスク上のページをメモリ上のバッファ(PostgreSQL の shared_buffers、InnoDB のバッファプール)にキャッシュして読み書きする。連番なら触るのは右端の数ページだけなので、インデックスが何十 GB になっても書き込みに必要なページはメモリに残り続ける。

v4 では INSERT のたびにインデックス全体のどこかのページが必要になるので、書き込みに必要な作業領域(ワーキングセット)がインデックス全体になる。テーブルが小さいうちは全ページがメモリに収まるので差は見えにくい。インデックスがバッファを超えた時点から、INSERT ごとにディスクからページを読み戻す処理が混ざり始め、「最初は速かったのにテーブルが育つほど遅くなる」という形で現れる。

WAL も増える。PostgreSQL は既定の full_page_writes = on で、チェックポイント後に初めて変更するページの全内容を WAL に書く。ランダムな挿入はチェックポイントの間に変更するページの種類が多いので、そのぶんページ丸ごとの記録が増える。RFC 9562 の 6.11 節も、ランダムな挿入と時刻順の挿入の性能差は 1 桁以上になりうると述べている。

InnoDB と PostgreSQL で痛み方が違う

InnoDB は主キーの B+Tree の葉に行データそのものを格納する。これをクラスタ化インデックスと呼ぶ。そのため主キーがランダムだと、インデックスだけでなく行データ本体の配置まで散らばる。さらにセカンダリインデックスの各レコードは行を引くために主キーの値を持つので、8 バイトの bigint を 16 バイトの UUID に変えるとすべてのセカンダリインデックスが太る。

PostgreSQL のテーブル本体(ヒープ)は主キー順に並ばないので、散らばるのは主キーのインデックスだけで済む。ただし葉ページの扱いには差がある。B-Tree の fillfactor(既定 90)は、初回構築時と「右端に新しい最大値を足すとき」に葉をどこまで詰めるかを決める設定で、右端に足し続ける時刻順の挿入ではこの割合どおり余白を残して詰まる。ランダムな挿入はページが満杯になってから途中で割れるので、この割合は効かない。

PostgreSQL 18(2025 年 9 月 25 日リリース)からは uuidv7() が組み込まれ、拡張なしで既定値にできる。Rails では既定値を明示すれば v4 から切り替わる。

create_table :orders, id: :uuid, default: -> { "uuidv7()" } do |t|
  t.string :status
  t.timestamps
end

default を書かなければ従来どおり gen_random_uuid()(v4)が使われる。v7 は採番が DB 側でもアプリ側でもよいが、同一ミリ秒内に複数のサーバーで生成した値の前後は乱数で決まる。完全な昇順ではなく「ほぼ右端」に入る値だと理解しておくとよい。

💼 実務でどう出会うか

典型は「ログやイベントのテーブルを UUID 主キーで作り、半年後に INSERT のレイテンシとディスク I/O がじわじわ上がる」パターンである。インデックスサイズがバッファの大きさを超えた頃に症状が出やすいので、pg_relation_size('テーブル名_pkey') を監視しておくと兆候を掴める。新規テーブルなら最初から v7 を既定値にするのが安上がりで、既存テーブルの採番を v7 に変えた場合は、以降の新しい行が右端に入るようになる(既存の散らばったページは REINDEX するまでそのまま残る)。

⌨️ 手を動かす(5 分)

PostgreSQL 18 のコンテナで、v4 と v7 を主キーにした同じ形のテーブルに 100 万行ずつ入れ、主キーインデックスの大きさを比べる。

docker run -d --name pg18 -e POSTGRES_PASSWORD=pw postgres:18
sleep 5
docker exec -i pg18 psql -U postgres <<'SQL'
CREATE TABLE t4 (id uuid PRIMARY KEY DEFAULT uuidv4(), n int);
CREATE TABLE t7 (id uuid PRIMARY KEY DEFAULT uuidv7(), n int);
\timing on
INSERT INTO t4 (n) SELECT g FROM generate_series(1, 1000000) g;
INSERT INTO t7 (n) SELECT g FROM generate_series(1, 1000000) g;
\timing off
SELECT relname, pg_size_pretty(pg_relation_size(oid))
  FROM pg_class WHERE relname IN ('t4_pkey', 't7_pkey');
SELECT id, uuid_extract_timestamp(id) FROM t7 LIMIT 3;
SQL
docker rm -f pg18

t4_pkey のほうが同じ行数でも大きくなるはずだ。所要時間の差は、100 万行のインデックスが手元のメモリに収まる規模なので小さいこともある(差が開くのはバッファに載り切らなくなってから)。最後の SELECT では、v7 の ID から生成時刻がそのまま取り出せることが確認できる。

🙅 よくある誤解
  • v7 にすれば連番 bigint と同じ性能になる — 挿入位置の局所性は近づくが、キーは 16 バイトで bigint の倍ある。InnoDB ではすべてのセカンダリインデックスが主キーを持つので、その差がインデックスの数だけ効く。
  • UUID は推測できないので、v7 もパスワードリセットのトークン等に使える — v7 は上位 48 ビットが生成時刻で、乱数は残りの最大 74 ビットしかない。RFC 9562 もセキュリティ用途に使うなら v4 を使うべきとしている。
  • UUID を varchar(36) で持っても中身は同じ — 文字列では 36 文字ぶんの領域と文字列比較が必要になる。RFC 9562 も可能なら 128 ビットのバイナリで保存するよう勧めており、PostgreSQL なら uuid 型、MySQL なら BINARY(16) を使う。
📖 用語ミニ辞典
UUID
中央の採番なしに重複しにくい ID を作る 128 ビットの値。版ごとにビットの割り当てが違う。
UUIDv7
上位 48 ビットにミリ秒単位の Unix 時刻を置いた UUID。バイト順に並べると生成順になる。
ページ分割
満杯の B+Tree ページに挿入するとき、ページを 2 つに割って中身を半分ずつ移す処理。
クラスタ化インデックス
インデックスの葉に行データそのものを持つ構造。InnoDB では主キーがこれになる。
ワーキングセット
ある処理を速く回すためにメモリに載っていてほしいページの集合。
fillfactor
インデックスのページをどこまで詰めるかの割合。PostgreSQL の B-Tree の既定は 90。
🔗 もっと深く