Snowflake 雪花算法与时钟回拨:分布式 ID 如何改进

在单体系统中,数据库自增主键基本够用。

但进入分布式系统后,多个服务、多个数据库节点都可能同时生成 ID,此时就需要一种:

  • 全局唯一
  • 高性能
  • 不依赖单点数据库
  • 最好还能趋势递增

的 ID 生成方案。

Snowflake 就是其中最经典的一种。

一、Snowflake 是怎么生成 ID 的?

经典 Snowflake 使用一个 64 bit 整数,大致拆成:

部分 位数 作用
符号位 1 bit 固定为 0
时间戳 41 bit 当前毫秒时间
节点 ID 10 bit 区分不同机器
序列号 12 bit 区分同一毫秒产生的 ID

可以简单理解为:

ID = 时间 + 哪台机器 + 这一毫秒的第几个 ID

12 bit 序列号意味着:

2¹² = 4096

所以单个节点每毫秒最多可以生成约 4096 个 ID。

整个过程基本只需要读取时间、位运算和内存计数,不需要每次访问数据库,因此性能非常高。

二、Snowflake 最大的问题:依赖系统时间

Snowflake 有一个非常重要的前提:

当前时间最好不要小于上一次生成 ID 时的时间。

正常情况下:

10001 → 10002 → 10003 → 10004

但是操作系统时间并不一定永远单调递增。

例如 NTP 校时、人工调整时间、虚拟机时间同步等,都可能造成:

10001 → 10002 → 10003 → 9998

这就是所谓的:

时钟回拨(Clock Rollback)。

三、为什么时钟回拨麻烦?

假设节点 3 曾经在时间戳 10000 生成过:

  • timestamp = 10000
  • workerId = 3
  • sequence = 25

后来时间已经运行到:

10010

结果发生时钟回拨:

10010 → 10000

如果实现直接重新使用这个时间戳,那么就有机会重新产生同样的:

10000 + workerId 3 + sequence 25

于是 ID 就可能发生冲突。

不过经典 Snowflake 实现通常不会在检测到时间倒退后继续直接发号,而是拒绝生成或者进入异常处理。

所以时钟回拨最直接的问题通常是:

发号服务暂时不可用。

如果某些实现处理不当,或者节点重启后丢失了上一次时间戳、重复分配 Worker ID,则还可能进一步造成重复 ID。

四、最简单的改进:等待时间恢复

一种常见做法是:

如果只回拨了几毫秒,就等待系统时间追上来。

例如:

  • lastTimestamp = 10010
  • currentTimestamp = 10007

回拨了 3ms。

程序等待几毫秒:

10007 → 10008 → 10009 → 10010 → 10011

然后继续生成 ID。

这种方案非常简单,适合:

  • 回拨时间很短
  • 对几毫秒延迟不敏感

但如果系统时间突然回拨几十秒甚至几分钟,就不能一直等。

因此工程上通常会设置阈值,例如:

  • 回拨 ≤ 5ms:等待
  • 回拨 > 5ms:拒绝发号并报警

五、改进方案:逻辑时钟

既然系统时间可能倒退,那就不要完全相信系统时间。

可以维护一个逻辑时间:

logicalTime = max(systemTime, lastTimestamp)

假设:

  • lastTimestamp = 10010
  • systemTime = 10000

那么生成器仍然认为:

logicalTime = 10010

系统恢复之前继续使用这个逻辑时间。

这样 Snowflake 内部的时间就不会倒退。

但它又带来了一个问题。

12 bit sequence 最多只有:

4096

如果一直停留在:

timestamp = 10010

很快就会把 sequence 用完。

因此完整实现通常还需要配合额外机制,而不是简单写一个 max() 就结束。

六、改进方案:增加时钟序列号

另一种常见思路是:

给 ID 增加一个:

Clock Sequence / 回拨版本号

原来是:

时间戳 | WorkerId | Sequence

可以改成:

时间戳 | ClockId | WorkerId | Sequence

正常情况下:

ClockId = 0

如果检测到时钟发生回拨:

ClockId = 1

这样即使时间戳、WorkerId、Sequence 都与过去相同,因为 ClockId 不同,最终 ID 仍然不会重复。

例如:

10000 | 0 | 3 | 25

和:

10000 | 1 | 3 | 25

是两个不同的 ID。

代价是需要从原来的 64 bit 中拿出几个 bit。

例如:

字段 位数
时间戳 40
ClockId 2
WorkerId 8
Sequence 13

具体怎么切并没有标准答案,要根据系统规模决定。

这也是 Snowflake 的一个重要特点:

bit 布局本身可以根据业务重新设计。

七、百度 UidGenerator

百度开源过一个基于 Snowflake 思想的 UidGenerator。

它仍然采用:

时间 + WorkerId + Sequence

但允许自行调整:

  • 时间位
  • WorkerId 位
  • Sequence 位

同时通过数据库为节点分配 WorkerId。

它还有一个比较特别的实现:

CachedUidGenerator

它不是每次请求来了再计算 ID,而是提前生成大量 UID,放进 RingBuffer 中。

可以理解为:

生产 ID → RingBuffer → 业务线程消费

这样业务线程获取 ID 时主要就是从内存 RingBuffer 中取数据。

特别适合高吞吐量场景。

不过需要注意:

它依然属于 Snowflake 家族,并没有从根本上抛弃时间戳。

八、美团 Leaf:Snowflake 和号段模式都支持

美团 Leaf 的设计比较有意思,因为它同时提供两套方案:

  • Leaf Snowflake
  • Leaf Segment

Leaf Snowflake

本质仍然是 Snowflake。

Leaf 通过 ZooKeeper 协助管理节点和 WorkerId,并针对分布式环境做了工程化处理。

适合:

  • 需要 64 bit long
  • 需要趋势递增
  • 不希望每次生成 ID 都访问数据库

Leaf Segment

Segment 则完全是另一条路线。

假设数据库保存:

maxId = 100000

服务一次申请:

step = 10000

那么这个节点就得到:

100000 ~ 109999

接下来生成:

  • 100000
  • 100001
  • 100002
  • …

全部都可以在内存中完成。

快用完时,再申请下一段:

110000 ~ 119999

因此数据库并不是:

每生成一个 ID 查询一次

而是:

每生成一批 ID 申请一次号段

所以性能依然很高。

九、号段模式为什么没有时钟回拨问题?

因为:

它根本不使用时间。

Segment 的唯一性依赖:

数据库分配的数字区间

而不是:

系统时间

所以不存在:

10:01 → 10:00

这种问题。

它的代价则是需要一个中心化的号段分配机制。

因此 Snowflake 和 Segment 实际上代表了两种完全不同的思路:

Snowflake Segment
时间驱动 数字区间驱动
去中心化程度高 需要中心化分配号段
本地即可生成 需要周期申请号段
有时钟问题 没有时钟回拨问题
天然趋势递增 通常递增

十、UUIDv7 也是一个值得关注的方案

过去我们经常使用 UUIDv4:

550e8400-e29b-41d4-a716-446655440000

UUIDv4 基本是随机的。

问题在数据库中比较明显:

随机主键会造成 B+Tree 索引频繁在不同位置插入。

UUIDv7 则采用了:

时间戳 + 随机数据

RFC 9562 中定义的 UUIDv7 将 Unix 毫秒时间放在高位,因此 UUID 大体按照时间排序。

可以简单理解成:

  • UUIDv4:随机 UUID
  • UUIDv7:时间有序 UUID

因此 UUIDv7 对数据库索引通常更加友好。

但 UUIDv7 是:

128 bit

而 Snowflake 通常是:

64 bit

所以两者并不是完全相同的定位。

十一、这些方案该怎么选?

可以简单按照业务需求判断。

场景 方案
普通单体系统 数据库自增 ID
分布式系统,需要 long Snowflake
高并发分布式系统 改进 Snowflake
特别担心时间回拨 Segment
希望使用成熟方案 Leaf
极高吞吐量 CachedUidGenerator
不要求 64 bit UUIDv7
不关心顺序 UUIDv4

Snowflake 最大的优势并不是“绝对完美”,而是:

在 64 bit 空间内同时实现了全局唯一、高性能和趋势递增。

它的代价则是:

把系统时间变成了算法的一部分。

十二、总结

Snowflake 的核心思想其实非常简单:

时间戳 + 节点 ID + 毫秒内序列号

它巧妙地把一个分布式协调问题,转化成了:

时间 + 节点隔离 + 本地计数

所以大部分时候节点之间完全不需要通信。

但代价也非常明确:

一旦把时间写进 ID 生成算法,就必须考虑时间并不可靠。

因此现代分布式 ID 方案真正值得关注的,往往已经不是“Snowflake 怎么写”,而是:

  • WorkerId 怎么分配
  • 时钟回拨怎么办
  • 节点重启怎么办
  • 容器扩缩容怎么办
  • 是否需要严格递增
  • 是否真的需要 64 bit

Snowflake 是一个经典起点,但并不是分布式 ID 的唯一答案。

参考资料