美文网首页Java技术升华程序员技术架构
第三方支付微服务幂等设计

第三方支付微服务幂等设计

作者: fantuanjiaozi | 来源:发表于2018-09-04 15:48 被阅读432次

    目录

    1.背景

    • 在传统的单体应用里,即同一进程内,对于一个函数的调用,结果只有两种:成功和失败。
    • 在分布式架构体系里,调用远程的接口服务,除了成功和失败,还会有第三种结果——超时。这个场景被称为:分布式的三态。而三态中的超时直接提升了分布式架构的复杂性,也带来了幂等的问题。

    2.第三方支付的幂等场景

    在支付领域,通常分为入金和出金两种交易场景。以下根据这两种场景来分析:不做幂等设计时,带来的问题。

    例1:入金场景

    幂等需求:对于同一笔订单,无论用户支付多少次,最终只能成功一次(扣一次款)。

    • 在传统的单体应用里:只需把下单和支付放在一个事务内即可,下单和支付的操作要么全成功,要么全失败。如下伪代码:
    @Transactional
    public void orderPay(){
        initOrder();
        pay(accountId,amout);
        handleOrder();
    }
    initOrder(){} //制单
    pay(String accountId,long amout){} //支付
    handleOrder(){} //根据支付结果,处理订单
    
    • 在分布式架构里,我们把单体应用拆分为订单系统和支付系统,大部分情况下,订单系统通过RPC调用支付系统,最终完成一笔交易。
    • 在不做幂等设计场景下,如下图所示,支付系统在第3步扣款成功,但是由于网络原因,订单系统没有获取到结果,没经验的开发工程师经常会把超时当做失败处理,又在第6步再次发起支付,最终导致用户扣款两次。
    入金幂等.png

    例2:出金场景

    幂等需求:对于同一笔提现订单(同样报文),无论用户提现多少次,最终只能成功代付一次。

    • 在传统的单体应用里:只需把提现和代付放在一个事务内即可,提现和代付的操作要么全成功,要么全失败。
    @Transactional
    public void orderPay(){
        initWithDraw();
        issue(accountId,amout);
        handleWithDraw();
    }
    initWithDraw(){} //制单
    issue(String accountId,long amout){} //代付
    handleWithDraw(){} //根据代付结果,处理订单
    
    • 在分布式架构里,我们把单体应用拆分为提现系统代付系统,大部分情况下,提现系统通过RPC调用代付系统,最终完成一笔交易。
    • 在不做幂等设计场景下,如下图所示,代付系统在第3步付款成功,但是由于网络原因,提现系统没有获取到结果,没经验的开发工程师经常会把超时当做失败处理,又在第6步再次发起提现请求,最终导致给用户付款两次,造成资金损失。
    出金幂等.png

    问题导火索

    在实际的线上环境中,造成重复出金和入金,主要有以下几种原因:

    • 1.应用程序对于同步超时的处理逻辑有误(参见上述的两个例子)。主要是由于开发人员经验不足导致。

    • 2.定时任务重复执行。这种情况较为常见,通常是由于开发或者运维人员误操作导致,本文后面章节有相应的解决方案。

    • 3.dubbo,zuul等第三方框架自身的重试机制。一些框架默认的配置就是:超时后自动重试。开发人员一旦不小心,就容易踩坑。

    • 4.HAProxy,Nginx等中间件自身的重试机制。部分企业里,由于开发和运维的部门壁垒,中间件的超时重试机制是最容易被忽视的。大部分企业的测试环境的网络配置远比生产环境简单,在一定程度上也导致中间件重试的问题在测试环境不会出现,只发生在生产的事故里。并且大部分的开发人员可能不清楚生产的系统用了哪些中间件,更不了解中间件的运行原理,也导致生产上排查问题效率低下。

    • 5.极限,高并发场景。极限和高并发场景是在测试用例不容易覆盖到的。


    3.什么是幂等?

    用户对于同一操作发起的一次请求和多次请求的结果是一致的。不会因为多次请求而产生副作用。


    4.怎么做幂等设计?

    4.1应用程序

    • 订单系统上送唯一标识orderId,支付系统根据orderId做订单唯一性校验。支付系统只要检测到扣款成功,就直接返回支付结果。参见下图:
    入金幂等ok.png
    • 状态机

    涉及到资金交易的系统,通常会把交易流程分解成多个阶段,每一个阶段用不同的状态来标识。如在订单受理或初始化阶段,把订单状态设为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数据库

    • 在实际生产的高并发、高复杂业务中,仅仅靠应用程序做幂等性设计是不足的。如在上例中的极限情况下,两笔相同报文的支付请求同时调用支付系统,在第一笔订单尚未入库前,第二笔订单也已经受理,两笔订单在做数据校验时,发现都没有相同的数据,而导致两笔订单全部入库。如下图:
    数据库幂等.png

    两笔相同订单全都入库,通常会出现两种可能: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定时任务重复启动

    常见问题

    在分布式系统的复杂架构中,即使应用程序做了幂等设计,数据库也做了唯一约束,仍会产生重复出金和入金,比如定时任务系统重复启动,如图:

    定时任务重复.png

    1.两个相同的代付定时任务A和B同时启动,且同时从数据库中获取到一条订单号orderId=1 and 状态status=0的的订单。
    2.两个定时任务执行:将该订单状态改成付款中=1。
    3.更新成功后,两个定时任务将该订单发送到银行。导致重复出金。

    解决办法
    • 数据库约束
    定时任务重复解决-sql.png

    在更新订单状态时,where条件增加约束status=0,当返回更新条数=1时,说明更新成功,则发送到通道;当返回更新条数=0,说明该订单可能已经被其他的定时任务抓取,则停止发送。

    • 定时任务调度中心

    依靠中心化的定时任务调度中心,各应用暴露业务逻辑接口,由定时任务调度中心统一发起调度任务。调度过程中,通过抢占数据库锁(也可以通过redis分布式锁实现,但是需要保证redis集群高可用)来实现幂等。

    调度中心.png

    相关文章

      网友评论

      本文标题:第三方支付微服务幂等设计

      本文链接:https://www.haomeiwen.com/subject/blogwftx.html