服务器一对时,ID 就开始重复:一次雪花算法时钟回拨的复盘

凌晨突发大量主键冲突,雪花算法生成的订单 ID 竟然重复。真凶是 NTP 对时把系统时钟往回拨了几百毫秒,而雪花算法强依赖时间戳单调递增。几天加固发号器:雪花 64 位结构、回拨为何致命、回拨分级处理、逻辑时钟兜底、workerId 动态分配、NTP 校时规范。

2024 年我们的订单服务出过一次很诡异的故障:某天凌晨,监控突然报出大量"主键冲突"的数据库异常,几分钟后又自己恢复了。订单 ID 用的是雪花算法(Snowflake)生成的,理论上全局唯一,怎么会冲突?排查了大半天才找到真凶——那台机器的系统时钟,在凌晨被 NTP 服务"对时"往回拨了几百毫秒。而雪花算法是强依赖系统时间来生成 ID 的,时钟一旦回拨,它就可能在"过去的那个毫秒"里生成出和之前重复的 ID。这件事让我们意识到,雪花算法看似简单,可"时钟回拨"这个坑我们压根没防。投了几天把发号器彻底加固了一遍,本文复盘这次实战。

问题背景

业务:订单服务,订单 ID 用雪花算法本地生成
事故现象:
- 凌晨突发大量 Duplicate entry 主键冲突异常
- 持续几分钟后自行恢复,没人操作
- 只有其中一台应用实例报错,其他实例正常

现场排查:
# 1. 看报错
Duplicate entry '1789563412345678901' for key 'PRIMARY'
# 雪花 ID 居然重复了

# 2. 看那台机器的系统日志
$ journalctl -u chronyd | grep -i step
chronyd: System clock wrong by -0.382 seconds, step
# NTP 对时,把系统时钟【往回拨了 0.382 秒】

# 3. 对上时间线
# 故障时刻 == 时钟回拨时刻,完全吻合

# 4. 看发号器代码
if (timestamp < lastTimestamp) {
    throw new RuntimeException("时钟回拨");  // 有判断
}
# 但回拨后,timestamp 退回到了过去某个值,
# 而那个毫秒的 sequence 又从 0 开始 -> 和历史 ID 撞了

根因:
1. 雪花算法的 ID = 时间戳 + 机器号 + 序列号,强依赖系统时钟
2. NTP 对时把时钟往回拨,生成器回到了"过去的毫秒"
3. 过去那个毫秒的序列号重新从 0 计,与历史已发的 ID 重复
4. 对"时钟回拨"只做了简单抛异常,没有真正的应对策略

修复 1:先看懂雪花算法的 64 位结构

// === 雪花算法:一个 64 位 long 型 ID 的组成 ===
// 0 |     41 位时间戳      | 10 位机器号 | 12 位序列号
// │   │                  │            │
// │   │                  │            └ 同一毫秒内的自增序号(0~4095)
// │   │                  └ 机器标识(数据中心+机器,最多 1024 台)
// │   └ 毫秒级时间戳(相对某个起始纪元的差值)
// └ 符号位,固定 0,保证 ID 是正数

public class SnowflakeIdGenerator {
    private final long epoch = 1577808000000L;  // 起始纪元 2020-01-01
    private final long workerIdBits = 10L;
    private final long sequenceBits = 12L;
    private final long maxWorkerId = ~(-1L << workerIdBits);   // 1023
    private final long maxSequence = ~(-1L << sequenceBits);   // 4095

    private final long workerId;       // 本机机器号
    private long sequence = 0L;        // 当前毫秒内的序列
    private long lastTimestamp = -1L;  // 上次生成 ID 的时间戳

    // === 三个部分的位移量 ===
    private final long workerIdShift = sequenceBits;           // 12
    private final long timestampShift = sequenceBits + workerIdBits; // 22

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > maxWorkerId)
            throw new IllegalArgumentException("workerId 越界");
        this.workerId = workerId;
    }
}

// === 为什么它能"全局唯一" ===
// 时间戳:不同毫秒,ID 必然不同
// 机器号:不同机器,即便同一毫秒,ID 也不同
// 序列号:同一机器同一毫秒,靠自增序列区分,一毫秒能发 4096 个
// 三者一组合,理论上就唯一了 —— 前提是【时间戳只增不减】。

修复 2:为什么时钟回拨会让 ID 重复

// === 一个最朴素(有 bug)的生成逻辑 ===
public synchronized long nextId() {
    long timestamp = System.currentTimeMillis();

    if (timestamp == lastTimestamp) {
        // 同一毫秒,序列自增
        sequence = (sequence + 1) & maxSequence;
        if (sequence == 0) {
            // 这一毫秒的 4096 个序号用完了,等下一毫秒
            timestamp = waitNextMillis(lastTimestamp);
        }
    } else {
        // 进入新的一毫秒,序列归 0
        sequence = 0L;
    }
    lastTimestamp = timestamp;

    return ((timestamp - epoch) << timestampShift)
         | (workerId << workerIdShift)
         | sequence;
}

// === 时钟回拨时,这段逻辑怎么出错 ===
// 假设当前时间是 T,已经发过 (T, workerId, 0..100) 这些 ID。
// 此刻 NTP 把时钟回拨,System.currentTimeMillis() 退回到了 T-50ms。
// 1. timestamp = T-50,它不等于 lastTimestamp(=T)
//    -> 走 else 分支,sequence 归 0
// 2. 生成的 ID = (T-50, workerId, 0)
// 3. 时间继续走到 T-49、T-48 ...
//    sequence 又从 0 开始 1、2、3 ...
// 4. 而 (T-50, workerId, 0..N) 这些 ID,
//    在时钟"第一次"经过 T-50 时,可能【早就发出去过了】!
// -> 重复 ID 就这样产生了。

// === 结论 ===
// 雪花算法的唯一性,建立在"时间戳单调递增"这个假设上。
// 时钟回拨直接打破了这个假设 —— 这是它的命门。

修复 3:回拨的应对策略——拒绝、等待还是容忍

// === 一个加固后的 nextId:对回拨分级处理 ===
private static final long MAX_BACKWARD_MS = 5L;  // 可容忍的回拨上限

public synchronized long nextId() {
    long timestamp = System.currentTimeMillis();

    // === 情况 1:检测到时钟回拨 ===
    if (timestamp < lastTimestamp) {
        long offset = lastTimestamp - timestamp;
        if (offset <= MAX_BACKWARD_MS) {
            // 小幅回拨(几毫秒):原地等待,直到时间追上来
            try {
                wait(offset << 1);   // 等待回拨时长的两倍
                timestamp = System.currentTimeMillis();
                if (timestamp < lastTimestamp) {
                    throw new IllegalStateException("时钟回拨,等待后仍未恢复");
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new IllegalStateException("时钟回拨等待被中断", e);
            }
        } else {
            // 大幅回拨:绝不能发 ID,直接拒绝,让上层降级/告警
            throw new IllegalStateException(
                "时钟回拨 " + offset + "ms,超过容忍上限,拒绝发号");
        }
    }

    // === 情况 2:正常路径 ===
    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 lastTs) {
    long ts = System.currentTimeMillis();
    while (ts <= lastTs) ts = System.currentTimeMillis();
    return ts;
}

// === 三种策略的取舍 ===
// 拒绝(抛异常):最安全,绝不发错 ID,但会让发号短暂不可用
// 等待:小幅回拨用,等时钟追上来,对业务几乎无感
// 容忍:把"最后一次时间戳"记下来,回拨期间继续在它之上累加,
//       不退回过去 —— 是更进阶的做法(见修复 4)

修复 4:不依赖墙上时钟,用"逻辑时钟"兜底

// === 思路:发号器自己维护一个"只增不减"的逻辑时钟 ===
// 不直接信任 System.currentTimeMillis(),
// 而是维护一个 lastTimestamp,保证它永远只往前走。

public synchronized long nextId() {
    long now = System.currentTimeMillis();
    // 取"系统时间"和"上次时间戳"中较大的那个 —— 永不后退
    long timestamp = Math.max(now, lastTimestamp);

    if (timestamp == lastTimestamp) {
        sequence = (sequence + 1) & maxSequence;
        if (sequence == 0) {
            // 序列用尽:逻辑时间戳直接 +1,不去问系统时钟
            timestamp = lastTimestamp + 1;
        }
    } else {
        sequence = 0L;
    }
    lastTimestamp = timestamp;
    return ((timestamp - epoch) << timestampShift)
         | (workerId << workerIdShift) | sequence;
}
// === 这样做的效果 ===
// 时钟回拨时,now 变小,但 Math.max 保证 timestamp 不退回。
// 发号器在"自己的逻辑时间"上继续前进,ID 依然单调、不重复。
// 代价:如果回拨幅度大,逻辑时钟会"领先"真实时间一段,
//       但 ID 唯一性这个底线被守住了 —— 这个取舍是值得的。

// === 进阶:序列号借位 ===
// 还有一种做法:雪花结构里留一个"扩展位"或借用序列高位,
// 检测到回拨时,把这个位 +1,等于切换到一个新的 ID 空间,
// 即便时间戳重复,这个位不同,ID 仍不冲突。
// 美团 Leaf、百度 UidGenerator 等成熟方案都做了类似处理。

修复 5:workerId 的分配也不能拍脑袋

// === 另一个隐藏的坑:两台机器拿到了相同的 workerId ===
// 雪花靠 workerId 区分机器,如果两台机器 workerId 一样,
// 那它们在同一毫秒发的 ID 就可能完全相同 —— 同样会冲突。
// 容器化部署后,实例动态扩缩,workerId 写死在配置里极易撞号。

// === 错误做法:workerId 写死在配置文件 ===
// snowflake.worker-id=1   <- 复制部署时很容易忘了改

// === 做法 1:用 Redis / ZooKeeper 动态分配 workerId ===
// 应用启动时,向注册中心申请一个【当前未被占用】的 workerId,
// 下线时释放。保证集群里 workerId 全局不重复。
public long acquireWorkerId() {
    for (long id = 0; id <= maxWorkerId; id++) {
        String key = "snowflake:worker:" + id;
        // SET NX:只有该 id 没被占用才能抢到
        if (redis.setIfAbsent(key, instanceIp, Duration.ofMinutes(5))) {
            startHeartbeat(key);  // 定时续期,防止自己的 id 被回收
            return id;
        }
    }
    throw new IllegalStateException("workerId 已耗尽(>1024 台?)");
}

// === 做法 2:用数据库自增 ===
// 建一张表,启动时 insert 一行拿到自增主键,
// 对 1024 取模作为 workerId。

// === 做法 3:基于 IP / MAC / K8s Pod 序号派生 ===
// 比如 StatefulSet 的 Pod 名带稳定序号,直接拿来当 workerId。

// === 经验 ===
// workerId 分配的核心要求只有一条:
// 任意时刻,集群里【绝不能有两台机器持有相同的 workerId】。

修复 6:发号器的监控与运维规范

# 发号器监控:时钟回拨和发号失败是必须盯紧的信号
groups:
- name: id-generator
  rules:
  # 1. 检测到时钟回拨(哪怕被容忍,也要知道发生了)
  - alert: SnowflakeClockBackward
    expr: increase(snowflake_clock_backward_total[5m]) > 0
    annotations:
      summary: "{{ $labels.instance }} 发号器检测到时钟回拨,需排查 NTP"

  # 2. 发号失败(回拨过大被拒绝 / workerId 耗尽)
  - alert: SnowflakeGenerateFailed
    expr: increase(snowflake_generate_failed_total[5m]) > 0
    annotations:
      summary: "{{ $labels.instance }} 发号失败,ID 生成不可用"

  # 3. 序列号频繁打满(单毫秒并发过高,接近 4096 上限)
  - alert: SnowflakeSequenceExhausted
    expr: increase(snowflake_sequence_overflow_total[1m]) > 100
    for: 3m
    annotations:
      summary: "{{ $labels.instance }} 单毫秒序列频繁耗尽,发号能力接近上限"
=== 运维规范:从根上减少时钟回拨 ===
1. NTP 对时改用"渐进式校正",不要"瞬间跳变"
   - chrony 默认对小偏差用 slew(缓慢调整速率)而非 step(跳变)
   - 配置 makestep 时设严格条件,避免运行期大幅 step
   chrony.conf:
     makestep 1.0 3      # 仅前 3 次校时允许 step,之后只 slew

2. 保证 NTP 服务持续健康,避免时钟长期漂移
   - 时钟长期漂移积累得越多,某次对时回拨的幅度就越大

3. 关键发号服务,机器时间监控单独告警
   - 监控本机时间与标准时间的偏差,偏差变大提前介入

=== 选型建议 ===
- 并发不极致、能接受少量改造:自己实现加固版雪花算法即可
- 大规模、要求高可用:直接用美团 Leaf、百度 UidGenerator
  这些成熟方案,它们对时钟回拨、workerId 分配都有完整处理
- 不要重复造一个"裸奔版"雪花算法埋进生产系统

优化效果

指标                      治理前              治理后
=============================================================
时钟回拨应对              简单抛异常或不处理   分级:小幅等待/大幅拒绝
ID 唯一性                 回拨时会重复         逻辑时钟兜底,永不重复
workerId 分配             写死配置,易撞号     Redis 动态分配 + 心跳续期
时钟回拨可观测            无                   回拨次数/发号失败监控
NTP 对时方式              允许运行期 step 跳变 渐进式 slew 校正为主
序列耗尽                  无感知               单毫秒序列打满告警
主键冲突故障              凌晨偶发             清零
发号方案                  裸奔版雪花算法       加固版 / 成熟发号器

治理过程:
- 定位时钟回拨导致 ID 重复:1 天
- 雪花算法加固:回拨分级 + 逻辑时钟:1 天
- workerId 改为 Redis 动态分配:1 天
- NTP 校时策略调整 + 监控接入:0.5 天
- 全量验证 + 团队规范沉淀:0.5 天

避坑清单

  1. 雪花算法 ID = 时间戳 + 机器号 + 序列号,它的唯一性建立在时间戳单调递增上
  2. NTP 对时可能把系统时钟往回拨,直接打破"时间只增不减"的假设
  3. 时钟回拨后生成器退回过去的毫秒,序列从 0 重数,会与历史已发 ID 重复
  4. 对回拨要分级处理:小幅回拨原地等待,大幅回拨直接拒绝发号并告警
  5. 更稳的做法是维护逻辑时钟,用 Math.max 保证时间戳永不后退
  6. 序列号用尽时让逻辑时间戳 +1,不去问系统时钟,守住唯一性底线
  7. workerId 绝不能写死配置,容器化部署极易让两台机器撞上同一个号
  8. Redis/ZooKeeper 动态分配 workerId 并心跳续期,保证集群内不重复
  9. NTP 校时配成渐进式 slew,避免运行期 step 跳变,从根上减少回拨
  10. 大规模场景直接用美团 Leaf、百度 UidGenerator,别用裸奔版雪花算法

总结

这次雪花算法的故障,表面看是一次罕见的"主键冲突",根上却是一个被我们长期忽视的隐含假设——雪花算法能保证 ID 全局唯一,这个承诺并不是无条件的,它有一个前提:系统时钟必须单调递增,只能往前走,绝不能往回退。雪花算法生成的那个 64 位 ID,本质上是三段信息的拼接:高位是毫秒级的时间戳,中间是标识机器的 workerId,低位是同一毫秒内的自增序列号。它之所以能做到唯一,逻辑很直白——不同毫秒,时间戳不同;同一毫秒不同机器,workerId 不同;同一机器同一毫秒,序列号不同;三者任意一个不同,ID 就不同。可你只要仔细想一下就会发现,这套逻辑里的"同一机器同一毫秒靠序列号区分",它默默假定了一件事:每一个毫秒,在这台机器的生命周期里只会被经过一次。而时钟回拨,恰恰就是让同一个毫秒被经过了第二次。当 NTP 对时把系统时钟往回拨了几百毫秒,生成器再去读系统时间,读到的是一个"过去的"毫秒值,它以为自己进入了一个全新的毫秒,于是把序列号归零、从 0 开始重新计数——可问题是,在时钟第一次正常流经那个毫秒的时候,(那个时间戳, workerId, 0、1、2……)这一批 ID 很可能早就发出去、写进数据库了。于是序列号从 0 重数的这一刻,重复的 ID 就诞生了。想通了这条因果链,解决方向其实就两个层次。第一个层次是直接对回拨做防御:发号器每次生成 ID 时,都把当前时间和上一次记录的时间戳比一比,如果发现时间倒退了,就根据倒退的幅度分级处理——回拨只有几毫秒,那就原地等一等,等系统时钟自己追上来,这对业务几乎无感;回拨幅度很大,那就必须果断拒绝发号、抛异常、告警,宁可让发号服务短暂不可用,也绝不能发出一个可能重复的 ID,因为一个重复的订单 ID 流进系统造成的数据混乱,远比发号短暂失败严重得多。第二个、也是更彻底的层次,是干脆不再无条件信任系统这个"墙上时钟":发号器自己维护一个只增不减的逻辑时间戳,每次取系统时间和这个逻辑时间戳中较大的那个值来用,这样一来,无论系统时钟怎么回拨,发号器始终在自己的逻辑时间轴上稳步向前,ID 的单调性和唯一性就被牢牢守住了——代价只是逻辑时间可能会暂时领先真实时间一点点,而这个代价,和"ID 绝不重复"这个底线比起来,完全值得。除了时钟,这次复盘还顺带暴露了另一个同样致命、却更隐蔽的坑:workerId 的分配。雪花算法靠 workerId 来区分机器,可一旦在容器化部署、频繁扩缩容的环境里,把 workerId 写死在配置文件里,就极容易出现两台机器拿着同一个 workerId 的情况,那它们在同一毫秒发出的 ID 会一模一样,后果和时钟回拨完全一样。正确的做法是让实例启动时向 Redis 或 ZooKeeper 动态申请一个当前没被占用的 workerId,并靠心跳维持占用。这次事故给我最大的一个观念转变是:雪花算法常常被当成一段"复制粘贴就能用"的工具代码,可它其实是一个对运行环境(系统时钟、机器标识)有着强假设的精密组件。真正能放进生产系统的,从来不是那个网上随处可见的、几十行的"裸奔版"实现,而是一个认真处理过时钟回拨、认真分配过 workerId、并且把这两件事都纳入了监控的加固版本——如果团队没有精力做这件事,那就直接用美团 Leaf、百度 UidGenerator 这些已经把坑都踩平了的成熟方案,这远比自己埋一颗定时炸弹要明智。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

写完马上读却读到旧数据:一次主从延迟踩坑的复盘

2026-5-20 13:56:06

技术教程

消息消费失败后悄悄没了:一次死信队列治理的复盘

2026-5-20 14:01:44

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索