活动系统中的幂等补偿与最终一致性
这类链路真正难的不是失败,而是失败后状态不清楚
活动系统一旦走到异步履约,链路通常就不会再是单服务闭环。
比较典型的一条链路会拆成:
1、入口受理请求
2、写用户事件或业务记录
3、资格判断或规则执行
4、调用下游履约能力
5、失败补偿或重放
这类链路最怕的不是某一步失败,而是失败后回答不了三个问题:
1、到底做没做
2、做了一半还是全做完了
3、再来一次会不会重复执行
这就是为什么幂等、补偿、最终一致性必须一起设计。
幂等要先落在入口,而不是补偿任务里补救
幂等最先解决的是重复入口问题:
1、上游重试
2、消息重复消费
3、人工补发
4、超时后再次请求
最简单的实现还是稳定幂等键 + 唯一约束:
1 | INSERT INTO t_idempotent_record (idempotent_id, biz_id, create_time) |
如果插入成功,说明当前请求第一次进入;如果插入失败,就按已处理或重复请求处理。
幂等键到底该怎么选
我自己的标准很简单:幂等键必须跟“业务唯一动作”绑定,而不是跟“本次请求”绑定。
比较稳的来源一般是:
1、订单号
2、外部请求号
3、事件唯一标识
4、业务主键 + 行为类型
最怕的写法是每次请求重新生成一个随机值,那实际上没有任何幂等意义。
我更倾向把状态拆成中间态,而不是只留成功失败
如果只有 SUCCESS/FAIL 两个状态,很多链路都会变得非常难排查。
更稳的状态模型一般至少包含:
1、INIT
2、PROCESSING
3、SUCCESS
4、FAIL
5、WAIT_COMPENSATE
这样做的好处是,能区分:
1、还没开始
2、正在处理中
3、失败但可补偿
4、失败且已终态
一条更像实际代码的处理顺序
我更认可的顺序通常是下面这样:
1 | public void handle(PrizeRequest request) { |
这里真正重要的是顺序:
1、先做幂等拦截
2、再把业务状态切到处理中
3、成功后更新成功态
4、失败后明确落补偿记录
如果顺序反过来,比如先调外部再记状态,中间一断就很难恢复现场。
补偿记录不要只存一坨 JSON
很多补偿表一开始只想“先记下来再说”,最后会变成只有一坨内容字段,排查和重放都很难用。
我更愿意至少保留这些字段:
1 | public class CompensateRecord { |
这张表至少要能回答:
1、补哪一类业务
2、补哪一笔数据
3、补了几次
4、为什么失败
5、现在还能不能继续补
补偿重放的核心不是“再调一次”,而是“安全地再调一次”
如果补偿任务只是定时扫表然后再调用一次下游,没有任何幂等和状态联动,那它本身就会变成制造重复数据的来源。
更稳的补偿任务通常会做这几步:
1 | public void replay(CompensateRecord record) { |
重点不是代码长短,而是这几个判断顺序:
1、是否已到最大次数
2、幂等记录是否已存在
3、执行成功后怎么更新状态
4、执行失败后怎么留在待补偿态
最终一致性并不等于“后面再说”
很多人第一次做最终一致性,容易把它理解成“失败了以后补一下就好”。
我现在更倾向把它理解成一个完整模型:
1、主链路接受短时间不一致
2、系统内部保留完整状态
3、失败记录可回放
4、重复执行有幂等兜底
如果只有“稍后再补”这个想法,但没有状态、幂等、重试上限,那不叫最终一致性,只是把问题往后拖。
最容易出错的几个点
1、先调外部,再写本地状态
中间一断,很难知道外部到底做没做。
2、补偿和正常链路不共用幂等校验
补偿任务一重放,就开始重复执行。
3、失败原因没有分类
可重试失败和不可重试失败混在一起,补偿任务会越跑越乱。
4、没有中间态
线上出问题时,只能看到成功和失败,完全看不到卡在哪一段。
小结
活动系统里真正把最终一致性做稳,不是补一张补偿表就结束了。
入口幂等负责挡住重复,状态机负责解释链路进度,补偿记录负责保存失败上下文,补偿任务负责安全重放。这几层一起配上,最终一致性才不是一句空话。



