一个支付服务的最终一致性实践案例(含伪代码)

标签: | 发表时间:2018-08-05 08:39 | 作者:
出处:https://mp.weixin.qq.com

“功夫贷”是一款线上贷款 APP,主要是给信用卡优质用户提供纯线上的信用贷款,以期限长、额度高、利息低为主要优势(类似的业务模式主要有宜人贷)。

和任何一种分期贷款一样,符合资质的用户,在功夫贷成功贷款之后,需要在约定还款日还款。目前还款主要有以下这几种方式:

  • 用户在 APP 上主动还款;

  • 系统定时通过后台任务扣款;

  • 催收人员通过内部作业系统,手动发起扣款;

真正的扣款操作(从银行卡扣款)主要是通过第三方支付来完成,比如京东支付、通联等。不同的第三方支付,支持的银行列表和限额不同,费用和稳定性也不尽相同,我们会选择出个最优通道、以及多层级备用通道,为此研发了支付路由系统,同时这些服务商的业务限制 / 出错概率还不低,所以我们又要考虑业务上的一致性,这也是本文要介绍的主题。

扣款业务是比较复杂的,包括如下几个主要步骤:

  1. 对业务表 (扣款任务表 / 还款计划表等) 的数据库操作

  2. 调用第三方支付

  3. 清算入账

这多个子功能需要保证同时成功或者同时失败,其中既有外部第三方调用,又有内部微服务的调用,所以这是个比较典型的分布式事务的场景。由于外部的第三方支付服务有时不稳定、且部分交易可能很长时间才能确认成功。

因此 我们没考虑两阶段提交的分布式事务,而是选择了最终一致性,而为了保证在状态不一致这个时间窗口的准确性 (比如不能在该窗口对用户重复扣款),我们也额外多做了很多的考虑。

主流程分析

扣款服务的主流程如下图所示(在这里仅举“第三方支付渠道是同步返回扣款结果”作为例子,在实际情况中,各家第三方支付渠道的接口并不一致,有同步返回的、也有异步 + 轮询方式的,这两种形式,在我们这的处理逻辑上没有明显区别)。

为了避免对业务流程造成干扰,上图中把同样处于主执行路径上的、起着日志记录作用的"log-x"这些步骤,在各自所处的位置以虚线表示,记得它们是主流程的一部分。这些“log-x”步骤在实现上,是建立一张日志表,以持久化、结构化的方式来记录,并不是 logback 之类的文件日志,因为这些日志在异常时的恢复,起着重要作用。

从上图可以看出,由 1、2、3、4、6 这五个步骤,形成一个整体,我们需要保证的是,这 5 个步骤同时成功、或者同时失败。其中包含几类操作:

  • 本地 DB 的 SQL 执行,包括步骤 1、4;

  • 远程 HTTP/RPC 调用,包括步骤 2;

  • 发送 MQ 消息,包括步骤 3;

  • 异步系统执行,包括步骤 6;

其中步骤 6 是另外一个服务(账务服务),是在支付服务之外的,所以用虚线框来表示,但在逻辑上是整体不可分的一部分,需要共同成功 / 失败。下面我们来看,在这些步骤中,会有哪些失败场景和各自特点:

  • 本地 DB 的 SQL 执行:SQL 错误、与 DB 网络中断或者 DB 不可用的时候,会失败,但这种失败可补偿,且概率很低;

  • 远程调用:在本例中是“同步调用第三方支付渠道扣款”,因为这是网络调用,最复杂的一种,可能会超时、也可能会连接中断或其他错误原因中断,这里的失败是有无法补偿的可能的,尤其是业务类错误——用户余额不足、用户银行卡状态不对等,都可能导致业务终止而无法继续下去;

  • 发送 MQ 消息:和本地 DB 的 SQL 执行类似,是可补偿的失败,从可用性的角度来看,比 SQL 执行的失败概率略高一些,在我们实际场景中,就有发送失败的情况(我们使用的是 RocketMQ,曾经出现过几次 broker 刷盘缓慢导致流控的发送失败);

  • 异步系统执行:我们这里是触发账务系统入账,是 RPC 类(我们用的 Dubbo)操作,有一定的失败可能性(账务系统压力过大、内存溢出、磁盘占满等都可能导致其不能或部分服务器不能提供服务),但又因为它在业务上是肯定能成功的记账操作,所以即使失败,也是可以补偿的;

综合上面这些分析,考虑到步骤 2“同步调用第三方支付渠道扣款”是唯一一种无法补偿的业务,且处于流程链最靠前的地方,所以整个业务流,我们是向着可补偿的方式,即保证最终都会成功的最终一致性的方向去做。如果步骤 2 靠后,则由于它的不可补偿性,我们就必须在前面步骤的步骤考虑回滚——或 DB 事务回滚、或二阶段回滚、或提供撤销功能,以达到最终都会失败的最终一致性。

详细设计

难题一:出现预期内的异常时,如何保证最终一致性?

我们先分析,如果主流程上的各个环节,出现了预期内的异常,我们大概要怎么处理,以保证最终一致性。预期内的异常,是指程序提前考虑到的——主要是 try/catch 中 catch 到 Exception 部分的逻辑。

步骤 1:更新 DB 的还款记录状态为“扣款中”:其是流程第一步,如果它失败,流程结束,不需补偿;

步骤 2:同步调用第三方支付渠道来扣款:例子中的这家服务商的扣款接口,提供的是只有两种结果状态的契约:“扣款成功”或“扣款失败”。如果在扣款中的话,则调用程序就在同步阻塞着。无论是由于调用超时、或调用中连接中断、或系统 Crash,导致失败,我们无法判定是否扣款是否成功,因此需要辅助以主动查询——轮询调用此家第三方支付服务商的查询接口,以确定扣款状态,达到“成功”或“失败”的终态为止,如下图所示。

步骤 3:发送 MQ 通知下游账务系统入账:如果失败的话,和上一步类似,需要日志表 + 定时任务补偿。

步骤 4/5:更新 DB 的还款记录状态为“扣款成功”或“扣款失败”:如果更新 DB 操作出现了失败,则需要定时任务,重试补偿,这需要借助日志表来恢复,后台定时任务去扫描该日志表,以从之前失败的步骤,继续执行下去,类似于“断点续传”,这里我们暂不详述;

步骤 5:发送 MQ 通知下游账务系统入账:如果发送失败的话,和上一步类似,需要日志表 + 定时任务补偿;

步骤 6:账务系统入账:由于通常的 MQ(我们用的是 RocketMQ)本身有 at-least-once 的重试机制,这就保证了消息必须被正确消费(只要账务系统程序不会主动 ignore 掉)才会被 ack,所以这个地方的最终成功,就由消息中间件来保证了;如果使用的 MQ 组件没有这种重试机制,则需要在账务系统端建立日志表,来补偿(如果 MQ 有丢失消息的风险,那仍然可能不一致)。

难题二:出现预期外的异常,如何保证最终一致性?

顾名思义,预期外的异常就是非程序提前感知到的,比如进程被强制 KILL、机器 CRASH,在这种情况下,程序执行到一半,突然结束了,这时怎么保证最终一致性?

在这种情况下,只能是靠日志表了,主流程或任何依赖内存记录的恢复程序都无效了。

定时任务的目的是补偿未能正常结束的扣款任务。一般来说,如果扣款任务未能正常结束,可能会有如下几种原因:

  1. 系统意外退出(进程被 KILL、宕机等);

  2. 系统重启——如当前某笔扣款记录在轮询第三方支付服务的扣款状态,此时重启也造成了流程中断;

  3. 执行过程中出错,如数据库异常、调用超时、MQ 不可用等;

为了达到补偿目标,需要设计若干张日志表来辅助。我们设计了 2 张,如图:

其一,“扣款途中日志表”是用于标识扣款任务是否仍然在途中。在扣款开始之前,往该表插入记录,扣款完成后 (成功或失败) 更新状态。该表主要目的是:可以方便地找出来,哪些扣款任务是没有正常结束的。为什么没直接用业务表“还款记录表”来查询在途扣款呢? 主要是从便捷性和性能上考虑——业务表的数据是不能删除的,而该日志表可以定期将已完成的扣款任务清除掉,以控制该表其数据量,保证查询效率;

其二,“扣款执行日志表”是用于记录扣款任务的执行过程。该表的记录不更新,只插入。如果某个扣款任务需要恢复补偿,则从该表中找到上次执行的“断点”,继续向后执行。上图中举了 3 组数据作为例子:黄色背景是一笔完成的、扣款成功的日志;浅绿色背景是一笔完成的、扣款失败的日志;浅橙色背景是一笔进行中(正在执行调用第三方扣款)的日志。

下面是定时补偿任务的主流程:

  1. 在实践中,一个正常的扣款任务在 1 分钟内都应该结束了,时间主要花费在调用第三方扣款服务上,绝大部分 30 秒内结束,少量的会拖的时间比较长,甚至跨日;

  2. 定时任务 3 分钟执行一次,每次扫描 3 分钟前开始的、且当前未结束的任务。3 分钟以内的任务不处理的原因是:它们可能仍然在自己的正常处理过程中,此时还不需要定时任务来接管;

伪代码

为了便于读者理解,这里以伪代码的形式把整个扣款过程写出来,且分几个迭代版本不断增强。

版本一

  1. 在执行之前,注意要把数据库事务设为自动提交,即不可把整个过程纳入到一个事务里——不仅是性能问题,更重要的是,如果过程中失败了,日志数据也被回滚掉了,无法恢复;

  2. 面对预期内的异常和预期外的异常,如详细设计里所述,或抛出异常结束、或 return 结束,后期由定时任务补偿。在主流程中不做各种各样繁杂的异常处理,既避免繁琐,也避免出错;

  3. 上面只是伪代码,在实践中应该打印出详细的 Exception 信息、以及 log 文件日志,以便于定位和查找问题;

版本一有 2 个问题

  1. 如果失败了,都要等定时任务补偿,那样响应有些慢,毕竟定时任务几分钟才执行一次;

  2. 定时任务补偿时,要判断之前执行到哪,如果补偿的起始阶段不同、代码逻辑也不一样,这也比较麻烦;

基于此,有了版本 II,这里取“调用第三方支付渠道扣款”的片段来说明。

版本二

  1. 红色部分增加了日志状态的判断。如果是补偿性的,如该步骤以前已经成功了,则跳过这段调用第三方的逻辑;

  2. 蓝色部分增加了先查询的操作,不论是否已经调用过扣款;

  3. 褐色部分增加了后台线程池轮询,而不是单单等定时任务去触发;这地方实践中稍微控制下线程池数量、且最好有多路复用的模式,防止很多线程都挂在那轮询;

  4. 绿色部分,其实是出现异常的话,上面这些步骤可以再来一遍;

不难看出,该版本主要是增加各个逻辑段的幂等性,既使其能安全执行、又使代码逻辑简洁。

版本二还可以更为严谨一点——拿下面这个代码段红框里的来说,如果在两段 SQL 之间失败了,有造成不一致的可能(概率很小)。

版本三

通过事务保证逻辑段能同时成功或同时失败。虽然概率很小,但如果线上发生了,很难找到原因。

上面这些伪码是本人用 markdown 纯粹手敲的,并不是生产代码,没有经过严格测试,所以如果有些地方写的笔误或逻辑有漏洞,请读者谅解。

通过上面分析,我们看到有多个地方可能会对同一笔还款记录扣款,包括:

  1. 正常执行扣款;

  2. 提交到后台线程池的重试 / 轮询;

  3. 定时任务补偿;

  4. 人工执行扣款

所以针对单笔还款记录的扣款操作,我们需要使用锁定,实践中我们采用的是 redission 来做的分布式锁,这比较简单,这里不多叙述,不忽略这一点就好。

兜底方案

上面我们分析了很多,对主流程中的分支都做了很多的考虑,但仍然有这两个风险:

  1. 有些异常分支没有考虑到;

  2. 随着业务的发展,新加进来的逻辑,或者新人进来,很可能有些新的分支点没有被充分考虑;

所以从严谨的角度,我们需要个兜底方案——主动检查 + 对账,以主动识别任何异常现象。从实践上看,由于业务的复杂性以及持续变化,可能很难完全梳理清楚所有的异常点,因此“主动检查 + 对账”可能更为重要。

主动检查

我们创建了个 Thread,定时查询还款计划表中,处于”扣款中“的异常数据,进行检查,如果有问题,自动修正或者通知出来人工干预。比如某条还款记录,从“还款中”的状态到现在,已经过去了 1 个小时了,这种情况就会被判定为可疑现象,需要人工介入。

对账

仍然有一些情况,是系统所覆盖不到的,需要双方对账 (我们和第三方支付对账、第三方支付和银行对账)。主要有以下这些场景:

  • 跨日——双方把订单归到不同日期。比如 23:59 的订单,我们归到今天,第三方支付那边可能归到第二天;

  • 第三方支付开始告诉我们是成功的,我们已经结束操作了,后来对账时,第三方支付说支付失败了(可能它的信息是来自于银行);

  • 我这边还款 1 笔,第三方支付那边搞成了 2 笔(可能是它们自己的原因,也可能是银行的原因);

对账主要是根据“订单号”、“状态”、“日期”,主要是看状态和日期,是否对的。金额之类的,一般是不核对的,因为它不会出错。

兜底方案虽然好,但往往需要人工介入,成本高、反馈慢,如果能够系统自动就识别并修正,保证系统一致,那么在用户体验和成本角度考虑,都是很合适的。所以兜底方案和系统一致性是相互补充、各自取长补短的事情。

总结

上面以我们的支付服务,作为一个最终一致性的例子。虽然场景不是很复杂,但写的比较细致,需要考虑的点也还是不少,希望能帮助到读者,将来在处理类似问题时,能够有比较清晰的思路。

作者介绍

作者张轲目前任职于杭州大树网络技术有限公司,担任首席架构师,负责系统整体业务架构以及基础架构,熟悉微服务、分布式设计、中间件领域,对运维、测试、敏捷开发等相关领域也有所涉猎,个人微信公众号:zhangke83。


当前分布式系统是大势所趋,Google、Facebook 等大型系统架构也是以分布式系统架构为基础。过去二十年,整个分布式系统架构演进史是从 C/S→B/S→分布式系统→网格计算→云计算,包括目标、定位、场景,影响深远。未来,如何从全球多域的角度去规划分布式架构呢?

下个月深圳 ArchSummit 架构师峰会邀请了 Pinterest 武永胜、百度王耀、菜鸟网络黄浩、美团宋斌来分享他们的一手分布式系统设计经验,这些内容一定可以助你一臂之力。

相关 [服务 一致性 实践] 推荐:

一个支付服务的最终一致性实践案例(含伪代码)

- -
“功夫贷”是一款线上贷款 APP,主要是给信用卡优质用户提供纯线上的信用贷款,以期限长、额度高、利息低为主要优势(类似的业务模式主要有宜人贷). 和任何一种分期贷款一样,符合资质的用户,在功夫贷成功贷款之后,需要在约定还款日还款. 目前还款主要有以下这几种方式:. 系统定时通过后台任务扣款;. 催收人员通过内部作业系统,手动发起扣款;.

随机高并发查询结果一致性设计实践

- - 掘金 架构
作者:京东物流 赵帅 姚再毅 王旭东 孟伟杰 孔祥东. 物流合约中心是京东物流合同管理的唯一入口. 为商家提供合同的创建,盖章等能力,为不同业务条线提供合同的定制,归档,查询等功能. 由于各个业务条线众多,为各个业务条线提供高可用查询能力是物流合约中心重中之重. 同时计费系统在每个物流单结算时,都需要查询合约中心,确保商家签署的合同内容来保证计费的准确性.

微服务下的数据一致性思考

- -
之前讲到了数据库层和缓存层的改造思路,而对于业务层的改造,采用了集中式服务转微服务的架构方案. 既然是微服务,就意味着面临大量的服务间的内部调用及服务依赖,这就意味着,如果一次请求的调用涉及到两个或多个微服务之间的调用,恰好有下游的微服务调用失败,我们就必须要考虑到回滚及服务间保证数据一致性的问题.

微服务分布式一致性模式 – ThoughtWorks洞见

- -
微服务拆分后遇到的一个麻烦是分布后的一致性问题. 单体架构的业务处理和数据都在一个进程里面,一致性保障很成熟,开发人员基本上不用关心. 当把业务系统拆分到不同进程时,就遇到了技术性一致性问题. 这带来了纠结,我们希望有一颗银弹,一把解决问题. 但由于分布式一致性在(CAP)理论上没有完美的解决方案,我们所能选择的方案是在特定业务场景下的选择.

消息系统在微服务间通讯的数据一致性

- - 互联网 - ITeye博客
微服务是当下的热门话题,今天来聊下微服务中的一个敏感话题:如何保证微服务的数据一致性. 谈到分布式事务,就避免不了CAP理论. CAP理论是指对于一个分布式计算系统来说,不可能同时满足以下三点: . 一致性(Consistence) (等同于所有节点访问同一份最新的数据副本). 可用性(Availability)(对数据更新具备高可用性).

一文讲透微服务架构下如何保证事务的一致性

- - InfoQ推荐
随着业务的快速发展、业务复杂度越来越高,传统单体应用逐渐暴露出了一些问题,例如开发效率低、可维护性差、架构扩展性差、部署不灵活、健壮性差等等. 而微服务架构是将单个服务拆分成一系列小服务,且这些小服务都拥有独立的进程,彼此独立,很好地解决了传统单体应用的上述问题,但是在微服务架构下如何保证事务的一致性呢.

Java Web 服务性能优化实践

- - 博客 - 伯乐在线
来源: IBM developerworks. 简介: 本文介绍如何提升 Java Web 服务性能,主要介绍了三种方法:一是采用 Web 服务的异步调用,二是引入 Web 服务批处理模式,三是压缩 SOAP 消息. 重点介绍在编程过程中如何使用异步 Web 服务以及异步调用和同步调用的差异点. 本文还示范了如何在项目中使用以上三种方法,以及各种方法所适合的应用场景.

微服务架构实践感悟

- - mindwind
从去年初开始接触微服务架构的一些理念,然后到今年开始实施系统第四个大版本的架构升级决定采用这套架构理念. 最近关于微服务架构的讨论还是多起来,因为国外一些著名互联网公司(如:Amazon、Netflix 等)从实践中摸索出了一套新的大型系统架构方法论,并取得了成功,树立了很好的示范,然后这套方法论渐渐就被一些技术理论派 人士命名为微服务架构(Microservices).

基于Golang的微服务——Micro实践

- - IT瘾-tuicool
开始开发前需要先配置好Go的开发环境,可以看我写的 基于Golang的微服务——上手篇. 在 GOPATH目录下的src目录下创建我们的实战项目目录 tech,切换到这个目录. go get github.com/micro/go-micro //用于开发的微服务的RPC框架,是micro架构的基础 go get github.com/micro/protoc-gen-micro // 用于生成Protobuf的代码 go get github.com/micro/micro // 工具集安装,会自动将 micro加入环境变量 复制代码.

美团 HTTP 服务治理实践

- - IT瘾-dev
2019 年 7 月 6 日,OpenResty 社区联合又拍云,举办 OpenResty × Open Talk 全国巡回沙龙·上海站,美团基础架构部技术专家张志桐在活动上做了《美团 HTTP 服务治理实践》的分享. OpenResty x Open Talk 全国巡回沙龙是由 OpenResty 社区、又拍云发起,邀请业内资深的 OpenResty 技术专家,分享 OpenResty 实战经验,增进 OpenResty 使用者的交流与学习,推动 OpenResty 开源项目的发展.