这类链路真正难的不是失败,而是失败后状态不清楚

活动系统一旦走到异步履约,链路通常就不会再是单服务闭环。

比较典型的一条链路会拆成:

1、入口受理请求

2、写用户事件或业务记录

3、资格判断或规则执行

4、调用下游履约能力

5、失败补偿或重放

这类链路最怕的不是某一步失败,而是失败后回答不了三个问题:

1、到底做没做

2、做了一半还是全做完了

3、再来一次会不会重复执行

这就是为什么幂等、补偿、最终一致性必须一起设计。

幂等要先落在入口,而不是补偿任务里补救

幂等最先解决的是重复入口问题:

1、上游重试

2、消息重复消费

3、人工补发

4、超时后再次请求

最简单的实现还是稳定幂等键 + 唯一约束:

1
2
INSERT INTO t_idempotent_record (idempotent_id, biz_id, create_time)
VALUES (#{idempotentId}, #{bizId}, now());

如果插入成功,说明当前请求第一次进入;如果插入失败,就按已处理或重复请求处理。

幂等键到底该怎么选

我自己的标准很简单:幂等键必须跟“业务唯一动作”绑定,而不是跟“本次请求”绑定。

比较稳的来源一般是:

1、订单号

2、外部请求号

3、事件唯一标识

4、业务主键 + 行为类型

最怕的写法是每次请求重新生成一个随机值,那实际上没有任何幂等意义。

我更倾向把状态拆成中间态,而不是只留成功失败

如果只有 SUCCESS/FAIL 两个状态,很多链路都会变得非常难排查。

更稳的状态模型一般至少包含:

1、INIT

2、PROCESSING

3、SUCCESS

4、FAIL

5、WAIT_COMPENSATE

这样做的好处是,能区分:

1、还没开始

2、正在处理中

3、失败但可补偿

4、失败且已终态

一条更像实际代码的处理顺序

我更认可的顺序通常是下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void handle(PrizeRequest request) {
String idempotentId = buildIdempotentId(request);

if (!idempotentService.saveIfAbsent(idempotentId, request.getBizId())) {
return;
}

bizRecordService.markProcessing(request.getBizId());
try {
thirdService.sendPrize(request);
bizRecordService.markSuccess(request.getBizId());
} catch (Exception e) {
bizRecordService.markWaitCompensate(request.getBizId());
compensateService.save(request, idempotentId, e.getMessage());
throw e;
}
}

这里真正重要的是顺序:

1、先做幂等拦截

2、再把业务状态切到处理中

3、成功后更新成功态

4、失败后明确落补偿记录

如果顺序反过来,比如先调外部再记状态,中间一断就很难恢复现场。

补偿记录不要只存一坨 JSON

很多补偿表一开始只想“先记下来再说”,最后会变成只有一坨内容字段,排查和重放都很难用。

我更愿意至少保留这些字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CompensateRecord {

private String compensateCode;

private String bizId;

private String idempotentId;

private Integer retryCount;

private Integer maxRetryCount;

private String errorMsg;

private Integer status;

private String content;
}

这张表至少要能回答:

1、补哪一类业务

2、补哪一笔数据

3、补了几次

4、为什么失败

5、现在还能不能继续补

补偿重放的核心不是“再调一次”,而是“安全地再调一次”

如果补偿任务只是定时扫表然后再调用一次下游,没有任何幂等和状态联动,那它本身就会变成制造重复数据的来源。

更稳的补偿任务通常会做这几步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void replay(CompensateRecord record) {
if (record.getRetryCount() >= record.getMaxRetryCount()) {
record.markFinalFail();
return;
}

if (idempotentService.isProcessed(record.getIdempotentId())) {
record.markIgnore();
return;
}

try {
thirdService.replay(record.getContent());
record.markSuccess();
} catch (Exception e) {
record.incrRetryCount();
record.markRetrying(e.getMessage());
}
}

重点不是代码长短,而是这几个判断顺序:

1、是否已到最大次数

2、幂等记录是否已存在

3、执行成功后怎么更新状态

4、执行失败后怎么留在待补偿态

最终一致性并不等于“后面再说”

很多人第一次做最终一致性,容易把它理解成“失败了以后补一下就好”。

我现在更倾向把它理解成一个完整模型:

1、主链路接受短时间不一致

2、系统内部保留完整状态

3、失败记录可回放

4、重复执行有幂等兜底

如果只有“稍后再补”这个想法,但没有状态、幂等、重试上限,那不叫最终一致性,只是把问题往后拖。

最容易出错的几个点

1、先调外部,再写本地状态

中间一断,很难知道外部到底做没做。

2、补偿和正常链路不共用幂等校验

补偿任务一重放,就开始重复执行。

3、失败原因没有分类

可重试失败和不可重试失败混在一起,补偿任务会越跑越乱。

4、没有中间态

线上出问题时,只能看到成功和失败,完全看不到卡在哪一段。

小结

活动系统里真正把最终一致性做稳,不是补一张补偿表就结束了。

入口幂等负责挡住重复,状态机负责解释链路进度,补偿记录负责保存失败上下文,补偿任务负责安全重放。这几层一起配上,最终一致性才不是一句空话。