UUID: 理解时间、随机数与唯一标识
在现代 Web 应用开发领域,数据库主键 (Primary Key, PK) 作为数据模型的基础组件之一发挥着巨大作用。通常情况下,默认的自增整数和自定义的代理键 (Surrogate keys),如 Snowflake ID 就已经足够满足需求。但是这次,我希望尝试一些支持更广泛,对现代数据库更友好的新的选择 ——UUID,更具体地说,UUIDv7。
Wikipedia: Snowflake 是一种生成分布式全局唯一 ID 的算法,生成的 ID 称为 Snowflake IDs 或 snowflakes。这种算法由 X (前身 Twitter) 创建,并用于推文的 ID。Snowflake IDs 由 64 bits 组成,定义如下:
--- title: "Snowflake IDs" --- packet 0: "0" 1-41: "Timestamp" 42-51: "Machine ID" 52-63: "Machine Sequence Number"
什么是 UUID?
UUID(Universally Unique IDentifier,通用唯一标识符),也常被称作 GUID(Globally Unique IDentifier,全局唯一标识符),用于在时间与空间维度上保证全局唯一性 —— 即便在不同系统、不同时刻生成,也不会重复。
它广泛应用于分布式系统、数据库记录、网络消息等场景的唯一标识。UUID 长度为 128 位,标准格式为 32 位十六进制字符,以连字符 - 分隔为 5 段,格式为 8-4-4-4-12,例如:f47ac10b-58cc-4372-a567-0e02b2c3d479。
在早期定义中 (RFC4122),UUID 有 UUIDv1-5 等 5 种变体,其中最常用的是 UUIDv4,一个完全基于 CSPRNG 构建的 UUID。
UUIDv4
---
title: "UUIDv4 字段和位布局"
---
packet
0-47: "Random A"
48-51: "Version"
52-63: "Random B"
64-65: "Variant"
66-127: "Random C"
其中,Version 设置为 0b0100 (4),Variant 设置为 0b10,Random A、Random B 和 Random C 由 CSPRNG 生成。UUIDv4 能确保抗碰撞性,全局和局部唯一性,特别适合用于赋予用户 ID,生成下载链接等场景。
然而,早期的 UUID 方案缺乏了一些重要特性:缺乏时序性,数据库索引局部性差,以及时间戳粒度过细,难以表示 (如 UUIDv1 使用纳秒级的时间戳) 等等。些问题限制了其适用场景,并催生出诸如 Snowflake、ShardingID 等有序 ID 方案。UUIDv7 则在此基础上演进,继承了时间戳前缀的设计思想,同时融入 UUID 标准框架,实现了有序性、索引友好与标准兼容的有序 ID 方案。
UUIDv7
---
title: "UUIDv7 字段和位布局"
---
packet
0-47: "Unix Epoch Timestamp"
48-51: "Version"
52-63: "Random A"
64-65: "Variant"
66-127: "Random B"
如图所示,UUIDv7 的内部结构包含:
- Unix Epoch Timestamp(0-47 位):这是一个 48 位的无符号整数,记录自 Unix 纪元(1970-01-01 00:00:00 UTC)以来的毫秒数。48 位足以覆盖到公元 10899 年,远比 32 位时间戳长寿。选用毫秒级精度(而非 UUIDv1 的纳秒级)既保证了足够细的粒度,又避免了时钟回拨和精度带来的复杂性。
- Version(48-51 位):固定为
0b0111 (7),标记这是一个 UUIDv7。 - Random A(52-63 位):12 位的随机数,为同一毫秒内的不同 UUID 提供额外的碰撞抵抗空间。
- Variant(64-65 位):固定为
0b10,遵循 UUID 标准对变体的定义。 - Random B(66-127 位):62 位的随机数,与前两部分随机位共同构成高达 74 位的随机性(2^74 的可能值),足以在每毫秒内生成海量唯一 ID 而几乎不发生碰撞。
将时间戳放在最高位意味着:UUIDv7 是按生成时间大致有序的。把它用作数据库主键时,新行总是倾向于追加到索引末尾,大幅缓解了 UUIDv4 那种随机插入带来的页分裂和缓存失效问题,对 B-Tree 索引极为友好。
生成要点与单调性保证
仅仅 “大致有序” 还不够。要真正成为数据库友好的有序 ID,UUIDv7 在实现时通常需要保证同一毫秒内的单调递增。也就是说,如果在 1 毫秒内生成了多个 UUID,这些 ID 在字节序上必须是严格递增的。按照 RFC 9562 的要求,实现这一点的方法有:
- 固定位长的专用计数器 (Fixed Bit-Length Dedicated Counter):在
rand_a中划出固定数量的比特位作为专用计数器,每生成一个 ID 就原子递增,严格保证单调。若计数器位不够,还可借用rand_b的部分高位。 - 单调随机 (Monotonic Random):将
rand_b等随机部分视为 “随机种子计数器”,每次递增一个大于零的随机增量,而非固定的 1。这样兼顾了单调性与不可猜测性。 - 用更高时钟精度替代左侧随机位 (Replace Leftmost Random Bits with Increased Clock Precision):提取系统时钟毫秒以下的时间精度(如微秒),将其映射填充到
rand_a的 12 位中,用更细粒度的时间替代部分随机数,使 ID 按更精确的时间自然排序并降低碰撞风险。
具体实现其实更复杂一点,比如位宽、长度、单调错误检查、生成器状态等等,但是 RFC 9562 实在啃不动了,先这样。
UUIDv7 实践
从 Python 3.14 开始,标准库 uuid 提供了 uuid7() 函数:
1 | import uuid |
SQLAlchemy 的 Uuid 类型会自动匹配数据库原生的 UUID 格式:PostgreSQL 使用 UUID 类型,MySQL 8.0+ 则可结合参数选择 CHAR(32),或更高效的 BINARY(16) (但此时该字段的可读性很差)。主键在插入时由 Python 端生成,具有时间有序性,大幅减少了 B-Tree 索引的页分裂。
结尾
UUIDv7 无法取代雪花算法这类代理键最佳实践,他需要更多的存储空间,此外,它出现的太晚了,已有的系统早已选边站队。但作为一个标准,它用零协调的成本换来了有序、全局唯一且全生态兼容的标识符。对于新项目,这是一种没有历史包袱的选择,能十分简单地获取标准化带来的生态优势。





