Java 雪花算法 ID 重复排查:workerId 配错,两台机器生成了一样的 ID(附源码)
老炮踩坑录· F14 · 翻车现场系列·基于「企业融合评估平台」真实源码·完整复盘雪花算法 workerId 撞车事故·关键词ID 重复 · workerId 撞车 · 唯一性崩塌 · 动态分配·上一篇雪花算法看起来简单但41位时间戳里藏了三个坑我全踩了一遍 欢迎阅读个人主页知守观我的专栏老炮踩坑录当前内容Java 雪花算法 ID 重复排查文章目录引子上篇里我说了句迟早有人忘记改结果真发生了事故现场数据库里出现了重复的主键现象确认问题排查过程从怀疑到确认第一步排除时钟回拨第二步检查 workerId 配置第三步还原事故现场代码级推演为什么一定会重复为什么这个问题这么隐蔽不是每次都会重复构造函数只校验了范围没校验唯一性怎么改临时方案手动改配置根本方案动态分配 workerId方案一ZooKeeper 临时节点方案二基于 IP 地址计算方案三Redis 自增这个坑给我的教训配置即代码但配置不是人脑唯一性需要系统级保证生产环境 Checklist老炮点评引子上篇里我说了句迟早有人忘记改结果真发生了上一篇拆解雪花算法源码的时候我指着application.yml里这行配置说了一句话“凡是靠人记着改的配置迟早有人忘记改。”当时我觉得这就是个理论风险——毕竟雪花算法的 workerId 是 5 位最多 32 个值项目里就一两台机器撞上的概率能有多大结果真撞了。而且撞的方式比我想象的更隐蔽不是两台机器同时启动而是一台机器重启之后ID 开始和另一台机器重复。结果我排查了整整两天最后发现原因简单到让人想笑——运维拷配置文件的时候忘了改 workId。今天这篇我把这个坑的前因后果完整讲一遍怎么发现的、怎么排查的、怎么修改的。事故现场数据库里出现了重复的主键现象某天的早上运维在群里丢了一条消息“线上有个表报DuplicateKeyException主键冲突了。”我的第一反应是不可能。这个项目用的雪花算法生成 ID64 位 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机器 Apubframe: id: workId: 10 centerId: 20机器 Bpubframe: id: workId: 10 # 和机器 A 一样 centerId: 20 # 也一样找到问题了。第三步还原事故现场于是问了下做运维的同事把整个过程还原下项目最初只有一台机器机器 AworkId: 10后来要扩容运维直接拷了机器 A 的配置文件到机器 B还改了数据库地址、改了端口但就是忘了改 workId了机器 B 上线后两台机器用相同的workId10, centerId20运行同一毫秒内两台机器各自从sequence0开始递增生成的 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 个值相同输出一定相同——这是纯数学计算没有任何随机性。假设两台机器都是workerId10, datacenterId20在T 1705276800000这个毫秒同时收到请求。初始状态变量机器 A机器 Bsequence00lastTimestamp-1-1workerId1010datacenterId2020sequence和lastTimestamp是实例变量每台机器各自维护互不影响。执行流程对比步骤代码机器 A机器 B相同① 获取时间戳long timestamp timeGen()TT✅时钟回拨检查timestamp lastTimestamp→T -1否否✅③ 同毫秒判断lastTimestamp timestamp→-1 T否否✅④ 走 else 分支sequence 0L00✅⑤ 记录时间戳lastTimestamp timestampTT✅⑥ 拼装 ID公式计算见下方见下方✅每一步的输入和分支走向都完全一样所以输出必然一样。代入公式机器 A (T - 1420041600000) 22 285235200000 22 1198377738240000 (20 17) 2621440 (10 12) 40960 sequence 0 0 ───────────────────────────────────────────── 按位或拼装 1198377740902400 机器 B 输入完全一样 → 结果完全一样 1198377740902400 ← 重复为什么序列号不会错开你可能会想机器 A 先拿到 sequence0机器 B 后拿到 sequence1不就错开了吗不会。因为sequence只在同一毫秒内多次调用时才递增if (lastTimestamp timestamp) { sequence (sequence 1) sequenceMask; // 同一毫秒才 1 } else { sequence 0L; // 新毫秒重置为 0 }两台机器都是新毫秒的第一次调用lastTimestamp(-1) ! T都走 else 分支sequence都被重置为 0。调用次序机器 A 的 sequence机器 B 的 sequenceID 是否重复A 第 1 次 vs B 第 1 次00重复A 第 2 次 vs B 第 1 次10不重复A 第 2 次 vs B 第 2 次11重复只要两台机器在同一个毫秒内的第 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 cant 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/RedisIP 地址天然唯一实现简单风险如果 IP 地址的第三段和第四段的高位相同低 5 位可能撞车容器环境下 IP 可能复用有个细节我没完全确认基于 IP 地址计算 workerId 这个方案在 K8s 容器化环境里到底稳不稳Pod 重启后 IP 可能会变如果新 IP 刚好和旧机器的 IP 算出来的 workerId 撞了会不会引发新的连环撞车我本地测过物理机容器环境没试过有踩过坑的兄弟评论区指点一下。方案三Redis 自增Bean public SnowflakeIdGenerator snowflakeIdWorker(RedisTemplateString, 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 或 workId看看是写死的还是动态获取的打在评论区我看看谁的分配方式最“硬核”。老炮点评这个坑的本质不是技术问题是工程习惯问题。拷配置文件忘记改 → 习惯问题没有启动校验 → 设计问题没有监控告警 → 运维问题技术上的解决方案很简单动态分配 workerId。但真正要解决的是“靠人记着的事迟早会忘”这个工程铁律。凡是靠人记着改的配置迟早有人忘记改。这句话我已经说了两遍我认为值得说第三遍。最后问一句你们项目里的雪花算法 workerId 是怎么分配的是手动在 yml 里写死还是用了 IP、ZK、Redis 自动分配评论区聊聊我看看有多少人还在靠“人脑”管配置。下期预告《AI 三分钟重构1600行Controller方案看起来很专业但我一条都没敢用》在第二篇中把项目跑起来了我第一件事就是把那个1600行的Controller扔给了AI让它出重构方案。仅仅三分钟它列出了6条建议看起来头头是道。其中一条建议一看就知道如果真按它说的做生产环境非出大事不可。下期讲人机对决的完整过程。AI能重构老项目吗我的答案可能和你想的不一样。我是老炮18 年 Java 老兵仍在一线。关注「Java老炮踩坑录」不错过每一篇真实案例少踩坑。