先把问题摆出来
(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); private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_BITS);
private static final long WORKER_SHIFT = SEQUENCE_BITS; private static final long DATACENTER_SHIFT = SEQUENCE_BITS + WORKER_BITS; private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_BITS + DATACENTER_BITS; private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
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) { 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; } }
|
两个细节容易被忽略:
nextId() 必须 synchronized(或改用 CAS/AtomicLong),单例前提下才能保证序列号不被并发抢乱。
MAX_BACKWARD_MS 别设太大。设成 1000ms 意味着你愿意为了”不断服”接受一秒的不可控等待,
换个说法就是:你在赌这一秒内没有重启、没有别的节点发号。
二、workerId 冲突:配置写死 = 埋一颗定时炸弹
10 位机器号看起来是”1024 个节点随便用”,但绝大多数事故是这么来的:
1 2 3 4
| 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 已耗尽,请检查是否有僵尸实例占用"); }
@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 发现不了,得有可执行的验证:
- 时钟可注入:把
System.currentTimeMillis() 换成可注入的 Clock,单测里把时间往回拨 1ms、10ms,分别断言”等差等待”和”抛异常”。
- 并发去重压测:单机 4 线程各发 10 万个 ID,塞进
ConcurrentHashMap.newKeySet(),最后断言 size 等于总数。
- 主键唯一索引必须留着:别为了性能把唯一索引去掉。它是这类问题的最后一道兜底——没有它,重号会变成静默的数据覆盖。
小结
(1)41 位时间戳够用 69 年,但前提是时间单调;小幅回拨等待、大幅回拨拒绝、重启校验 lastTimestamp。
(2)workerId 不是”配一下就行”,要能被自动申请、自动续约、自动释放;容器环境尤其别写死。
(3)ID 出了 JVM 就是字符串,浏览器、MQ、Excel 三个出口都要检查。
如果业务峰值不超过每秒几千个 ID,其实更省心的选择是号段模式(Leaf-segment / 数据库号段):
一次取 1000 个号在内存里发,不依赖时钟,也不依赖机器号。雪花算法真正划算的场景是高并发、
ID 还要能反映时间顺序、且希望完全无中心化依赖——这时候再认真处理上面这三个坑。