2024 年我们的订单系统用雪花算法(Snowflake)生成订单号,某天凌晨数据库突然报主键冲突:两笔不同的订单拿到了同一个 ID。排查到最后,根因是一台机器做了 NTP 时间同步,系统时钟被往回拨了几百毫秒 —— 雪花算法最怕的就是时钟回拨。投了几天做分布式 ID 专项治理,补上时钟回拨的防御、重构 ID 生成器,之后再没出现 ID 重复。本文复盘雪花算法时钟回拨的完整实战。
问题背景
业务:订单系统,自研雪花算法生成 19 位订单 ID,12 台应用实例
事故现象:
- 凌晨 03:00 左右,数据库报 Duplicate entry for key 'PRIMARY'
- 两笔下单时间相差 1 秒的订单,拿到了完全相同的 ID
- 故障持续约 2 分钟后自行消失
现场排查:
# 1. 数据库错误日志
[ERROR] Duplicate entry '1783920100000133121' for key 'orders.PRIMARY'
# 2. 翻应用日志,定位到出问题的实例 order-svc-7
03:00:01.882 生成 ID 1783920100000133121
03:00:02.103 生成 ID 1783920100000133121 ← 同一个 ID 出现两次!
# 3. 查这台机器的系统日志
$ journalctl -u systemd-timesyncd | grep -A2 '03:00'
Mar 12 03:00:02 systemd-timesyncd: Synchronized to time server
(adjusting local clock by -340ms)
# 真相:NTP 同步把这台机器的时钟往回拨了 340ms
根因:
1. 雪花算法强依赖"时间单调递增",时钟一旦回拨,
同一毫秒会生成与过去重复的 ID
2. 我们的实现里完全没有处理时钟回拨,回拨后直接照常算
3. workerId 是启动时读配置写死的,扩容时有两台配了同一个 id
(这是另一个独立隐患,一并修掉)
修复 1:看懂雪花算法的 64 位结构
// 雪花算法生成的是一个 64 位 long,按位切分成四段:
//
// 0 | 41 bit 时间戳 | 10 bit 机器号 | 12 bit 序列号
// ^ ^ ^ ^
// | | | 同一毫秒内自增,0~4095
// | | 最多 1024 台机器
// | 从某个纪元起的毫秒数,够用 69 年
// 符号位,恒为 0(保证 ID 是正数)
//
// === 各段的位数与偏移 ===
public class SnowflakeIdGenerator {
private final long epoch = 1704067200000L; // 自定义纪元:2024-01-01
private final long workerIdBits = 10L; // 机器号 10 位
private final long sequenceBits = 12L; // 序列号 12 位
private final long maxWorkerId = ~(-1L << workerIdBits); // 1023
private final long maxSequence = ~(-1L << sequenceBits); // 4095
// 各段在 long 里的左移量
private final long workerIdShift = sequenceBits; // 12
private final long timestampShift = sequenceBits + workerIdBits; // 22
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > maxWorkerId) {
throw new IllegalArgumentException("workerId 越界: " + workerId);
}
this.workerId = workerId;
}
}
// 关键认知:雪花算法的"唯一性"建立在两个前提上 ——
// 1. 同一时刻,workerId 全局唯一(机器不撞号)
// 2. 同一台机器,时间戳单调递增(时钟不回拨)
// 我们的事故,正是第 2 个前提被 NTP 打破了
修复 2:朴素实现为什么会重复
// === 出事的旧实现:完全没防时钟回拨 ===
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp == lastTimestamp) {
// 同一毫秒,序列号自增
sequence = (sequence + 1) & maxSequence;
if (sequence == 0) {
// 这一毫秒的 4096 个序列号用完,自旋等下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L; // 进入新的毫秒,序列号归零
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << timestampShift)
| (workerId << workerIdShift)
| sequence;
}
// === 时钟回拨时这段代码会发生什么 ===
// 假设 lastTimestamp = 1000(上次生成时记下的)
// NTP 把时钟拨回,这次 System.currentTimeMillis() = 700
//
// timestamp(700) != lastTimestamp(1000) → 走 else 分支
// → sequence 归零,lastTimestamp 改成 700
// → 用 timestamp=700 算 ID
//
// 但 700 这个毫秒,机器之前已经生成过 ID 了!
// 序列号又从 0 开始 → 和过去那批 ID 直接重复
//
// 这就是我们 03:00 那两分钟里发生的事:时钟被拨回 340ms,
// 这 340ms 内的每一毫秒都在重新生成一遍已经发过的 ID
修复 3:时钟回拨的防御策略
// === 核心:检测到回拨,绝不能照常生成 ID ===
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// === 关键防御:发现时钟回拨 ===
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
// 回拨幅度很小(≤5ms):等它追上来
// 大多数 NTP 微调就是几毫秒,等一下即可
try {
wait(offset << 1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new IllegalStateException("时钟回拨,等待后仍未恢复");
}
} else {
// 回拨幅度大:不能等,直接拒绝,让上层降级/告警
throw new ClockBackwardException(
"时钟回拨 " + offset + "ms,拒绝生成 ID");
}
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & maxSequence;
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << timestampShift)
| (workerId << workerIdShift)
| sequence;
}
// 自旋等到下一毫秒
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
// 时钟回拨专用异常,上层可针对性捕获
public static class ClockBackwardException extends RuntimeException {
public ClockBackwardException(String msg) { super(msg); }
}
// 防御原则:小回拨等待、大回拨拒绝,绝不在回拨时段照常发号
修复 4:更稳的方案 — 用历史最大时间兜底
// 修复 3 的"小回拨等待"在回拨频繁时还是会阻塞请求。
// 更优雅的思路:不直接用墙上时钟,而是维护一个"逻辑时间",
// 它只增不减,即使墙上时钟回拨,逻辑时间也不倒退。
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 逻辑时间 = max(墙上时钟, 上次记录的时间)
// 墙上时钟回拨时,继续沿用 lastTimestamp,不倒退
if (timestamp < lastTimestamp) {
// 回拨幅度记录下来用于监控告警
clockBackwardCounter.increment();
// 继续用 lastTimestamp,在它上面靠序列号往前走
timestamp = lastTimestamp;
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & maxSequence;
if (sequence == 0) {
// 序列号耗尽,逻辑时间 +1ms(借未来的时间)
timestamp = lastTimestamp + 1;
sequence = 0L;
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << timestampShift)
| (workerId << workerIdShift)
| sequence;
}
// 原理:回拨时不再用真实时钟,而是"在 lastTimestamp 这一毫秒里
// 继续消费序列号",4096 个用完就把逻辑时间 +1ms 借用未来。
// 只要回拨总量不超过 (序列号容量 × 毫秒数) 就能平滑扛过去。
//
// 代价:回拨期间生成的 ID 时间戳会"略微超前于真实时间",
// 但 ID 仍严格单调递增、绝不重复 —— 这个取舍通常划算。
//
// 注意:借未来时间不能无限借,要给一个上限(如 +5ms),
// 超了仍然要拒绝 + 告警,否则一次大回拨会把时间戳借穿。
修复 5:workerId 不能手配 — 自动分配
// === 另一个隐患:workerId 靠人写配置,扩容时撞号 ===
// 两台机器配了同一个 workerId → 即使时钟正常也会生成重复 ID
// 必须让 workerId 自动分配、全局唯一。
// === 方案 A:Redis 自增分配 workerId ===
public class RedisWorkerIdAssigner {
private final StringRedisTemplate redis;
private static final long MAX_WORKER_ID = 1023;
public long assign() {
// INCR 原子自增,取模落到 0~1023
Long id = redis.opsForValue().increment("snowflake:worker:seq");
long workerId = id % (MAX_WORKER_ID + 1);
// 用一个带过期的 key 占位,实例宕了 key 过期可被复用
String holder = "snowflake:worker:holder:" + workerId;
Boolean ok = redis.opsForValue()
.setIfAbsent(holder, instanceIp(), Duration.ofSeconds(30));
if (Boolean.FALSE.equals(ok)) {
throw new IllegalStateException("workerId " + workerId + " 已被占用");
}
// 起个定时任务每 10s 续期 holder,保持占用
scheduleRenew(holder);
return workerId;
}
}
// === 方案 B:借助注册中心(Zookeeper 临时顺序节点)===
// 在 /snowflake/workers 下创建临时顺序节点,
// 节点序号即 workerId,实例下线节点自动删除,天然回收。
// === 方案 C:数据库分配表 ===
// CREATE TABLE worker_id (
// id INT PRIMARY KEY AUTO_INCREMENT,
// ip VARCHAR(32), heartbeat DATETIME);
// 启动时 INSERT 拿自增 id 作为 workerId,定时更新 heartbeat,
// 长期无心跳的行可被回收复用。
//
// 三种方案共同点:workerId 由系统分配,绝不写死在配置文件里
修复 6:监控告警
# 雪花算法的健康度,核心就盯三件事:回拨、撞号、序列号耗尽
groups:
- name: snowflake-id
rules:
# 1. 时钟回拨发生(最关键,出现就要警惕)
- alert: SnowflakeClockBackward
expr: increase(snowflake_clock_backward_total[5m]) > 0
annotations:
summary: "{{ $labels.instance }} 检测到时钟回拨,排查 NTP"
# 2. 时钟回拨触发拒绝(已经影响发号了)
- alert: SnowflakeIdRejected
expr: increase(snowflake_id_rejected_total[5m]) > 0
annotations:
summary: "{{ $labels.instance }} 因时钟回拨拒绝发号,业务受影响"
# 3. 同一毫秒序列号耗尽频繁(发号压力过大)
- alert: SnowflakeSequenceExhausted
expr: increase(snowflake_sequence_exhausted_total[1m]) > 100
for: 3m
annotations:
summary: "{{ $labels.instance }} 序列号频繁耗尽,单机发号超 409w/s"
# 4. workerId 续期失败(占位 key 可能已失效,有撞号风险)
- alert: SnowflakeWorkerIdRenewFail
expr: increase(snowflake_workerid_renew_fail_total[5m]) > 0
annotations:
summary: "{{ $labels.instance }} workerId 续期失败,有撞号风险"
# === 系统层面:从根上减少时钟回拨 ===
# 1. NTP 用 slew 模式(平滑校正)而非 step 模式(瞬间跳变)
# chrony 配置 maxslewrate,让校时缓慢进行,不出现回拨
$ cat /etc/chrony.conf
makestep 1.0 3 # 仅前 3 次允许 step,之后一律 slew
maxslewrate 1000 # 平滑校正速率上限
# 2. 关键发号机器,关掉会瞬间改时钟的服务,统一走 chrony
$ timedatectl set-ntp true
$ systemctl status chronyd
# 3. 校验当前时钟偏移,偏移大说明 NTP 有问题
$ chronyc tracking | grep -E 'System time|Last offset'
优化效果
指标 治理前 治理后
=============================================================
ID 重复事故 凌晨 NTP 同步必现 0
时钟回拨处理 无防御,照常发号 检测 + 兜底 + 告警
小回拨(≤5ms) 生成重复 ID 逻辑时间平滑扛过
大回拨 生成重复 ID 拒绝 + 降级 + 告警
workerId 分配 人工写配置(撞号) Redis 自动分配 + 续期
单机发号能力 约 409 万/s 约 409 万/s(不变)
NTP 校时模式 step(瞬间跳变) slew(平滑校正)
回拨可观测性 完全不可见 Prometheus 全量监控
压测(单机持续发号 + 人为注入回拨):
- 治理前:注入 100ms 回拨 → 立即产生重复 ID
- 治理后:注入 100ms 回拨 → ID 仍严格递增、零重复,
回拨计数器准确 +1,告警正常触发
排查与改造:
- 定位时钟回拨根因:0.5 天
- 重构 ID 生成器(回拨防御 + 逻辑时间兜底):1.5 天
- workerId 自动分配改造:1 天
- chrony 平滑校时 + 监控接入:0.5 天
- 注入回拨压测验证:0.5 天
避坑清单
- 雪花算法的唯一性建立在两个前提:workerId 不撞号、时钟不回拨
- 朴素实现遇到时钟回拨会直接生成重复 ID,必须显式防御
- 小幅回拨可短暂等待时钟追上,大幅回拨必须拒绝发号 + 告警
- 更稳的做法是维护只增不减的逻辑时间,回拨时沿用 lastTimestamp
- 逻辑时间"借未来"也要设上限,借穿了仍要拒绝,不能无限借
- workerId 绝不能手写配置,扩容时极易撞号,要自动分配
- workerId 自动分配方案:Redis 自增、ZK 临时顺序节点、DB 分配表
- 分配出去的 workerId 要带占位 + 续期,实例宕了能回收复用
- NTP 用 chrony 的 slew 平滑校正,避免 step 模式瞬间跳变时钟
- 时钟回拨、发号拒绝、序列号耗尽、workerId 续期都要上监控
总结
这次雪花算法 ID 重复的事故,排查过程很曲折,但结论非常清晰:雪花算法看起来是个纯粹的位运算小工具,但它的正确性其实悬在两根细线上 —— 一是同一时刻全集群的 workerId 必须互不相同,二是同一台机器的时间戳必须单调递增。这两根线只要断一根,它就会安静地生成重复 ID,而且不报错、不抛异常,等你发现时已经是数据库主键冲突了。我们栽的是第二根线:NTP 在凌晨做时间同步,把一台机器的时钟往回拨了 340 毫秒,而我们的实现对时钟回拨毫无防备,回拨那段时间里每一毫秒都在重新生成一遍已经发出去的 ID。修复的核心思路有两层:第一层是"检测到回拨就不能照常发号",小幅回拨可以短暂等待时钟自己追上来,大幅回拨则必须果断拒绝、抛异常让上层降级,绝不能装作无事发生;第二层是更优雅的"逻辑时间"思路 —— 不直接信任墙上时钟,而是维护一个只增不减的逻辑时间戳,墙上时钟回拨时就沿用上次的时间、靠序列号继续往前走,序列号用完了就向未来借一毫秒,用这种方式把回拨平滑地"吃掉",代价只是生成的 ID 时间戳会略微超前于真实时间,但换来了 ID 严格单调、绝不重复,这个取舍在绝大多数业务里都是划算的。除了时钟,我们还顺手修掉了另一个埋了很久的雷:workerId 一直是人工写在配置文件里的,扩容时手一抖就配了两台一样的,这等于直接破坏了第一根线,所以 workerId 必须由系统自动分配,无论是 Redis 自增、Zookeeper 临时顺序节点还是数据库分配表,核心都是"机器自己不决定自己的编号"。最后,从系统层面把 NTP 的校时模式从 step 改成 slew,让校时变成缓慢平滑的过程而不是瞬间跳变,等于从根上大幅降低了回拨发生的概率。分布式 ID 是整个系统的基石,基石一旦出现重复,上层所有依赖唯一性的逻辑都会跟着崩塌,所以这种地方的防御,值得做到偏执。
—— 别看了 · 2026