前言
幂等是分布式系统中保证数据一致性和安全性的重要保障之一,尤其是在金融、支付领域,其作为资损防控的硬性指标体现在系统架构设计中。今天我们就来浅谈一下幂等相关的设计。
幂等的定义
幂等( idempotent、idempotence )的概念来源于数学,并被广泛应用于计算机科学。在数学中,其语意是 f ( x ) = f ( f ( x )),比如求取绝对值,abs ( x ) = abs ( abs ( x )),就是幂等的。
在计算机科学中,幂等即相同的请求调用一次和调用多次,服务端处理的的结果相同,并且最多受理一次。
幂等的重要性
我们就拿支付公司的资金调拨举个例子。一般的,第三方支付公司需要借助清算公司(如网联)提供的支付通道进行备付金账户资金调拨,以保证资金池充足可用。当第三方支付公司发起资金调拨请求时,如果清算公司的返回结果丢失,这时,支付公司是否可以重试?如果重试,是否会发生资金的重复调拨?
互联网公司的应用间存在物理边界,请求和响应信息会通过网络进行传递。我们说远程调用的结果会有三个状态:成功,失败,未知。前两者都是明确的状态,而未知具有不确定性,一般都是由网络超时、丢包引起的。如上例中,如果出现了超时,其实有两种方案,我们可以建立查询补偿机制,来研判是否要重新发起资金调拨。或者,清算公司做好幂等控制,支付公司可以无脑重试,既可以保证资金调拨业务的正常,又能保证不会发生多次调拨。
在架构设计中,幂等的应用面非常广泛,比如 MQ 规避重复消费、表单规避重复提交等。
幂等设计
幂等两大要素
幂等包含两大要素,幂等标记和关键请求参数。
幂等号:它对应服务端的唯一约束,在设计上,它一般由上游的幂等单号和来源组成。服务端的接口文档中,需要明确指出幂等号的信息组成,它的作用是对请求信息进行身份标识,相同幂等号的请求将被服务端识别为同一请求。
关键请求信息:接收的核心业务信息,常见的如收款账户、打款账户,打款金额、币种、商品数量等等。相同的请求中,调用方需要保证关键请求信息不变,一旦信息发生变动,则需要替换幂等号。
幂等原则
调用方必须保证幂等号的唯一性、不变性
说明
调用方需要保证幂等号不重复,且对同一业务单据的同一次操作,无论请求多少次,都要保证幂等号不变。
反例
幂等号重复,原因基本如下
- sequence cycle 问题,未评估好业务量同 sequence 增长速度,导致幂等号重复。
- sequence 步长、分段设置问题,导致跨区域/单元/库/表幂等号重复;
幂等号变化,原因基本如下
- 事务中生成幂等号,并发起远程调用,调用超时本地事务回滚,第二次请求又会生成新的幂等号。
调用方必须保证关键业务请求参数的不变性
说明
当服务端没有返回结果时,调用方关键业务请求参数不允许变更。
反例
初次请求,由于网络异常导致 timeout 调用方没有拿到结果,而服务端受理成功。客户端修改单据金额,请求信息发生变化,调用方与服务端处理出错。
img
调用方禁止幂等号纯内存拼接,不进行持久化
说明
幂等号不持久化,对于异步回执处理,上下游数据稽核带来困难,所以幂等号持久化是一个基本要求。
反例
RPC 调用,调用方的幂等号,是内存中根据业务映射拼接得来,不做持久化。
//内存中拼接幂等号
request.setRequestId(BizTypeEnum.getPrefix(×xxDO.getBizType()) + xxxDO.getId()):
调用方幂等号生成事务内禁止包含 RPC
反例
transactionTemplate.execute (status ->
//生成流水号 xxx
SerialDO serialDO = buildSerialDO();
//播入 aaa 表
serialDAO.insert(serialDO);
someDAO.update (someDO) ;
// dubbo 调用 rpc,流水号 xxxId 作为幂等号
invokeRpc(request);
return true,
));
正例
- RPC 放在事务外面
transactionTemplate.execute (status ->
//生成流水号 xxx
SerialDO serialDO = buildSerialDO();
//播入 aaa 表
serialDAO.insert(serialDO);
someDAO.update (someDO) ;
return true;
));
// dubbo 调用 rpc,流水号 xxxId 作为幂等号
invokeRpc(request);
- 使用事务同步器:如果事务在外层开启,为了不破坏代码结构,使用事务同步器,事务提交后发起 RPC 调用,调用异常后应用需要做恢复。
/**
* 外层已开启事务
*/
public static void execute (){
//更新单据状态
Runnable runnable = () -> {
response = dubboService.call(request);
};
register(runnable);
}
public static void register (Runnable runnable) {
if (TransactionSynchronizationManager.isActualTrangactionActive()) {
TransactionSynchronizationManager.registersynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit () {
runnable.run();
}
}
);
} else {
LOGGER.debug( "No active transaction.");
runnable.run();
}
}
- 业务自研组件:事务中插入本地任务,统一恢复执行。
服务端不能单纯依赖查询做幂等
说明
分布式下并发场景,并不能单纯的依赖查询做到插入 幂等。常见唯一性保障方式:
- DB 约束:对插入流水的幂等号建 DB 唯一索引约束
- 分布式锁:如 redis、 zookeeper 等。若持久层在 DB,不推存使用(依赖外部存储做幂等控制,与 DB 的强一致性无法保证),涉及资金等强一致性场景不推荐。
反例
RPC 调用超时,本地事务回滚。下次重试,会生成新的幂等号,导致资损。
服务端必须保证受理结果一致性
说明
针对相同请求,不论调用方请求多少次,服务端仅受理一次,且受理结果相同。
反例
售中退款的场景中,第一次服务端正常受理调用方请求,但调用方因为超时丢弃响应;当第二次调用方重试,服务端发现退款金额不足,返回受理失败,导致故障。
//1、基本校验
//2、悲观锁内,可退款金额判断;
Assert.isTrue(refundable(xxx), "cannot refund");
//3、逻辑处理
try {
process(xxx);
} catch (Exception e) {
//幂等判断处理
}
调用方收到服务端幂等结果后,比对关键业务参数
说明
客户端收到服务端结果后,本着不信任的原则,针对关键业务请求参数如账户、 金额同服务端受理内容对比。
反例
服务端做幂等判断时,只看幂等号,虽然第二次请求幂等号不变,但是金额又可能被篡改,如果服务端直接返回成功,将导致资金损失。
正例
- 服务端:根据幂等号查询 DB 流水,返回已经受理的关键业务信息。
- 调用方:对服务方返回的幂等内容做校验,确保与预期一致。
总结
以上规则是借鉴历史项目和互联网经验总结而成,主要侧重于幂等设计的原则,幂等的落地方案有很多,比如幂等表、乐观锁、悲观锁等,这里就不赘述。