UUID v7を自前実装する:時刻順キーでインデックス断片化を減らす仕組みを手を動かして確かめる
UUID を主キーにするのは便利だが、v4(完全ランダム)を使うと挿入のたびにB-treeのあちこちに書き込みが飛ぶため、インデックスが断片化し、キャッシュ効率も落ちやすい。ここで効くのが UUID v7。先頭48ビットにUnixミリ秒タイムスタンプを持つ「時刻順(time-ordered)」UUIDで、生成順にほぼ単調増加する。挿入がインデックスの末尾側に集中するので、断片化が起きにくい。
仕組みは実装すると一番よく分かる。ブラウザでもNodeでも動く、依存ゼロの v7 を書いてみる。
v7 のビット配置
128ビットの内訳はこうだ。
- 先頭 48ビット: Unixミリ秒タイムスタンプ(ビッグエンディアン)
- 次の 4ビット: バージョン(
0111= 7) - 続く 12ビット: ランダム
- 次の 2ビット: バリアント(
10) - 残り 62ビット: ランダム
実装(約30行)
暗号学的乱数 crypto.getRandomValues で16バイト埋め、先頭6バイトをタイムスタンプで上書きし、バージョンとバリアントのビットを立てる。
1 | |
b[6] の上位4ビットを 0x70 にするとバージョン桁が必ず 7 になり、b[8] の上位2ビットを 10 にするとバリアントが RFC 準拠になる。生成した値の3ブロック目が 7xxx、4ブロック目が 8/9/a/b で始まれば正しい。
時刻順に並ぶことを確かめる(同一ミリ秒の注意つき)
v7 は先頭48ビットがミリ秒タイムスタンプなので、異なるミリ秒に生成した値は文字列ソートしても生成順に並ぶ。ここで一つ落とし穴がある。この最小実装は先頭6バイトだけが時刻で、残りは毎回ランダムなので、同一ミリ秒内に連続生成した複数のUUIDは互いの順序が保証されない(RFC 9562 が定める単調カウンタ方式を入れていないため)。まずはミリ秒がずれるように生成して、昇順になることを確かめる。
1 | |
sort -c は入力が既にソート済みなら何も言わずに終わる。ミリ秒をまたいで生成した5件はそのまま昇順になるので、これが通る。v4 で同じことをやると順序はバラバラで sort -c は必ず失敗する。これが「挿入がインデックス末尾に集中する(v7)/全体に飛ぶ(v4)」ことの実体だ。実運用で同一ミリ秒内の順序まで固定したい場合は、時刻が同じときに rand_a 相当の12ビットをカウンタとしてインクリメントする「単調カウンタ版」に拡張する。
PostgreSQL でも同様で、uuid 型カラムに v7 を入れれば、時刻順の挿入によって B-tree の右端追記に近い挙動になり、ページ分割が減る。MySQL/MariaDB なら BINARY(16) に詰めると更に効率が良い。
使いどころと注意
- 主キー・外部キーに向く: 挿入が時系列で増えるテーブル(ログ・注文・イベント)で断片化を抑えられる。
- 時刻が漏れる: 先頭にミリ秒が入るので、UUIDから生成時刻がおおよそ読める。秘匿したいIDには使わない。
- 一意性: 122ビットの乱数を含むので実用上の衝突確率は極めて低いが、絶対の一意性が要るならDBのユニーク制約と併用する。
手早く大量に欲しいときや、v4/v7・大文字・ハイフン無しの書式を切り替えて試したいときは、同じロジックをブラウザ内で動かすUUID生成ツール(v4 / v7)を使うと、件数指定でまとめて生成して .txt に保存できる。実装を読んでから触ると、出力の各桁が何を表しているかが腑に落ちるはずだ。