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 的唯一答案。
参考资料
- Twitter Snowflake Archive
https://github.com/twitter-archive/snowflake - 美团 Leaf
https://github.com/Meituan-Dianping/Leaf - 百度 UidGenerator
https://github.com/baidu/uid-generator - RFC 9562:Universally Unique IDentifiers (UUIDs)
https://www.rfc-editor.org/rfc/rfc9562.html
评论