正文
-
本地 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 有丢失消息的风险,那仍然可能不一致)。
难题二:出现预期外的异常,如何保证最终一致性?