上周四晚上十一点,一通电话把我从床上拽了起来:线上订单表里突然出现三千多个连续空洞,订单号从 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,它们只要求唯一性,不要求连续性,跳号不会对任何系统造成影响。这种场景下做废弃序列号补偿,相当于用高成本换一个没人关心的“表面连续”,纯属自找麻烦。
真正需要补偿的场景,通常有以下几个特征:
- 号码本身代表一种稀缺资源。比如某个下游系统对序列号有上限约束,或者号码段要从外部申请,不能无限扩张。
- 下游对账要求号段闭合。比如支付渠道的对账单按流水号逐笔勾对,中间缺号会导致对账逻辑不得不跳过空洞或者报错。
- 号码需要支持按区间聚合统计。比如工单号、批次号,如果空洞太多,统计模型会出现偏差。
我们之所以下定决心做补偿,是因为订单号下游接了物流分单系统,它对订单号前缀和区间做了分区路由,空洞太多导致某些分区过早耗尽,被迫频繁扩容。所以做补偿之前,先想清楚自己的业务是不是真的需要,别把方案做成自嗨。
3. 同步回收为什么不行:三个现场事故复盘
一开始我们并没想到要“异步”补偿。第一版设计很直接:业务方发现号废弃了,同步调用 returnNumber() 把号还给 SequenceGenerator,发号时优先复用回收池里的号。听起来没毛病,但上线之后连续出了三个事故,每一个都让我印象极其深刻。
3.1 事故A:回收的号被并发分发给了两个人
第一个事故是重复发号。当时回收池用的是一个 ConcurrentLinkedQueue,业务回滚时把号码入队,发号时先从回收池取。结果在一次压测里,出现了两个线程同时拿到同一个订单号的情况。
原因不复杂:业务方在事务回滚后调 returnNumber(),但这个号在业务发起回滚到真正执行回滚之间,可能已经在别的事务里被读取过。更致命的是,我们当时允许“简易模式”下的业务线程直接从本地缓存取号,而本地缓存并不感知“该号已被回收”,于是一个号被同时放进了回收池和本地缓存,两个线程各自取走,重复了。
这个事故给我一个教训:废弃序列号的回收和重新分发,绝对不能只在“发号服务内部”做,必须让所有取号入口都感知状态变化,否则就会并发重复。
3.2 事故B:回滚高峰期的“回收风暴”打垮了数据库
第二个事故更直接。大促期间回滚率飙升,每个回滚事务都会同步调用回收接口,回收接口内部要对序列号日志表做一次更新,标记状态为“ABANDONED”。那些原本应该很快结束的回滚事务,因为额外多了一次数据库写操作,事务时间被拉长,数据库连接池很快就满了。
后来我们统计过,回滚高峰期每秒会触发几万次回收调用,如果每次都是同步写库,这套方案会在系统最脆弱的时候,给数据库狠狠补上一刀。这个教训是:回收逻辑如果绑在业务事务的同步路径上,它就会放大业务的抖动,必须在业务事务之外异步消化。
3.3 事故C:补偿号与“在途号”撞车
第三个事故埋得最深。某个业务方在拿到订单号之后,自己又做了一层本地缓存,用于防重提交。回收机制上线后,一个被废弃的号很快被重新分发给了另一个新请求,而这个新请求恰好命中了业务方的防重缓存,导致新订单走到一半被误判为“重复请求”直接拒绝。
这暴露了一个本质问题:号码一旦离开发号服务,下游可能已经把它记在某个地方。同步回收立即复用,会让下游在毫秒级别就收到一个曾经出现过的号码,没有任何缓冲时间,必然引发各种诡异冲突。
三个事故叠在一起,结论已经非常清楚:回收必须异步,复用必须延迟,状态必须统一可观测。于是我们才启动了真正的异步补偿方案。
4. 异步补偿机制完整设计:状态机与安全窗口是关键
4.1 序列号状态机:从分配到确认、废弃、回放
异步补偿的第一步,是给每个号码建立一条有状态的生命周期记录。我们不再把“号码已发出”当作终点,而是把它当作一个需要被追踪的实体。这条记录的状态转移如下:
| 状态 | 含义 | 进入条件 | 离开条件 |
|---|---|---|---|
| ALLOCATED | 已分配给业务方 | 发号成功,生成日志记录 | 收到确认,或触发补偿判定 |
| CONFIRMED | 业务确认使用 | 业务提交成功,调用 confirm() | 无,归档 |
| ABANDONED | 确认废弃 | 业务显式上报,或超时未确认 | 补偿 Worker 扫描到并进入回放等待 |
| REPLAYABLE | 可回放 | 已废弃且超过安全窗口 | 被补偿 Worker 回放入池 |
| REPLAYED | 已回放复用 | 成功进入补偿池 | 记录归档 |
这套状态机最关键的一点是:**废弃和复用之间,必须隔着 REPLAYABLE 这个状态。**它不是可有可无的中间态,而是“安全窗口”的载体。只有经过一段时间之后,号码才允许重新进入分发池。
4.2 安全窗口怎么定:从最慢事务反推
安全窗口的长度是这个方案里的核心参数,定短了会撞车,定长了补偿效率低。我们最终确定了一个计算思路:安全窗口必须大于“一个业务请求从取号到彻底失效的最长时间”。
简单说,一个号被取走后,最坏情况下它会在业务系统里存活多久?需要考虑:
- 调用方最长事务时间:我们统计过线上事务,超过 99.9% 的事务在 15 秒内完成。
- 网络超时与重试:调用发号服务超时时,调用方重试间隔最大 10 秒。
- 异步消息链路:消息可能被延迟消费,最多 30 秒。
- 时钟偏差:多台机器之间时钟差,我们控制在 1 秒以内。
把这些加起来,我们的安全窗口定在了 60 秒,再加一倍余量,最终取了 120 秒。也就是说,一个号码被标记为废弃后,至少要等 120 秒,补偿 Worker 才会把它重新放回分发池。这个数字不是拍脑袋拍的,而是业务排查后反推出来的。
另外我建议每个团队把安全窗口做成配置项而不是硬编码。因为你无法预料未来会不会接入一个新的重试链,把某个请求的存活时间拉长到 5 分钟,如果窗口是写死的,你只能改代码发版。
4.3 补偿池模型与索引设计
状态机落地到存储,我们建了一张序列号日志表,记录每一次发号及其生命周期。这张表既给补偿 Worker 扫描用,也给对账系统查证用。
sql复制CREATE TABLE t_seq_issue_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(64) NOT NULL COMMENT '业务类型: ORDER/PU/STOCK',
seq_no VARCHAR(64) NOT NULL COMMENT '序列号',
batch_id VARCHAR(64) NOT NULL COMMENT '取号批次ID',
status TINYINT NOT NULL COMMENT '1-ALLOCATED 2-CONFIRMED 3-ABANDONED 4-REPLAYABLE 5-REPLAYED',
biz_key VARCHAR(128) DEFAULT NULL COMMENT '业务方订单号/请求ID',
issued_time DATETIME NOT NULL COMMENT '发号时间',
abandoned_time DATETIME DEFAULT NULL COMMENT '废弃时间',
replay_deadline DATETIME DEFAULT NULL COMMENT '允许回放时间',
replay_times INT NOT NULL DEFAULT 0 COMMENT '已重试回放次数',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
UNIQUE KEY uk_biz_seq (biz_type, seq_no),
KEY idx_status_deadline (status, replay_deadline),
KEY idx_batch (batch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有两个设计细节值得说。第一个是唯一索引 uk_biz_seq,它保证同一个业务类型下,一个序列号永远只有一条生命周期记录,回放时如果重复处理会被数据库拦住。第二个是 idx_status_deadline,它是补偿 Worker 扫描的核心索引,每次扫描只查 status = 3 AND replay_deadline <= NOW() 的数据,避免全表扫描。
补偿池本身不落库,我们用内存队列加 Redis 集合各保留一份。内存队列保证发号服务本机优先复用,Redis 集合用于多实例之间共享补偿信息。只放内存不放 Redis,会导致某个实例重启后补偿池丢失;只放 Redis 不放内存,性能又跟不上发号的高频读取。
5. 核心代码实现:标记、探测、回放的完整链路
5.1 业务侧怎么告诉发号服务“这个号废了”
状态机设计好之后,第一件事是明确业务侧上报接口的语义。我们给业务方提供了三个方法:
java复制public interface SequenceGenerator {
/**
* 获取一个序列号
*/
String next(String bizType);
/**
* 业务处理成功后,确认该序列号已有效消费
*/
void confirm(String bizType, String seqNo);
/**
* 业务处理失败/取消时,标记该序列号为废弃
*/
void abandon(String bizType, String seqNo);
}
confirm() 和 abandon() 的实现都只是异步发一条消息到内部主题,由发号服务消费后更新状态。业务方不再同步写库,只发消息,这样对事务时间几乎没有影响。
但这里有一个隐蔽问题:业务方在事务回滚时调用 abandon(),如果这个调用本身也依赖一个已经被回滚的事务,就会失败。所以我们要求业务方必须在事务边界完成之后,再发送消息。在我们的 Spring 场景里,是用 TransactionSynchronizationManager.registerSynchronization() 注册 afterCompletion 回调,在事务真正结束后才发消息。
5.2 补偿 Worker:扫描、判定、入池
真正干活的补偿 Worker 是一个定时任务,每隔 5 秒扫描一次。它的逻辑并不复杂,但每一步都必须稳。
java复制@Component
public class SequenceCompensateWorker {
@Resource
private IssueLogMapper issueLogMapper;
@Resource
private NumberCompensatePool compensatePool;
private static final int BATCH_SIZE = 500;
@Scheduled(fixedDelay = 5000)
public void scan() {
long lastId = 0;
while (true) {
List<IssueLog> logs = issueLogMapper.scanAbandoned(lastId, BATCH_SIZE,
LocalDateTime.now());
if (logs.isEmpty()) {
break;
}
for (IssueLog log : logs) {
if (log.getReplayTimes() >= 3) {
// 超过重试上限,进入死信处理
deadLetterHandler.handle(log);
continue;
}
try {
int rows = issueLogMapper.tryReplay(log.getId(), log.getVersion());
if (rows == 1) {
compensatePool.offer(log.getBizType(), log.getSeqNo());
}
} catch (DuplicateKeyException e) {
// 唯一索引冲突说明已经被其他实例回放,跳过
log.warn("duplicate replay seqNo: {}", log.getSeqNo());
}
}
lastId = logs.get(logs.size() - 1).getId();
}
}
}
这里的核心是 tryReplay 这个 SQL,它是一条带乐观锁的更新语句:
sql复制UPDATE t_seq_issue_log
SET status = 4,
replay_deadline = NOW(),
version = version + 1
WHERE id = #{id}
AND version = #{version}
AND status = 3
只有更新行数为 1,当前 Worker 才算拿到这个废弃号的操作权。如果多个补偿实例同时扫描到同一个废弃号,只有一个会成功,其他实例会拿到 0 更新行数,自然放弃。这就是前面说的并发安全入口。
5.3 回放时的并发安全:用版本号做 CAS
补偿池里攒了号之后,问题还没结束——业务方每次调用 next() 时,需要先从补偿池取号,取不到才走正常号段。这一步的并发安全同样要靠 CAS。
我们用的是内存队列提供的 poll() 方法,并发安全由 ConcurrentLinkedQueue 保证。但跨实例的共享补偿池,则用 Redis 的 LPOP 命令,它是原子的,多个实例同时取号也不会重复。
真正守住防线的,其实还是前面那张日志表的唯一索引。退一万步说,哪怕补偿池因为某种 bug 被重复写入了多次,业务方真正提交业务数据时,序列号对应的日志记录只有一条,下游数据库的唯一约束也会拦住重复的单号。我们后来把订单表的 order_no 也加了唯一索引,等于给序列号的唯一性上了双保险。
5.4 号段模式与 Redis 模式如何复用这套机制
如果你用的不是本地号段模式,而是 Redis 自增或者数据库自增,这套机制依然可以套用,只是废弃号码的“回收”路径不同。
- Redis 模式:SequenceGenerator 从 Redis INCR 取号,废弃号回收后不能直接改 Redis 计数器回退,因为并发下会错乱。正确做法是维护一个“补偿集合”,发号时先从集合里取,取不到再 INCR。
- 数据库自增模式:同样不能回退自增主键。维护一张废弃号表,发号时先从废弃号表取一个未复用的号,取不到再搞自增。
核心思想是一致的:**废弃号不回退发号游标,而是进入一个独立的补偿池,作为发号时的优先数据源。**这样做的好处是,发号主链路的取号逻辑几乎不变,只是加了一个“先查补偿池”的前置步骤,风险可控。
6. 补偿失败的兜底策略:重试、死信与告警
6.1 重试退避与补偿上限
补偿动作本身也可能失败。比如补偿 Worker 成功把状态改成了 REPLAYABLE,但号码在入补偿池时 Redis 集群抖动,写入失败了。这时状态已经是 4,但实际不在补偿池里,号码就“悬空”了。
为了避免这种悬空,我们设计了两条路径兜底。第一条是扫描重试:补偿 Worker 每次扫描时,也会筛查 status = 4 且 replay_deadline 已经过了 10 分钟 的记录,把这些记录重新入池,并累加 replay_times。第二条是等待重试:如果 replay_times 还没到上限,就把记录留在表中,等待下一轮扫描重新判定。
重试要考虑退避,不能每 5 秒无脑重试。我们的策略是:第一次失败后 30 秒再试,第二次 2 分钟,第三次 5 分钟。超过 3 次就进死信流程。阈值不用设太高,因为补偿的是序列号,不是业务请求,如果连续三次都入不了池,大概率是系统性问题,继续盲试只会增加数据库压力。
6.2 死信表与人工核对流程
死信处理是我们很容易忽略但后来发现极其重要的一环。没有死信表之前,报废的号如果一直重试不进池,会无限占用数据库记录,而且没有人知道。
现在我们建了一张 t_seq_issue_dead 表,字段基本复制日志表,但多了一个 dead_reason 字段。补偿 Worker 连续失败 3 次后,会把原记录搬进死信表,并给值班群发一条告警。告警里会带上 biz_type、seq_no、batch_id 三个字段,方便排查。
值班同学处理死信时,一般是两步:先确认发号服务本身是否正常,再看 Redis 是否存在数据倾斜。大多数时候,死信是某个 Redis 分片不可用导致的,等集群恢复后,手动把死信表中的记录重新放回补偿池,就能闭环。
这套流程在初期看起来有点“重”,但半年跑下来,至少帮我们发现了四次 Redis 集群的隐患。所有没有被显式处理的废弃号,最后都会落到死信表里被人看见,而不是默默消失。
7. 压测记录与参数调优建议
7.1 不同废弃率下的补偿吞吐量
方案落地后,我们做了一轮针对性的压测。压测环境是 4 台 4C8G 的云主机,数据库是标准的 MySQL 8.0,单表数据量控制在 500 万。补偿 Worker 也是独立部署的一台 2C4G 实例,没有和发号服务混布。
我们模拟了三种废弃率场景,每个场景跑 30 分钟,结果如下:
| 场景 | 发号速率 | 废弃率 | 补偿池容量 | 单 Worker 补偿吞吐 | 平均补偿时延 |
|---|---|---|---|---|---|
| 日常 | 2000 QPS | 1% | 20000 | 22 个/秒 | 90 秒 |
| 活动峰值 | 8000 QPS | 4% | 80000 | 320 个/秒 | 110 秒 |
| 极端故障 | 8000 QPS | 12% | 200000 | 950 个/秒 | 95 秒 |
极端故障场景下,补偿 Worker 的 CPU 使用率达到了 84%,主要消耗在批量更新和 Redis 写入上。后面我们通过加大批量大小,把单次扫描从 200 条提高到 500 条,CPU 降到 65%,吞吐量反而上升了 20%。原因很简单:大批量扫描可以更充分地利用数据库索引,减少网络往返。
7.2 三个关键参数怎么调
根据压测数据,我总结出三个最关键的可调参数:
**补偿扫描间隔。**默认 5 秒一次。如果业务对废弃号复用的实时性要求不高,可以延长到 10 秒,数据库扫描压力会减半。如果废弃率高,缩短到 3 秒,但要注意扫描 SQL 的 replay_deadline 索引必须命中,否则数据库 CPU 会变成瓶颈。
**批量大小。**压测发现批量越大,总体吞吐越高,但单次扫描耗时也会变长。如果一批 500 条记录都处于待回放状态,处理完这批可能要 300 多毫秒,这个过程中新产生的废弃记录会累积。建议批量大小控制在 200 到 500 之间,不要超过 1000。
**安全窗口。**这直接决定废弃号从废弃到重新可用的时间。窗口太短,下游容易撞号;窗口太长,补偿池长期空转,等于没做。建议收敛到业务最长事务时间的 1.5 到 2 倍,并且用配置中心下发,不要写死在代码里。
另外要强调的是,补偿 Worker 本身要独立部署,或者至少要用独立的线程池。我们第一版把补偿任务放在发号服务的主线程池里,结果废弃率一高,补偿任务和正常发号请求抢线程,发号延迟直接飙到 300 毫秒。分开之后,两者互不干扰,发号延迟稳定在 2 毫秒以内。
8. 踩坑实录:我在这个过程里翻过的车
8.1 没加版本号,两个 Worker 同时回放同一个号
这个坑说起来很丢人,但我觉得值得分享给所有人。第一版实现里,我们的 tryReplay SQL 只判断了 status = 3,没有判断 version 字段。当时理由是“状态是 3 的号,总不可能有两个 Worker 同时抢吧”。
结果还真有。因为补偿任务在新版本发布时是灰度部署的,老版本实例和新版本实例同时在线,两边扫描到了一批废弃号,一个号被两个 Worker 同时更新成功,进了两次补偿池,最终造成订单号重复。虽然没有上线生产环境,但压测时吓得我一身冷汗。
从那以后,所有涉及“抢占并修改”的操作,我一律要求带版本号或时间戳做乐观锁。数据库永远是你最后的防线,而不是你指望它兜底的方案。
8.2 安全窗口设太短,新订单撞上旧号码
早期我们把安全窗口设置为 15 秒,理由是当时业务事务平均只有 200 毫秒,15 秒绰绰有余。结果某个渠道商接进来之后,他们有一个异步对账线程最长延迟 40 秒。于是经常出现这种情况:用户取消了一个订单,订单号被回收;40 秒后另一个用户下单,系统把这个旧号发给了他;然后第一个用户的对账请求姗姗来迟,发现“订单号存在但状态对不上”,疯狂告警。
最后我们对整个调用链重新做了一遍时间审计,才把窗口调到 120 秒。所以当你接入新的下游系统时,一定要重新评估安全窗口,不要沿用旧值。这种问题不会在压测时暴露,只会在线上用奇怪的方式提醒你。
8.3 补偿池热点导致的旧号码霸占
补偿池上线一段时间后,我们又发现一个有趣的现象:某些号码段被补偿回收后,会频繁被再次废弃,然后又回到补偿池,形成一个“热点循环”。
原因是这类号码段恰好对应某个风控策略的重点监控区间,用户下单时会优先命中风控,废弃率天然偏高。补偿机制把号收回来再发出去,它还是会命中风控,还是会报废,于是一个号可能在一个小时内被回放六七次,每次都消耗一次日志写入和补偿入池。
针对这个现象,我们加了一个“回放次数上限”的权重:同一个号回放过两次之后,就不允许再次进入补偿池,直接从日志表归档,让业务侧正常跳号。这样既保证了补偿机制解决“批量空洞”的核心问题,又避免了个别号码无限循环占用资源。
说实话,做完这套机制我才真正理解,序列号补偿不是一个纯粹的“发号服务内部”问题,它牵扯到业务事务边界、下游系统的状态感知、分布式并发安全,还有各种你想不到的业务节奏。安全窗口、状态机、版本号、死信表,这些看起来都是很小的设计点,但任何一个细节没做好,最终都会以线上事故的形式还回来。如果你也正在设计类似的废弃序列号补偿方案,建议先把自己的业务最长事务时间和下游重试链路摸清楚,再动手写代码。
