先说结论:雪花算法这玩意儿,看起来就是位运算加几个if判断,网上随便一搜就是一堆实现,但真正写对、写稳、能扛住大促还不出错的,远比想象中难。我这次栽的跟头,就是一个典型的"以为自己在造火箭,结果点火后才发现轮子没装上"的例子。
事情发生在一次大促的深夜,订单系统开始疯狂报警——不是慢查询,也不是连接池爆了,而是主键冲突。我刚接手这套系统不到三个月,连夜爬起来排查,最后定位到的根因让我特别尴尬:我们自己封装的一个雪花算法工具类,在多个实例上生成了完全相同的ID。这篇文章就当是复盘记录吧,讲讲雪花算法为什么会重复、我是怎么一步步排查看走眼、以及这次教训之后我对"造轮子"这件事的全新理解。如果你也在用或者打算自己写一个雪花算法,建议认真看完。
1. 故障现场与第一波误判:我先怀疑了数据库,浪费了整整两小时
先交代一下系统背景。我们的订单服务一共部署了四个实例,数据库是MySQL,订单表主键用的就是自研的"雪花ID"。平时流量不大,一天几十万单,这套工具类上线大半年,一直很安静。然而在大促当天晚上,报警群里突然涌进来大量错误日志,清一色都是:
Duplicate entry '1634285123000000001' for key 'PRIMARY'第一反应,这肯定是数据库层面的问题。我按照惯性思维开了个排查清单:
- 检查是否有人手工插入数据,导致主键碰撞?
- 检查主键索引是否异常、自增偏移是否有问题?
- 检查是不是我们最近做了数据迁移,导数据时ID对不上?
结果这三板斧下去,啥也没发现——没有人工导入,索引正常,自增没问题。当时真的有种"鬼打墙"的感觉,因为错误日志里那串ID,第一位是时间戳,对应的毫秒时间确实就是当晚大促的时刻,说明生成器的确是在正常输出ID。
更迷惑的是,报错的ID并不是连续出现的。如果算法本身坏了,应该是大批量、有规律地重复;但日志里是间歇性的,有时候一秒报几十条,有时候隔几分钟才来一条。这个特征让我一度怀疑是网络抖动或事务重试导致的"回放",也没往生成器本身想。
后来我把时间轴拉出来,对照订单创建时间逐条比对,发现了一个关键规律:所有冲突ID的生成时刻,都是集中在我们流量最高的那几个毫秒窗口里。比如23:00:01.123 这个毫秒内,系统生成了超过一个相同后缀的ID。看到这里,我才意识到问题不在数据库,而在应用层的ID生成链路上。
这就是我想说的第一个教训:排查故障时,永远先怀疑自己最近改过的东西和自我感觉良好的封装。数据库在被你检查之前大概率是清白的,反倒是那些"上线半年没问题"的自研代码,往往在某次发版后悄悄埋雷。
2. 完整排查链路:从日志、位运算到配置项,我终于抓到了真凶
2.1 第一轮代码走查:表面完美,深挖全是坑
定位到ID生成器之后,我立刻把负责的工具类拉出来逐行Review。先说一下我们当时的实现思路,非常经典的"标准雪花算法"风格:
- 1位符号位,固定为0;
- 41位毫秒时间戳,相对于自定义的起始纪元;
- 5位数据中心ID(datacenterId);
- 5位工作节点ID(workerId);
- 12位序列号(sequence),同一毫秒内从0递增到4095。
代码逻辑看起来无懈可击:timestamp用当前系统时间减起始时间,sequence每次自增并判断是否溢出,溢出就等待下一毫秒。读第一遍的时候我甚至找不到bug在哪里。
但当我打印出每个实例实际使用的datacenterId和workerId时,整个人都不好了——四个实例,全部是0和0。
也就是说,所有实例都在用同一套"数据机房+机器"的标识。正常情况下,四个实例应该需要不同的workerId来把ID空间区分开;现在它们全都一样,那么在同一个毫秒内,只要两个实例各自生成的序列号恰好撞上,ID就必然重复。
2.2 复现实验:本地双进程一跑,真相立刻浮出水面
光看日志还不够,我直接在本地写了个复现脚本:
- 同一个JVM进程内,分别用
workerId=0和datacenterId=0模拟所有实例共享配置; - 用
CountDownLatch模拟四个线程同时并发调nextId(); - 跑一分钟,统计重复率。
结果本地很快出现了大量重复ID。实验基本坐实了根因路径:
- 自研工具类里,
datacenterId和workerId是从Spring配置文件读取的; - 配置文件里,这两个值写的是
${snowflake.datacenterId:0}和${snowflake.workerId:0},意思就是"如果配置没传,默认给0"; - 大促前有新同事调整过配置中心,把这两个key改名了,线上一次全量发布后,每个实例都读不到配置,全部回落到了默认的0;
- 平时流量低,同一毫秒内两个实例共享序列号的概率极小,所以没暴露;大促流量一起来,碰撞概率指数级上升。
2.3 定位根因之后,再说说"默认值兜底"有多坑
这个bug的元凶不是雪花算法本身,而是我自己在设计"默认值兜底"时留下的后门。
当时写工具类的想法很简单,觉得"万一有人部署时没配置,直接给个0也能跑",怕启动报错影响业务。结果这个"友好"的兜底,直接让所有实例的机器标识归零,彻底破坏了雪花算法最重要的假设——每个节点的ID空间必须互相隔离。
雪花算法的核心,靠的就是datacenterId + workerId这一共10位来区分不同节点。如果所有节点都是同一个值,整个分布式唯一性模型就瞬间坍缩成"单机唯一",只要并发一上去就必炸。
这也是为什么很多成熟框架宁可启动失败,也不允许节点标识缺省。换句话说:对于分布式ID生成器来说,"宁可启动报错,也不能静默降级"。
3. 雪花算法为什么会"看起来简单,一写就错":五个高频Bug复盘
趁这次机会,我把过去一年在各个技术群里看到、以及自己踩过的雪花算法相关坑,系统性地盘了一遍。"简单"是对懂的人说的,对以为看懂了位运算就想上生产的同学,这五个Bug应该够喝一壶。
3.1 Bug A:workerId/datacenterId缺省导致节点共用同一ID空间
这个就是我踩的坑。表现形式是:线上多实例,但所有实例的workerId同时为0或某个固定值,导致ID空间完全没有被切分。触发原因包括:
- 配置项key改名、合并、迁移,导致读取不到值;
- 配置文件里同时写了
0作为默认值; - 多个环境共用一套配置模板,复制粘贴时忘了改节点编号;
- 容器化部署时没有通过环境变量动态注入节点编号。
防范措施很简单:节点标识不是"可选项",而是"必填项"。如果没有读到有效的datacenterId/workerId,直接抛出异常或启动失败,绝不允许默默落到默认值上。另外,启动时把实际生效的节点标识打成日志,部署后只要看一眼日志就能立刻发现所有节点是不是撞在一起了。
3.2 Bug B:同一毫秒内序列号溢出,碰撞概率被放大
标准雪花算法中,12位序列号在同一毫秒内最多只能生成4096个ID。如果QPS超过4096/ms,序列号就会溢出。经典的错误处理方案是"阻塞等待下一毫秒",但很多自研实现里,这块写得非常粗糙。
我见过最离谱的一种写法是:溢出之后不清零sequence,继续往上加。表面上看起来ID还是递增的,但位运算结果已经被污染了——原本高位的机器ID和低位的序列号分界线全乱了,最终生成出来的ID可能和另一个节点的时间戳+序列号完全一致。
另一种常见错误是:每个实例被重启后,sequence重置为0。如果是同一毫秒内重启再生成ID,就会和重启之前生成的ID重复。大型集群中,滚动发布时每台机器都在重启,这种碰撞概率不容小觑。
**正确的做法是:**序列号溢出必须等待新的毫秒到来后归零;重启后第一次生成ID,需要确保时间戳大于上次记录的时间戳,而不是直接"从0开始"。
3.3 Bug C:时钟回拨处理不当,时间戳倒退直接撞车
雪花算法的时间戳占41位,所以它强依赖系统时钟。NTP同步、物理机时钟漂移、手动改时间,都可能造成时间戳回退。
假设上次生成ID的时间戳是T=1699999999123,某个瞬间系统时钟被拨回到了1699999998000。如果实现里没有处理回拨,直接用当前时间生成ID,那么生成出的时间戳小于上次,序列号又从0开始——这时候,时间戳+机器节点+序列号三者完全可能和过去某个ID一致,形成"穿越式重复"。
处理方案一般有三种:
- 抛异常:检测到回拨超过阈值(比如5ms),直接拒绝生成ID;
- 等待同步:回拨幅度小就自旋等待,直到系统时间追平上次记录;
- 借位补偿:回拨期间人工模拟一个"伪时间戳",在回拨范围内填充序列号或使用备用扩展位。
每种方案都有成本,根据业务容忍度选择。但至少,绝不能假装回拨没发生,更不能不记录上次生成ID时的系统时间。
3.4 Bug D:时间单位搞错,起始纪元设错,ID直接"变形"
有些语言里时间戳用的是秒而不是毫秒,有些实现里把起始纪元直接设成了1970年,还有些人把最大时间区间算错了。
举个例子:41位时间戳能表示的最大毫秒数大约是69年。如果起始纪元设置不当,比如设成1970年,那么到了2039年(1970+69),时间戳就会溢出,41位直接变成负号位,生成的ID一秒钟从正数变负数。而且很多系统的ID字段定义为无符号BigInt,负数会被数据库直接拒收。
更隐蔽的错误是:记录起始纪元的人用的是"当前时间的一年",结果写成了System.currentTimeMillis(),导致整个ID空间少了整整一年的偏移,短期内看不出问题,但长时间运行后,时间戳很快就逼近上限。
正确做法是:起始纪元写死为一个固定日期(比如2020-01-01),换算成毫秒常量,严禁动态计算。上线后还要用脚本验证:生成的ID解析出来的时间戳,和当前系统时间误差应在几十毫秒内。
3.5 Bug E:跨语言实现偏移不一致,串联系统直接错乱
如果你在Java服务里生成ID,在Go或Python服务里解析ID,或者反过来——要特别小心不同语言里位运算的符号处理和数据类型不一致的问题。
最经典的翻车场景:Java的long是有符号的,如果第63位(符号位)被置1,打印出来就是个负数。而Go的int64支持负数,但有些语言的JSON解析器会把负数变成-9223372036854775808这种值,再传到前端就变成了科学计数法或直接溢出。
另一个典型问题是:字节序。Snowflake标准是大端位序,但个别库实现时用了小端位序,导致ID移位后时间戳和机器ID字段的顺序完全颠倒。这种问题不到两个系统真正对接时根本发现不了。
所以,如果公司内部有多语言栈,强烈建议抽出一个独立的ID生成服务,统一由它产出ID,其他业务系统只消费字符串,不做位运算解析。除非你能保证所有语言的理解完全一致,不然就别各自实现一遍。
4. 修正方案与经验沉淀:我重新设计了一套"安全落地"的Snowflake实现
4.1 核心代码结构:宁可启动失败,也不静默降级
这次事故之后,我从头到尾重新设计了一遍ID生成服务。我把核心代码简化后贴出来,需要说明的是,这段代码属于"可直接抄作业"的版本,但不是完整的生产实现——生产中还要加上监控、告警、CMDB配置同步等周边设施。
public class SnowflakeIdGenerator { private final long twepoch = 1577808000000L; // 2020-01-01 private final long workerIdBits = 5L; private final long datacenterIdBits = 5L; private final long maxWorkerId = ~(-1L << workerIdBits); // 31 private final long maxDatacenterId = ~(-1L << datacenterIdBits); // 31 private final long workerIdShift = 12L; private final long datacenterIdShift = 12L + workerIdBits; private final long timestampShift = 12L + workerIdBits + datacenterIdBits; private final long sequenceMask = ~(-1L << 12); // 4095 private final long workerId; private final long datacenterId; private volatile long lastTimestamp = -1L; private volatile long sequence = 0L; public SnowflakeIdGenerator(long workerId, long datacenterId) { // 这里是关键:节点ID不合法就直接拒绝启动,绝不兜底 if (workerId > maxWorkerId || workerId < 0) { throw new IllegalArgumentException("workerId 不合法,必须介于0和31之间,当前值: " + workerId); } if (datacenterId > maxDatacenterId || datacenterId < 0) { throw new IllegalArgumentException("datacenterId 不合法,必须介于0和31之间,当前值: " + datacenterId); } this.workerId = workerId; this.datacenterId = datacenterId; } public synchronized long nextId() { long timestamp = System.currentTimeMillis(); // 时钟回拨:直接抛异常,绝不用旧时间戳生成ID if (timestamp < lastTimestamp) { throw new IllegalStateException( String.format("时钟回拨拒绝生成ID,回拨了 %d ms", lastTimestamp - timestamp)); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & sequenceMask; if (sequence == 0) { // 同一毫秒内序列号用尽,自旋等到下一毫秒 timestamp = waitNextMillis(timestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - twepoch) << timestampShift) | (datacenterId << datacenterIdShift) | (workerId << workerIdShift) | sequence; } private long waitNextMillis(long lastTimestamp) { long current; do { current = System.currentTimeMillis(); } while (current <= lastTimestamp); return current; } }这段代码我重点改了三处:
- 构造时校验节点ID,非法直接抛异常,不给你"默默降级"的机会;
- 序列号溢出必须等待下一毫秒,不允许序列号无序增长;
- 时钟回拨直接抛异常,留给上层业务决定是重试还是熔断。
4.2 workerId 分配策略对比:写配置是最low的方案,动态分配才是正道
节点ID的分配是整个雪花算法落地中最容易被忽略的部分。我整理了几种常见方案的对比:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 静态配置(Spring配置文件/YAML) | 每个实例手工指定workerId | 实现最简单,直观 | 极易配错,配置中心改key就翻车(我踩的坑) |
| 数据库号段+分布式锁 | 用数据库行锁/乐观锁来抢占一段workerId | 方案可靠,无额外组件 | 高频率申请时有锁竞争和数据库连接开销 |
| Redis自增 | 启动时INCR一个计数器,返回的值作为workerId | 简单高效,天然唯一 | 依赖Redis可用性;宕机或重建时可能重复分配(需持久化) |
| Zookeeper/Nacos 临时节点 | 注册时顺序创建节点,节点名即workerId,销毁时自动释放 | 动态分配,回收复用,最不踩坑 | 需引入配置中心/注册中心,运维复杂度最高 |
我现在的做法是:把机器节点列表维护在Nacos配置里,应用启动时从配置中心读取自己的节点号,并且在启动日志中输出"本实例 workerId = x,datacenterId = y"。
如果你不想引入注册中心,那Redis自增方案也是个不错的选择:
INCR biz:snowflake:worker:order启动时执行一次,加个EXPIRE后仍能保证基本不重复。但要注意:如果Redis数据被清空或者执行了FLUSHALL,再上线的新实例会重用旧的workerId。所以要在ID解析监控中加一道校验,发现同一workerId下时间戳回退或序列号冲突立刻报警。
4.3 时钟回拨的三种处理策略分级
生产环境绝不能只有"抛异常"这一招,因为有些场景下业务无法接受直接失败。我按回拨幅度做了三级处理:
- 回拨小于10ms:自旋等待,直到系统时间追平上次生成ID的时间戳。这种抖动很常见,等待成本极低;
- 回拨在10~100ms之间:记录日志并走"借位策略",即用上次时间戳的"时间+1毫秒"作为伪时间戳,同时序列号从0开始。因为回拨幅度小,只要系统时间一经恢复,伪时间戳会被迅速追平,不会造成长期混乱;
- 回拨超过100ms:直接抛异常,返回错误给调用方,触发熔断和告警。这种量级的回拨多半是NTP大幅校准或人工改时,ID的绝对一致性已经无法保证,保命要紧。
别忘了配套一条监控:在下一个合法时间戳生成后,统计伪时间戳与真实系统时间的时间差,防止异常回拨成为常态化。
4.4 生成ID的监控与预警:故障不能光靠"主键冲突"来暴露
这次事故告诉我们,ID重复不能靠数据库主键冲突来当报警器,那已经是灾难现场了。我在日志埋点里增加了几个指标:
- 每次生成ID时打印
workerId、datacenterId、timestamp、sequence四个字段抽样日志; - 统计每个
workerId在单位时间内的ID生成总量,定时聚合并告警,防止某个节点ID空间耗尽(比如序列号频繁溢出); - 定期用位运算反向解析ID里的时间戳,对比服务器当前时间,偏差超过500ms直接报警;
- 引入一个"冲突检测任务",不停机扫描最近24小时的ID记录,按
workerId+datacenterId+毫秒时间戳分组,发现组内ID去重数小于总数时,说明序列号出现复用,立刻定位。
有了这套监控,我可以在故障发生前就知道某个节点的ID空间正在逼近极限,而不是等到线上业务中断了才看到异常日志。监控不是事后诸葛,是提前发现"可能快不行了"的信号。
5. "请勿轻易造轮子"的真正含义:什么时候该用现成库,什么时候才值得自己写
5.1 现成库的成熟度对比:别再拿自己的头发验证别人验证过的东西
坦白讲,雪花算法这类基础组件,社区里已经打磨得非常成熟了。我列一下我实际用过或调研过的几个主流方案:
| 库/方案 | 核心能力 | 生产级亮点 | 适用场景 |
|---|---|---|---|
| Twitter Snowflake(原版) | 雪花算法基准实现 | 代码量少,逻辑经典 | 高并发分布式ID基础 |
| MyBatis-Plus IdWorker | Java生态里最常用 | 集成简单,基于原生雪花,自动生成workerId | 中小团队快速落地 |
| Hutool Snowflake | 工具库集成 | 可配置workerId/datacenterId,API友好 | 工具型项目、非核心链路 |
| 美团Leaf | 雪花+号段双引擎 | 长期维护,文档丰富,支持时钟回拨 | 大规模分布式系统,需要高可用和降级策略 |
| 百度UidGenerator | 基于数据库+时间戳扩展 | 容忍时钟回拨,workerId自动分配 | 对时钟回拨敏感、强一致性要求的业务 |
对大多数业务团队来说,直接选一个现成的、长期在维护的库,比自己写要靠谱得多。这些库的作者都已经把边界情况想过了,哪些坑该避免、哪些参数该怎么调,社区里有大量实践文档可以查。
5.2 如果一定要自研,至少满足这几个条件
我不是反对造轮子,但我建议先做一次"必要性检测"。至少满足以下三个条件,才值得自研:
- 业务有定制需求:比如需要在ID里编码业务类型、需要控制起始纪元避开某个日期、需要ID可排序对应数据库分库分表;
- 团队有足够的位运算和并发基础:能理解
sequence溢出、lastTimestamp更新的并发可见性、时钟回拨的全部处理细节; - 有时间和人力做完整的压测与故障演练:不能只测"能用",还要测"时钟回拨时能用""高并发下能用""重启后能用"。
如果三个条件都满足,再开工不迟。否则,就老老实实用现成库,把省下来的精力投入到业务功能上。
5.3 如果你非要自己写,这个测试清单至少得过一遍
我给自己写了个"自研雪花算法上线前必测清单",每条都是我这次事故后总结出来的,你要自研的话建议直接抄走:
- [ ]单实例并发测试:单线程/多线程并发调用,生成1000万个ID,确认无重复、无负数、单调递增;
- [ ]多实例负载测试:模拟4个、8个、16个实例,每实例不同
workerId,压测各自生成ID后做全局去重; - [ ]同workerId多实例测试:故意让多实例共用同一
workerId,验证碰撞概率是否符合预期(应该必然有问题,用于压测告警); - [ ]时钟回拨测试:手动把系统时间调慢100ms/1s/10s,依次验证自旋等待、抛异常、借位策略是否正确触发;
- [ ]重启测试:同一实例快速重启100次,确认每次生成ID的起始时间戳和序列号都比上次大;
- [ ]跨语言解析测试:用Java生成一批ID,写个Python脚本解析,确认各个bit段与期望一致;
- [ ]配置缺失测试:删除配置文件中的
workerId/datacenterId,启动进程,确认会失败而不是静默降级; - [ ]间隙/连续性弱校验:不强制连续,但确认ID大小关系和实际业务时序基本一致。
这8条测试某种意义上比写代码本身重要。我当时要是老老实实跑一遍,就不会有这次事故了。
5.4 反思:运维层面的"缺省值"才是自研组件的隐形炸弹
这次踩坑让我意识到,大部分自研组件的bug不是出在算法核心,而是出在"为了用户体验好一点"做的各种兜底逻辑上。配置读不到就默认为0、获取中失败就降级、异常了吞掉继续跑——这些"友好"的妥协,放在业务代码里可能只是小毛病,放在基础组件里就是系统性灾难。
道理其实是通用的:凡是涉及全局唯一性、分布式一致性、幂等这类"一票否决"性质的逻辑,宁可失败,不可模糊。
最后再分享一个实际经验
事故之后,我每周都会做一次"ID健康度复盘":把生成日志里解析出的workerId、timestamp、sequence指标导出来,核对一下各节点ID空间的真实用量。这个习惯在后来一次版本升级中立刻派上了用场——新版本把datacenterId从5位扩展到了8位,差点又出现旧版ID空间不兼容的问题。提前发现了以后,我改用了个巧妙办法:新版本预留出一段独立的ID区间作为过渡,双版本并存一周后再切换,整个过程完全无感。
如果你现在正打算自己写一个雪花算法,或者还没想过"多实例配置会归零"这个问题,建议先停一下,把上面三个问题问清楚:我的节点ID怎么分配?时钟回拨怎么办?配置缺失了是报错还是默默跑?想明白再动手,别让程序员的自信成了生产事故的垫脚石。