“功夫贷”是一款线上贷款 APP,主要是给信用卡优质用户提供纯线上的信用贷款,以期限长、额度高、利息低为主要优点(相似的业务模式主要有宜人贷)。html
和任何一种分期贷款同样,符合资质的用户,在功夫贷成功贷款以后,须要在约定还款日还款。目前还款主要有如下这几种方式:redis
用户在 APP 上主动还款;数据库
系统定时经过后台任务扣款;安全
催收人员经过内部做业系统,手动发起扣款;服务器
真正的扣款操做(从银行卡扣款)主要是经过第三方支付来完成,好比京东支付、通联等。不一样的第三方支付,支持的银行列表和限额不一样,费用和稳定性也不尽相同,咱们会选择出个最优通道、以及多层级备用通道,为此研发了支付路由系统,同时这些服务商的业务限制 / 出错几率还不低,因此咱们又要考虑业务上的一致性,这也是本文要介绍的主题。markdown
扣款业务是比较复杂的,包括以下几个主要步骤:网络
这多个子功能须要保证同时成功或者同时失败,其中既有外部第三方调用,又有内部微服务的调用,因此这是个比较典型的分布式事务的场景。因为外部的第三方支付服务有时不稳定、且部分交易可能很长时间才能确认成功。多线程
所以 咱们没考虑两阶段提交的分布式事务,而是选择了最终一致性,而为了保证在状态不一致这个时间窗口的准确性 (好比不能在该窗口对用户重复扣款),咱们也额外多作了不少的考虑。异步
扣款服务的主流程以下图所示(在这里仅举“第三方支付渠道是同步返回扣款结果”做为例子,在实际状况中,各家第三方支付渠道的接口并不一致,有同步返回的、也有异步 + 轮询方式的,这两种形式,在咱们这的处理逻辑上没有明显区别)。分布式
为了不对业务流程形成干扰,上图中把一样处于主执行路径上的、起着日志记录做用的"log-x"这些步骤,在各自所处的位置以虚线表示,记得它们是主流程的一部分。这些“log-x”步骤在实现上,是创建一张日志表,以持久化、结构化的方式来记录,并非 logback 之类的文件日志,由于这些日志在异常时的恢复,起着重要做用。
从上图能够看出,由 一、二、三、四、6 这五个步骤,造成一个总体,咱们须要保证的是,这 5 个步骤同时成功、或者同时失败。其中包含几类操做:
本地 DB 的 SQL 执行,包括步骤 一、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,在这种状况下,程序执行到一半,忽然结束了,这时怎么保证最终一致性?
在这种状况下,只能是靠日志表了,主流程或任何依赖内存记录的恢复程序都无效了。
定时任务的目的是补偿未能正常结束的扣款任务。通常来讲,若是扣款任务未能正常结束,可能会有以下几种缘由:
系统意外退出(进程被 KILL、宕机等);
系统重启——如当前某笔扣款记录在轮询第三方支付服务的扣款状态,此时重启也形成了流程中断;
执行过程当中出错,如数据库异常、调用超时、MQ 不可用等;
为了达到补偿目标,须要设计若干张日志表来辅助。咱们设计了 2 张,如图:
其一,“扣款途中日志表”是用于标识扣款任务是否仍然在途中。在扣款开始以前,往该表插入记录,扣款完成后 (成功或失败) 更新状态。该表主要目的是:能够方便地找出来,哪些扣款任务是没有正常结束的。为何没直接用业务表“还款记录表”来查询在途扣款呢? 主要是从便捷性和性能上考虑——业务表的数据是不能删除的,而该日志表能够按期将已完成的扣款任务清除掉,以控制该表其数据量,保证查询效率;
其二,“扣款执行日志表”是用于记录扣款任务的执行过程。该表的记录不更新,只插入。若是某个扣款任务须要恢复补偿,则从该表中找到上次执行的“断点”,继续向后执行。上图中举了 3 组数据做为例子:黄色背景是一笔完成的、扣款成功的日志;浅绿色背景是一笔完成的、扣款失败的日志;浅橙色背景是一笔进行中(正在执行调用第三方扣款)的日志。
下面是定时补偿任务的主流程:
在实践中,一个正常的扣款任务在 1 分钟内都应该结束了,时间主要花费在调用第三方扣款服务上,绝大部分 30 秒内结束,少许的会拖的时间比较长,甚至跨日;
定时任务 3 分钟执行一次,每次扫描 3 分钟前开始的、且当前未结束的任务。3 分钟之内的任务不处理的缘由是:它们可能仍然在本身的正常处理过程当中,此时还不须要定时任务来接管;
为了便于读者理解,这里以伪代码的形式把整个扣款过程写出来,且分几个迭代版本不断加强。
在执行以前,注意要把数据库事务设为自动提交,即不可把整个过程归入到一个事务里——不只是性能问题,更重要的是,若是过程当中失败了,日志数据也被回滚掉了,没法恢复;
面对预期内的异常和预期外的异常,如详细设计里所述,或抛出异常结束、或 return 结束,后期由定时任务补偿。在主流程中不作各类各样繁杂的异常处理,既避免繁琐,也避免出错;
上面只是伪代码,在实践中应该打印出详细的 Exception 信息、以及 log 文件日志,以便于定位和查找问题;
版本一有 2 个问题
若是失败了,都要等定时任务补偿,那样响应有些慢,毕竟定时任务几分钟才执行一次;
定时任务补偿时,要判断以前执行到哪,若是补偿的起始阶段不一样、代码逻辑也不同,这也比较麻烦;
基于此,有了版本 II,这里取“调用第三方支付渠道扣款”的片断来讲明。
红色部分增长了日志状态的判断。若是是补偿性的,如该步骤之前已经成功了,则跳过这段调用第三方的逻辑;
蓝色部分增长了先查询的操做,不管是否已经调用过扣款;
褐色部分增长了后台线程池轮询,而不是单单等定时任务去触发;这地方实践中稍微控制下线程池数量、且最好有多路复用的模式,防止不少线程都挂在那轮询;
绿色部分,实际上是出现异常的话,上面这些步骤能够再来一遍;
不难看出,该版本主要是增长各个逻辑段的幂等性,既使其能安全执行、又使代码逻辑简洁。
版本二还能够更为严谨一点——拿下面这个代码段红框里的来讲,若是在两段 SQL 之间失败了,有形成不一致的可能(几率很小)。
经过事务保证逻辑段能同时成功或同时失败。虽然几率很小,但若是线上发生了,很难找到缘由。
上面这些伪码是本人用 markdown 纯粹手敲的,并非生产代码,没有通过严格测试,因此若是有些地方写的笔误或逻辑有漏洞,请读者谅解。
经过上面分析,咱们看到有多个地方可能会对同一笔还款记录扣款,包括:
正常执行扣款;
提交到后台线程池的重试 / 轮询;
定时任务补偿;
人工执行扣款
因此针对单笔还款记录的扣款操做,咱们须要使用锁定,实践中咱们采用的是 redission 来作的分布式锁,这比较简单,这里很少叙述,不忽略这一点就好。
兜底方案
上面咱们分析了不少,对主流程中的分支都作了不少的考虑,但仍然有这两个风险:
有些异常分支没有考虑到;
随着业务的发展,新加进来的逻辑,或者新人进来,极可能有些新的分支点没有被充分考虑;
因此从严谨的角度,咱们须要个兜底方案——主动检查 + 对帐,以主动识别任何异常现象。从实践上看,因为业务的复杂性以及持续变化,可能很难彻底梳理清楚全部的异常点,所以“主动检查 + 对帐”可能更为重要。
主动检查
咱们建立了个 Thread,定时查询还款计划表中,处于”扣款中“的异常数据,进行检查,若是有问题,自动修正或者通知出来人工干预。好比某条还款记录,从“还款中”的状态到如今,已通过去了 1 个小时了,这种状况就会被断定为可疑现象,须要人工介入。
对帐
仍然有一些状况,是系统所覆盖不到的,须要双方对帐 (咱们和第三方支付对帐、第三方支付和银行对帐)。主要有如下这些场景:
跨日——双方把订单归到不一样日期。好比 23:59 的订单,咱们归到今天,第三方支付那边可能归到次日;
第三方支付开始告诉咱们是成功的,咱们已经结束操做了,后来对帐时,第三方支付说支付失败了(可能它的信息是来自于银行);
我这边还款 1 笔,第三方支付那边搞成了 2 笔(多是它们本身的缘由,也多是银行的缘由);
对帐主要是根据“订单号”、“状态”、“日期”,主要是看状态和日期,是否对的。金额之类的,通常是不核对的,由于它不会出错。
兜底方案虽然好,但每每须要人工介入,成本高、反馈慢,若是可以系统自动就识别并修正,保证系统一致,那么在用户体验和成本角度考虑,都是很合适的。因此兜底方案和系统一致性是相互补充、各自取长补短的事情。
总结
上面以咱们的支付服务,做为一个最终一致性的例子。虽然场景不是很复杂,但写的比较细致,须要考虑的点也仍是很多,但愿能帮助到读者,未来在处理相似问题时,可以有比较清晰的思路。
原文连接:http://www.10tiao.com/html/773/201806/2247487977/1.html