基于有限状态机与消息队列的三方支付系统

2020-12-08 14:06:31 浏览数 (1)

0. 引言

在日常生活中,从线下的超市购物到线上的外卖点餐、电商网购等,支付无时无刻不在发生,不论是通过现金、pos 机刷卡还是微信支付宝等第三方支付。线上支付有着及时便捷一气呵成的极致体验,当然也有少数的时候体验不够丝滑,比如早期我们在 PC 版 12306 买火车票,当支付完成后,订单的支付状态却经常不能及时更新,会有一段时间的延迟,有时甚至会等待很长时间处在未支付状态。

在支付的过程中由于各种各样的原因(比如外部渠道处理出了问题,异步回调迟迟不来)导致流程走了一半停了下来,用户看到订单依然是未支付状态,会不知所措,此时就需要一种机制来推动完成这笔交易。本文就以三方支付系统中的补单机制为例,来介绍一种较为通用的单据补偿模式。

1. 三方支付系统简介

1.1 什么是三方支付

所谓第三方支付,就是和各大银行签约,独立于商户和银行,具备一定实力和信誉保障的,为商户与消费者提供支付结算服务的第三方独立机构。它是处于买方和卖方之间具备公信力的第三方,承担担保人和资金托管人的角色。三方支付也可以称为虚拟账户支付,由消费者在第三方支付机构开设虚拟账户,并用虚拟账户中的资金进行支付。业界常见的三方支付有支付宝、微信支付、美团支付、京东支付等等。

1.2 三方支付中的交易&支付系统

交易是什么,最直观的描述就是“一手交钱、一手交货” ,交易会使买卖双方形成债权和债务关系。交易的存在是支付发生的前提,用户通过使用某种支付方式去完成交易。交易是支付流程的驱动者,根据具体场景组合不同的支付指令,来完成交易资金的转移。

支付是交易处理资金流的工具,目的是清偿债权和债务关系;支持多种支付方式(如银行卡支付、余额支付、优惠券组合支付、类似花呗的信用支付等),负责对接账务、会计、计费系统等资金处理能力,接收支付指令,驱动完成资金交换。将实际的支付行为(实际资金)与内部的记账(虚拟资金)相结合,保证虚实一致。

三方支付整体业务架构如图 1 所示,其中交易核心与支付核心在业务划分上处于"收单支付域",具备普通交易的收款、付款、退款及充值、转账与提现等常见功能,还包括了支撑电商业务的合单支付、担保与分账的能力。其中交易与支付核心都有一个异常查补模块,它囊括了所有业务的补偿流程,也是本文主要介绍的部分。

![](data:;base64,<?xml version=)" alt="img" data-src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/00aba92b401a4d908d0538dd3c99d6da~tplv-k3u1fbpfcp-zoom-1.image" data-width="800" data-height="600" />

图 1. 三方支付业务架构

2. 什么是补单&为什么需要补单

一笔交易在支付过程中由于链路中的各种异常而中断,此时的交易处在一种中间状态,这种情况俗称"卡单",即卡在那里不动了,没有继续向下推进。还有一种情形,支付核心向渠道发起了扣款,渠道受理后,银行卡扣款成功,但由于种种原因没有向支付核心发起回调,导致这笔支付没有完成,用户没有享受到相应权益,但银行卡的钱已经扣了,这种情况称为“掉单”。

不管是卡单还是掉单,都是处在中间态的订单。补单就是将处在中间态的订单进行补偿,直到推进到终态(成功或失败)。补单一般有两个关键点,一个是补偿的有效性,极端情况下可能补偿多次都不成功,不能就此放弃了,需要有兜底的机制;另一个是补偿的及时性,因为交易挂起的时间越长,用户的体验越差。

交易核心和支付核心的补单相得益彰,具有一定相似程度的设计与实现,我们就以支付核心的补单为例介绍下异常补单机制。

3. 补单是如何实现的

本章首先了解一下业务流程,说明一下实现补单需要的前提基础,然后介绍一下补单机制的演进路线,每个版本存在的问题以及在下一个版本是如何解决的。

3.1 有限状态机与幂等性

标识资金操作的有限状态机

我们首先以用户发起一笔余额提现为例,说明下业务流程,简化后如图 2 所示。

![](data:;base64,<?xml version=)" alt="img" data-src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7b4a97961b2946de8741e2730883915e~tplv-k3u1fbpfcp-zoom-1.image" data-width="800" data-height="600" />

图 2. 余额提现流程

首先生成支付订单,然后请求账务系统,扣减用户账户下的余额,接着向外部渠道发起付款操作,资金操作完成后统一处理结果并更新单据信息,最后还有一些对上下游的异步通知,形式上包括消息和 RPC 回调。

我们将每个关键资金操作的状态记录落库,如下表所示。其中出款即钱从哪里来,入款即钱到哪里去,冲正即回滚交易。在提现场景下就是从用户支付账户出钱,到用户的银行卡去。其他场景比如充值(银行卡->用户支付账户),会有不同的资金流向。表中最后两行加粗的场景还没有达到终态,是我们需要去补偿的。

余额提现的不同阶段场景

出款状态

入款状态

冲正状态

总状态

初始状态

INIT

INIT

INIT

PROCESSING

提现成功

SUCCESS

SUCCESS

INIT

SUCCESS

用户余额扣减成功,向银行卡付款失败,需要向账务发起冲正,即回滚用户的余额

SUCCESS

FAILED

SUCCESS

REVERSED

用户余额不足,扣款失败

FAILED

INIT

INIT

FAILED

用户账户扣款超时或返回处理中

PROCESSING

INIT

INIT

PROCESSING

调用渠道向银行卡付款超时或返回处理中

SUCCESS

PROCESSING

INIT

PROCESSING

为了便于理解,我们在这里省略了冲正的相关操作,一次余额提现流程的状态机转换如图 3 所示。

![](data:;base64,<?xml version=)" alt="img" data-src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/42bd0dad828f4f15a261074d606d7766~tplv-k3u1fbpfcp-zoom-1.image" data-width="800" data-height="600" />

图 3. 附带状态机转换的余额提现流程

可重入与幂等性保证

发起一次支付会涉及到多个系统间调用,由于网络原因导致的通信超时是常见的问题。同时上游系统也可能会重新发起请求,这需要我们的系统保证操作结果的幂等性。少数用户也可能会有多个终端同时操作带来的并发请求,需要我们保证接口的可重入性。除了服务自身,我们的下游依赖同样也需要保证其接口具有同样的能力。

先说一说什么是可重入与幂等性。

  • 可重入:在并发请求下可以保证正确性。
  • 幂等性:重复多次相同的输入,获得相同的输出。幂等性在技术上其实也包含了可重入的要求。

具体到业务中,幂等性是针对一笔已经到达终态的支付而言的,对于初次未能拿到最终业务结果的请求,再次发起调用的结果可以是不同的(处理中->处理成功或失败)。那么我们如何保证业务流程的可重入与幂等性呢?我们分别拆解每一步来看:

  1. 生成支付单:首先支付单据可以将业务方传递的可保证唯一性的外部订单号作为唯一索引,插入数据库时若发生唯一索引冲突,则将查询已有数据进行幂等参数校验,若与当次请求的参数完全一致说明是重复请求,可使用 DB 中的支付单继续推进后续流程;若不一致则返回错误。
  2. 资金处理流程:账户和渠道系统各自保证其接口幂等性。我们也维护了每个下游操作的状态,根据状态机决定是否要继续推进,尽量不向下游输出重复流量。比如支付单已经完成了所有资金处理,状态机已经是终态,那么接口可以直接返回相应结果。
  3. 更新支付单信息,先将支付单加行级排他锁,再进行更新,保证多个并发请求只会有一个成功。
  4. 异步通知,在支付单推进到终态后进行。

有了可重入与幂等性保证,我们就可以大量地复用正向流程来实现补单接口。

3.2 初始版

一般来说最常见的补单形式是设置一个定时任务,定时去扫表完成业务补偿,实现比较简单,但是及时性不够,对于收款转账类的交易而言用户体验不佳。我们采用了通过消息队列进行即时补偿的方式,如图 4 所示。补偿消费者并没有去做补偿工作,而是解析消息然后通过 RPC 调用支付核心暴露的补偿接口。为什么不在消费者中直接补偿?这么做主要是考虑将逻辑收敛到一处便于维护。

图 4. 余额提现发生异常时的补单流程

当然,补单可能依然失败,我们可以再次发送补偿消息。但不能一直这样循环下去,所以需要设置一个最大重试次数,超出后不再发送。当补偿多次都不成功时,一般是下游系统出了问题,这时我们需要放缓补偿的频次,随着重试次数增加,会让每次补偿时间间隔逐渐增大,通过 RocketMQ 的延时消息实现。

这里有三个很容易想到的问题:

  1. 如果异常消息发送失败,上游也没有重试机制,这笔订单就可能一直挂在这里不了了之了,如图 5 所示。
  2. 补偿消费者请求支付核心补单时不成功,可能是超时但实际补偿成功了,或者是请求压根没有过去,如图 6 所示。
  3. 如果重试达到最大次数依然没有成功,这笔单子该怎么处理。

图 5. 补偿消息发送失败

图 6. 补偿消息消费失败

3.3 改进版

针对问题 1,如果重试依然发送失败,我们通过引入一张异常消息表,将发送失败的消息落库来解决。表中记录了订单号、当前的重试次数、异常分类、记录状态、消息体等字段。如果图 6 中的第 4 步消息发送失败,就将这笔订单放入 DB 中的一张异常表中,会设置一个定时任务去处理。以目前 RocketMQ 的可用性来说,异常数据很少会出现。如图 7 所示。

图 7. 针对消息生产/消费异常的改进版 - 1

对于第 2 个问题,如果补偿消费者调用支付核心失败,补偿消费者 HandleMessage 会向上层抛出 error,利用 RocketMQ 的梯度重试机制,当消费重试次数达到一定上限后会进入死信队列。如图 8 所示,这种情况一般是服务或网络出了问题,待恢复之后,可以从死信队列拉取这些消息再统一处理。

图 8. 针对消息生产/消费异常的改进版 - 2

当然还有更极端的情况,请求 MQ 和 DB 都失败了咋办?以目前 MQ 和 DB 的可用性来说,同时失败这种基本可以不用考虑,报警人工介入即可。

针对问题 3,如果重试超过最大次数,依然补偿不成功,一般是下游依赖出了问题。这种情况我们也将它放进异常表中。

对于这两类漏网之鱼,需要支持单条/批量支付单补偿的运营能力以供人工介入;最好有一个在业务低峰期运行的兜底任务,扫描业务单据表,将一段时间内还未完成的订单进行补偿。

另外,兜底任务可能造成消息的短暂堆积,影响线上的实时补偿流程推进,对此可以使用独立的队列隔离开来。

3.4 最终版

其实如果只是异步通知类的操作出现了异常,并没有必要每次都重新走一遍完整业务流程,缺啥补啥即可。因此我们将异常划分为多种类型,将一些异步操作和业务处理划分开来,进行精细化的处理:

  • 通知下游的 MQ 失败,就将这个消息体重发一次即可;
  • 通知交易的回调 RPC 失败,可将 RPC 请求序列化到消息体中,补偿时通过反序列化消息体中的 RPC 请求,直接再发起一次 RPC 即可;
  • 对于更新 DB 失败,将更新参数序列化到消息体中,补单时再次发起一次更新;
  • 如果在业务处理时遇到了异常情况,需要再走一遍业务补偿;

图 9 以这几种异常为例,说明了每种补偿类型的消息参数。

图 9. 分类补偿

我们最终的补单体系见图 10,它既通过即时消息保证了补偿的及时性,是主动出击之茅;也利用延时消息重试、落地失败消息的异常表与兜底任务保证了补偿的有效性,是万无一失之盾。不仅可用于支付单据的补偿,通过保证流程的可重入性,也可作为一种通用解决方案,但不适用于无状态、不可重入的业务形态。

图 10. 异常补偿体系

4. 总结

本文首先介绍了什么是补单,接着基于三方支付系统的实现完整阐述了补单机制的演进过程,最终演化为一种相对通用的异常处理模式,即基于消息队列、有限状态机与多重任务兜底的业务层最终一致性保障机制,供大家参考指正。

0 人点赞