UUID と ULID 入門 - 分散システムで衝突しない ID を、時系列に強く設計する

UUID と ULID 入門 - 分散システムで衝突しない ID を、時系列に強く設計する

作成日:
読了:14
更新日:

「主キーはとりあえず AUTO_INCREMENT の連番でいい」——単一のデータベースで完結するうちは、それで十分です。ところが、複数のサーバーやサービスが独立して ID を発行するようになると、連番はとたんに扱いづらくなります。この記事では、分散システムで衝突しない ID を設計するための定番である UUIDULID を、RFC 9562ulid/spec を一次ソースに整理します。

なぜ連番 ID ではなく分散 ID なのか

連番の主キーには、いくつか運用上の弱点があります。

  • 採番の一元化が必要: 次の値を決めるのは 1 つのデータベースだけ。複数ノードで独立して発行しづらく、シャーディングやマルチマスター構成で衝突しやすい
  • 推測されやすい: /users/1001 の次は /users/1002。URL に連番 ID を露出すると、件数の推定や順番のなぞりができてしまう
  • マージがつらい: 別々に採番したデータを後から統合すると、ID がぶつかる

これらを避けるため、各ノードが単独で、事前調整なしに生成できる ID が求められます。その代表格が UUID です。

なお、ID を URL に含めてページを送る設計そのものについては、ページネーション設計(オフセット vs カーソル) も合わせて読むと、ソート可能な ID がなぜ効いてくるかが見えてきます。

UUID とは

UUID(Universally Unique Identifier)は、128 ビットの一意な識別子です。長らく RFC 4122 が仕様でしたが、これを置き換える RFC 95622024 年 5 月に発行され、新しく v6 / v7 / v8 が標準化されました。RFC 9562 は v1 から v8 までのバージョンと、Nil(全 0)・Max(全 1)の特殊な UUID を定義しています。

代表的なバージョンを挙げます。

  • UUIDv1: タイムスタンプと MAC アドレスから生成。時刻順に近いが、MAC アドレス由来のため生成元がわかってしまう懸念がある
  • UUIDv4: ほぼ全ビットがランダム。最も広く使われる
  • UUIDv7: Unix ミリ秒タイムスタンプ + ランダム。時系列にソートできる新しい標準

文字列としての UUID は、550e8400-e29b-41d4-a716-446655440000 のように、ハイフン区切りの 36 文字(16 進 32 桁 + ハイフン 4 個)で表現します。

NOTE

RFC 9562 は RFC 4122 を「obsolete(廃止)」にしていますが、既存の v1 / v4 の生成方法が変わったわけではありません。v6 / v7 / v8 という選択肢が新たに標準へ加わった、と捉えるのが正確です。

UUIDv4 の課題 - ランダム性と DB インデックス

UUIDv4 は 128 ビットのうち、バージョン(4 ビット)とバリアント(2 ビット)を除いた 122 ビットがランダムです。RFC 9562 では、このランダム部が random_a(48 ビット)・random_b(12 ビット)・random_c(62 ビット)の 3 フィールドに分かれると定義されています。衝突確率は現実的に無視できるほど低く、事前調整なしにどのノードでも生成できます。

一方で、この「完全ランダム」がデータベースのインデックスと相性が悪いという副作用を持ちます。

多くの RDBMS の主キーインデックスは B-Tree(B+tree)で、キーをソート済みで保持します。連番や時刻順の値なら、新しい行はつねに「末尾(右端)」に追記され、挿入が局所的にまとまります。ところが UUIDv4 はランダムなので、挿入位置がインデックス全体に散らばります。その結果、次のような傾向が生じやすくなります。

  • ページ分割(page split)が増える: 埋まったページの途中に割り込むため、ページを分割して詰め直す処理が起きやすい
  • キャッシュ効率が落ちる: アクセスするページが毎回バラバラで、バッファに乗りにくい
  • 断片化しやすい: とくに InnoDB のように主キーでテーブル本体を並べる(クラスタ化インデックス)方式では、行の物理配置まで散らばる

B-Tree そのものの仕組みは データベースインデックス入門 で解説しています。

WARNING

「UUIDv4 は遅い」と断言はできません。効果はテーブルサイズ・ストレージ・バッファ設定・書き込み量に強く依存し、環境依存です。ここでは「ランダムキーは挿入局所性が低い」という一般的な性質までを押さえてください。数値ベンチは条件次第で大きく変わります。

UUIDv7 の登場 - 時系列にソートできる UUID

この課題への標準的な答えが UUIDv7 です。RFC 9562 では、UUIDv7 を「よく知られた Unix エポックのタイムスタンプ(1970 年 1 月 1 日 UTC からのミリ秒数、うるう秒を除く)に基づく時刻順の値」と定義しています。ビット構成は次のとおりです。

  • unix_ts_ms: 48 ビット(Unix ミリ秒タイムスタンプ)
  • ver: 4 ビット(バージョン。0111
  • rand_a: 12 ビット(ランダム、または単調増加のための構造)
  • var: 2 ビット(バリアント)
  • rand_b: 62 ビット(ランダム、または単調性のためのカウンター)

先頭にミリ秒タイムスタンプが来るため、生成した順にほぼ単調増加し、バイト列としてそのままソートすると時系列順になります。RFC 9562 も「v6 / v7 はデータベースインデックスなどソートを要する用途向けに、不透明な生バイトとしてソートできるよう設計されている」と述べています。同一ミリ秒内の順序を保証したい実装では、rand_b の一部をカウンターとして使う「単調性」の仕組みも用意されています。

つまり UUIDv7 は、UUID の衝突耐性・分散生成のしやすさと、時系列キーの挿入局所性を両立させたバージョンだと言えます。新規設計で「UUID を使うが、主キーにもしたい」なら、まず v7 を検討する価値があります。

ULID とは

ULID(Universally Unique Lexicographically Sortable Identifier)は、UUIDv7 とよく似た発想を持つ、仕様が ulid/spec で公開されている識別子です。UUIDv7 が RFC で標準化される前から使われてきました。

ULID の構成は次のとおりで、合計 128 ビットです(UUID と互換のサイズ)。

  • タイムスタンプ: 48 ビット(UNIX ミリ秒時刻)
  • ランダム: 80 ビット

特徴を UUIDv7 と対比すると分かりやすくなります。

  • 文字列表現: Crockford の Base32(1 文字あたり 5 ビット)で符号化し、26 文字で表現します。たとえば 01ARZ3NDEKTSV4RRFFQ69G5FAV のような見た目です。使う文字は 0123456789ABCDEFGHJKMNPQRSTVWXYZ で、紛らわしい I L O U を除いてあります
  • ソート可能: 先頭 10 文字がタイムスタンプ、後半 16 文字がランダム。文字列としてそのまま辞書順(レキシコグラフィカル)に並べると時系列順になります
  • URL セーフ: ハイフンや記号を含まないため、URL やファイル名にそのまま使いやすい
  • 単調性: 同じミリ秒内で生成する場合、ランダム部を最下位ビットから 1 増やしていくことで、同一ミリ秒内でも順序を保てます

UUIDv7 との主な違いは、文字列表現と長さです。UUIDv7 は 36 文字のハイフン付き 16 進表現、ULID は 26 文字の Base32 表現で、より短く読みやすくなります。バイナリとしてはどちらも 128 ビットで、48 ビットのミリ秒タイムスタンプを先頭に置く点は共通です。

使い分けと実装例

まずは JavaScript(Node.js)での生成例です。UUIDv4 は追加ライブラリなしで生成できます。

// UUIDv4: 標準 Web Crypto API で生成
const id = crypto.randomUUID();
console.log(id);
// 例: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'

crypto.randomUUID() が返すのは UUIDv4 です。UUIDv7 や ULID は、専用ライブラリを使うのが手軽です。

// UUIDv7: uuid パッケージ(v7 対応版)
import { v7 as uuidv7 } from 'uuid';
console.log(uuidv7());
// 例: '018f6f9c-5b3a-7c1e-8a2b-1f4d9c0e5a6b'
//     先頭がミリ秒タイムスタンプなので、時間が進むと文字列も増加する
 
// ULID: ulid パッケージ
import { ulid } from 'ulid';
console.log(ulid());
// 例: '01ARZ3NDEKTSV4RRFFQ69G5FAV'(26 文字の Crockford Base32)

3 つを並べると、見た目と性質の違いがはっきりします。

UUIDv4  9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d   36 文字 / ランダム(ソート不可)
UUIDv7  018f6f9c-5b3a-7c1e-8a2b-1f4d9c0e5a6b   36 文字 / 先頭が時刻(ソート可)
ULID    01ARZ3NDEKTSV4RRFFQ69G5FAV             26 文字 / 先頭が時刻(ソート可)

データベース側での生成もできます。PostgreSQL 18 では、時系列ソート可能な UUID を生成する uuidv7() と、明示的に v4 を生成する uuidv4() が組み込み関数として追加されました。あわせて uuid_extract_timestamp()uuid_extract_version() も追加され、生成値の検証がしやすくなっています。

-- PostgreSQL 18: 主キーを UUIDv7 で自動生成
CREATE TABLE orders (
  id         uuid PRIMARY KEY DEFAULT uuidv7(),
  created_at timestamptz NOT NULL DEFAULT now()
);
 
INSERT INTO orders DEFAULT VALUES;
SELECT id, uuid_extract_timestamp(id) FROM orders;
-- id は時刻順に増加し、主キーインデックスへの挿入が末尾側にまとまる

NOTE

PostgreSQL 17 以前や他の RDBMS で v7 を使いたい場合は、拡張やアプリケーション側での生成に頼ります。MySQL には v7 の組み込み生成関数は標準では用意されていない(未確認の部分を含むため、利用時はバージョンごとの公式ドキュメントで確認してください)ため、アプリ側での生成が現実的です。

使い分けの目安は次のとおりです。

  • とにかく一意で、順序も予測もされたくない: UUIDv4。トークンや外部公開する不透明 ID 向き
  • 主キーにして挿入性能と時系列を両立したい: UUIDv7。標準(RFC)に沿う点も安心材料
  • 短く読みやすい文字列で、URL やログにも使いたい: ULID。26 文字で扱いやすい

なお、時刻ベースの ID を扱うときは、生成側のタイムゾーンやクロックの扱いも影響します。日時の扱いは タイムゾーンと日時処理入門 も参考にしてください。

注意点 - セキュリティと運用

時系列 ID は便利ですが、その「時刻が埋まっている」性質が、そのままリスクにもなります。

  • タイムスタンプの露出: UUIDv7 と ULID は先頭 48 ビットに生成ミリ秒時刻が入っています。ID を外部に見せると、レコードの作成時刻を第三者が復元できます。作成時刻を隠したい ID(招待トークン、パスワードリセットのキーなど)には向きません
  • ランダム部からの推測: UUIDv7 / ULID のランダム部はあくまで「衝突回避」が主目的で、暗号学的な秘匿性の保証ではありません。秘密の URL やセッション ID には、専用のランダムトークン(十分なビット数の暗号乱数)を使ってください
  • 件数・生成レートの漏洩: 連番ほど露骨ではないものの、時系列 ID は「いつ・どれくらいの間隔で作られたか」を示唆します。公開 API のリソース ID として使うなら、その情報が漏れて困らないか検討します

そのうえで、UUIDv7 / ULID は内部の主キーとしては優れた選択肢です。REST API のリソース設計で ID をどう扱うか(同じ操作を安全に繰り返せるか)は、REST API のべき等性設計 とあわせて検討すると設計が締まります。

まとめ

  • UUIDは 128 ビットの一意な識別子で、仕様は 2024 年 5 月発行の RFC 9562(旧 RFC 4122 を置き換え)。v6 / v7 / v8 が新たに標準化されました
  • UUIDv4は 122 ビットがランダムで衝突しにくい反面、ランダムキーは B-Tree インデックスへの挿入局所性が低く、ページ分割やキャッシュ効率の低下を招きやすい(効果は環境依存)
  • UUIDv7は 48 ビットの Unix ミリ秒タイムスタンプ + ランダムで、時系列にソート可能。主キーとインデックスの相性がよい
  • ULIDは 128 ビット(48 ビットタイムスタンプ + 80 ビットランダム)を Crockford Base32 の 26 文字で表現し、辞書順で時系列ソート可能。UUIDv7 とねらいは近く、より短い文字列が特徴
  • 時系列 ID は作成時刻が露出するため、秘匿トークン用途には使わない。内部の主キーとして活かすのが基本方針です

連番の手軽さと、分散生成・順序性のバランスを取れるのが UUIDv7 と ULID です。新規設計では、まず「主キーに v7 / ULID、公開トークンは別途暗号乱数」という切り分けから始めるのがおすすめです。

トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

13

データベースのトランザクションを実務目線で整理します。ACID(原子性・一貫性・分離性・永続性)の意味、BEGIN/COMMIT/ROLLBACKの基本、4つの分離レベル(READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE)と、各レベルで起き得る異常(dirty read・non-repeatable read・phantom read)の対応関係を表で確認します。さらにPostgreSQLのデフォルトはREAD COMMITTED、MySQL InnoDBのデフォルトはREPEATABLE READという製品差や、PostgreSQLではREAD UNCOMMITTEDがREAD COMMITTED扱いになる点まで、PostgreSQL・MySQL公式を一次ソースにまとめます。

ページネーション設計 - オフセット方式とカーソル(キーセット)方式の使い分け

ページネーション設計 - オフセット方式とカーソル(キーセット)方式の使い分け

8

一覧の分割表示(ページネーション)を実務目線で整理します。LIMIT/OFFSET によるオフセット方式の利点(任意ページへジャンプ・総件数)と欠点(大きな OFFSET で遅い・挿入や削除でページずれ)、前回の最後の行を基準にするカーソル/キーセット方式の利点(大規模でも高速・安定)と欠点(任意ジャンプ不可・総ページ数が出しにくい)、複合キーのタイブレーク、不透明カーソルと API レスポンス設計(next_cursor / has_more / GraphQL Relay)、そして使い分けと落とし穴まで、PostgreSQL 公式・Use The Index, Luke・Stripe・Slack を一次ソースにまとめます。

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

10

データベースのインデックスを実務目線で整理します。フルスキャンとの違い、B-tree インデックスがなぜ速いのか、等価・範囲・前方一致・ORDER BY・JOIN で効く理由、複合インデックスの左端プレフィックス、カバリングインデックス(index-only scan)、列に関数を使うと効かない・前方ワイルドカード LIKE が効かないといった落とし穴、書き込みコストやストレージのトレードオフ、InnoDB のクラスタ化インデックスと PostgreSQL の違い、EXPLAIN の読み方まで、PostgreSQL・MySQL 公式と Use The Index, Luke を一次ソースにまとめます。