· 14分で読了

データベース主キー:AUTO_INCREMENT、UUID、そしてUUIDv7

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

主キーにはauto incrementを使うべきか、それともUUIDを使うべきか?最近関連する議論を追っていると、この問いに対する答えはどうやら徐々にUUIDv7へと収束してきているようだ。だが、収束しているとはいえ、その背後にあるトレードオフや背景をしっかりと整理しておく価値はある。さもなければ、ただ新しい流行に飛びついているだけにすぎない。

TL;DR

  • UUIDv4 を使うメリットはほとんどない
  • 単一データベース構成であり、IDを外部に露出させず、将来的にデータベース間でのデータ統合の要件がないと確定しているなら、Auto Increment が最もシンプル
  • それ以外のすべてのケースでは UUIDv7 を選ぶ

UUID とは何か

UUID(Universally Unique Identifier)は128ビットの識別子であり、標準的なフォーマットは以下のようになっている:

550e8400-e29b-41d4-a716-446655440000

36文字で構成され、- で5つのセグメント(8-4-4-4-12)に区切られている。この区切り形式は最初期のUUIDv1に由来し、各セグメントが異なる内部フィールドに対応していた。v4やv7になると、大半のフィールドは乱数で埋め尽くされるようになったが、後方互換性のためにこのフォーマットが維持されている。

UUIDにはいくつかのバージョンが存在するが、本記事で主に扱うのは以下の2つだ:

  • UUIDv41:122ビットの純粋な乱数で、構造は全く持たない。現在最も広く使われているバージョンだが、データベースに対してあまり優しくない。
  • UUIDv72:先頭48ビットにミリ秒単位のタイムスタンプを配置し、残りの約74ビットに乱数を配置する。時間順のソートとランダム性を両立しており、現在推奨される選択肢となっている。

両者の見た目は全く同じだが、内部構造が異なる。見分け方は第3セグメントの先頭文字だ。4xxx ならv4、7xxx ならv7となる。

UUIDv4: 550e8400-e29b-41d4-a716-446655440000
                      ^
UUIDv7: 018f3a3b-7a5d-7a3b-8b3a-3b7a5d7a3b8b
                      ^

なぜ主キーはそれほど重要なのか?

ユースケースによって、データの書き込みや検索の効率、システムの将来的な拡張性、将来的なデータ移行時に地雷を踏むかどうかに大きく影響する。後述のベンチマークを見ればわかる通り、7倍以上のパフォーマンス差が生じることすらある。

Auto Increment vs UUID:結局何が違うのか

まずは最も基本的な判断基準から始めよう。主キー(Primary Key)を決める際には、いくつかの視点から考えることができる:

  • 挿入(INSERT)のパフォーマンス
  • インデックス(INDEX)のパフォーマンス
  • 検索のパフォーマンス

Auto Increment のメリットは極めて直感的だ。データベースが自動でインクリメントしてくれ、8バイトの整数で軽量かつ高速、B-Treeインデックスとの相性も抜群だ。常に末尾に追加されるため、INSERTのパフォーマンスは基本的に最高峰となる。

しかし、Auto Incrementには根本的な制約が2点ある:

1. 分散アーキテクチャとの相性が悪い

分散システムにおいて、グローバルにユニークなIDをどのように生成するかは難題だ。これはTwitterが公開した Snowflake アルゴリズムのように、中央の発番サービスを実装して都度取得するIDのグローバルな一意性を保証することで解決できる。

しかし、中央の発番サービスはパフォーマンスのボトルネックや単一障害点(SPOF)のリスクを抱える。単一障害点を避けるため、実践では複数のワーカーを同時にデプロイして協調させる必要があり、システムの複雑さと保守すべきコンポーネントが増加してしまう。

2. 予測可能性

数字を1024や1022に変えるだけで、他人の注文が見えてしまうリスクがある(実装が甘い場合)。競合他社にIDの増加ペースから自社のユーザー数や取引量を推計される可能性もある(逆にそれをブラフとして利用する手もあるが)。

UUID(UUIDv4に代表される)はこの2つの問題を解決した。128ビットの乱数空間により、どのノードでも中央の協調なしに独立して生成でき、推測されることもほぼ不可能だ。

しかしUUIDv4は完全なランダムであるため、B-TreeにINSERTする際にすでに埋まっているページへあちこち挿入されることになり、大量のページスプリット(page split)を引き起こす。結果として書き込み性能が著しく低下する。これが、多くの人がUUIDを採用した後に「なぜかデータベースが遅くなった」と感じる原因だ。

そのため、選択時に考慮すべきなのは実質的に以下の項目となる:

  • システムに分散生成の要件があるか?
  • IDが外部(URL、APIレスポンス)に露出するか?
  • データベースの書き込み性能に対する要求はどれほど高いか?

もしシステムがモノリス構成で、IDが外部に露出しないなら、auto incrementで全く問題ない。だが、分散環境に関わる場合や、IDがURLに現れる場合は、UUID系のソリューションがほぼ必須となる。

衝突(コリジョン)したらどうする?

これはUUIDの議論において、おそらく毎回必ず聞かれる質問だ。

UUIDv4には122ビットの乱数空間があり、その組み合わせは約 5.3 × 1036 通り存在する。

衝突の確率が50%に達するためには、約 2.7 × 1018 個のUUIDを生成する必要がある。この数字は「全世界で毎秒10億個のUUIDを生成し続けて、およそ85年かかる」規模感だ。

一方、UUIDv7の乱数空間は「見た目上」122ビットから約74ビットへと縮小しているが、衝突が起こり得るのは同一ミリ秒内に限られる。つまり、同一ミリ秒内に生成されたUUIDだけが、衝突を避けるために74ビットの乱数に依存することになる。そして74ビットは約 1.8 × 1022 通りの組み合わせを提供するため、単一のミリ秒に対しては十分すぎるほどの余裕がある。

UUIDv7 vs created_at + インデックス:実測パフォーマンス

UUIDv7の最も魅力的な特性は、先頭48ビットにミリ秒単位のUnixタイムスタンプが格納されているため、生まれつき時間順にソートされているという点だ。

UUIDv7 (128 bits)
┌──────────────────────┬────┬──────────────────────────────┐
│  Unix Timestamp (ms) │ ver│         Random               │
│      48 bits         │ 4b │        ~74 bits              │
└──────────────────────┴────┴──────────────────────────────┘
   ↑ 時間順ソート可能    ↑v7   ↑ 衝突防止の乱数

主キーにUUIDv7を使用すると、ORDER BY id そのものが実質的に ORDER BY created_at と同等になる。時間順ソートのために追加の created_at カラムやそれに対応するインデックスを用意する必要がなくなるのだ。

理論上は魅力的に聞こえるが、実際にはどれほどの差が出るのだろうか?Docker上でPostgreSQL 17のインスタンスを立ち上げ、2,000万件のデータを投入して実際に測定してみた。

テスト設定

テーブルは3つ用意した。データ構造は同一で、主キーの戦略のみが異なる:

-- プランA:UUIDv7主キー(追加のcreated_atは不要)
CREATE TABLE orders_uuidv7 (
  id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
  user_id int NOT NULL,
  amount numeric(10,2) NOT NULL,
  status text NOT NULL
);

-- プランB:Serial主キー + created_atインデックス
CREATE TABLE orders_serial (
  id bigserial PRIMARY KEY,
  user_id int NOT NULL,
  amount numeric(10,2) NOT NULL,
  status text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX idx_orders_serial_created_at ON orders_serial (created_at);

-- プランC:UUIDv4主キー(対照群)
CREATE TABLE orders_uuidv4 (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id int NOT NULL,
  amount numeric(10,2) NOT NULL,
  status text NOT NULL
);

テスト環境:

  • Mac Mini M4
  • Docker PostgreSQL 17
  • shared_buffers = 256MB

1ラウンドにつき100万件を書き込み、計20ラウンド実施して累計2,000万件まで投入した。UUIDは事前に生成するのではなく、ラウンドごとに即時生成しているため、生成関数のオーバーヘッドも含まれている。

テスト1:INSERT性能の推移(100万件ごと、単位 ms)

累計行数SerialUUIDv7UUIDv4
1M1,1323,1852,998
5M1,4742,6612,972
10M1,1652,5606,497
13M1,1232,7459,668
15M1,4112,6309,188
16M1,1332,55215,857
20M1,0172,53920,845

重要なのは絶対値ではなく、推移の傾向だ。

UUIDv4は3秒から一気に21秒まで跳ね上がった。

B-Treeインデックスが肥大化するほど、ランダムな挿入によってすでにディスクへフラッシュされたページにヒットする確率が高くなる。その結果、INSERTのたびに「古いページをディスクからバッファプールへ読み戻す → ページスプリット → ディスクへ書き戻す」という処理が頻発するからだ。

各ラウンドで100万件をINSERTする際のWAL量は、データベースがディスクに対して実際に負っている書き込み負荷を如実に示している:

累計行数Serial WALUUIDv7 WALUUIDv4 WAL
1M146 MB171 MB744 MB
5M147 MB171 MB1,227 MB
8M148 MB171 MB1,715 MB
10M147 MB171 MB2,418 MB

SerialとUUIDv7のWAL量は最初から最後までほぼ一定(約147 MB vs 約171 MB)だ。両者の差(24 MB)は、UUIDの16バイトがbigintの8バイトより大きいという純粋なデータ量の差にすぎない。

しかし、UUIDv4のWAL量は744 MBから2,418 MBへと膨れ上がっている。同じ100万件の書き込みであるにもかかわらず、ラウンド10の時点ですでにUUIDv7の14倍ものWALが書き込まれているのだ。

これこそが、高い書き込み増幅(high write amplification)3である。

UUIDv7は終始2.5〜2.8秒で安定しており、全く劣化しなかった。

時系列順(time-ordered)という特性のおかげで、新しいキーはB-Treeの最も右側にあるリーフページへとほぼ常に追記される。これはauto-incrementと同様の挙動であり、DBはシーケンシャルに書き込むことができ、ページスプリットも極めて少なく抑えられる。

Serialは1.0〜1.5秒で安定している。

文句なしにSerialが最速だが、UUIDv7とSerialの差は主に以下の要因から生じている:

  1. UUID自体のサイズが16バイトとbigintの8バイトの2倍あるため、インデックスの1ページに収められるエントリ数が少ないこと
  2. 実際のアプリケーションではUUIDはアプリケーション層のネイティブ関数で生成されることが多く、その場合は差がさらに縮まること(UUID生成のオーバーヘッドがアプリケーション側に移るため)

Postgresの組み込み関数でUUIDを生成すべきかどうかについて、以前の僕は「データベースに任せられることはすべてデータベースに任せる」という発想が好きで、手間が省けるならそれに越したことはないと考えていた。

しかし、ビジネスロジックの一部をデータベース内に記述してしまうと、将来的に見落としやすくなる。そのため現在の僕は、UUIDの生成はアプリケーション層で行うべきだという考えに傾いている。

テスト2:ストレージ容量(2,000万行)

プランHeapIndex合計サイズ
Serial1,149 MB428 MB1,578 MB
UUIDv71,302 MB749 MB2,051 MB
UUIDv41,302 MB770 MB2,072 MB

UUID系のheapはSerialより153 MB大きい(各行のPKが8バイトから16バイトになるため)。インデックスは約1.75倍大きくなっているが、ストレージ全体の観点から見れば極端な差とは言えない。

テスト3:カーソルベースのページネーション(Cursor-Based Pagination)

カーソルベースのページネーションは、前のページの最後の値を「カーソル」として利用し、次ページのクエリを WHERE > cursor ORDER BY ... LIMIT N のように行う仕組みだ。OFFSET と比較して、何ページ目まで進んでも安定したパフォーマンスを維持できる利点がある。

110万件のデータ規模において、カーソル位置を90万件目に設定し(深いページネーションをシミュレート)、3つのシナリオをそれぞれ100回実行した:

クエリ方式100回あたりの総所要時間
Serial + OFFSET 900000(対照群)7,012 ms
Serial + cursor on created_at index1.9 ms
UUIDv7 + cursor on PK1.6 ms

いくつかの観察結果:

  1. UUIDv7のカーソルページネーションは主キーをそのまま使えばよい。 追加のインデックスは不要だ。Serialの構成で時間順のカーソルページネーションを行う場合、created_at のインデックスを利用する必要がある。これはセカンダリインデックスであるため、クエリ時にはまずインデックスを引いてからテーブル本体のデータを取得しに行く(index scan → heap fetch)必要がある。
  2. UUIDv7はcreated_atインデックスよりもわずかに高速。 UUIDv7のカーソルが走査するのはプライマリキーであるのに対し、created_atはセカンダリインデックスだからだ。
  3. 深いページネーションにおけるOFFSETは大惨事となる。 7秒 vs 2ミリ秒未満で、その差は3,600倍に達する。OFFSETは手前にあるすべての行をスキャンして破棄しなければならない。この結論は本記事の主題からは少し外れるが、ページネーション機能を実装する際に OFFSET を使うのは避けたほうがいい。簡単にパフォーマンスを崩壊させてしまう。

まとめ

UUIDv7を主キーにするメリットは以下の通りだ:

  1. 書き込み性能が低下しない:auto-incrementと同様にシーケンシャルな追記となるため、データ量の増加に伴って遅くなることがない。UUIDv4は2,000万件時点で既に7倍遅くなっており、件数が増えればさらに悪化の一途をたどる。
  2. ひとつのインデックスで2つの役割をこなせる:主キー制約でありながら、時間順ソートの基準にもなる。created_at カラムとそのインデックスを省略でき、カーソルベースのページネーションも WHERE id > :last_id ORDER BY id だけで完結する(実務上は依然としてcreated_atが必要になることもあるが)。

クエリパターンが時間順のソートに強く依存している場合(フィード、タイムライン、注文履歴一覧など)、UUIDv7を採用することでスキーマをより簡潔にし、インデックスの数も減らすことができる。

ただし、マイクロ秒単位で正確な作成日時が必要な場合や、created_at にビジネスロジック上の意味がある場合(時間範囲での検索が必要など)は、やはり created_at カラムを残しておくべきだ。UUIDv7のタイムスタンプの精度はミリ秒であり、同一ミリ秒内の順序は乱数によって決まるため、厳密な挿入順序までは保証されない。

外部URLはどう扱うべきか?

UUIDv7を内部の主キーとして扱うのは問題ないが、URLにそのまま露出させるのにはいくつかデメリットがある。文字列として長すぎる(36文字)こと、そしてタイムスタンプ部分から作成日時が推測されてしまうことだ。

一般的なアプローチはいくつかある:

  1. 16バイトのUUIDをBase62やBase58でエンコードし、21〜22文字程度に圧縮する。データの本質は変わらず、より短い表現形式に変えるだけだ。Base58は 0/OI/l といった混同しやすい文字をあらかじめ排除しているため、手入力が想定される場面に優しい。
  2. 外部用として独立したIDを別途用意する:NanoIDなどを用いて、内部IDとは全く無関係な外部識別子を別途生成する。NanoIDは純粋な乱数で時間の構造を持たず、デフォルトの21文字で約126ビットのエントロピーを持ち、衝突耐性はUUIDv4と同等だ。

僕自身はアプローチ2を好んでいる。Base62/58エンコードは文字列こそ短縮されるものの、本質的には同一のUUIDであるため、復号すればタイムスタンプを割り出すことが可能だ。一方NanoIDは内部IDと完全に切り離されているため、外部IDが知られたところでデータベース内の情報を逆算される恐れは一切ない。外部IDを保存するためのカラムとインデックスが1つ増えるという代償はあるが、このトレードオフは通常受け入れる価値がある。

本当に「絶対に重複しない」ことが求められる場合は?

UUID(どのバージョンであれ)の一意性は確率に基づいている。衝突する確率は極めて低いが、理論上ゼロではない。大多数のシステムにとってはこれで十二分だが、特定のユースケースでは100%の保証がどうしても必要になる。金融取引のシリアルナンバーや請求書番号、医療記録のIDなどがその典型だ。

そのような場面では、「中央集権型」のモデルへと立ち戻る必要がある。単一の信頼できる情報源(権威)が発番を担当するのだ。一般的な手法には以下のようなものがある:

  • データベースのシーケンス + 分散ロック。 最も直接的な方法として、PostgreSQLの SEQUENCE やRedisの INCR に分散ロックを組み合わせ、グローバルな一意性を確保する。単一障害点のリスクがあり、高並発な環境下ではロックの競合がボトルネックになるというデメリットがある。

  • Twitter Snowflakeおよびその亜種。 前述したSnowflakeも本質的には中央集権型だ。Worker IDの割り当てには中央での協調(ZooKeeper / etcd)が必要となる。BaiduのUidGeneratorやSonyのSonyflakeも同様の思想に基づいており、ビット配分やWorker IDの管理方法に微調整を加えたものだ。

  • データベースネイティブの分散ID。 CockroachDBやTiDBといったNewSQLデータベースは、分散ユニークID生成の仕組みを組み込みで備えている。データベース層でこの問題を解決してくれるため、アプリケーション層で余計な処理をする必要がない。

どの方式を選ぶにせよ、本質的には可用性や運用の複雑性と引き換えにして絶対的な一意性を手に入れている。もしビジネスの現場において、法規制やコンプライアンス上「絶対に一意であること」の保証が求められないのであれば、UUIDv7 の確率的な一意性で十分だ。

その他の豆知識

  • 00000000-0000-0000-0000-000000000000 は有効なUUIDであり、Nil UUIDと呼ばれる4
  • FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF は有効なUUIDであり、Max UUIDと呼ばれる
  • UUID生成関数を自分で自作してはいけない

障害現場の実態を見ると、衝突が発生して破綻することなど滅多になく、十中八九は単にアプリケーション側の実装が甘いだけだ。そう考えると、それすらも考慮に入れるのであれば、やはり最も安全なのはSerialなのかもしれない。

Footnotes

  1. https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-4

  2. https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-7

  3. 単一の挿入によって複数のページ書き換えが引き起こされ、実際に書き込んだデータ量を大幅に超過する現象

  4. https://www.rfc-editor.org/rfc/rfc9562.html#name-nil-uuid

関連記事

他のトピックを探索