ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

分布式ID重复事故复盘:自研雪花算法为何会翻车?

分布式ID重复事故复盘:自研雪花算法为何会翻车? 凌晨两点半我正在工位上盯着屏幕上一行行滚动日志眼皮打架但脑子清醒得可怕。数据库唯一键冲突的报警已经断断续续持续了两个小时从最初的“偶发”变成了“频繁”。测试环境、预发环境凡是对外发布的接口几乎无一幸免。更要命的是所有冲突的字段都指向同一个字段ID。我扒开日志里的具体值看到了一批数字相近、但逻辑上完全不应该重复的ID。那一刻我脊背发凉——雪花算法生成的ID居然真的重复了。这个事故让我花了一整夜排查也让我彻底想明白了一件事ID生成这种基础组件真的不要轻易造轮子除非你已经把里面每一个坑都踩了一遍。这篇复盘我会把整个事故的前因后果、雪花算法的位段原理、自研实现常见的翻车点、以及我当时一步步定位和修复的过程完整记录下来。如果你也在用自己封装的雪花算法或者在纠结“网上这么多代码我抄一个改一改能用吗”这篇文章值得你花十分钟看完。1. 从一次深夜事故说起雪花算法ID为什么会重复1.1 事故现场第一波报警和错误初判那天晚上的第一波报警来自于一个内部管理系统的写入接口。调用方反馈批量导入数据时明明每次都是新数据但数据库反复抛出类似这样的异常### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException: ### Duplicate entry 7143282793184997376 for key orders.id ### The error occurred while handling a batch insert of 200 records. ### Cause: java.sql.SQLIntegrityConstraintViolationException: ### Duplicate entry 7143282793184997376 for key orders.id冲突的ID是7143282793184997376和它冲突的另一条记录ID是7143282793184997377。注意两个ID只差1但它们应该属于两条完全不同的订单。我当时第一反应是这会不会是数据库主从不同步或者哪段代码用了固定的ID再或者缓存穿透把同一个请求打到了接口上于是我先查了一圈确认没有显式传ID的代码、确认数据库没有触发器改写主键、确认缓存层没有命中同一个Key。什么都查了就是没怀疑到ID生成器身上——因为这是我之前从开源项目里“抄”过来、自认为已经理解透的雪花算法实现。1.2 矛盾点单实例测试却又复现更诡异的是我在本地单机起服务用并发工具疯狂压这个接口居然也能复现。这就奇怪了单机单进程、没有多实例部署为什么会重复后来我冷静下来把重复的ID放在一起看发现一个规律冲突的ID基本都集中在某个很小的区间内而且是毫秒级连续生成的。比如上面这两个ID时间戳和序列号看起来都很接近明显是同一毫秒内产生的。这时候我把目光重新投向了那段“雪花算法”代码开始逐行看实现结果发现里面有一个非常经典的错误序列号的循环使用条件写错了——在同一毫秒内序列号达到最大值后不是阻塞等待下一秒而是直接归零重新生成。这一下全对上了高并发时同一毫秒内超过4096个请求序列号绕了一圈回来和这一毫秒早先生成的ID完全撞车。好家伙这不是并发竞争这是算法实现本身的边界没处理好。2. 先吃透64位雪花算法结构拆解与设计意图在我给出完整的修复方案前我需要先带你把雪花算法这位“当事人”研究明白。很多人抄了代码但没理解位段设计所以踩坑都踩得稀里糊涂。2.1 64位布局每一段都是有用意的标准雪花算法Twitter Snowflake风格生成的ID是一个64位Long型整数通常这样分配位段长度含义符号位1 bit固定为0保证ID为正数时间戳41 bit毫秒级时间戳相对自定义纪元的偏移数据中心ID5 bitdatacenterId最多32个机器ID5 bitworkerId最多32个序列号12 bit同一毫秒内自增序列最多4096个这种分配方案的设计意图很直接时间戳保证ID大致有序数据中心和机器ID保证分布式环境下不同节点不冲突序列号保证单节点单毫秒内有足够的吞吐。三者合在一起64位正好容纳且大致有序、紧凑、高性能。2.2 关键位段的作用与容量边界我们逐段细看41位时间戳以自定义纪元epoch为起点计算毫秒偏移量。41位二进制能表示的最大值是2^41 - 1 2199023255551毫秒大约是69年。如果从Twitter最初的12888349746572010-11-04起算可以支撑到2080年左右但如果有人把epoch设置成一个很近的时间点甚至0那么时间戳位能覆盖的年份范围就会大幅缩短极端情况下可能在项目上线几年内就“爆掉”。我在事故当晚就排查过这个参数——好在我那版用的是正常epoch不然还得再申请一次灾。10位机器信息datacenterId workerId这10位决定了最多支持32 * 32 1024个节点。注意这是上限但部署时如果超过1024个实例ID就有概率碰撞。而且如果配置得不严谨比如两个实例拿到了相同的datacenterId和workerId那么就算当前只有两个节点也会直接撞车。12位序列号每毫秒最多生成4096个ID。如果在同一毫秒内要生成超过4096个就必须等下一毫秒。这三段中任何一段出问题都会导致ID重复或趋势错乱。但现实中的自研实现里几乎每一段都有对应的“坑位”下面我逐个说。3. 为什么会重复自研雪花算法最常见的几个翻车点3.1 时间回拨你永远猜不到系统时间会怎么跳雪花算法高度依赖系统时钟。如果机器的当前时间比上一次生成ID时的时间早那么新生成的时间戳就会变小这直接导致两件事一是新ID可能小于旧ID破坏有序性二是时间戳一旦回到之前用过的值再加上相同的机器ID和序列号就会产生和过去完全相同的ID。哪些情况会造成时间回拨实际生产里我遇到过的就有运维手动校准系统时间把时间从未来调回现在NTP客户端自动校时发现偏差过大进行跳跃式同步虚拟机或容器从快照恢复系统时间回退到快照时刻物理机电池没电、BIOS时间异常开机后时间倒退。成熟的实现一般会有回拨处理策略要么拒绝生成并抛出异常或阻塞等待要么记录最后的生成时间并在回拨时用备用方案。但我见到的不少自研雪花只是简单判断了一下当前时间戳是否小于上一次时间戳然后随便continue或return null。打日志一看错误根本没被正确处理甚至把错误吞掉了直接继续往下生成。3.2 workerId/datacenterId分配混乱多个实例共用一套机器标识这是这次事故里最让我无语的坑。我的那版“改写过”的代码里机器ID是这么算的workerId : int(ipSeg[3]) % 32 datacenterId : int(ipSeg[2]) % 32在单机测试时IP是127.0.0.1得到的workerId是1 % 32 1服务部署到测试环境后两台机器的IP后一段分别是0.10和0.10——没错它们处于同一个NAT网段解析到外层IP是一模一样的。于是两台服务实例用了完全相同的(datacenterId, workerId)。只要这两台机器在同一毫秒内各自生成了相同序列号的ID就必然重复。类似的问题在容器化部署中更容易发生K8s里多个Pod如果没有显式注入节点标识只靠hostname或IP取模很容易撞车。因为容器看到的IP可能是同一个NAT出口IPhostname也可能是随机的但截断后相同。正确做法是把workerId的分配交给一个可靠的注册中心如ZooKeeper、Redis、数据库自增或者在部署时通过环境变量强制配置。无论用哪种方式目标是保证“全局唯一”而不是“大概唯一”。3.3 序列号溢出与并发边界一毫秒并不总能扛住4096个ID序列号的作用就是解决同一毫秒内多个并发请求的ID唯一性。但它的容量是有限的超过4096就必须阻塞至下一毫秒。很多自研实现的问题在于没有加锁或没有用原子操作当多个线程同时进入方法时读到了同一个当前序列号。序列号达到4096后处理错误有些实现直接序列号重置成0而不是等到下一毫秒更有甚者把序列号最大值判断写错导致在溢出边界反复生成冲突ID。我事故里的那版代码就是典型的第二种错误——错误处理时的逻辑让序列号直接归0并且不等待下一毫秒。在批量导入场景下200条数据的插入在同一毫秒内很容易超过4096的上限于是序列号立刻“回头”与这一毫秒稍早产生的ID撞车。3.4 业务量预估错误你以为的极限不是极限有人说“我们的接口峰值也就每秒几千每毫秒怎么可能超过4096”——那你低估了批量写入、重试、定时任务叠加的威力。比如批量导入一个批就有几百上千条内部循环到ID生成器的时候不只是每秒N次请求而是每次请求内循环N次。毫秒级局部流量完全可能冲破4096。我当时属于第三种和第二种叠加序列号溢出逻辑错误 批量导入高频调用两个条件凑在一起直接变成稳定复现的线上BUG。我把自研实现里常见的错误和成熟方案的处理方式放在一起对比一下自研常见错误后果成熟方案通常怎么处理时间回拨不处理或吞异常生成过期时间戳的ID可能冲突/乱序屏蔽并缓存未来时间/等待/拒绝服务用IP或hostname取模分配workerId多实例复用同一机器位直接冲突注册中心/配置中心统一分配数据库自增预留序列号溢出时归零继续同一毫秒内ID冲撞自旋等待至下一毫秒必要时让位没有互斥锁或原子类并发下多个线程取出相同序列号CAS、synchronized、atomic类保证原子性epoch设置错误或符号位误用ID范围缩短、可能出现负数严格校验符号位规范epoch基准不监测时钟偏差长时间漂移后在调校时踩雷定期上报当前时间/时钟偏差异常预警4. ID重复定位过程从报错到逐位拆解实操记录如果你也遇到了ID重复但不确定原因我建议严格按照下面这套流程来排查非常有效。这套方法帮我从“什么都像原因”到“锁定唯一元凶”。4.1 抓取冲突样本先把证据留下来当数据库抛出唯一键冲突时异常信息里通常会带上冲突的ID值。比如MySQL的Duplicate entry xxx for key PRIMARY那个xxx就是ID。但线上日志刷得极快异常转瞬即逝建议应用层额外做一件事catch住唯一键冲突异常把异常里的ID、时间、业务参数、堆栈原样记录到专门的日志文件或日志表。我当时是写了一个简单的AOP切面在所有DAO层插入方法上捕获DuplicateKeyException把错误线程栈和参数打成WARN日志这样能拿到足够多的冲突样本。4.2 二进制位拆解“三步法”把ID的每一段剥出来当你拿到疑似重复的两个ID后不要盯着大数字看用程序把它们拆成二进制再按64位布局切分。下面是我事故后写的一个小脚本Python示例你也可以直接用IDE的进制查看功能def parse_snowflake_id(snow_id, epoch_ms1288834974657): # 假设标准布局1位符号 41位时间戳 5位数据中心 5位机器 12位序列 binary f{snow_id:064b} sign binary[0] timestamp_bin binary[1:42] datacenter_bin binary[42:47] worker_bin binary[47:52] sequence_bin binary[52:64] timestamp_ms int(timestamp_bin, 2) epoch_ms import datetime dt datetime.datetime.fromtimestamp(timestamp_ms / 1000) return { binary: binary, sign: sign, timestamp_bin: timestamp_bin, datacenter_id: int(datacenter_bin, 2), worker_id: int(worker_bin, 2), sequence: int(sequence_bin, 2), datetime: dt.strftime(%Y-%m-%d %H:%M:%S.%f)[:-3], timestamp_ms: timestamp_ms } # 使用示例 id_a 7143282793184997376 id_b 7143282793184997377 print(parse_snowflake_id(id_a)) print(parse_snowflake_id(id_b))输出结果类似这样{binary: ..., sign: 0, datacenter_id: 1, worker_id: 1, sequence: 0, datetime: 2024-...} {binary: ..., sign: 0, datacenter_id: 1, worker_id: 1, sequence: 1, datetime: 2024-...}看到没两个ID的datacenter_id和worker_id都是(1, 1)时间戳完全相同序列号分别是0和1。这立刻说明了两个事实第一这两个ID确实在同一毫秒内产生第二产生它们的“机器标识”是一样的。再加上当时服务是多实例部署答案几乎呼之欲出——两个实例用了一样的(datacenterId, workerId)而且序列号从0开始于是各自生成了同一毫秒编号为0、1的ID互相撞了。4.3 时间戳反查把ID还原成具体时刻如果你想知道“这个ID到底是在哪个精确时间生成的”计算公式很简单timestamp_ms (snow_id 22) epoch_ms也就是把二进制时间戳位段转成十进制再加上你实现里定义的epoch偏移量。这个推算在事故复盘时很有用你可以把重复ID对应的时间和应用日志、NTP校时记录、部署记录做交叉对比判断到底是时间回拨、机器位冲突还是序列号溢出。当时我用这条公式反推出所有冲突ID都集中在某个批量导入任务的执行时间内进一步印证了“批量高频调用 错误边界逻辑”的结论。5. 我不是不让你造轮子但请按工程标准造5.1 成熟方案与自研的边界在哪很多人一看到“请勿轻易造轮子”就理解成“永远不要自己实现”。其实不是。我私以为造轮子最大的问题不是写不出可运行的代码而是写不出扛得住生产环境的代码。成熟方案经历过大流量考验踩过无数边界比如标准雪花算法Twitter Snowflake最原始的参考实现但需要自己处理时钟回拨和workerId分配美团Leaf腾讯、美团等大厂实践沉淀支持号段模式与雪花模式提供HTTP服务对外发号并且有完善的回拨处理如用ZooKeeper持久化、缓存上次时间等百度UidGenerator基于Snowflake的变体使用workId占用更多位并借助数据库为每个Worker分配唯一ID滴滴Tinyid更偏号段模式适合对性能要求较高的发号场景sonyflake用时间区间代替毫秒时间戳在流量较小的场景更省位。它们在关键边界上的处理明显比“临时抄一段代码”要严谨得多。下表是它们大致的差异方案时钟回拨处理workerId分配部署要求适用场景标准Snowflake由使用者额外处理需要自建分配机制单机/简单分布式中小规模需快速上手美团Leaf内置处理ZooKeeper保证注册中心下发依赖ZK大流量、高可用百度UidGenerator内置容忍策略数据库分配依赖MySQL对时间回拨敏感的金融类场景滴滴Tinyid不依赖时间基于数据库号段部署独立服务高性能发号、订单号等5.2 如果坚持自研必须满足的底线我知道总有人会继续选择自研我自己现在也会在非核心模块里造轮子练手所以我给出我事故后总结的“自研必备底线清单”必须有互斥或原子操作同一进程内序列号增长必须线程安全必须处理时间回拨当检测到当前时间小于最后一次生成时间时至少要等待到时钟追上或直接拒绝生成、抛出明确异常绝不能静默吞掉必须校验机器ID唯一性不能依赖IP取模这种“看起来随机”的方式建议通过Redis/数据库自增/配置中心启动时申请并在进程内部缓存必须处理序列号溢出达到4096后必须自旋等待下一秒必须有监控和告警别等到DB唯一键冲突才感知。监控每毫秒序列号使用率、时钟回拨次数、生成速率上限超过阈值立即告警必须留DuplicateKey兜底在使用雪花ID当主键的表上继续保留唯一索引即便算法有bug也不会在数据库层面产生脏数据。上面几点看起来简单但每一个都对应着一类真实的线上事故。如果你自研的实现不具备这些那就不是“简化版”而是“炸弹简化版”。下面是一个带基本防护的简化示例Go语言思路type Snowflake struct { mu sync.Mutex lastTs int64 dcId int64 workerId int64 sequence int64 } func (s *Snowflake) NextID() (int64, error) { s.mu.Lock() defer s.mu.Unlock() ts : time.Now().UnixMilli() if ts s.lastTs { // 时钟回拨等待追平而不是直接生成 diff : s.lastTs - ts if diff 5 { // 回拨超过5ms直接报错 return 0, fmt.Errorf(clock moved backwards: %d ms, diff) } time.Sleep(time.Duration(diff) * time.Millisecond) ts time.Now().UnixMilli() } if ts s.lastTs { s.sequence (s.sequence 1) 4095 if s.sequence 0 { // 序列号溢出等到下一毫秒 for ts s.lastTs { ts time.Now().UnixMilli() } } } else { s.sequence 0 } s.lastTs ts id : (ts 22) | (s.dcId 17) | (s.workerId 12) | s.sequence return id, nil }5.3 我最终是怎么收场的那次事故最终的修复方案我没有选择修复自研代码了事——虽然修复起来不难但我对这套实现已经彻底失去了信心。我直接把核心发号组件替换成了基于美团Leaf思路的成熟实现并且把workerId的分配改成了启动时从Redis申请一个全局唯一值。同时在数据库表上保留了唯一索引作为最后一道防线并把“重复ID率”“时钟回拨次数”“序列号使用率”三个监控指标接入了告警。结果非常明显替换之后再没有出现过ID冲突。而且因为监控到位后来有一次某台容器时钟漂移还没等影响业务告警就先打到了我手机上。6. 最后分享两个实操心得这个事故让我彻底改变了对待基础组件的态度。我现在看到任何“XX算法实现代码”的开源文章第一反应都是先看它怎么处理边界再看它怎么测试。你能在文章里把正常流程跑通不代表你处理得了回拨、溢出、多实例分配。心得一当你想自研一个基础组件时先列一个“我可能踩的坑”清单。如果你只能列出两三个坑说明你还没准备好千万别动手。真正靠谱的做法是把边界写进测试用例里比如在代码里注入一个假的时钟手动回拨一秒看看组件是拒绝服务还是默默生成重复ID。我当时如果提前做了这个测试后面那通宵就不用熬了。心得二ID生成器的监控比ID生成器本身更重要。一次ID重复如果没被唯一约束挡住可能直到数据变成脏数据、报表对不上、客户投诉才被发现。给ID生成器增加“生成量突刺”“时钟偏差”“重复率”三块监控这种投入的性价比极高。雪花算法本身是好东西我只是想说把好东西用坏往往是认知不到位。希望这篇复盘能帮你避开我踩过的那个坑也让你在下次看到一张雪花算法代码图时多留一个心眼。
返回列表