你的订单服务多年来一直使用 Postgres 的 BIGSERIAL 主键。后来 orders 表被拆到四个分片上,每个分片的序列都发出了订单 10452,客服发现一个订单号背后有两位客户。又或者,移动应用在连上服务器之前就需要一个 ID。
一旦不止一台机器生成 ID,ID 就不再免费了。各种方案在大小、排序、每个 ID 的协调成本,以及对 B-tree 索引的友好程度之间做取舍。本文对比序列、ticket server、UUIDv4、UUIDv7、ULID 和 Snowflake 风格的 ID,然后设计一个 Snowflake 风格的生成器:位布局、worker ID 租约、时钟跳变和碰撞概率的计算。
分布式 ID 的需求
- 永久唯一。 跨分片、跨区域、跨重启都不能重复。
- 大致按时间排序, 这样
WHERE id > ?分页和按时间排序的索引才能正常工作。Twitter 最初的 Snowflake 承诺在一秒内跨机器保持顺序。 - 紧凑。 64 位能放进
BIGINT;128 位会让每个键的大小翻倍。 - 每个 ID 无需协调。 Snowflake 的目标是每个进程每秒至少 10,000 个 ID,每个都在 2 ms 内返回。
- 可以安全暴露, 不泄露超出预期的信息。
生成 ID 的六种方式
| 方案 | 大小 | 排序 | 协调 | B-tree 插入 |
|---|---|---|---|---|
| 数据库序列 | 64 位 | 递增 | 每个 ID 都来自同一个数据库 | 右边缘 |
| Ticket server | 64 位 | 每台服务器内递增 | 每个 ID 或每段范围调用一次 | 右边缘 |
| UUIDv4 | 128 位 | 无 | 无 | 随机页 |
| UUIDv7 | 128 位 | 按毫秒 | 无 | 靠近右边缘 |
| ULID | 128 位 | 按毫秒 | 无 | 靠近右边缘 |
| Snowflake 风格 | 64 位 | 按毫秒,大致有序 | 每个生成器一个 worker ID | 靠近右边缘 |
数据库序列在由一个数据库写入所有行时是合适的,一旦多个数据库共用一个 ID 空间就会出问题。它会留下空洞,因为 nextval 从不回滚;在 PostgreSQL 中,CACHE 大于 1 时数值仍然不重复,但跨会话会乱序。连续的订单号还会暴露你的订单量。
Ticket server 把序列集中起来。Flickr 的 MySQL ticket server 是两台机器,一台发奇数,一台发偶数,客户端在两者之间轮流调用。租用 ID 块可以省去每个 ID 一次的调用,就像短链接服务设计里那样。
UUIDv7、ULID 和 Snowflake 都把时间戳放在最前面,所以同时创建的 ID 会排在一起;只有 Snowflake 能放进 64 位,代价是需要 worker ID 并依赖时钟。
Snowflake 的位布局
| 位数 | 字段 | 容量 |
|---|---|---|
| 1 | 符号位,始终为 0 | 作为有符号 64 位整数,ID 保持为正 |
| 41 | 自定义纪元起的毫秒数 | 2^41 ms,约 69.7 年 |
| 10 | worker ID | 1,024 个生成器 |
| 12 | 毫秒内的序号 | 每个 worker 每毫秒 4,096 个 ID |
Twitter 2010 年的版本采用了这种划分,纪元设在 2010 年 11 月,所以它的 ID 会在 2080 年 7 月用完。如果从 Unix 纪元算起,41 位会在 2039 年 9 月用完,所以要把纪元设在接近上线的时间。每个实例保存自己最后一次的毫秒数和一个计数器:
// Next is called with g.mu held.
func (g *Generator) Next() (int64, error) {
if time.Now().After(g.leaseDeadline) {
return 0, ErrLeaseLost // another instance may own this worker ID now
}
now := time.Now().UnixMilli()
if now < g.lastMs {
if g.lastMs-now > 5 {
return 0, ErrClockMovedBack
}
now = g.waitUntil(g.lastMs) // small step back: wait it out
}
if now == g.lastMs {
g.seq = (g.seq + 1) & 0xFFF // 12 bits
if g.seq == 0 { // 4,096 IDs used in this millisecond
now = g.waitUntil(g.lastMs + 1)
}
} else {
g.seq = 0
}
g.lastMs = now
return (now-epochMs)<<22 | g.workerID<<12 | g.seq, nil
}
一个 worker 每秒最多可发出 410 万个 ID;真正的瓶颈是外面的 RPC,所以让客户端批量获取 ID。ID 在纪元后约 25 天就会超过 2^53,超出 JavaScript 的 Number.MAX_SAFE_INTEGER,所以要像 X 和 Discord 的 API 那样,把它们作为 JSON 字符串发送。
这种划分是一份预算。Discord 用 42 位表示时间,并把节点位拆成 worker 和进程。Instagram 在每个 PostgreSQL 分片内部生成 ID,使用 13 位的分片 ID,这在你对数据库做分片时很有帮助。Sonyflake 以 10 ms 为单位计数,可以用 174 年,每个实例每 10 ms 发出 256 个 ID。
分配 worker ID
Snowflake 不需要每个 ID 协调,只需要每个进程协调一次:两个同时运行、worker ID 相同的生成器可能发出相同的 ID。
- 静态配置。 每台主机分配一个
WORKER_ID。适合规模固定的集群;只要复制了一份配置文件,就会产生重复。 - StatefulSet 序号。 Kubernetes 从 0 开始给 StatefulSet 的 Pod 编号,并给每个 Pod 打上
apps.kubernetes.io/pod-index标签(自 v1.32 起稳定),再通过 downward API 传入。序号只在同一个集群内的同一个 StatefulSet 中唯一,强制删除 Pod 可能让两个 Pod 拥有相同的序号。 - etcd 或 ZooKeeper 中的租约。 启动时,实例用一个仅在键不存在时才会成功的事务,申请最小的空闲键,例如
/idgen/workers/17,租约为 10 秒,每 3 秒续期一次。实例一旦挂掉,键就会过期。Twitter 2010 年的 Snowflake 使用 ZooKeeper 的临时节点,并且在自己的时钟与同伴平均值相差超过 10 秒时拒绝启动。
env:
- name: WORKER_ID
valueFrom:
fieldRef:
fieldPath: metadata.labels['apps.kubernetes.io/pod-index']
租约需要两条规则。一是当最后一次成功的 keepalive 早于授予的 TTL 减去一个余量时,就停止发 ID,这样被暂停或被网络分区的实例会在别人认领它的 ID 之前停下来。二是保存一个 high-water mark:每隔几秒,把一个略超前于时钟的时间戳存到单独的键里,绝不发出超过它的 ID,并让下一个持有者等到自己的时钟超过它。etcd 用 Raft 保证这两者的一致。
当时钟倒退时
NTP 守护进程通常用 slew 调整时钟,也就是让时钟稍快或稍慢地走,但偏差较大时会直接 step:ntpd 默认在偏差超过 128 ms 时 step,而在 chrony 默认的闰秒模式下,遇到插入的闰秒,内核会把时钟拨回一秒。chrony 文档建议只在启动时 step:
makestep 1.0 3 # step (if off by over 1 s) only in the first three updates
leapsecmode slew # absorb a leap second by slewing, not by stepping back
对于漏过的 step,选一条规则:
| 策略 | 生成器的行为 | 代价 |
|---|---|---|
| Fail | 拒绝请求,直到时钟超过 last_ms |
step 持续多久就报多久的错 |
| Wait | 睡眠,直到时钟超过 last_ms |
几毫秒还行;长时间的 step 会让所有调用方卡住 |
| 逻辑时钟 | 从 last_ms 继续计数 |
从不阻塞;时间戳会跑到真实时间前面 |
Twitter 最初的版本选择了 Fail。先等几毫秒,再带着告警失败,是一个合理的默认做法。用永不倒退的 CLOCK_MONOTONIC 计时,可以保护正在运行的进程;high-water mark 则覆盖重启的情况。
UUIDv7 与 B-tree 索引
2024 年 5 月发布的 RFC 9562 取代了 RFC 4122,并定义了 UUIDv7:48 位的毫秒级 Unix 时间戳、4 位版本、2 位变体,以及 74 位随机数;如果需要毫秒内的严格顺序,这 74 位也可以用作计数器。这个时间戳可以用到 10889 年。
RFC 警告说,没有时间顺序的 UUID 索引局部性很差,对 B-tree 的影响可能非常严重。在 PostgreSQL 中,新的 v7 键进入最右侧的叶子页,那里的分裂会让页保持 90% 满,这是 B-tree 默认的 fillfactor。随机的 v4 键会命中任何一页:整个索引都成为工作集,满页会分裂,而且每次检查点之后,每个页的第一次修改都会把整页写入 WAL。MySQL 的 InnoDB 还会把主键复制到每个二级索引中,所以一个 16 字节的键会让它们全部变大。
PostgreSQL 18 在 2025 年 9 月加入了 uuidv7()。UUID 要存成 16 字节的二进制,而不是 36 个字符的文本。另外,v7 会暴露创建时间;RFC 建议凡是 ID 承担安全作用的地方都用 v4。
ULID
ULID 规范的结构与此相同:48 位的毫秒时间戳加 80 位随机数。它 26 个字符的 Crockford base32 文本不含 I、L、O 和 U,作为字符串也能正确排序,在 URL 中读起来也很顺。monotonic 模式在同一毫秒内加 1,这样能保持顺序,但下一个值就可以被猜到。ULID 是社区规范;对于数据库主键,UUIDv7 能提供同样的效果,而且有原生支持。
碰撞的概率有多大?
对于 n 个带 b 位随机数的 ID,发生任何碰撞的概率约为 n² / 2^(b+1)。带时间前缀的 ID 只可能在同一毫秒内碰撞,所以关键是速率,而不是总量。以每秒 100 万个 ID、持续一年、均匀分布来算:
| 方案 | 随机位数 | 可能与谁碰撞 | 一年内的概率 |
|---|---|---|---|
| UUIDv4 | 122 | 任何其他 ID | 约 100 亿分之一 |
| ULID | 80 | 同一毫秒的 ID | 约 7,700 万分之一 |
| UUIDv7 | 74 | 同一毫秒的 ID | 约 120 万分之一 |
| Snowflake | 0 | 相同 worker ID 和相同毫秒 | 零,只要 worker ID 唯一且时钟向前走 |
随机方案以很小且可计算的概率失败。Snowflake 只会因为运维问题而失败,但一旦失败就是必然的。无论哪种方案,都要保留唯一约束。
Snowflake 风格 ID 生成器图
图表内容
- ID service · 3 replicas
- id-gen-0 · worker ID 0
- id-gen-1 · worker ID 1
- id-gen-2 · worker ID 2
- Payment service
- Order service
- orders · id BIGINT PRIMARY KEY
- etcd cluster
- NTP servers
- 0
- 41 bits · milliseconds since the custom epoch 2^41 ms ≈ 69.7 years
- 10 bits · worker ID 1,024 instances
- 12 bits · sequence 4,096 IDs per ms
- NextId()
- INSERT row with the new id
- lease worker ID
- time sync
这张图的读法如下:
- 客户端在三个实例中的任意一个上调用
NextId(),实例直接从内存中应答:最后一次的毫秒数和一个 12 位的计数器。 - etcd 用 10 秒的租约保存每个 worker ID,还保存已存下的时间戳。
- NTP 让各个时钟保持接近(虚线边);chrony 只在启动时 step。
- Order 服务向 Postgres 写入行,递增的 ID 会落在最右侧的 B-tree 页上。
- 条形图按比例展示 64 位;Markdown 块列出了各项设置。
把 ID 生成画清楚
- 按比例画出位布局。 预算一目了然。
- 标出 worker ID 的来源, 并写上租约 TTL。重复就是从这里开始的。
- 把时钟画成一个依赖。 生成器的正确性取决于它的时钟。
- 把故障规则写在画布上: 时钟倒退、租约丢失、重启。系统设计图示例也用同样的方式把数字放在方框旁边。
在 Archboard 中构建
- 用 Google 或 GitHub 登录,并创建一张图。
- 为 ID 服务添加一个来自基础包的框架,里面放三个来自 C4 Model 包的琥珀色容器节点:id-gen-0 到 id-gen-2,各对应一个 worker ID。
- 在左侧为 Payment 服务和 Order 服务添加两个容器节点,并在它们下方添加一个来自数据包的 Postgres 节点。
- 在右侧添加一个紫色的容器表示 etcd,一个来自网络包的蓝色服务器表示 NTP,以及一个来自基础包、写有各项设置的 Markdown 块。
- 把它们连起来:两个客户端到框架的 NextId(),到 Postgres 的 INSERT,到 etcd 的租约,以及来自 NTP 的一条虚线时间同步边。
- 在下方添加四个来自基础包的矩形椭圆节点,尺寸比例为 1 : 41 : 10 : 12,用来表示位布局,再加几条基础包的文本注释。
这个设计的 Mermaid 流程图可以用 Mermaid 转换器变成可编辑的初稿。这个示例共用 14 个图表节点,在 Free 套餐的限制之内。
常见问题
什么是 Snowflake ID? 一种 64 位的 ID,由时间戳、worker ID 和每毫秒的序号组成,由 Twitter 在 2010 年提出。各个生成器无需互相通信即可工作,ID 大致按创建时间排序。
UUIDv7 比 ULID 更好吗?
两者都包含 48 位的毫秒时间戳和随机位。UUIDv7 是 IETF 标准,并且有数据库支持,例如 PostgreSQL 18 中的 uuidv7();ULID 的文本形式更短。对于数据库主键,优先选 UUIDv7。
主键应该用 UUIDv4 还是 UUIDv7? 大多数表用 UUIDv7:新值排在旧值之后,所以插入始终靠近索引的右边缘。如果 ID 不能暴露创建时间,就用 UUIDv4。
Snowflake 生成器每秒能产生多少个 ID? 每个 worker 每毫秒 4,096 个,约每秒 410 万个,1,024 个 worker 合计 42 亿个。生成器外面的网络调用通常才是真正的瓶颈。
时钟倒退时会发生什么? 生成器不能重用它可能已经用过的毫秒。它可以失败直到时钟追上来,可以等待,也可以维护一个跑在真实时间前面的逻辑时钟;Twitter 最初的版本选择了失败。