第三方支付微服务幂等设计
目录
1.背景
- 在传统的单体应用里,即同一进程内,对于一个函数的调用,结果只有两种:成功和失败。
- 在分布式架构体系里,调用远程的接口服务,除了成功和失败,还会有第三种结果——超时。这个场景被称为:
分布式的三态
。而三态中的超时直接提升了分布式架构的复杂性,也带来了幂等的问题。
2.第三方支付的幂等场景
在支付领域,通常分为入金和出金两种交易场景。以下根据这两种场景来分析:不做幂等设计时,带来的问题。
例1:入金场景
幂等需求:对于同一笔订单,无论用户支付多少次,最终只能成功一次(扣一次款)。
- 在传统的单体应用里:只需把下单和支付放在一个事务内即可,下单和支付的操作要么全成功,要么全失败。如下伪代码:
@Transactional
public void orderPay(){
initOrder();
pay(accountId,amout);
handleOrder();
}
initOrder(){} //制单
pay(String accountId,long amout){} //支付
handleOrder(){} //根据支付结果,处理订单
- 在分布式架构里,我们把单体应用拆分为订单系统和支付系统,大部分情况下,订单系统通过RPC调用支付系统,最终完成一笔交易。
- 在不做幂等设计场景下,如下图所示,支付系统在第3步扣款成功,但是由于网络原因,订单系统没有获取到结果,没经验的开发工程师经常会把超时当做失败处理,又在第6步再次发起支付,最终导致用户扣款两次。
例2:出金场景
幂等需求:对于同一笔提现订单(同样报文),无论用户提现多少次,最终只能成功代付一次。
- 在传统的单体应用里:只需把提现和代付放在一个事务内即可,提现和代付的操作要么全成功,要么全失败。
@Transactional
public void orderPay(){
initWithDraw();
issue(accountId,amout);
handleWithDraw();
}
initWithDraw(){} //制单
issue(String accountId,long amout){} //代付
handleWithDraw(){} //根据代付结果,处理订单
- 在分布式架构里,我们把单体应用拆分为
提现系统
和代付系统
,大部分情况下,提现系统通过RPC调用代付系统,最终完成一笔交易。 - 在不做幂等设计场景下,如下图所示,代付系统在第3步付款成功,但是由于网络原因,提现系统没有获取到结果,没经验的开发工程师经常会把超时当做失败处理,又在第6步再次发起提现请求,最终导致给用户付款两次,造成资金损失。
问题导火索
在实际的线上环境中,造成重复出金和入金,主要有以下几种原因:
-
1.应用程序对于同步超时的处理逻辑有误(参见上述的两个例子)。
主要是由于开发人员经验不足导致。 -
2.定时任务重复执行。
这种情况较为常见,通常是由于开发或者运维人员误操作导致,本文后面章节有相应的解决方案。 -
3.dubbo,zuul等第三方框架自身的重试机制。
一些框架默认的配置就是:超时后自动重试。开发人员一旦不小心,就容易踩坑。 -
4.HAProxy,Nginx等中间件自身的重试机制。
部分企业里,由于开发和运维的部门壁垒,中间件的超时重试机制是最容易被忽视的。大部分企业的测试环境的网络配置远比生产环境简单,在一定程度上也导致中间件重试的问题在测试环境不会出现,只发生在生产的事故里。并且大部分的开发人员可能不清楚生产的系统用了哪些中间件,更不了解中间件的运行原理,也导致生产上排查问题效率低下。 -
5.极限,高并发场景。
极限和高并发场景是在测试用例不容易覆盖到的。
3.什么是幂等?
用户对于同一操作发起的一次请求和多次请求的结果是一致的。不会因为多次请求而产生副作用。
4.怎么做幂等设计?
4.1应用程序
- 订单系统上送唯一标识orderId,支付系统根据orderId做订单唯一性校验。支付系统只要检测到扣款成功,就直接返回支付结果。参见下图:
- 状态机
涉及到资金交易的系统,通常会把交易流程分解成多个阶段,每一个阶段用不同的状态来标识。如在订单受理或初始化阶段,把订单状态设为0;开始付款时设为1,标识当前订单是处于付款中状态;支付完成后,根据实际的结果,将订单状态设为成功或者失败。
状态机.png- 支付系统pay()接口幂等实现伪代码:
//1.查询当前订单
select order_status from t_pay where order_id=#{orderId} and system_no='订单系统'
//2.1订单已经存在且状态为终态,则直接返回
if("2".equals(orderStatus)){
return "成功";
}
if("3".equals(orderStatus)){
return "失败";
}
//2.2订单已经存在且状态非终态
if("1".equals(orderStatus)){
//查询下游系统or重试。
}
//2.3订单初始化
if("0".equals(orderStatus)){
//不再入库,直接调用支付逻辑以及后续处理
}
//2.4订单不存在
if(null==orderStatus){
//3.订单初始化入库,状态order_status=0
insert into t_pay(order_id,order_status,account_id,amount,....) values(#{orderId},'0',#{accountId},#{amount},...)
//3.1各种准备工作,如路由,记账等
//3.2更新为付款中
update t_pay set order_status='1' where order_id=#{orderId} and system_no='订单系统' and order_status='0'
//4.调用支付
status=doPay(accountId,amount);
//4.1更新订单状态
update t_pay set order_status=#{status} where order_id=#{orderId} and system_no='订单系统' and order_status='1'
return status;
}
注:
1.如果实际情况没有3.1,可以省略3.2,入库时状态order_status直接设成1。
2.如果调用支付功能是本地调用,可以考虑使用事务。
4.2数据库
- 在实际生产的高并发、高复杂业务中,仅仅靠应用程序做幂等性设计是不足的。如在上例中的极限情况下,两笔相同报文的支付请求同时调用支付系统,在第一笔订单尚未入库前,第二笔订单也已经受理,两笔订单在做数据校验时,发现都没有相同的数据,而导致两笔订单全部入库。如下图:
两笔相同订单全都入库,通常会出现两种可能:1.重复出入金;2.两笔订单状态不一致,造成上游系统无法正常处理业务。同时对账模块也会受到影响。
- 那么如何解决这个问题呢?
有一种思路是通过类似Redis的NOSQL,使用Redis的高性能以及串行化特性。
思路1.将部分订单的关键信息存储在Redis中,通过Redis进行校验。但这又带来了额外的问题:一是要保证Redis的高可用,应用也要做针对Redis故障的降级处理,增加了应用程序的复杂度。二是在极限情况或是Redis发生卡顿时,也会造成数据写入Redis不及时,而引起两笔订单重复入DB的情况。
思路2.为了解决写入Redis不及时的问题,订单系统将订单关键信息写入Redis,支付系统直接取同一份Redis数据做校验。但是这种方案违反了单一自责原则。
建立数据库的唯一约束
是目前比较常用解决办法。在实际的支付业务中,通常把订单号orderId和请求系统编码system_no(或是请求商户号merchantNo)做为数据库的联合唯一约束。保证同样的订单在数据库只有唯一的一条记录。当有重复数据请求时,应用程序在捕获此SQL异常后,重新调用pay()实现。
4.3定时任务重复启动
常见问题
在分布式系统的复杂架构中,即使应用程序做了幂等设计,数据库也做了唯一约束,仍会产生重复出金和入金,比如定时任务系统重复启动,如图:
定时任务重复.png1.两个相同的代付定时任务A和B同时启动,且同时从数据库中获取到一条订单号orderId=1 and 状态status=0的的订单。
2.两个定时任务执行:将该订单状态改成付款中=1。
3.更新成功后,两个定时任务将该订单发送到银行。导致重复出金。
解决办法
- 数据库约束
在更新订单状态时,where条件增加约束status=0,当返回更新条数=1时,说明更新成功,则发送到通道;当返回更新条数=0,说明该订单可能已经被其他的定时任务抓取,则停止发送。
- 定时任务调度中心
依靠中心化的定时任务调度中心,各应用暴露业务逻辑接口,由定时任务调度中心统一发起调度任务。调度过程中,通过抢占数据库锁(也可以通过redis分布式锁实现,但是需要保证redis集群高可用)来实现幂等。
调度中心.png