雪花算法的三个坑:时钟回拨、workerId 冲突与前端精度丢失

先把问题摆出来

(1)NTP 校时把时间往回拨了 1 秒,发号服务为什么可能发出完全相同的 ID?

(2)两台机器配了同一个 workerId,为什么上线第一天不报错,扩容之后才开始主键冲突?

(3)数据库里明明是 19 位 ID,前端列表里末几位变成了 0,点详情直接 404?

这三个问题我在项目里都踩过,它们不是”实现写错了”,而是雪花算法落地时绕不开的三类边界。
下面按踩坑顺序说,附最小可用代码。

一、时钟回拨:41 位时间戳的前提是”时间单调”

雪花 ID 的结构很朴素:1 位符号 + 41 位时间戳 + 10 位机器号 + 12 位序列号。
41 位毫秒时间戳够用约 69 年,10 位机器号支持 1024 个节点,12 位序列号支持单毫秒 4096 个。

问题在于:41 位时间戳默认时间是单调递增的。而现实里时间会往回走:

  • NTP 微调:小到几毫秒的回拨,最常见
  • 运维手改系统时间、时区配置错
  • 容器/虚拟机迁移后时钟漂移
  • 虚机挂起恢复(suspend/resume)后时间跳变

一旦回拨,lastTimestamp 比当前时间大,代码如果只做 ts == lastTimestamp 的序列号自增判断,
就会直接拿一个已经用过的 (时间戳, 机器号, 序列号) 组合发号——ID 重复。

处理策略

按回拨幅度分档处理,比”一律抛异常”更实用:

  • 小幅回拨(≤ 5ms):自旋等到时间追上来,业务无感
  • 大幅回拨:直接抛异常,让上层重试或降级到号段模式(美团 Leaf-segment 那种,压根不依赖时间)
  • 进程重启:lastTimestamp 是内存变量,重启后归零。如果同一毫秒内重启两次,就可能撞号。
    生产上要把 lastTimestamp 落盘或写 Redis,启动时校验一次
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class SnowflakeIdGenerator {

private static final long WORKER_BITS = 5L;
private static final long DATACENTER_BITS = 5L;
private static final long SEQUENCE_BITS = 12L;

private static final long MAX_WORKER_ID = ~(-1L << WORKER_BITS); // 31
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_BITS); // 31

private static final long WORKER_SHIFT = SEQUENCE_BITS; // 12
private static final long DATACENTER_SHIFT = SEQUENCE_BITS + WORKER_BITS; // 17
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_BITS + DATACENTER_BITS; // 22
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); // 4095

/** 起始纪元,一旦上线就不要改,改了等于换了一套 ID 空间 */
private static final long EPOCH = 1288834974657L;
/** 容忍的小幅回拨,超过就拒绝服务 */
private static final long MAX_BACKWARD_MS = 5L;

private final long workerId;
private final long datacenterId;

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

public SnowflakeIdGenerator(long datacenterId, long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("workerId 必须在 0~" + MAX_WORKER_ID);
}
if (datacenterId < 0 || datacenterId > MAX_DATACENTER_ID) {
throw new IllegalArgumentException("datacenterId 必须在 0~" + MAX_DATACENTER_ID);
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}

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

if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset > MAX_BACKWARD_MS) {
throw new IllegalStateException("时钟回拨 " + offset + "ms,超过阈值,拒绝发号");
}
// 小幅回拨:等到时间追上来(等待两倍偏移量,留一点余量)
try {
wait(offset << 1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("发号等待被中断", e);
}
timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new IllegalStateException("等待后仍未追上,剩余回拨 " + (lastTimestamp - timestamp) + "ms");
}
}

if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
// 同一毫秒 4096 个用完了,等下一毫秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}

lastTimestamp = timestamp;

return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (datacenterId << DATACENTER_SHIFT)
| (workerId << WORKER_SHIFT)
| sequence;
}

private long tilNextMillis(long last) {
long timestamp = System.currentTimeMillis();
while (timestamp <= last) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}

两个细节容易被忽略:

  1. nextId() 必须 synchronized(或改用 CAS/AtomicLong),单例前提下才能保证序列号不被并发抢乱。
  2. MAX_BACKWARD_MS 别设太大。设成 1000ms 意味着你愿意为了”不断服”接受一秒的不可控等待,
    换个说法就是:你在赌这一秒内没有重启、没有别的节点发号。

二、workerId 冲突:配置写死 = 埋一颗定时炸弹

10 位机器号看起来是”1024 个节点随便用”,但绝大多数事故是这么来的:

1
2
3
4
# application.yml —— 上线时从别的服务复制过来的
snowflake:
worker-id: 1
datacenter-id: 1

然后 Docker 起了 3 个副本、K8s 扩到 5 个 Pod,全都读同一份配置,全都用 workerId=1。

这类问题不会在上线第一天暴露。 因为冲突要满足两个条件:两个节点在同一毫秒内各发一个 ID,
并且序列号还撞上。低频业务可能跑一周都不撞,等流量涨上来、日志开始报
Duplicate entry 'xxx' for key 'PRIMARY' 时,你才知道有节点重号。

三个可落地的分配方式

方式 A:启动时向 Redis 注册,运行时续约(最通用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Component
public class WorkerIdAllocator {

private static final int MAX_WORKER_ID = 31;
private static final String KEY_PREFIX = "idgen:worker:";

private final StringRedisTemplate redis;
private volatile int workerId = -1;

public int allocate() {
for (int i = 0; i <= MAX_WORKER_ID; i++) {
Boolean ok = redis.opsForValue()
.setIfAbsent(KEY_PREFIX + i, instanceId(), Duration.ofSeconds(60));
if (Boolean.TRUE.equals(ok)) {
this.workerId = i;
return i;
}
}
throw new IllegalStateException("workerId 已耗尽,请检查是否有僵尸实例占用");
}

/** 每 30 秒续约一次,进程被 kill -9 时最多占用 60 秒后自动释放 */
@Scheduled(fixedDelay = 30_000)
public void renew() {
if (workerId >= 0) {
redis.expire(KEY_PREFIX + workerId, Duration.ofSeconds(60));
}
}

private String instanceId() {
return System.getenv().getOrDefault("HOSTNAME", "unknown");
}
}

要点:续约周期必须小于过期时间,否则网络抖动一下,锁就掉了,另一个实例会抢走同一个号。
反过来,过期时间也别设太长,否则 Pod 被强杀后这个号要等很久才能被复用。

方式 B:按机器标识哈希取模

hash(IP + 端口 + 机器名) % 32。实现零依赖,但冲突概率不是零——1024 个号段,
32 个 workerId 时按生日问题算,节点一多碰撞概率上升得比直觉快。适合节点数固定且很少的场景,
并且必须配一个启动自检:注册表里已经有人用了,就往后顺延。

方式 C:K8s 场景直接用序号

StatefulSet + metadata.name 里的序号作为 workerId,或者干脆用 Leaf 的 zookeeper 顺序节点。
容器化环境里,能自动分配就别手工配置。

三、前端精度丢失:ID 出了 JVM 就该是字符串

JS 的 Number 是双精度浮点,安全整数上限是 2^53 - 1 = 9007199254740991(16 位)。
而雪花 ID 是 19 位,典型值 1888888888888888888。

现象很有迷惑性:

  • 列表页看着正常,因为 JSON.parse 之后精度已经丢了,但显示出来还是”一串数字”
  • 末几位悄悄变成 0(...888 变成 ...000)
  • 点详情 → 404,因为前端拿错的 ID 去查
  • 编辑保存 → 把错的 ID 写回库里,数据被”改”到另一个主键上

解决办法

字段级最稳:

1
2
3
4
public class OrderVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
}

全局改造适合新项目,老项目要评估影响面——所有 Long 都会变成字符串,包括分页 total、
金额、状态码,前端 TS 定义和比较逻辑都得跟着改:

1
2
3
4
5
6
7
8
9
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToString() {
return builder -> {
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
builder.modules(module);
};
}

容易漏掉的出口:

  • Feign / RestTemplate 的反序列化端:消费方如果还是 Long 接收,字符串进来会直接转换失败
  • MQ 消息体、缓存里的 JSON(比如 Redis 里存的老结构)
  • Excel 导出:Long 直接写单元格会被 Excel 显示成科学计数法,得按文本写
  • 前端 TS:id: string,别图省事写 number

四、怎么验证这三个点

光靠 code review 发现不了,得有可执行的验证:

  1. 时钟可注入:把 System.currentTimeMillis() 换成可注入的 Clock,单测里把时间往回拨 1ms、10ms,分别断言”等差等待”和”抛异常”。
  2. 并发去重压测:单机 4 线程各发 10 万个 ID,塞进 ConcurrentHashMap.newKeySet(),最后断言 size 等于总数。
  3. 主键唯一索引必须留着:别为了性能把唯一索引去掉。它是这类问题的最后一道兜底——没有它,重号会变成静默的数据覆盖。

小结

(1)41 位时间戳够用 69 年,但前提是时间单调;小幅回拨等待、大幅回拨拒绝、重启校验 lastTimestamp。

(2)workerId 不是”配一下就行”,要能被自动申请、自动续约、自动释放;容器环境尤其别写死。

(3)ID 出了 JVM 就是字符串,浏览器、MQ、Excel 三个出口都要检查。

如果业务峰值不超过每秒几千个 ID,其实更省心的选择是号段模式(Leaf-segment / 数据库号段):
一次取 1000 个号在内存里发,不依赖时钟,也不依赖机器号。雪花算法真正划算的场景是高并发、
ID 还要能反映时间顺序、且希望完全无中心化依赖——这时候再认真处理上面这三个坑。