Hacker News 中文摘要

RSS订阅

UUIDv7 并不具备你以为的那些特性 -- UUIDv7 doesn't have the properties you think it does

我要直说了:通用唯一标识符第7版(UUIDv7)很奇怪。很多关于 UUIDv7 的博客文章都宣传它有三个好处:ID 单调递增、内嵌对象创建时间的时间戳,以及不可猜测的 ID。但严格来说,这些属性都不成立。某些符合 RFC 的 UUIDv7 实现会极端地破坏这些属性。看起来 UUIDv7 唯一严格具备的有用属性是唯一性,也就是抗碰撞性。我之前已经讨论过 UUIDv7 的 ID 可能被猜到,所以这里转而探讨其他属性。

正文

我直接下结论:UUIDv7 很奇怪。

大多数关于 UUIDv7 的博客帖子都会吹捧这些好处:

  • 单调递增的 ID
  • 一个有用的对象创建时间戳
  • 不可猜测的 ID

但这些性质没有一个是严格成立的。某些符合 RFC 的 UUIDv7 实现会在极大程度上违背这些性质。作者此前已经讨论过 UUIDv7 ID 可被猜测 的问题,所以下面只看另外两个性质。

时间戳真的有用吗?

表面上看,把时间戳嵌进 ID 里,可以省去在单独数据库列中记录创建时间的麻烦,让数据库操作略微高效一点。如果你经常需要按创建时间列取数据,那么主键索引本身就能提供这种行为,就不必再建一个索引。

遗憾的是,UUIDv7 ID 中嵌入的时间戳未必和对象的创建时间有关。Postgres 的 uuid_extract_timestamp 文档 就明确警告:“提取出的时间戳不一定完全等于 UUID 生成的时间。”

RFC 9562 允许实现修改时间戳字段,使用并不严格来自系统时钟的值。这涉及 UUID 单调性与时间戳准确性之间的张力。在某些场景下,时间戳与当前时间的差异可以任意大。

实现可以修改实际时间戳。——RFC 9562 §6.1

本规范不要求也不保证时钟值与实际时间需要接近到什么程度。——RFC 9562 §6.1

……实现可以将时间戳增加到实际时间之前……——RFC 9562 §6.2

把时间戳增加到系统时钟之前,是个有意思的问题。如果生成器负载太高,导致计数器字段频繁溢出,时间戳就可能加速跑到未来。用足够大的计数器是明智的,RFC 说计数器最小尺寸应为 12 位。计数器溢出需要相当激进的创建速率:每个节点每毫秒超过 4096 个 ID,这超出了很多系统的极限。不过,如果时间戳被冻结,这种溢出就变得重要了,后面一节会讨论。

示例:Python 的 uuid.uuid7()

一个会递增时间戳的典型实现,就是 Python 标准库里的 UUIDv7。递增只在某些情形下发生,比如计数器溢出时。不幸的是,如果时钟曾经向后跳变,生成器可能卡在递增循环里,时间戳会以任意大的幅度跑到系统时间之前。

下面是一个简单示例:先用当前时间初始化生成器,记录初始时间差;再把系统时钟回调一小时;然后在循环中每次生成 10 万个 UUID 后取一次时间戳,打印新的时间差。用 Python 3.14 运行后,初始 delta 为 -0.000748 秒;回调时钟后,连续输出的新 delta 依次为 3695.277581、3795.042411、3894.80756、3994.573106 秒,并继续增长直到手动中断。

按理说,由于实现不允许嵌入的时间戳回退,差值最开始应该是 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 确实替他们处理了这一点。

评论

讨论主要在“UUIDv7 到底该不该带时间戳”上分裂。较多人认为文章是在吹毛求疵,UUIDv7 的真正用途是让相近时间创建的记录在索引里局部聚集、从而大致按创建时间排序和存储,并非提供可靠创建时间;少数人则坚持 UUID 应只作随机唯一标识,认为多数场景直接用 UUIDv4 或不用 UUID 即可,并提醒 v7 存在时间戳泄漏、可猜测与被盲目推荐的风险。

认为 UUIDv7 的核心价值是索引局部性与近似时间排序,而非可靠时间戳

  • 时间戳成分主要是为了让相近时间生成的行落在相邻索引页,减少随机读写和昂贵的索引重建,并在大规模、分片、无中心服务场景下改善写入聚集;即便只能“大致”按时间排序,也足以服务游标分页、信息流、聊天消息、玩家操作等场景,且可直接存进原生 UUID 类型列,与 UUIDv1、UUIDv4 等版本通用兼容(较多人) > 无法可靠提取精确时间戳是特性而非缺陷;要么接受这一点,要么就得面对冲突,而冲突是不能接受的(@skrebbel)

认为 UUID 应只作随机标识,时间排序应交给独立字段与索引

  • 嵌入式时间戳属于把额外语义塞进 UUID,主键只该负责唯一标识;如果业务需要按创建时间或创建顺序查询,就应单独增加时间列并为其建索引,而不是依赖 UUIDv7 的顺序特性(少数人) > 如果把主键当成行的唯一标识以外的用途,几乎肯定做错了;需要创建时间顺序,就该单独建列并加索引(@dspillett)

认为 UUIDv7 被过度推荐,多数场景用 UUIDv4 或不用 UUID 即可

  • 很多 Postgres 主键等场景被一刀切地建议使用 UUIDv7,但实际只有在确认时间戳泄漏可接受、标识不可猜测且索引性能确实关键时才成立;有人几乎只用 UUIDv4,也有人因幂等与确定性偏好 UUIDv5,甚至认为系统设计应先避免冲突,是否使用 UUID 并不重要(少数人) > 我几乎只用 UUIDv4;只有在确认时间戳泄漏无害且索引性能确实关键时,UUIDv7 才合适,而一刀切推荐使用 UUIDv7 的场景远多于我真正需要它的场景(@mahboi)

提醒 UUIDv7 并非安全、不可猜测或严格单调的标识

  • 时间戳会带来泄漏与可猜测风险,也不应作为公开标识使用;若真的需要单调且不可猜测的标识,应考虑 CUID2 等方案,同时需要确定性时可用 UUIDv5(少数人) > UUIDv7 并非魔法,也不应被当作安全或严格有序的标识;若真需要单调且不可猜测,可考虑 CUID2 等方案(@rcoder)

担心客户端生成 UUIDv7 的时钟与顺序问题,倾向由数据库或中心化服务生成

  • 低用量时客户端自行生成 UUIDv7 看似无害,但随着客户端、用量和事件量增长,时间戳回拨与排序问题会让客户端生成失宠;有人因此转向数据库时间,或考虑中心授时服务加消息队列来保证顺序(少数人) > 客户端生成 UUIDv7 在低用量时看似没问题,但随着客户端、用量和事件量增长,时间戳相关问题让客户端生成很快失宠(@jefecoon)

有价值的补充

  • Python 标准库在实现 UUIDv7 时,若当前毫秒时间戳落后于上次生成值,会将其强制加 1 以维持顺序;这正是想要“单调递增标识”时的行为(@nitwit005)。
  • PyPI 上的 uuid7 包并不符合正式 UUIDv7 草案,实际来自一个不兼容的旧草案;需要合规生成时应改用 uuid-utils 等实现(@btown)。
  • ULID、Snowflake ID 等也有类似的时间排序特性,但 UUIDv7 的关键优势是能无损存入 UUID 类型列或结构体字段,且各版本 UUID 在尺寸和序列化格式上通用,标准库可互换生成(@rcoder)。
  • 针对时间戳不可能精确同步,有人搬出庞加莱来调侃:这甚至在理论上都被证明是不可能的,折射出社区对时钟可靠性的自嘲(@deepsun)。