データベース主キー: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)
| 累計行数 | Serial | UUIDv7 | UUIDv4 |
|---|---|---|---|
| 1M | 1,132 | 3,185 | 2,998 |
| 5M | 1,474 | 2,661 | 2,972 |
| 10M | 1,165 | 2,560 | 6,497 |
| 13M | 1,123 | 2,745 | 9,668 |
| 15M | 1,411 | 2,630 | 9,188 |
| 16M | 1,133 | 2,552 | 15,857 |
| 20M | 1,017 | 2,539 | 20,845 |
重要なのは絶対値ではなく、推移の傾向だ。
UUIDv4は3秒から一気に21秒まで跳ね上がった。
B-Treeインデックスが肥大化するほど、ランダムな挿入によってすでにディスクへフラッシュされたページにヒットする確率が高くなる。その結果、INSERTのたびに「古いページをディスクからバッファプールへ読み戻す → ページスプリット → ディスクへ書き戻す」という処理が頻発するからだ。
各ラウンドで100万件をINSERTする際のWAL量は、データベースがディスクに対して実際に負っている書き込み負荷を如実に示している:
| 累計行数 | Serial WAL | UUIDv7 WAL | UUIDv4 WAL |
|---|---|---|---|
| 1M | 146 MB | 171 MB | 744 MB |
| 5M | 147 MB | 171 MB | 1,227 MB |
| 8M | 148 MB | 171 MB | 1,715 MB |
| 10M | 147 MB | 171 MB | 2,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の差は主に以下の要因から生じている:
- UUID自体のサイズが16バイトとbigintの8バイトの2倍あるため、インデックスの1ページに収められるエントリ数が少ないこと
- 実際のアプリケーションではUUIDはアプリケーション層のネイティブ関数で生成されることが多く、その場合は差がさらに縮まること(UUID生成のオーバーヘッドがアプリケーション側に移るため)
Postgresの組み込み関数でUUIDを生成すべきかどうかについて、以前の僕は「データベースに任せられることはすべてデータベースに任せる」という発想が好きで、手間が省けるならそれに越したことはないと考えていた。
しかし、ビジネスロジックの一部をデータベース内に記述してしまうと、将来的に見落としやすくなる。そのため現在の僕は、UUIDの生成はアプリケーション層で行うべきだという考えに傾いている。
テスト2:ストレージ容量(2,000万行)
| プラン | Heap | Index | 合計サイズ |
|---|---|---|---|
| Serial | 1,149 MB | 428 MB | 1,578 MB |
| UUIDv7 | 1,302 MB | 749 MB | 2,051 MB |
| UUIDv4 | 1,302 MB | 770 MB | 2,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 index | 1.9 ms |
| UUIDv7 + cursor on PK | 1.6 ms |
いくつかの観察結果:
- UUIDv7のカーソルページネーションは主キーをそのまま使えばよい。 追加のインデックスは不要だ。Serialの構成で時間順のカーソルページネーションを行う場合、
created_atのインデックスを利用する必要がある。これはセカンダリインデックスであるため、クエリ時にはまずインデックスを引いてからテーブル本体のデータを取得しに行く(index scan → heap fetch)必要がある。 - UUIDv7はcreated_atインデックスよりもわずかに高速。 UUIDv7のカーソルが走査するのはプライマリキーであるのに対し、created_atはセカンダリインデックスだからだ。
- 深いページネーションにおけるOFFSETは大惨事となる。 7秒 vs 2ミリ秒未満で、その差は3,600倍に達する。OFFSETは手前にあるすべての行をスキャンして破棄しなければならない。この結論は本記事の主題からは少し外れるが、ページネーション機能を実装する際に
OFFSETを使うのは避けたほうがいい。簡単にパフォーマンスを崩壊させてしまう。
まとめ
UUIDv7を主キーにするメリットは以下の通りだ:
- 書き込み性能が低下しない:auto-incrementと同様にシーケンシャルな追記となるため、データ量の増加に伴って遅くなることがない。UUIDv4は2,000万件時点で既に7倍遅くなっており、件数が増えればさらに悪化の一途をたどる。
- ひとつのインデックスで2つの役割をこなせる:主キー制約でありながら、時間順ソートの基準にもなる。
created_atカラムとそのインデックスを省略でき、カーソルベースのページネーションもWHERE id > :last_id ORDER BY idだけで完結する(実務上は依然としてcreated_atが必要になることもあるが)。
クエリパターンが時間順のソートに強く依存している場合(フィード、タイムライン、注文履歴一覧など)、UUIDv7を採用することでスキーマをより簡潔にし、インデックスの数も減らすことができる。
ただし、マイクロ秒単位で正確な作成日時が必要な場合や、created_at にビジネスロジック上の意味がある場合(時間範囲での検索が必要など)は、やはり created_at カラムを残しておくべきだ。UUIDv7のタイムスタンプの精度はミリ秒であり、同一ミリ秒内の順序は乱数によって決まるため、厳密な挿入順序までは保証されない。
外部URLはどう扱うべきか?
UUIDv7を内部の主キーとして扱うのは問題ないが、URLにそのまま露出させるのにはいくつかデメリットがある。文字列として長すぎる(36文字)こと、そしてタイムスタンプ部分から作成日時が推測されてしまうことだ。
一般的なアプローチはいくつかある:
- 16バイトのUUIDをBase62やBase58でエンコードし、21〜22文字程度に圧縮する。データの本質は変わらず、より短い表現形式に変えるだけだ。Base58は
0/OやI/lといった混同しやすい文字をあらかじめ排除しているため、手入力が想定される場面に優しい。 - 外部用として独立した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と呼ばれる4FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFFは有効なUUIDであり、Max UUIDと呼ばれる- UUID生成関数を自分で自作してはいけない
障害現場の実態を見ると、衝突が発生して破綻することなど滅多になく、十中八九は単にアプリケーション側の実装が甘いだけだ。そう考えると、それすらも考慮に入れるのであれば、やはり最も安全なのはSerialなのかもしれない。
Footnotes
-
https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-4 ↩
-
https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-7 ↩
-
単一の挿入によって複数のページ書き換えが引き起こされ、実際に書き込んだデータ量を大幅に超過する現象 ↩
関連記事
- 測定が目標になるとき:窓税からPull Request数まで かつて僕は小さなツールを自作し、四半期で自分がどれだけPRに貢献したか、レビューコメントをどれだけ残したか、チケットをどれだけ消化したかを集計して、上司にアウトプットを証明しようとしたことがある。上司は淡々と、評価はアウトプットだけで見るものではないと言った。数年後、僕はようやく理解した――測定が目標になるとき、それはもはや良い測定ではなくなるのだ。英国の窓税、ハノイのネズミ駆除の報奨金から、現代のPR数による開発者評価に至るまで、そのメカニズムはまったく同じだ。
- Cloudflare Images を画像ストレージ・変換ソリューションとして使う ウェブページに画像を1枚置くのはフロントエンドにとって最も簡単なことだが、リサイズや各種フォーマットの生成、さらにはトラフィックの負荷に耐えることまで完璧にやろうとすると、実際には一つの包括的なソリューションが必要になる。僕はその後、すべて Cloudflare Images に任せるようになり、オリジナル画像1枚だけを渡すようにしている。
- もう AWS Access Key を使うのはやめよう Access Key は AWS において見落とされがちなセキュリティリスクだ。OIDC と IAM Role を組み合わせることで、GitHub Actions にシークレットを一切保持させることなく、安全に AWS リソースを操作できるようにする。
- Zeaburを使ってみた感想とレビュー 個人開発者がサービスをデプロイする際、一般的にはVercelなどのプラットフォームを選ぶことが多いが、データベース接続などもう少し高度な要件が必要になるとVercelはそこまで便利ではなくなる。一方で一般的なクラウドプロバイダーの価格は個人開発にとってはかなり高額だ。この記事ではZeaburを使ってみた感想をシェアし、おすすめしたいと思う!