在现代 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 使用纳秒级的时间戳) 等等。些问题限制了其适用场景,并催生出诸如 SnowflakeShardingID 等有序 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 的要求,实现这一点的方法有:

  1. 固定位长的专用计数器 (Fixed Bit-Length Dedicated Counter):在 rand_a 中划出固定数量的比特位作为专用计数器,每生成一个 ID 就原子递增,严格保证单调。若计数器位不够,还可借用 rand_b 的部分高位。
  2. 单调随机 (Monotonic Random):将 rand_b 等随机部分视为 “随机种子计数器”,每次递增一个大于零的随机增量,而非固定的 1。这样兼顾了单调性与不可猜测性。
  3. 用更高时钟精度替代左侧随机位 (Replace Leftmost Random Bits with Increased Clock Precision):提取系统时钟毫秒以下的时间精度(如微秒),将其映射填充到 rand_a 的 12 位中,用更细粒度的时间替代部分随机数,使 ID 按更精确的时间自然排序并降低碰撞风险。

具体实现其实更复杂一点,比如位宽、长度、单调错误检查、生成器状态等等,但是 RFC 9562 实在啃不动了,先这样。

UUIDv7 实践

从 Python 3.14 开始,标准库 uuid 提供了 uuid7() 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import uuid
from sqlalchemy import Uuid
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
pass

class User(Base):
__tablename__ = "users"

id: Mapped[uuid.UUID] = mapped_column(
Uuid, primary_key=True, default=uuid.uuid7
)
name: Mapped[str]

SQLAlchemy 的 Uuid 类型会自动匹配数据库原生的 UUID 格式:PostgreSQL 使用 UUID 类型,MySQL 8.0+ 则可结合参数选择 CHAR(32),或更高效的 BINARY(16) (但此时该字段的可读性很差)。主键在插入时由 Python 端生成,具有时间有序性,大幅减少了 B-Tree 索引的页分裂。

结尾

UUIDv7 无法取代雪花算法这类代理键最佳实践,他需要更多的存储空间,此外,它出现的太晚了,已有的系统早已选边站队。但作为一个标准,它用零协调的成本换来了有序、全局唯一且全生态兼容的标识符。对于新项目,这是一种没有历史包袱的选择,能十分简单地获取标准化带来的生态优势。