10 ポイント 投稿者 GN⁺ 2025-12-16 | 1件のコメント | WhatsAppで共有
  • UUID v4 はランダム性が高く、インデックス効率の低下と過剰な I/O を引き起こし、PostgreSQLで主キーとして使用すると性能低下が発生する
  • ランダムな挿入により ページ分割(page split)インデックス断片化 が頻発し、WALログサイズの増加 と書き込み遅延が発生する
  • UUIDは 16バイト のサイズで、bigintの2倍の領域を占有し、キャッシュヒット率の低下と メモリ浪費 につながる
  • セキュアな識別子と誤解されがちだが、RFC 4122によれば UUIDは推測防止のためのセキュリティ手段ではない
  • 新しいデータベースでは 整数シーケンスベースのキー を使い、やむを得ない場合は 時系列UUID v7 を使うことが推奨される

UUID v4の性能問題

  • PostgreSQLで UUID v4主キー を使用したデータベースは、この10年間一貫して 性能低下と過剰な I/O を示してきた
    • UUID v4は122ビットがランダムに生成されるため、インデックスの整列ができない
    • 挿入時に連続したページへ保存されず ランダムアクセス が発生し、更新・削除時にも非効率な探索が必要になる
  • B-Treeインデックス は整列されたデータを前提とするが、UUID v4には順序性がないため 挿入効率が低い
    • 各挿入が任意のページに書き込まれることで 中間ページの分割 が頻繁に発生する
    • これにより 書き込みレイテンシWALの増加 が発生する

UUIDの構造と代替案

  • UUIDは128ビット(16バイト)の識別子であり、PostgreSQLでは binary uuid型 として保存される
  • UUID v4 はランダムビットベース、UUID v7 は先頭48ビットに タイムスタンプ を含むためインデックス効率が高い
  • PostgreSQL 18(2025年予定)ではUUID v7が標準サポートされる予定
  • UUID v7 は時系列順のソートが可能なため、ページ密度とキャッシュ効率 が改善される

UUIDが選ばれる理由と限界

  • 複数クライアントやマイクロサービス環境で 衝突のない識別子生成 が必要なときにUUIDが使われる
    • 例: 複数のデータベースインスタンスで同時にIDを生成する場合
  • しかしRFC 4122には「UUIDは推測が難しいと仮定してはならない」と明記されており、セキュアな識別子としては不適切
  • 衝突確率は2.71×10¹⁸個生成時に50%であり、現実的には衝突可能性は低いが 性能コストが大きい

UUIDの領域とI/Oの非効率

  • UUIDは bigint(8バイト) の2倍、int(4バイト) の4倍の領域を占める
    • 大規模テーブルでは 保存領域の増加バックアップ・リストア時間の増加 につながる
  • インデックスページ密度 の実験結果
    • integerインデックス: 97.64%
    • UUID v4インデックス: 79.06%
    • UUID v7インデックス: 90.09%
  • Cybertecのテストでは UUID v4インデックス参照時に850万件の追加ページアクセス31229%の I/O増加 が確認された
    • 同一条件でbigintインデックスは27,332バッファアクセス、UUID v4は8,562,960バッファアクセス

キャッシュとメモリへの影響

  • UUIDはランダム分布のため バッファキャッシュヒット率(cache hit ratio) が低い
    • より多くのページをキャッシュに読み込む必要があり、必要なページが頻繁に エビクション(eviction) される
  • キャッシュ効率の低下によってクエリ遅延が発生し、メモリ使用量が増加 する
  • 性能維持のため 定期的なインデックス再構築(REINDEX CONCURRENTLY)pg_repack の利用が推奨される

性能を緩和する方法

  • メモリ拡張: データベースサイズの4倍のRAMを確保することが推奨される(例: DB 25GB → メモリ 128GB)
  • work_memの調整: ソート演算時により多くのメモリを割り当てることで性能改善が可能
  • Rails環境 では implicit_order_column の設定を通じて、UUIDの代わりに created_at など並び替え可能なフィールドを使う
  • CLUSTERコマンド で並び替え可能なフィールドを基準にテーブルを再配置できるが、排他ロック が必要

整数型キーとシーケンスの推奨

  • 新規データベースには 整数型シーケンスベースのキー の利用が推奨される
    • integer(4バイト)は約20億個、bigint(8バイト)はそれよりはるかに多くの一意な値を提供する
  • ほとんどのビジネスアプリではintegerで十分であり、大規模サービスでは bigint の利用が適している
  • UUID v4の代わりにUUID v7 または sequential_uuids拡張 を使うのが現実的な代替案である

まとめ

  • UUID v4は ランダム性によるインデックス非効率、高い I/O、低いキャッシュ効率 を招く
  • セキュアな識別子 としては使えず、領域の浪費 も大きい
  • 整数型シーケンスキー がほとんどのアプリケーションにより適している
  • やむを得ずUUIDを使うなら 時系列UUID v7 を選ぶべきである
  • PostgreSQLで gen_random_uuid() を主キーとして使うのは避けるべきである

1件のコメント

 
GN⁺ 2025-12-16
Hacker Newsの意見
  • これは典型的な早すぎる最適化の例だと思う
    永続識別子にデータを埋め込むのは、データ管理では禁じ手とされる
    ノルウェーの住民番号のように生年月日をIDに入れると、後になって生年月日を誤って把握していた移民が出てきたり、1月1日生まれが多すぎて番号が足りなくなるといった問題が起きる
    昔のカードカタログ時代なら検索コストを下げるためにデータと識別子を混ぜるのも理解できるが、今は強力なデータベースがあるので、わざわざそうする必要はない

    • この例は実際にはデフォルト値の設定がまずかった問題だと思う
      分からない誕生日を1月1日にしたのが問題なのであって、日付をキーに入れたこと自体が本質的な問題ではない
      もし00や99のような日付ではない値を使っていれば衝突は起きなかったはずだ
      UUIDにタイムスタンプを入れるのは意味を持たせるためではなく、性能最適化が目的だ
      時系列で増加するキーはB-treeの再編成コストを減らし、DBの挿入性能を高める
    • イタリアの住民番号も性別を含んでいるが、性別適合手術の後に問題が生じる
      「永続識別子にデータを入れるな」というのは一般論にすぎず、状況によってはトレードオフを受け入れることもある
      例えばmd5ハッシュをUUIDとして使ってインデックスを構成すれば断片化は起きるが、管理可能な水準ではある
    • UUIDv7は単に時間バイアス(random bias) を持つ生成方式であって、実際の情報を埋め込んでいるわけではない
      ランダムUUIDか時間ベースUUIDかの選択で、ミリ秒単位ではなく秒単位の性能差が出ることもある
    • 小さなDBでは早すぎる最適化だが、規模が大きくなるとむしろ逆のアプローチが必要になる
      大規模DBではシャーディングと分散が必須なので、UUIDのほうがオートインクリメントよりうまく機能する
    • ノルウェーの住民番号の例については、実際に1月1日生まれがそこまで多くなり得るのか疑問だ
      フォーマットがDDMMYYXXXXXなら10万人までカバーできるが、本当にそこまで集中し得るのか気になる
      おそらく特定の年に難民が大量流入した場合のような特殊事情なのだろう
  • UUIDはセキュリティトークンのように使うべきではない
    推測しにくいという理由だけでセキュリティ機能として使うのは危険だ
    ランダム値の目的は推測防止だけでなく、連続したID同士の関係を隠すことにもある

  • DBの種類によってPK戦略は大きく変わる
    PostgresではランダムPKは非効率だが、CockroachやSpannerのような分散DBでは、むしろ単調増加キーがホットシャード問題を引き起こす

    • 分散DBでも完全なランダムより増加傾向のあるキーのほうがよい
      UUIDv7は上位ビットがソート可能で下位ビットはランダムなので、ノード間の分散性とローカル保存効率を同時に得られる
    • DBの種類というよりDBの構造の違いとして見るべきだ
      一般的な非シャーディングDBではランダムキーがB-treeの断片化を引き起こす
    • Google Cloud Bigtableでは連番キーを逆順(reverse) にして使い、自動分散を促している
    • シャーディングされたPostgresならランダムPKが有利だ
      ただし範囲検索(range query)が多いならランダムキーは不利になる
      結局はワークロード特性に応じて選ぶべきだ
    • 書き込み中心で、時間バイアスの大きいワークロードなら、PostgresでもランダムPKのほうがよい場合がある
  • 記事の主旨はUUIDv4をPKに使う欠点をよく指摘しているが、提示された整数難読化方式は本番サービスには不向きに見える
    小規模DBならUUIDv7が妥当な折衷案だ

    • 私はUUIDv7よりUUIDv4を好む
      生成時刻が露出してほしくないからだ
      データ量が多く、UUIDv4のランダム性が性能問題を引き起こさない限り、v4のほうが安全な選択だ
    • Postgresでは単一シーケンスを使うのが好きだ
      多少の情報露出はあるが、運用上は十分に曖昧
    • 単にユーザー数を隠したいだけなら、オートインクリメントキーに暗号化(permutation) を適用すればよい
      例えばAES-128で変換してbase64でエンコードすれば、YouTubeの動画IDのように見せられる
  • 私は技術デューデリジェンスの中で多くの企業を見ているが、素早くシャーディングできる可能性が企業成長の鍵だ
    すべてのテーブルにUUIDを持たせておけば、シャーディング時に構造変更なしで拡張できる
    わずかな空間・時間の損失よりはるかに大きいスケーラビリティ上の利点がある

    • UUIDv7は単調増加の特性のおかげで、Postgresでも性能面の利点がある
    • 私たちもシャーディングの過程でUUIDがなくて苦労した
      結局、複雑なデータモデルだったので、UUIDの有無よりマイグレーション自体が難しかった
  • 私たちのアプリでは整数PKを暗号化してUUIDのように見せている
    連番IDが露出すると顧客数の推定や辞書攻撃が可能になるからだ
    暗号化されたIDなら、復号に失敗することでスキャンの試みを即座に検知できる

    • ただしキーの紛失やローテーション時に復号不能の問題が起こり得る
    • キー管理をどうしているのか気になる — 環境変数で注入するのか、コードに埋め込むのか、AES-GCMのようなAEADスキームを使うのかなど、セキュリティ管理が重要だ
  • 「20億あれば十分だ」という言い方は危険だ
    すべてのDBAは、そうした決定から始まった悪夢のような事例を少なくとも1つは持っている

  • 記事では「ランダム値はソートが非効率」としていたが、実際にはバイト順ソートは可能だ
    ただしランダムキーは順次挿入ではないため、B-treeの再平衡が頻繁に起きて性能低下を招く

    • UUIDv4は分散環境では有用だが、128ビット空間と非順次性の代償を受け入れる必要がある
    • 筆者はその後、B-treeインデックス比較の実験を追記したという
      整数PKはインデックスがメモリに収まりやすく、UUIDv4はページアクセスが多くなってレイテンシが大きくなったとのことだ
    • 技術的根拠が弱いという意見もあった
    • B-treeは増加キーであるほど挿入効率が高く、ランダムキーはキャッシュ親和性が低い
    • データアクセスが生成時点と密接に結びついているほど、時系列順のソートが性能上有利になる
  • この記事は問題よりも解決策が先に出た早すぎる最適化に見える
    UUIDv4はたいていの場合十分に問題ない
    性能問題は実際に起きたときに考えればよい

    • ただし一度UUIDv4で始めると、後からint64にキー再設定(rekey) するのはほぼ不可能だ
    • 実際に性能問題が出る頃には、すでに成長段階に入っていてPKを変える余裕はない
  • 要するに、PostgresではUUIDv7はv4よりわずかに良い性能を示す
    最新バージョンではプラグインなしでもUUIDv7をサポートできる

    • ただし記事の核心は「可能ならシーケンス・整数型PKを使え」という推奨だ
    • Postgres 18からは組み込みの uuidv7() 関数があるが、拡張機能のほうが多機能かどうかはまだ不明だ
    • ほとんどのユーザーは、今や別途拡張を必要としないだろう