雪花算法时钟回拨导致 ID 重复:一次凌晨主键冲突的复盘

订单系统用雪花算法生成订单号,某天凌晨数据库报主键冲突:两笔订单拿到同一个 ID。根因是 NTP 同步把一台机器时钟往回拨了 340ms,雪花算法最怕时钟回拨。几天治理:补时钟回拨防御(小回拨等待、大回拨拒绝)、用只增不减的逻辑时间兜底、workerId 改自动分配、NTP 改 slew 平滑校时。之后再没出现 ID 重复。

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 天

避坑清单

  1. 雪花算法的唯一性建立在两个前提:workerId 不撞号、时钟不回拨
  2. 朴素实现遇到时钟回拨会直接生成重复 ID,必须显式防御
  3. 小幅回拨可短暂等待时钟追上,大幅回拨必须拒绝发号 + 告警
  4. 更稳的做法是维护只增不减的逻辑时间,回拨时沿用 lastTimestamp
  5. 逻辑时间"借未来"也要设上限,借穿了仍要拒绝,不能无限借
  6. workerId 绝不能手写配置,扩容时极易撞号,要自动分配
  7. workerId 自动分配方案:Redis 自增、ZK 临时顺序节点、DB 分配表
  8. 分配出去的 workerId 要带占位 + 续期,实例宕了能回收复用
  9. NTP 用 chrony 的 slew 平滑校正,避免 step 模式瞬间跳变时钟
  10. 时钟回拨、发号拒绝、序列号耗尽、workerId 续期都要上监控

总结

这次雪花算法 ID 重复的事故,排查过程很曲折,但结论非常清晰:雪花算法看起来是个纯粹的位运算小工具,但它的正确性其实悬在两根细线上 —— 一是同一时刻全集群的 workerId 必须互不相同,二是同一台机器的时间戳必须单调递增。这两根线只要断一根,它就会安静地生成重复 ID,而且不报错、不抛异常,等你发现时已经是数据库主键冲突了。我们栽的是第二根线:NTP 在凌晨做时间同步,把一台机器的时钟往回拨了 340 毫秒,而我们的实现对时钟回拨毫无防备,回拨那段时间里每一毫秒都在重新生成一遍已经发出去的 ID。修复的核心思路有两层:第一层是"检测到回拨就不能照常发号",小幅回拨可以短暂等待时钟自己追上来,大幅回拨则必须果断拒绝、抛异常让上层降级,绝不能装作无事发生;第二层是更优雅的"逻辑时间"思路 —— 不直接信任墙上时钟,而是维护一个只增不减的逻辑时间戳,墙上时钟回拨时就沿用上次的时间、靠序列号继续往前走,序列号用完了就向未来借一毫秒,用这种方式把回拨平滑地"吃掉",代价只是生成的 ID 时间戳会略微超前于真实时间,但换来了 ID 严格单调、绝不重复,这个取舍在绝大多数业务里都是划算的。除了时钟,我们还顺手修掉了另一个埋了很久的雷:workerId 一直是人工写在配置文件里的,扩容时手一抖就配了两台一样的,这等于直接破坏了第一根线,所以 workerId 必须由系统自动分配,无论是 Redis 自增、Zookeeper 临时顺序节点还是数据库分配表,核心都是"机器自己不决定自己的编号"。最后,从系统层面把 NTP 的校时模式从 step 改成 slew,让校时变成缓慢平滑的过程而不是瞬间跳变,等于从根上大幅降低了回拨发生的概率。分布式 ID 是整个系统的基石,基石一旦出现重复,上层所有依赖唯一性的逻辑都会跟着崩塌,所以这种地方的防御,值得做到偏执。

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

HikariCP 连接池频繁耗尽:从一次接口雪崩看连接池调优实录

2026-5-20 12:34:39

技术教程

ThreadLocal 内存泄漏:网关服务每三天重启一次的真相

2026-5-20 12:39:25

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