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

目录

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容