凌晨 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
- 对外暴露的订单号、用户号:用 INCR + 批量预取,严格连续
- 内部主键、日志 trace_id:本地雪花 + clockVersion,性能优先
- K8s / 云原生:worker_id 用 ZK / etcd 注册
- NTP 强制配置
tinker step 0,禁止时钟大跳 - 监控时钟回拨指标 + 节点时钟偏差
- 故障演练:模拟回拨 100ms / 1s / 10s,验证生成器行为
雪花是个看起来简单实际坑很多的东西。事故之后我们把生成器封装成独立的 SDK,所有业务接进来,统一处理 worker_id 注册、回拨、监控。一年多了再没出过 ID 冲突。代码已经开源,有兴趣的同学可以自行搜索 leaf-snowflake / uid-generator,生产可用。
—— 别看了 · 2026