雪花算法时钟回拨导致主键冲突的事故复盘:4 种工程化修法对比

凌晨 NTP 回拨 1.2 秒,雪花生成器爆出 ID 重复,几百订单创建失败。本文讲透 64 位结构、4 种工程实现(等待 / clockVersion 备份位 / Redis 持久化 / 全局 INCR)、worker_id 分配、NTP 防御配置、压测对比和监控告警。附完整 Java 代码。

凌晨 3 点,订单系统出现"主键冲突"告警,几百个订单创建失败。查日志发现两个不同业务上下文居然生成了相同的雪花 ID。罪魁祸首是机器时钟被 NTP 拉回了 1.2 秒。本文把雪花算法的原理、时钟回拨问题、四种工程实现写清楚,附完整代码可直接拿走用。

故障场景

2026-03-08 03:00:00 NTP daemon 检测到本机时钟比标准时间快 1.2 秒
2026-03-08 03:00:00 ntp 平滑修正,但回拨发生
2026-03-08 03:00:01 服务 A 雪花生成器 lastTimestamp = 1709833201500ms
                    新请求 currentTimestamp = 1709833200300ms
                    currentTimestamp < lastTimestamp(回拨了!)
                    默认实现:抛 ClockMovedBackwardsException
                    或 重置 sequence + 用 lastTimestamp 生成 → ID 重复

这种事故有个典型特征:不是高频出现,半年到一年偶发一次,但每次都很痛。NTP/PTP/容器迁移/虚拟机暂停恢复都可能触发。

雪花算法回顾

Twitter 雪花算法 (Snowflake) 把 64 位 long 分成 4 段:

  1 bit (符号位,固定 0)
| 41 bit (毫秒级时间戳,可用 ~69 年)
| 10 bit (机器 ID,最大 1024 台)
| 12 bit (毫秒内序列号,最大 4096 / ms / 机器)

|0|0000000000000000000000000000000000000000|0000000000|000000000000|
|s|------------- timestamp -----------------|--workerId-|-- sequence-|

单机单毫秒最多生成 4096 个 ID,1024 台机器,总容量 419 万 ID/ms。

朴素实现(有坑)

public class SnowflakeBuggy {
    private final long workerIdBits = 10L;
    private final long sequenceBits = 12L;
    private final long maxSequence = (1L << sequenceBits) - 1;     // 4095
    private final long workerIdShift = sequenceBits;
    private final long timestampShift = sequenceBits + workerIdBits;
    private final long epoch = 1577836800000L;                       // 2020-01-01

    private final long workerId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public SnowflakeBuggy(long workerId) {
        this.workerId = workerId;
    }

    public synchronized long nextId() {
        long ts = System.currentTimeMillis();
        if (ts < lastTimestamp) {
            throw new RuntimeException("clock moved backwards: " + (lastTimestamp - ts) + " ms");
        }
        if (ts == lastTimestamp) {
            sequence = (sequence + 1) & maxSequence;
            if (sequence == 0) {
                ts = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = ts;
        return ((ts - epoch) << timestampShift) | (workerId << workerIdShift) | sequence;
    }

    private long waitNextMillis(long last) {
        long ts = System.currentTimeMillis();
        while (ts <= last) ts = System.currentTimeMillis();
        return ts;
    }
}

坑在 throw new RuntimeException:回拨直接业务异常,凌晨告警全员起床。

修法 1:小回拨等待,大回拨拒绝

public synchronized long nextId() {
    long ts = System.currentTimeMillis();
    if (ts < lastTimestamp) {
        long offset = lastTimestamp - ts;
        if (offset <= 5) {
            // 5ms 以内,等一等
            try {
                wait(offset << 1);                          // 等 offset*2 ms 给时钟追上
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            ts = System.currentTimeMillis();
            if (ts < lastTimestamp) {
                throw new ClockMovedException(offset);
            }
        } else {
            // 大回拨,拒绝
            throw new ClockMovedException(offset);
        }
    }
    // ... 后续生成逻辑同上
}

适合 NTP 微调场景。生产环境 NTP 一般以毫秒为单位平滑调整,5ms 阈值能 cover 99% 的回拨。

修法 2:预留扩展位,回拨切到备份位

美团 Leaf 用过的方案。把 workerId 的 10 bit 拆成 2 bit "时钟版本" + 8 bit workerId(256 台)。每次大回拨就让"时钟版本" +1,这样 ID 段不重叠。

public class SnowflakeWithBackup {
    private static final long WORKER_ID_BITS = 8L;          // 改 8 bit
    private static final long CLOCK_VERSION_BITS = 2L;       // 2 bit clock version
    private static final long SEQUENCE_BITS = 12L;

    private static final long CLOCK_VERSION_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
    private static final long MAX_CLOCK_VERSION = (1L << CLOCK_VERSION_BITS) - 1;    // 3

    private long clockVersion = 0L;
    private long workerId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public synchronized long nextId() {
        long ts = System.currentTimeMillis();
        if (ts < lastTimestamp) {
            // 切到下一个 clock version
            if (clockVersion >= MAX_CLOCK_VERSION) {
                throw new RuntimeException("clock version exhausted, need manual recovery");
            }
            clockVersion++;
            sequence = 0L;
            // 切换之后,即使 ts 比 lastTimestamp 小也安全:clockVersion 不同
        } else if (ts == lastTimestamp) {
            sequence = (sequence + 1) & ((1L << SEQUENCE_BITS) - 1);
            if (sequence == 0) ts = waitNextMillis(lastTimestamp);
        } else {
            sequence = 0L;
        }
        lastTimestamp = ts;
        return ((ts - EPOCH) << (SEQUENCE_BITS + WORKER_ID_BITS + CLOCK_VERSION_BITS))
             | (clockVersion << CLOCK_VERSION_SHIFT)
             | (workerId << SEQUENCE_BITS)
             | sequence;
    }
}

缺点:clockVersion 只有 2 bit,最多回拨 3 次,需要程序重启才能恢复。但对绝大多数场景"一年内最多回拨几次"已经够用。

修法 3:用 Redis 持久化 lastTimestamp

百度 UidGenerator 思路:重启不丢上次的 timestamp,启动时拿持久化的值和当前时间比,只要不小于,就接着用。

public class PersistedSnowflake {
    private final RedisTemplate<String, String> redis;
    private final String key;       // "snowflake:last_ts:worker_5"

    public synchronized long nextId() {
        long ts = System.currentTimeMillis();
        if (ts < lastTimestamp) {
            // 回拨,直接用 lastTimestamp + 自增 sequence
            ts = lastTimestamp;
            sequence = (sequence + 1) & maxSequence;
            if (sequence == 0) ts = ++lastTimestamp;
        } else {
            if (ts == lastTimestamp) {
                sequence = (sequence + 1) & maxSequence;
                if (sequence == 0) ts = waitNextMillis(lastTimestamp);
            } else {
                sequence = 0L;
            }
        }
        lastTimestamp = ts;

        // 每 100ms 持久化一次,异步,不阻塞 nextId
        if (ts % 100 == 0) {
            CompletableFuture.runAsync(() -> redis.opsForValue().set(key, String.valueOf(ts)));
        }
        return ((ts - epoch) << timestampShift) | (workerId << workerIdShift) | sequence;
    }

    @PostConstruct
    public void init() {
        String persisted = redis.opsForValue().get(key);
        if (persisted != null) {
            lastTimestamp = Long.parseLong(persisted);
        }
    }
}

这个方案能容忍任意大的回拨:把时间往未来推到 lastTimestamp,反正 ID 还是单调递增。代价:重启或回拨期间 ID 看起来"比当前时间领先",大多数业务不在意。

修法 4:DBM 一把全局序列(不推荐但稳)

不用本地时钟,改用 Redis INCR / DB sequence。性能差但绝对没有回拨问题:

public class RedisIdGen {
    private final RedisTemplate<String, Long> redis;

    public long nextId() {
        // INCR 是原子的,Redis 单点
        return redis.opsForValue().increment("id:order", 1);
    }

    // 批量预取 1000 个,本地分配,减少 Redis 压力
    private final AtomicLong localStart = new AtomicLong(0);
    private final AtomicLong localEnd = new AtomicLong(0);
    public synchronized long nextIdBatched() {
        long current = localStart.get();
        if (current >= localEnd.get()) {
            long end = redis.opsForValue().increment("id:order", 1000);
            localStart.set(end - 1000);
            localEnd.set(end);
        }
        return localStart.incrementAndGet();
    }
}

缺点:依赖 Redis 可用性,Redis 挂了业务全瘫。一般用 Snowflake 配 Redis 做 fallback。

对比表

worker_id 怎么分配

1024 台机器 ID 分配是雪花算法另一个隐藏坑。常见方案:

// 方案 A:Zookeeper 顺序节点(强烈推荐)
public long acquireWorkerId() {
    String basePath = "/snowflake/workers";
    String node = zk.create(basePath + "/w-", new byte[0],
        ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
    // node = "/snowflake/workers/w-0000000123"
    long id = Long.parseLong(node.substring(node.lastIndexOf('-') + 1)) % 1024;
    log.info("acquired workerId={}", id);
    return id;
}

// 方案 B:基于 hostname / IP 哈希
public long workerIdFromHost() {
    try {
        InetAddress ip = InetAddress.getLocalHost();
        byte[] bytes = ip.getAddress();
        long hash = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
        return hash % 1024;     // 风险:不同 IP 哈希冲突
    } catch (Exception e) {
        return new Random().nextInt(1024);
    }
}

// 方案 C:配置中心 + 本机 hostname
public long workerIdFromConfig() {
    String hostname = System.getenv("HOSTNAME");
    String value = configCenter.get("snowflake.worker." + hostname);
    if (value == null) throw new RuntimeException("workerId not configured: " + hostname);
    return Long.parseLong(value);
}

K8s 环境下推荐方案 A,因为 Pod IP 不固定 + StatefulSet 才有稳定 hostname。

NTP 配置防回拨

# 不让 ntp 一次性大跳,而是 slew(平滑调整,每秒 0.5ms)
$ cat /etc/ntp.conf
tinker step 0           # 禁止一次性大跳,永远 slew
tinker panic 0          # 禁止 panic
disable kernel          # 不用内核时间补偿

# chrony 配置(现代发行版默认)
$ cat /etc/chrony.conf
makestep 0.1 -1         # 偏差 >0.1s 才允许 step,负数次数禁止
maxslewrate 100         # slew 最高 100ppm

# 关键:配置完后 systemctl restart chronyd
# 然后用 chronyc tracking 看是不是真的 slew 而不是 step

监控告警

# Prometheus 告警:雪花生成器拒绝率
- alert: SnowflakeRejectHigh
  expr: rate(snowflake_clock_moved_total[5m]) > 0
  for: 1m
  annotations:
    summary: '雪花生成器在 {{ $labels.host }} 上拒绝请求'
    description: '可能是 NTP 大回拨或机器时间异常'

- alert: NodeClockDrift
  expr: abs(node_timex_offset_seconds) > 0.5
  for: 5m
  annotations:
    summary: '节点 {{ $labels.instance }} 时钟偏差 > 500ms'
    description: '检查 NTP 服务和网络'

压测数据

单机基准 (i7-12700K,JDK 17,1 个线程):
朴素雪花                         420 万 IDs / 秒
+ 5ms 等待回拨                   420 万 IDs / 秒(无回拨时无影响)
+ clockVersion 备份              410 万 IDs / 秒
+ Redis 持久化(异步)              410 万 IDs / 秒
纯 Redis INCR                     8 万 IDs / 秒(网络往返开销)
Redis INCR + 1000 批量预取        380 万 IDs / 秒

8 线程并发:
朴素雪花 (synchronized)          820 万 IDs / 秒(锁争抢)
无锁雪花 (CAS)                   1800 万 IDs / 秒

选型 checklist

  1. 对外暴露的订单号、用户号:用 INCR + 批量预取,严格连续
  2. 内部主键、日志 trace_id:本地雪花 + clockVersion,性能优先
  3. K8s / 云原生:worker_id 用 ZK / etcd 注册
  4. NTP 强制配置 tinker step 0,禁止时钟大跳
  5. 监控时钟回拨指标 + 节点时钟偏差
  6. 故障演练:模拟回拨 100ms / 1s / 10s,验证生成器行为

雪花是个看起来简单实际坑很多的东西。事故之后我们把生成器封装成独立的 SDK,所有业务接进来,统一处理 worker_id 注册、回拨、监控。一年多了再没出过 ID 冲突。代码已经开源,有兴趣的同学可以自行搜索 leaf-snowflake / uid-generator,生产可用。

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

ConcurrentHashMap.computeIfAbsent 嵌套调用导致 CPU 100% 的真实事故复盘

2026-5-19 11:15:47

技术教程

Redis 分布式锁踩过的 5 个坑:从 SETNX 到 Redlock 到 fencing token

2026-5-19 11:20:07

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