UUIDv7 にはあなたが思っているような特性はない
UUIDv7 のタイムスタンプは作成時刻とは限らず、RFC 準拠の生成器はクロックより進むこともある。実際に保証されるのは何か。
日本語
コピー
この記事は UUID シリーズ の一篇だ。
先に言っておく。UUIDv7 はかなり妙な仕様だ。
UUIDv7 を解説するブログのほとんどは、次の利点を並べる。
- ID が単調増加する
- オブジェクトの作成時刻が埋め込まれていて便利
- ID を推測できない
だが、これらの性質はどれも厳密には成り立たない。RFC 準拠の UUIDv7 実装の中には、これらの性質を極端な形で裏切るものもある。UUIDv7 が厳密に保証できる有用な性質は、結局のところ一意性、つまり衝突耐性だけのようだ。UUIDv7 の ID は推測可能であることについては以前書いたので、本記事では残りの性質を検討する。
タイムスタンプは役に立つのか
一見すると、ID にタイムスタンプを埋め込めば作成時刻を別のデータベース列に持つ必要がなくなり、データベース操作が少し効率化されるように思える。作成時刻で並べたオブジェクト一覧を頻繁に必要とするなら、主インデックスがその順序をそのまま提供してくれるので、別のインデックスも不要になる。
残念ながら、UUIDv7 の ID に埋め込まれたタイムスタンプがオブジェクトの作成時刻と対応しているとは限らない。Postgres の uuid_extract_timestamp のドキュメントは、親切にもこう注意している。「抽出されたタイムスタンプは、その UUID が生成された時刻と正確に一致するとは限りません」。
RFC 9562 は、実装がタイムスタンプフィールドを変更し、システムクロックから厳密に取得したとは限らない値を使うことを許している。背景にあるのは、UUID の単調性とタイムスタンプの正確さの間の緊張関係だ。状況によっては、タイムスタンプと現在時刻の差がいくらでも大きくなりうる。
実装は実際のタイムスタンプを変更してもよい(MAY)。—— RFC 9562 §6.1
本仕様は、クロック値が実際の時刻にどれだけ近いかについて、いかなる要件も保証も課さない。—— RFC 9562 §6.1
……実装はタイムスタンプを実際の時刻より先に進めてもよい(MAY)……—— RFC 9562 §6.2
タイムスタンプをシステムクロックより先に進めるのは、なかなか面白い問題だ。生成器にかかる負荷が大きく、カウンタフィールドが頻繁にオーバーフローするようだと、タイムスタンプは未来に向かってどんどん加速しうる。十分に大きなカウンタを使うのが賢明で、RFC はカウンタの最小長を 12 ビットとすべき(SHOULD)としている。カウンタをオーバーフローさせるには、ノードあたり 1 ミリ秒に 4,096 個を超える ID を作る必要があり、多くのシステムの上限を超えている。ただ、タイムスタンプが凍結すると、このオーバーフロー問題が現実味を帯びてくる。これについては後の節で触れる。
例:Python uuid.uuid7()
Python 標準ライブラリの UUIDv7 は、タイムスタンプを進める典型的な実装だ。インクリメントが起きるのはカウンタのオーバーフロー時など特定の状況に限られる。厄介なのは、一度クロックが巻き戻ると生成器がインクリメントのループに陥り、タイムスタンプがシステム時刻より先に、上限なく進んでしまうことだ。
簡単な例:
import uuid
from datetime import datetime
# Prime the generator with the current time
t = uuid.uuid7().time
now = datetime.now()
delta = (datetime.fromtimestamp(t/1000) - now).total_seconds()
print("Initial delta: ", delta)
# Ask the user to change the time
input("Set clock back one hour")
while True:
for _ in range(100000):
uuid.uuid7()
t = uuid.uuid7().time
now = datetime.now()
delta = (datetime.fromtimestamp(t/1000) - now).total_seconds()
print("New delta: ", delta)
出力はこうなる:
$ uv run --python 3.14 main.py
Initial delta: -0.000748
Set clock back one hour
New delta: 3695.277581
New delta: 3795.042411
New delta: 3894.80756
New delta: 3994.573106
^C
差は 3600 秒(1 時間)になるはずだ。この実装は埋め込みタイムスタンプの後退を許さないので、システムクロックが凍結したタイムスタンプに追いつくにつれて差は縮まっていく。ところが負荷が 1 ミリ秒に 1 個の ID を超えると、差は際限なく広がる。負荷が低ければシステムクロックがタイムスタンプに追いつくまでに時間がかかるだけで、こうした暴走は起きない。
関連する issue で、Python の開発者はこう主張している。この挙動は RFC に準拠しており、他の実装とも一致している。「欠陥のあるシステム」に対して「できることは多くない」。私もこれらの主張は正しいと思う。他の実装でも同じ挙動を確認しているからだ。それでも、既存の UUIDv7 実装がシステムクロックの跳躍時に暴走しないようになれば助かる。クロックの巻き戻りは現実に珍しくなく、数十年単位でずれるケースすらあり、しかもそれが時刻に敏感な環境で起きる。
タイムスタンプの漏洩は有害か
ID にタイムスタンプを埋め込むということは、タイムスタンプを晒さずにその ID を共有することはできない、ということだ。
これが実際に問題になる場面はなかなか思い浮かばない。一つ挙げるならユーザープロフィールページだ。ページ自体はアカウント作成時刻を開示していないのに、URL に含まれる UUIDv7 の ID が高精度のタイムスタンプを漏らしてしまう。
私のセキュリティ懸念リストでは、この問題はたいていかなり下位に位置する。セキュリティ評価の仕事で、はるかに深刻な問題をいくつも見てきたからだ。それでも特定の状況では重要になり、エンジニアリングリソースが優先的に割かれることもある。これに該当するなら、UUIDv7 がメタデータを漏らす問題には気をつけてほしい。
UUIDv7 は単調なのか
この節はさらに細かい話なので、もう十分という人は読み飛ばして構わない。要するに、UUIDv7 の ID はある程度粗い順序を与えるもので、あなたの要件を満たすかもしれない、ということだ。
UUIDv7 の RFC を読んで意外だったのは、ノードをまたいで単調に順序づけられた ID を生成する方法が書かれていたことだ。そんなことは1970 年代には不可能だと証明されていて、ベクタークロックやその類の技術が生まれるきっかけになった。大づかみに言えば、問題はシステムクロックを十分な精度で同期できないことにある。タイムスタンプで並べるには、今が何時かを正確に知っている必要がある。時刻に少しでも不確かさがあれば、イベントの順序にも不確かさが生じる。
ひと言でいえば、タイムスタンプが時刻順に並ぶのは今が何時か分かっているときだけで、今が何時かは分かっていない。
だからこそ、似たような ID 形式は近似順序しか主張しない。UUIDv7 がより強い主張を掲げながらそれを果たせていないのは奇妙に思える。少なくとも、システム間でクロックを同期する難しさを考えれば、ミリ秒単位、ましてやサブミリ秒単位のタイムスタンプはかなり疑わしい。
しかし、単一ノード上で同じ実装を使っていても、UUIDv7 の ID が厳密に単調になるわけではない。 順序が崩れた UUIDv7 の ID を手に入れる最も簡単な方法は、ミリ秒のタイムスタンプだけを取り、カウンタを持たない実装を使うことだ。 この方法なら小さな for ループで簡単に順序の崩れた ID を生成できる。
つまり、UUIDv7 の仕様のどこにも、ID が厳密に単調増加することを保証するものはない。 実装によってはもっとうまくやれる(サブミリ秒のタイムスタンプ、カウンタ、よく同期されたクロックなど)が、この問題が厳密には解決できないことは 1970 年代から分かっている。
UUIDv7 にとって有利なのは、大まかに順序付けられた ID でも十分に役立つという点だ。 だからといって、すぐに UUIDv7 をシステムから外す必要はない。 ただし、自分がどんな前提を置いているかには気を付けてほしい。
UUIDv7 生成器の再起動
RFC はクロックの巻き戻しの扱いに多くの紙幅を割いている。 タイムスタンプが時系列順に並ぶのはクロックが前に進んでいるときだけなので、ここには特別な注意が要る。
緩和策のひとつは前述のとおりだ。最後に生成したタイムスタンプを記録し、それより前のタイムスタンプを決して使わないようにする。これを安全にやるには、最後に使ったタイムスタンプを永続ストレージに書き込み、システムやプロセスの再起動後にも読み戻せるようにする必要がある。
いくつかの UUIDv7 実装を調べたが、タイムスタンプを永続ストレージに書き込むものはひとつもなかった。実装者たちはこの問題を解決する気がないらしく、クロックの巻き戻しの直後にプロセスが再起動すると、順序の崩れた ID を生成してしまう。
現実のクロック同期の問題を見渡すと、クロックが未来の日付に設定されてしまう例はいくらでもある。こうなると、巻き戻しはむしろタイムスタンプを実際の時刻に揃える方向に働くが、「最後のタイムスタンプを記録する」という防護策は、システムクロックが追いつくまでその誤ったタイムスタンプを使い続けるよう強制する。システムクロックが追いつくまでに長くかかると、カウンタのラップアラウンドが始まるかもしれない。
では UUIDv7 とは何なのか
私の理解では、UUIDv7 はいくつもの異なるアイデアの寄せ集めであり、実装者は自分の好きなように取捨選択できる。UUIDv7 実装が提供できる特性は大きく幅があり、それを確実に提供できる度合いもそれぞれ違う。自分で実装すれば、自分の要件を満たすものがだいたい作れるだろう。だがほとんどの人が欲しいのは既製の実装で、とりわけ標準ライブラリが提供している場合はそうだ。だから通常は、他人の実装上の判断に身を委ねるしかない。実装は時間とともに変わり、RFC に準拠したまま挙動を大きく変えることもある。分散システムの各サブシステムがそれぞれ別の実装を採用し、特性が異なることもある。本当に当てにできるのは、強い衝突回避と ID の一意性だけだ。
UUIDv7 実装を選ぶときは、必ずこうした問いをぶつけてほしい。
- その実装は RFC に準拠しているか?
- その実装は追加でどんな性質を保証しているか?
- その保証が成り立つのは、どんな制約のもとでか?
- それらの性質を成り立たせるには、システムクロックが正確でなければならないといった前提が要るか?
- その実装はどんな状況でタイムスタンプを書き換えるか?
- 生成される ID はどれくらい推測されやすいか?
残念ながら、こうした分析は RFC 準拠の生成器を書くよりずっと難しい。 UUIDv7 生成器の作者がこれらの問いの答えを知っているとは限らず、意外に思うかもしれない。RFC は実は、こうした問題を作者に代わって処理してくれているのだ。