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 说计数器的最小长度应当(SHOULD)为 12 位。 要让计数器溢出,创建速率得相当激进,每个节点每毫秒超过 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 秒(一小时),因为该实现不允许内嵌时间戳倒退;随着系统时钟追上被冻结的时间戳,差值应当逐渐缩小。但当负载超过每毫秒一个 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 其实已经替他们处理了这些问题。

来源: alexsci.com← 返回首页