尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

雪花算法ID冲突排查:从主键重复到配置归零的分布式陷阱

雪花算法ID冲突排查:从主键重复到配置归零的分布式陷阱 先说结论雪花算法这玩意儿看起来就是位运算加几个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位符号位固定为041位毫秒时间戳相对于自定义的起始纪元5位数据中心IDdatacenterId5位工作节点IDworkerId12位序列号sequence同一毫秒内从0递增到4095。代码逻辑看起来无懈可击timestamp用当前系统时间减起始时间sequence每次自增并判断是否溢出溢出就等待下一毫秒。读第一遍的时候我甚至找不到bug在哪里。但当我打印出每个实例实际使用的datacenterId和workerId时整个人都不好了——四个实例全部是0和0。也就是说所有实例都在用同一套数据机房机器的标识。正常情况下四个实例应该需要不同的workerId来把ID空间区分开现在它们全都一样那么在同一个毫秒内只要两个实例各自生成的序列号恰好撞上ID就必然重复。2.2 复现实验本地双进程一跑真相立刻浮出水面光看日志还不够我直接在本地写了个复现脚本同一个JVM进程内分别用workerId0和datacenterId0模拟所有实例共享配置用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 AworkerId/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的时间戳是T1699999999123某个瞬间系统时钟被拨回到了1699999998000。如果实现里没有处理回拨直接用当前时间生成ID那么生成出的时间戳小于上次序列号又从0开始——这时候时间戳机器节点序列号三者完全可能和过去某个ID一致形成穿越式重复。处理方案一般有三种抛异常检测到回拨超过阈值比如5ms直接拒绝生成ID等待同步回拨幅度小就自旋等待直到系统时间追平上次记录借位补偿回拨期间人工模拟一个伪时间戳在回拨范围内填充序列号或使用备用扩展位。每种方案都有成本根据业务容忍度选择。但至少绝不能假装回拨没发生更不能不记录上次生成ID时的系统时间。3.4 Bug D时间单位搞错起始纪元设错ID直接变形有些语言里时间戳用的是秒而不是毫秒有些实现里把起始纪元直接设成了1970年还有些人把最大时间区间算错了。举个例子41位时间戳能表示的最大毫秒数大约是69年。如果起始纪元设置不当比如设成1970年那么到了2039年197069时间戳就会溢出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 xdatacenterId 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记录按workerIddatacenterId毫秒时间戳分组发现组内ID去重数小于总数时说明序列号出现复用立刻定位。有了这套监控我可以在故障发生前就知道某个节点的ID空间正在逼近极限而不是等到线上业务中断了才看到异常日志。监控不是事后诸葛是提前发现可能快不行了的信号。5. 请勿轻易造轮子的真正含义什么时候该用现成库什么时候才值得自己写5.1 现成库的成熟度对比别再拿自己的头发验证别人验证过的东西坦白讲雪花算法这类基础组件社区里已经打磨得非常成熟了。我列一下我实际用过或调研过的几个主流方案库/方案核心能力生产级亮点适用场景Twitter Snowflake原版雪花算法基准实现代码量少逻辑经典高并发分布式ID基础MyBatis-Plus IdWorkerJava生态里最常用集成简单基于原生雪花自动生成workerId中小团队快速落地Hutool Snowflake工具库集成可配置workerId/datacenterIdAPI友好工具型项目、非核心链路美团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怎么分配时钟回拨怎么办配置缺失了是报错还是默默跑想明白再动手别让程序员的自信成了生产事故的垫脚石。
返回列表