上周四晚上十一点,一通电话把我从床上拽了起来:线上订单表里突然出现三千多个连续空洞,订单号从 A-129032 直接跳到 A-132450。第一反应是发号服务出了问题,登录日志查了一圈,SequenceGenerator 本身没有任何异常,没有慢 SQL,没有锁冲突,每个号都是按照规则发出去的。真正的问题出在“发出去的号”上——那个时段有大批事务回滚,发出去的号码随着回滚一起变成了没人认领的废弃序列号。
这个问题的可怕之处在于:它不是 bug,而是高并发交易系统的常态。事务回滚率只要超过 1%,像我们这种日均发号量 2000 万的系统,每天都会产生几十万个无人处理的废弃序列号。最开始我们觉得“跳号就跳号嘛,数据库自增本来也不连续”,直到审计、对账、以及下游系统的资源上限一起找上门,才意识到必须设计一套废弃序列号异步补偿机制。
这篇文章会从一个真实的 SequenceGenerator 实现出发,讲清楚废弃序列号是怎么产生的、为什么不能同步回收、以及我最终落地的异步补偿方案从状态机设计、表结构、核心代码到压测调优的完整细节。如果你正在做发号服务、资源 ID 生成,或者对序列连续性有要求,这篇应该能帮你省掉不少弯路。
1. 一次跳号事故,把我逼到了异步补偿这条路
1.1 那天夜里的告警:订单号突然跳了三千
先说那个晚上具体发生了什么。我们的订单号走的是号段模式:SequenceGenerator 从数据库批量取号,比如一次取 2000 个号码到本地内存,由一个 AtomicLong 慢慢发。这个模式本身没问题,问题出在发出去的号码被业务事务丢弃。
那晚有个活动上线,用户在下单时频繁触发风控拦截,事务在执行到一半时回滚,每次回滚都会让已经取出来的一个或多个订单号作废。之前这种量级不大,大家没在意。但活动流量一冲上来,回滚率从平时的 0.3% 涨到 6%,三千多个号码被“分配后废弃”。对账系统第二天早上跑批时会发现单个商户的订单号段出现大量空洞,审计那边直接报了异常。
听起来像是小事,但你要知道,一旦用了号段模式,已发出的号码不会自动回到数据库。它是从号段区间里划走的,本地缓存用掉之后,剩下的号段继续往后取。废弃的号既不会重新发给别人,也不会在数据库里做任何标记,它就那样凭空消失了。
1.2 排查结论:不是 bug,是“废弃序列号”没人管
排查过程比想象中曲折。最开始我怀疑是并发发号时 AtomicLong 被重置了,或者是号段取号时出现了死锁,翻了两小时代码都没找到逻辑错误。最后是拉了下游系统的调用日志,发现一批订单号在业务库里根本没有对应的业务记录,才反应过来:号发出去了,业务没成功,号就这样被丢掉了。
这个结论比发现 bug 更让人后背发凉。如果是代码逻辑错了,改掉就好;但“事务回滚导致序列号作废”是业务常态,是永远修不完的。只要发号和解耦业务事务继续存在,废弃序列号就会持续产生。
所以问题的核心变成:怎么把已经废弃的序列号安全地收回来,再重新利用,而且不能影响正在并发发号的正常链路。
当时我们把问题拆成了三个子问题:
- 如何知道一个号是否被废弃?靠业务主动上报,还是靠状态探测?
- 回收回来的号什么时候可以重新分发?会不会和正在使用中的号撞车?
- 回收过程放在同步链路里做,还是异步做?并发和性能怎么保证?
这也是这篇文章后面所有设计要回答的三件事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 废弃序列号的三个来源与“不可回收”假象
2.1 事务回滚:最常见,也很容易漏
在订单、支付、库存这类系统里,发号通常是业务事务的一部分:先调 SequenceGenerator 拿到一个订单号,然后写订单表、扣库存、调用下游。任何一个环节失败,整个业务事务回滚,订单号就不再属于任何业务记录。
这种场景里,废弃序列号的特点是“量大、突发、容易漏报”。量大是因为大促活动或风控拦截时回滚率会成倍上涨;突发是因为回滚往往集中在某个时间段;容易漏报是因为业务开发经常忘记在事务回滚回调里清理序列号,或者回调本身还依赖已经回滚的数据,导致通知失败。
我们在早期版本里,完全依赖业务方在 catch 块里调用 abandon(),结果发现大量废弃号没有被上报。后来改成“业务事务提交后再确认”,凡是到了超时时间还没有确认的号码,统一按废弃处理,这才把漏报问题兜住。
2.2 发号未用:超时、取消与异步任务丢号
除了事务回滚,还有一种非常隐蔽的来源:号码发出去了,但业务从此时起再也没有用过它。
常见情况包括:
- 服务调用超时:调用方等不到响应,直接抛异常,发号服务其实已经生成并返回了号码,业务方没拿到。
- 用户取消:比如下单后 15 分钟内未支付自动取消,订单号已经生成,但后续流程被取消。
- 异步任务丢失:发号之后把消息丢进 MQ,结果消费者处理失败,重试次数耗尽,任务被丢弃。
这类废弃号更麻烦,因为它们没有一个明确的“回滚时刻”,只会在超时后被我们发现。所以异步补偿机制里必须有一个“最长等待窗口”的概念——在窗口内不确认的号码,默认按废弃处理。
2.3 不是所有跳号都必须补偿:先分清场景
我得先泼一盆冷水:不要一看跳号就想做补偿机制。很多场景里,序列号中间的“空洞”是完全可以接受的。
比如纯展示用的用户 ID、日志流水 ID、消息 ID,它们只要求唯一性,不要求连续性,跳号不会对任何系统造成影响。这种场景下做废弃序列号补偿,相当于用高成本换一个没人关心的“表面连续”,纯属自找麻烦。
真正需要补偿的场景,通常有以下几个特征:
- 号码本身代表一种稀缺资源。比如某个下游系统对序列号有上限约束,或者号码段要从外部申请,不能无限扩张。
- 下游对账要求号段闭合。比如支付渠道的对账单按流水号逐笔勾对,中间缺号会导致对账逻辑不得不跳过空洞或者报错。
- 号码
