【聚合系统业务场景设计】异步回调先于同步响应,怎么办?
以请求三方付款为例,通常是先发起付款请求,然后等待三方异步通知付款结果,或者我方主动调三方查询付款结果。见下方时序示意图①。
# | 我方聚合系统 | 三方支付系统 | |
---|---|---|---|
#1 | 发起付款请求 | → | 接收付款请求,处理付款 |
#2.1 | 接收请求,变更付款状态 | ←← | 付款完成,主动回调 |
#2.2 | 主动查单 | → | 接收查单请求,返回付款状态 |
本文我们谈三方的异步回调,所以,我们细化一下#1、#2.1,其中包含了付款状态的正常流转。
# | 我方聚合系统 | 三方支付系统 | |
---|---|---|---|
#1 | 发起付款请求(状态=INIT) | → | 接收付款请求,处理付款 |
保存同步响应结果 (状态变更 INIT→PAYING) |
← | 同步响应 | |
#2.1 |
接收请求,变更付款状态 (状态变更 PAYING→PAY_SUCCESS) |
←← | 付款完成,主动回调 |
某些三方支付,如支付宝,对于支付宝账户转账业务,处理非常快。 这时,会出现 异步回调 先于 同步响应 的个别情况。见下方时序示意图③。从示意图中可知,回调过来的付款状态将无法得到持久化变更。
# | 我方聚合系统 | 三方支付系统 | |
---|---|---|---|
#1 | 发起付款请求(状态=INIT) | → | 接收付款请求,处理付款 |
#2.1 |
接收请求,变更付款状态 (发现前置状态不是PAYING,状态变更失败) |
←← | 付款完成,主动回调 |
#1 |
保存同步响应结果 (状态变更 INIT→PAYING) |
← | 同步响应 |
这里,我们先贴出来聚合系统里处理三方回调的代码————talk is cheap,程序员直接看代码,往往更容易理解。从代码中可以看出,是“前置状态校验”失败,中止了方法的执行,进而无法修改交易单的付款状态。
1 void handleBankNotify(BankNotifyMessage notifyMessage) { 2 PaymentOrder paymentOrder = paymentOrderDAO.getByPaymentTransNo(notifyMessage.merTransNo); 3 if (paymentOrder == null) return; 4 5 //** 付款状态流转:INIT → PAYING → PAY_SUCCESS / PAY_FAIL 6 7 // 前置状态校验 8 if (paymentOrder.payState != "PAYING") { 9 return; 10 } 11 12 // 处理银行回调,更新付款状态 13 // ... 14 }
从逻辑上讲,这没有什么问题。处理回调失败,我们可以等三方通道的重试回调(如有),或者等我们的主动查单,来完成付款终态的变更。
这样讲的话,至此,本文就可以结束了。不过,这里要强调的是一个重要的东西————时效——>付款时效。
付款时效,指的是付款交易从 付款请求 到 付款完成 整个生命周期的持续时长。聚合系统为满足商户体验,尤其要关注这个业务指标。
那么,针对这种”异步回调在先,同步响应在后“的情况,应该怎么兼顾付款时效呢?
修改状态机。处理回调的方法里,将前置状态由 PAYING
改为 PAYING
和 INIT
。
如果你这么做的话,那你的得分是0。因为这是一个❎错误姿势。
“看似”解决了问题,但是,会存在
- 安全隐患。--->当回调方法不慎被非正常执行时,会出现”未请求银行,付款状态却被更新成了终态“。
- 程序设计隐患。——这为系统熵增埋下伏笔。--->你在这儿修改了状态机控制,日后,付款交易的其他方法里的状态机控制也会被修改。
那么,针对这种”异步回调在先,同步响应在后“的情况,应该怎么兼顾付款时效呢?
首先,我们要清楚通道付款领域知识及常见的付款程序逻辑。付款请求三方通道后,会在通道响应后,变更付款状态由“INIT” 为 “付款中”,同时,三方通道收到付款请求后,会异步发起付款转账。通常情况下,必然是三方通道先响应付款请求,然后三方通道在异步的的付款转账业务完成后,才会做付款回调。见上方的时序示意图①。
接着分析。既然存在了时序示意图③中的这种”异步回调在先,同步响应在后“的个别非正常情况,我们在处理回调的代码稍作改动,就可以在付款时效方面柳暗花明。
最简单的技术是最有效的解决办法。————使用 Thread#sleep。
要使用 Tread#sleep重试,我们就从”前置状态校验”这里着手修改。
void handleBankNotify(BankNotifyMessage notifyMessage) { PaymentOrder paymentOrder = paymentOrderDAO.getByPaymentTransNo(notifyMessage.merTransNo); if (paymentOrder == null) return; //** 付款状态流转:INIT → PAYING → PAY_SUCCESS / PAY_FAIL // 前置状态校验 if (paymentOrder.payState != "PAYING") { // 如果状态不是PAYING,可能是我方的同步付款请求还没结束。这种情况,尝试重试获取付款状态 retryIfNecessary(paymentOrder); } // 经过”努力“后,依然要做前置状态校验 if (paymentOrder.payState != "PAYING") { return; } // 处理银行回调,更新付款状态 // ... }
其中所调用的 retryIfNecessary
可以是下面的重试逻辑:
private void retryIfNecessary(PaymentOrder paymentOrder) { if (paymentOrder.payState != "INIT") return;// INIT状态才处理 final long maxWaitMillis = 3 * 1000; // 我方同步的付款请求可能还没处理完,银行侧(或三方支付通道侧)的回调先来了,这里最多等它3s(通常情况下,三方支付通道3s足以响应,银行则未必)。不成的话就靠主动查询啦 long start = System.currentTimeMillis(); while (true) { try { Thread.sleep(RandomUtils.nextInt(200, 500)); paymentOrder = paymentOrderDAO.getByPaymentTransNo(paymentOrder.paymentNo); if (paymentOrder.payState == "PAYING") { break; } if (System.currentTimeMillis() - start > maxWaitMillis) { break; } } catch (InterruptedException e) { break; } } // end of while-loop }
此外,我们还可以借助juc下的 ScheduledExecutorService#schedule(Runnable command, long delay, TimeUnit unit)
,延迟一定时间(3~5s)后,异步处理回调。这种方案不影响接口响应时间,这对于那些对接口RT要求高的三方通道来说更合适。
final static ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); void handleBankNotify(BankNotifyMessage notifyMessage) { PaymentOrder paymentOrder = paymentOrderDAO.getByPaymentTransNo(notifyMessage.merTransNo); if (paymentOrder == null) return; //** 付款状态流转:INIT → PAYING → PAY_SUCCESS / PAY_FAIL // 前置状态校验 if (paymentOrder.payState != "PAYING") { // 如果状态不是PAYING,可能是我方的同步付款请求还没结束。这种情况,尝试延迟处理 scheduledExecutorService.schedule(() -> handleBankNotify(notifyMessage, paymentOrder), 5, TimeUnit.SECONDS); } else { handleBankNotify(notifyMessage, paymentOrder); } } void handleBankNotify(BankNotifyMessage notifyMessage, PaymentOrder paymentOrder) { Assert.isTrue(paymentOrder.payState == "PAYING", "付款状态异常"); // 处理银行回调,更新付款状态 // ... }
以此类推,有同学可能想到mq,并称使用mq能规避”服务故障“、”服务重启“等某些不稳定因素。
还有同学可能想到分布式锁,在付款请求时加锁,在处理回调时判断锁,使用同步锁机制实现串行处理。
我认为没什么必要,有些小题大做,越简单越好。毕竟,付款终态的变更,不是只靠这次的回调。
知识就是力量。上述解决办法涉及的技术大家都会,其实其中还有一个重点,就是对那个重试时间的评估和拿捏,进一步讲,就是对业务场景的熟悉程度。看似简单的问题,考验的是综合能力。
【EOF】欢迎大家关注我的微信公众号「靠谱的程序员」,让我们一起做靠谱的程序员。
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/18526176