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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function uuidV7() {
const ts = Date.now();
const b = new Uint8Array(16);
crypto.getRandomValues(b);

// 先頭48ビットにミリ秒タイムスタンプ(ビッグエンディアン)
b[0] = (ts / 0x10000000000) & 0xff;
b[1] = (ts / 0x100000000) & 0xff;
b[2] = (ts / 0x1000000) & 0xff;
b[3] = (ts / 0x10000) & 0xff;
b[4] = (ts / 0x100) & 0xff;
b[5] = ts & 0xff;

b[6] = (b[6] & 0x0f) | 0x70; // version = 7
b[8] = (b[8] & 0x3f) | 0x80; // variant = 10

const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}

console.log(uuidV7());
// 019f5e38-0 add ... 先頭が時刻由来なので、続けて生成すると前方一致で増える

b[6] の上位4ビットを 0x70 にするとバージョン桁が必ず 7 になり、b[8] の上位2ビットを 10 にするとバリアントが RFC 準拠になる。生成した値の3ブロック目が 7xxx、4ブロック目が 8/9/a/b で始まれば正しい。

時刻順に並ぶことを確かめる(同一ミリ秒の注意つき)

v7 は先頭48ビットがミリ秒タイムスタンプなので、異なるミリ秒に生成した値は文字列ソートしても生成順に並ぶ。ここで一つ落とし穴がある。この最小実装は先頭6バイトだけが時刻で、残りは毎回ランダムなので、同一ミリ秒内に連続生成した複数のUUIDは互いの順序が保証されない(RFC 9562 が定める単調カウンタ方式を入れていないため)。まずはミリ秒がずれるように生成して、昇順になることを確かめる。

1
2
3
4
5
6
7
8
9
10
11
node -e '
function uuidV7(){const ts=Date.now();const b=new Uint8Array(16);crypto.getRandomValues(b);
b[0]=(ts/0x10000000000)&0xff;b[1]=(ts/0x100000000)&0xff;b[2]=(ts/0x1000000)&0xff;
b[3]=(ts/0x10000)&0xff;b[4]=(ts/0x100)&0xff;b[5]=ts&0xff;
b[6]=(b[6]&0x0f)|0x70;b[8]=(b[8]&0x3f)|0x80;
const h=[...b].map(x=>x.toString(16).padStart(2,"0")).join("");
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;}
const out=[];let last=0;
while(out.length<5){const now=Date.now();if(now!==last){out.push(uuidV7());last=now;}}
out.forEach(u=>console.log(u));
' | sort -c && echo "OK: ミリ秒がずれていれば昇順(生成順=辞書順)"

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 に保存できる。実装を読んでから触ると、出力の各桁が何を表しているかが腑に落ちるはずだ。


UUID v7を自前実装する:時刻順キーでインデックス断片化を減らす仕組みを手を動かして確かめる
https://blog.hashito.biz/2026/07/14/uuid-v7-implementation-time-ordered-primary-key/
著者
hashito
作成日
2026年7月14日
著作権