老炮踩坑录 · F14 · 翻车现场系列
· 基于「企业融合评估平台」真实源码
· 完整复盘雪花算法 workerId 撞车事故
· 关键词:ID 重复 · workerId 撞车 · 唯一性崩塌 · 动态分配
引子:上篇里我说了句"迟早有人忘记改",结果真发生了
上一篇拆解雪花算法源码的时候,我指着 application.yml 里这行配置说了一句话:
"凡是靠人记着改的配置,迟早有人忘记改。"
当时我觉得这就是个"理论风险"——毕竟雪花算法的 workerId 是 5 位,最多 32 个值,项目里就一两台机器,撞上的概率能有多大?
结果真撞了。
而且撞的方式比我想象的更隐蔽:不是两台机器同时启动,而是一台机器重启之后,ID 开始和另一台机器重复。结果我排查了整整两天,最后发现原因简单到让人想笑——运维拷配置文件的时候,忘了改 workId。
今天这篇,我把这个坑的前因后果完整讲一遍:怎么发现的、怎么排查的、怎么修改的。
事故现场:数据库里出现了重复的主键
现象
某天的早上,运维在群里丢了一条消息:
"线上有个表报
DuplicateKeyException,主键冲突了。"
我的第一反应是:不可能。
这个项目用的雪花算法生成 ID,64 位 long,理论上全局唯一、趋势递增。主键冲突?除非数据库自增主键和雪花 ID 混用了。
但是查了一下,这张表的主键确实是雪花算法生成的 long 型 ID。
确认问题
于是我连上数据库,跑了一条 SQL语句:
SELECT id, COUNT(*) as cnt
FROM apply_info
GROUP BY id
HAVING cnt > 1;
结果出来了——真的有重复 ID,而且不止一条:
+------------------+-----+---------------------+
| id | cnt | first_seen |
+------------------+-----+---------------------+
| 1198377740902400 | 2 | 2022-07-15 09:23:11 |
| 1198401256783872 | 2 | 2022-07-15 15:47:33 |
| 1198523904521216 | 2 | 2022-04-16 08:12:05 |
| 1198687432196096 | 2 | 2022-03-17 14:38:22 |
| 1199012847632384 | 2 | 2022-01-19 10:05:47 |
+------------------+-----+---------------------+
5 rows in set (0.03 sec)
更诡异的是,这些重复 ID 不是连续的,而是分散在不同时间段。如果是时钟回拨导致的重复,ID 应该是集中在某个时间段的。但这次的重复 ID 时间跨度很大,说明两台机器在持续地生成相同的 ID。
排查过程:从怀疑到确认
第一步:排除时钟回拨
上篇里讲过,雪花算法有一个时钟回拨检测:
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
如果时钟回拨,直接抛异常,不会生成 ID。所以时钟回拨不会导致重复 ID,只会导致服务不可用。
因此排除这种情况。
第二步:检查 workerId 配置
雪花算法的 "全局唯一性" 保证,是建立在每台机器的 workerId 不同这个前提之上的。
如果两台机器的 workerId 相同,那么它们在同一毫秒内、用相同的序列号,就会生成完全一样的 ID。
因此去查两台机器的 application.yml:
机器 A:
pubframe:
id:
workId: 10
centerId: 20
机器 B:
pubframe:
id:
workId: 10 # 和机器 A 一样!
centerId: 20 # 也一样!
找到问题了。
第三步:还原事故现场
于是问了下做运维的同事,把整个过程还原下:
- 项目最初只有一台机器(机器 A),
workId: 10 - 后来要扩容,运维直接拷了机器 A 的配置文件到机器 B
- 还改了数据库地址、改了端口,但就是忘了改 workId了
- 机器 B 上线后,两台机器用相同的
workId=10, centerId=20运行 - 同一毫秒内,两台机器各自从
sequence=0开始递增,生成的 ID 完全一样
用上一篇文章里的公式推算一下:
机器 A 在 T 毫秒生成:((T - twepoch) << 22) | (20 << 17) | (10 << 12) | seq
机器 B 在 T 毫秒生成:((T - twepoch) << 22) | (20 << 17) | (10 << 12) | seq
↑ 完全一样
workerId 和 centerId 都相同,时间戳相同,序列号从 0 开始递增——生成的 ID 100% 重复。
代码级推演:为什么一定会重复?
最终生成 ID 的就 nextId() 里这一行:
return ((timestamp - twepoch) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
4 个输入变量:timestamp、datacenterId、workerId、sequence。只要这 4 个值相同,输出一定相同——这是纯数学计算,没有任何随机性。
假设两台机器都是 workerId=10, datacenterId=20,在 T = 1705276800000 这个毫秒同时收到请求。
初始状态:
| 变量 | 机器 A | 机器 B |
|---|---|---|
sequence | 0 | 0 |
lastTimestamp | -1 | -1 |
workerId | 10 | 10 |
datacenterId | 20 | 20 |
sequence和lastTimestamp是实例变量,每台机器各自维护,互不影响。
执行流程对比:
| 步骤 | 代码 | 机器 A | 机器 B | 相同? |
|---|---|---|---|---|
| ① 获取时间戳 | long timestamp = timeGen() | T | T | ✅ |
| 时钟回拨检查 | timestamp < lastTimestamp → T < -1? | 否 | 否 | ✅ |
| ③ 同毫秒判断 | lastTimestamp == timestamp → -1 == T? | 否 | 否 | ✅ |
| ④ 走 else 分支 | sequence = 0L | 0 | 0 | ✅ |
| ⑤ 记录时间戳 | lastTimestamp = timestamp | T | T | ✅ |
| ⑥ 拼装 ID | 公式计算 | 见下方 | 见下方 | ✅ |
每一步的输入和分支走向都完全一样,所以输出必然一样。
代入公式:
机器 A:
(T - 1420041600000) << 22 = 285235200000 << 22 = 1198377738240000
(20 << 17) = 2621440
(10 << 12) = 40960
sequence = 0 = 0
─────────────────────────────────────────────
按位或拼装 = 1198377740902400
机器 B:
输入完全一样 → 结果完全一样 = 1198377740902400 ← 重复!
为什么序列号不会"错开"?
你可能会想:机器 A 先拿到 sequence=0,机器 B 后拿到 sequence=1,不就错开了吗?
不会。 因为 sequence 只在同一毫秒内多次调用时才递增:
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask; // 同一毫秒才 +1
} else {
sequence = 0L; // 新毫秒,重置为 0
}
两台机器都是"新毫秒的第一次调用",lastTimestamp(-1) != T,都走 else 分支,sequence 都被重置为 0。
| 调用次序 | 机器 A 的 sequence | 机器 B 的 sequence | ID 是否重复? |
|---|---|---|---|
| A 第 1 次 vs B 第 1 次 | 0 | 0 | 重复 |
| A 第 2 次 vs B 第 1 次 | 1 | 0 | 不重复 |
| A 第 2 次 vs B 第 2 次 | 1 | 1 | 重复 |
只要两台机器在同一个毫秒内的"第 N 次调用"对上,ID 就重复。请求量越大,撞上的概率越高。
一句话总结:
雪花算法的 ID = f(timestamp, datacenterId, workerId, sequence),是一个纯确定性函数。两台机器 workerId 和 datacenterId 相同,同一毫秒内 sequence 都从 0 开始递增——相同的输入必然产生相同的输出,没有任何随机因子可以"错开。
为什么这个问题这么隐蔽?
不是每次都会重复
雪花算法的序列号是每毫秒重置的。两台机器只有在同一毫秒内、序列号也相同时才会生成重复 ID。
如果两台机器的请求量不大,同一毫秒内都在生成 ID 的概率其实不高。这就是为什么:
- 机器 B 上线后,不是立刻出现重复 ID
- 重复 ID 是分散在不同时间段的
- 问题潜伏了很久才被发现
构造函数只校验了范围,没校验唯一性
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(
String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
// ...
}
构造函数只检查了 workerId 是否在 0~31 范围内,没有检查是否和其他机器重复。
这就像你考驾照,只检查了年龄是否够 18 岁,没检查是不是已经有人用了同一个驾照号。
怎么改?
临时方案:手动改配置
最直接的办法:把机器 B 的 workId 改成 11。
# 机器 B 的 application.yml
pubframe:
id:
workId: 11 # 改成 11,和机器 A 的 10 不同
centerId: 20
改完重启,重复 ID 的问题立刻消失。
但这个方案治标不治本——下次再加一台机器,运维可能又忘了改。
根本方案:动态分配 workerId
凡是靠人记着改的配置,迟早有人忘记改。 正确的做法是让 workerId 自动分配。
方案一:ZooKeeper 临时节点
@Bean
public SnowflakeIdGenerator snowflakeIdWorker(ZooKeeper zk) throws Exception {
// 在 ZK 上创建一个临时顺序节点
String path = zk.create("/snowflake/worker-",
new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
// 从节点名里提取序号作为 workerId
String seqStr = path.substring(path.lastIndexOf("-") + 1);
long workerId = Long.parseLong(seqStr) % 32; // 取模保证在 0~31 范围内
log.info("Auto-assigned workerId: {} from ZK path: {}", workerId, path);
return new SnowflakeIdGenerator(workerId, 0);
}
原理:每个实例启动时,在 ZK 上注册一个临时节点。ZK 保证节点名全局唯一,从中提取序号作为 workerId。实例宕机后临时节点自动删除,workerId 释放。
优势:
- 全自动,不需要人改配置
- 实例重启后自动重新分配
- ZK 保证全局唯一
方案二:基于 IP 地址计算
@Bean
public SnowflakeIdGenerator snowflakeIdGenerator() throws UnknownHostException {
InetAddress addr = InetAddress.getLocalHost();
byte[] ip = addr.getAddress();
// 取 IP 地址的第三段和第四段,各取低 5 位
long workerId = ip[2] & 0x1F; // 0~31
long datacenterId = ip[3] & 0x1F; // 0~31
log.info("Auto-assigned workerId: {}, datacenterId: {} from IP: {}",
workerId, datacenterId, addr.getHostAddress());
return new SnowflakeIdGenerator(workerId, datacenterId);
}
原理:不同机器的 IP 地址不同,从 IP 地址里提取 workerId 和 datacenterId。
优势:
- 不需要额外的基础设施(ZK/Redis)
- IP 地址天然唯一
- 实现简单
风险:
- 如果 IP 地址的第三段和第四段的高位相同,低 5 位可能撞车
- 容器环境下 IP 可能复用
方案三:Redis 自增
@Bean
public SnowflakeIdGenerator snowflakeIdWorker(RedisTemplate<String, Object> redis) {
// 用 Redis 的 INCR 命令获取全局唯一的 workerId
long workerId = redis.opsForValue().increment("snowflake:workerId:counter") % 32;
// 用 Redis SETNX 做幂等,防止重启后重复分配
String key = "snowflake:workerId:" + workerId;
Boolean acquired = redis.opsForValue().setIfAbsent(key, "locked", 1, TimeUnit.HOURS);
if (!acquired) {
throw new IllegalStateException("workerId " + workerId + " already in use");
}
log.info("Auto-assigned workerId: {} from Redis", workerId);
return new SnowflakeIdGenerator(workerId, 0);
}
原理:用 Redis 的 INCR 命令获取全局递增的序号,取模后作为 workerId。用 SETNX 做幂等校验,防止重复分配。
这个坑给我的教训
配置即代码,但配置不是人脑
workId: 10 写在 yml 里,看起来是个配置,但它本质上是分布式系统的身份标识。
身份标识不应该由人来分配,应该由系统自动分配。就像你不会让人工给每台服务器手动分配 MAC 地址一样。
唯一性需要系统级保证
雪花算法的"全局唯一性"是一个系统级承诺,不能只靠算法本身保证。
- 算法层面:位结构设计保证理论上不重复
- 配置层面:workerId 分配保证实际上不重复
- 监控层面:启动时校验 + 运行时告警保证出了问题能发现
这个项目只做到了第一层,第二层和第三层都是空的。
生产环境 Checklist
部署雪花算法时,逐项检查:
| 检查项 | 本项目状态 | 正确做法 |
|---|---|---|
| workerId 是否全局唯一 | ❌ yml 写死 | ZK/Redis 自动分配 |
| 启动时是否校验 workerId 冲突 | ❌ 无 | SETNX / 临时节点 |
| 是否有 workerId 监控告警 | 无 | 启动日志 + Prometheus |
| 数据库主键是否有唯一索引兜底 | ✅ 有 | 保持,最后一道防线 |
最后一道防线:即使生成端真的出了重复,数据库表上的唯一索引仍然能挡住它。重复插入会报错而不是静默覆盖,把损失控制在"报错重试"而不是"数据错乱"。
老炮点评
这个坑的本质不是技术问题,是工程习惯问题。
- 拷配置文件忘记改 → 习惯问题
- 没有启动校验 → 设计问题
- 没有监控告警 → 运维问题
技术上的解决方案很简单:动态分配 workerId。但真正要解决的是 "靠人记着的事,迟早会忘" 这个工程铁律。
凡是靠人记着改的配置,迟早有人忘记改。 这句话我已经说了两遍,我认为值得说第三遍。
下期预告:《AI 三分钟重构1600行Controller,方案看起来很专业,但我一条都没敢用》
在第二篇中把项目跑起来了,我第一件事就是把那个1600行的Controller扔给了AI,让它出重构方案。 仅仅三分钟,它列出了6条建议,看起来头头是道。其中一条建议,一看就知道如果真按它说的做,生产环境非出大事不可。
下期讲人机对决的完整过程。AI能重构老项目吗?我的答案可能和你想的不一样。
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。