
分布式 ID 生成:从 Snowflake 位运算到号段双 Buffer,以及那个绕不开的时钟回拨
关键词标签 :
分布式IDSnowflake号段模式时钟回拨Leaf
一个反直觉的结论先放在这里:Snowflake 的难点从来不是那 64 位怎么切,而是当 NTP 把机器时间往回拨了 3 秒时,你的服务该怎么办。 位运算部分看十分钟文档就能写对,但时钟回拨的处理策略,直接决定了这套 ID 方案在生产环境里是"能用"还是"能扛"。本系列前面聊过线程池、AQS、CompletableFuture 这些并发底座,分布式 ID 恰好是把这些底座和分布式协调揉在一起的一个典型场景,值得单独拆一次。
一、Snowflake 的 64 位到底怎么切
1.1 标准结构
Twitter 原始 Snowflake 方案把 64 位 long 切成四段:
| 段 | 位数 | 含义 |
|---|---|---|
| 符号位 | 1 bit | 恒为 0,保证 ID 为正数 |
| 时间戳 | 41 bit | 相对某个自定义纪元的毫秒差 |
| 机器 ID | 10 bit | 最多 1024 个节点 |
| 序列号 | 12 bit | 同一毫秒内的自增序号 |
41 位毫秒时间戳的理论上限是 2^41 - 1 毫秒,换算过来约 69.7 年。所以"自定义纪元"(epoch)的选择很关键------它决定了你这套 ID 能用多久。常见做法是把纪元设成系统上线那年的某个时间点,而不是 1970。
1.2 位运算实现
public class SnowflakeIdGenerator {
// 自定义纪元:2024-01-01 00:00:00 UTC
private static final long EPOCH = 1704067200000L;
private static final long WORKER_ID_BITS = 5L;
private static final long DATACENTER_ID_BITS = 5L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 31
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); // 31
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; // 12
private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 17
private static final long TIMESTAMP_SHIFT =
SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; // 22
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); // 4095
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException("workerId out of range");
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId out of range");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new IllegalStateException(
"Clock moved backwards. Refusing to generate id for "
+ (lastTimestamp - timestamp) + " ms");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
// 当前毫秒的 4096 个序列号用尽,自旋到下一毫秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long ts = System.currentTimeMillis();
while (ts <= lastTimestamp) {
ts = System.currentTimeMillis();
}
return ts;
}
}
几个容易忽略的细节:
~(-1L << n)是取 n 位全 1 掩码的惯用写法,比(1L << n) - 1更安全(后者在 n=64 时溢出)。- 拼接时用
|而不是+,因为各段位不重叠,|语义更清晰且不会进位串扰。 nextId()加了synchronized。在高并发下这把锁会成为热点,后面会讲怎么用号段模式绕开它。
1.3 序列号用尽的自旋
注意 sequence == 0 那个分支:当同一毫秒内第 4096 次请求进来时,序列号回绕到 0,此时必须等到下一毫秒。这意味着单节点 Snowflake 的理论 QPS 上限约为 4096 × 1000 = 409.6 万/秒(示意数值,非真实测量)。绝大多数业务远达不到这个量级,所以 12 位序列号通常够用。
二、时钟回拨:Snowflake 的真正命门
2.1 为什么回拨一定会发生
System.currentTimeMillis() 读的是操作系统时钟,而操作系统时钟会被 NTP 校正。当本机时间比 NTP 服务器快时,NTP 会把时间往回拨。这在容器化环境里尤其常见------虚拟机迁移、宿主机负载抖动、NTP 同步策略激进,都可能触发。
回拨的后果很直接:如果时间戳变小,新生成的 ID 可能比之前生成的 ID 还小,破坏单调递增;更糟的是,如果回拨跨过了纪元,可能生成重复 ID。
2.2 三种处理策略
策略一:直接抛异常(上面代码的做法)。 简单,但服务可用性受损------回拨期间整个 ID 生成不可用。
策略二:小回拨等待,大回拨报警。 设定一个阈值(比如 5ms),回拨小于阈值就自旋等待时间追上;超过阈值则抛异常或降级。这是很多生产实现的选择。
策略三:备用位/扩展位。 预留几位作为"回拨计数",回拨发生时递增该计数,用空间换时间。但会缩短 ID 可用年限。
MongoDB 官方文档在讨论 ObjectId 时也提到过类似的时间戳单调性问题,值得对照阅读。Snowflake 的原始讨论可以参考 Twitter 工程博客的历史资料。
2.3 一个务实的回拨处理
private static final long MAX_BACKWARD_MS = 5L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= MAX_BACKWARD_MS) {
// 小幅回拨:自旋等待,直到时间追上 lastTimestamp
timestamp = tilNextMillis(lastTimestamp);
} else {
// 大幅回拨:交给上层决定降级还是告警
throw new IllegalStateException(
"Clock moved backwards by " + offset + " ms, exceeds threshold");
}
}
// ... 后续逻辑同上
}
这里有个取舍:自旋等待会阻塞调用线程。如果回拨频繁且幅度接近阈值,会拖慢整体吞吐。所以阈值不宜设大,且要配合监控------回拨次数本身就是重要的运维指标。
三、号段模式:把数据库从热点里解放出来
3.1 核心思想
Snowflake 依赖机器时钟,号段模式(Segment)则依赖数据库。核心思路是:一次从数据库取一批 ID(一个号段),在内存里分发,用完再取下一批。
数据库表可以简单到只有一行:
CREATE TABLE id_segment (
biz_tag VARCHAR(128) NOT NULL,
max_id BIGINT NOT NULL DEFAULT 0,
step INT NOT NULL,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (biz_tag)
);
每次取号段执行:
UPDATE id_segment
SET max_id = max_id + step, update_time = NOW()
WHERE biz_tag = #{bizTag};
SELECT max_id, step FROM id_segment WHERE biz_tag = #{bizTag};
拿到 [max_id - step + 1, max_id] 这个区间,缓存在内存里逐个分发。数据库的写压力从"每个 ID 一次"降到"每个号段一次",量级直接下降 step 倍。
3.2 双 Buffer 解决取号段时的毛刺
单 Buffer 有个问题:号段用完那一刻才去数据库取,取的过程(网络 + DB)会阻塞。美团 Leaf 的经典解法是双 Buffer:内存里维护两个号段,当前号段消耗到某个阈值(比如 10%)时,异步去加载下一个号段。
public class SegmentBuffer {
private final Segment[] segments = new Segment[2];
private volatile int currentPos = 0;
private volatile boolean nextReady = false;
private final AtomicBoolean loading = new AtomicBoolean(false);
public long nextId() {
while (true) {
Segment current = segments[currentPos];
long value = current.nextValue();
if (value != -1) {
// 当前号段消耗超过阈值,触发异步预加载
if (!nextReady
&& current.usageRatio() > 0.1
&& loading.compareAndSet(false, true)) {
asyncLoadNext();
}
return value;
}
// 当前号段耗尽,切换
if (nextReady) {
switchPos();
} else {
// 下一个还没准备好,只能同步等待
waitForNext();
}
}
}
// ...
}
关键在于 nextReady 标志和异步加载:只要预加载能在当前号段耗尽前完成,切换就是无感的。这本质上是把"取号段"这个慢操作从关键路径上挪走了。
3.3 号段模式的边界
- ID 不连续:服务重启会丢弃内存里没用完的号段,造成空洞。对绝大多数业务无所谓,但如果业务要求 ID 严格连续(比如某些财务场景),号段模式不适用。
- 强依赖数据库:数据库挂了,新号段取不到。双 Buffer 只能撑一段时间,撑多久取决于 step 和消耗速度。
- step 的权衡:step 太小则 DB 压力大,step 太大则重启浪费多、ID 增长快。常见做法是按业务 QPS 动态调整。
四、两种方案怎么选,以及混合思路
| 维度 | Snowflake | 号段模式 |
|---|---|---|
| 依赖 | 机器时钟 | 数据库 |
| 趋势递增 | 是(同毫秒内) | 是 |
| 时钟回拨影响 | 严重 | 无 |
| DB 故障影响 | 无 | 有(双 Buffer 缓解) |
| ID 连续性 | 有空洞 | 有空洞 |
| 单点吞吐 | 受锁和序列号限制 | 受 step 和内存限制 |
实践中常见的是混合方案:用号段模式从数据库取"workerId 段",再在本地用 Snowflake 生成 ID。这样既避免了 workerId 手工分配的问题,又保留了 Snowflake 的高吞吐。百度 UidGenerator 走的是另一条路------用 RingBuffer 缓存已生成的 ID,把生成和消费解耦。
五、什么时候别用 / 别踩的坑
- 别在容器里硬编码 workerId。 容器重启后 IP 可能变,workerId 需要动态分配机制(ZooKeeper 顺序节点、Redis INCR、或号段模式),否则重复 workerId 会导致 ID 冲突。
- 别忽略 NTP 配置。 生产机器应统一 NTP 源,并考虑用
chrony的 slew 模式(渐进调整)替代step模式(直接跳变),减少大回拨概率。 - 别把 Snowflake 的序列号位数想当然。 如果你的业务单节点 QPS 接近 409.6 万(示意数值),12 位序列号就会成为瓶颈,需要重新分配位宽。
- 号段模式别把 step 设太小。 step 太小等于把数据库又变回热点;但设太大又浪费。建议根据实际 QPS 和重启频率估算。
- 别用 ID 当业务语义。 分布式 ID 只保证唯一,不保证连续、不保证趋势严格递增(跨节点看)。任何依赖"ID 大小反映创建顺序"的逻辑都是脆弱的。
- 回拨监控必须做。 无论哪种策略,回拨次数和幅度都应该是告警指标,而不是等出问题才查日志。
系列预告
分布式 ID 解决的是"唯一性",但唯一性之外还有"顺序性"和"一致性"的问题。下一篇会聊分布式锁的几种实现路径,以及 Redlock 争议背后的分布式共识本质------同样是"看起来简单、深挖全是坑"的主题。感兴趣的话点个关注,我们下篇见。
参考链接
- Twitter Snowflake 原始方案说明(历史归档):https://github.com/twitter-archive/snowflake
- 美团 Leaf 号段模式与双 Buffer 设计:https://tech.meituan.com/2017/04/21/mt-leaf.html
- MongoDB ObjectId 规范(时间戳单调性讨论):https://www.mongodb.com/docs/manual/reference/method/ObjectId/