buguge - Keep it simple,stupid

知识就是力量,但更重要的,是运用知识的能力why buguge?

导航

【聚合系统开发专栏】支付请求发起后,回调通知先于同步响应,怎么办?

以请求三方付款为例,通常是先发起付款请求,然后等待三方异步通知付款结果,或者我方主动调三方查询付款结果。见下方时序示意图①

#我方聚合系统 三方支付系统
#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_FAILED
 6 
 7         // 前置状态校验
 8         if (paymentOrder.payState != "PAYING") {
 9             return;
10         }
11 
12         // 处理银行回调,更新付款状态
13         // ...
14     }

 

从逻辑上讲,这没有什么问题。处理回调失败,我们可以等三方通道的重试回调(如有),或者等我们的主动查单,来完成付款终态的变更。

这样讲的话,至此,本文就可以结束了。不过,这里要强调的是一个重要的东西————时效——>付款时效

付款时效,指的是付款交易从 付款请求 到 付款完成 整个生命周期的持续时长。聚合系统为满足商户体验,尤其要关注这个业务指标。

 

 

那么,针对这种”异步回调在先,同步响应在后“的情况,应该怎么兼顾付款时效呢?

 

修改状态机。处理回调的方法里,将前置状态由 PAYING 改为 PAYINGINIT

 

如果你这么做的话,那你的得分是0。因为这是一个❎错误姿势。

“看似”解决了问题,但是,会存在

  • 安全隐患。--->当回调方法不慎被非正常执行时,会出现“付款并未请求银行,付款状态却被更新成了终态”,产生资损。
  • 程序设计隐患。——这为系统熵增埋下伏笔。--->你在这儿修改了状态机控制,日后,付款交易的其他方法里的状态机控制也会被修改。

 

那么,针对这种”异步回调在先,同步响应在后“的情况,应该怎么兼顾付款时效呢?

首先,我们要熟知支付领域知识及常见的付款程序逻辑。付款请求三方通道后,会在通道响应后,变更付款状态由“INIT” 为 “付款中”,同时,三方通道收到付款请求后,会异步发起付款转账。通常情况下,必然是三方通道先响应付款请求,然后三方通道在异步的的付款转账业务完成后,才会做付款回调。见上方的时序示意图①

接着分析。既然存在了时序示意图③中的这种”异步回调在先,同步响应在后“的个别非正常情况,我们在处理回调的代码稍作改动,就可以在付款时效方面柳暗花明。

 

一种可能的办法,是在前置状态校验失败后,直接回写处理失败的消息,这样可以等待三方通道重试回调。不过,三方通道的重试策略,会直接影响到付款时效。如果三方通道那边在首次回调失败后,30s后才再次重试发起回调,就未必有下文要提的方案好了。(当然,对于不支持回调重试的三方通道,这个方法不可行)。

 

最简单的技术是最有效的解决办法。————使用 Thread#sleep。

要使用 Tread#sleep重试,我们就从”前置状态校验”这里着手修改。

   void handleBankNotify(BankNotifyMessage notifyMessage) {
        PaymentOrder paymentOrder = paymentOrderDAO.getByPaymentTransNo(notifyMessage.merTransNo);
        if (paymentOrder == null) return;

        //** 付款状态流转:INIT → PAYING → PAY_SUCCESS / PAY_FAILED

        // 前置状态校验
        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_FAILED

        // 前置状态校验
        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能规避”服务故障“、”服务重启“等某些不稳定因素。

还有同学可能想到分布式锁,在付款请求时加锁,在处理回调时判断锁,使用同步锁机制实现串行处理。

我认为没什么必要,有些小题大做,越简单越好。毕竟,付款终态的变更,不是只靠这次的回调。

 

BTW,上面线程池异步的方案,落地到系统中的时候,假如我们要在延迟执行时打印一个log,用以标记xx付款交易单触发了延迟重试,该怎么做呢? 直接的实现办法是,见下方改动,为 handleBankNotify 增加一个boolean参数。然后2个调用的地方分别给true和false。

    void handleBankNotify(BankNotifyMessage notifyMessage, PaymentOrder paymentOrder, boolean isFirstCall) {
        if (isFirstCall == false) {
            log.info("{}付款交易单触发了延迟重试", paymentOrder.tradeNo);
        }
    
        Assert.isTrue(paymentOrder.payState == "PAYING", "付款状态异常");

        // 处理银行回调,更新付款状态
        // ...
    }
View Code

这个办法存在的问题是,可读性可理解性差,你往往还要在这个boolean参数命名上费一番脑细胞。为此,我们还可以巧(qiǎo)用下面的办法。

scheduledExecutorService.schedule(() -> {
    log.info("{}付款交易单触发了延迟重试", paymentOrder.tradeNo);
    handleBankNotify(notifyMessage, paymentOrder);
}, 5, TimeUnit.SECONDS);
View Code

 

知识就是力量。上述解决办法涉及的技术大家都会,其实其中还有一个重点,就是对那个重试时间的评估和拿捏,进一步讲,就是对业务场景的熟悉程度。看似简单的问题,考验的是综合能力。

 

 

 

 

 

 


 

【EOF】欢迎大家关注我的微信公众号「靠谱的程序员」,让我们一起做靠谱的程序员。

 

posted on 2024-11-04 20:29  buguge  阅读(72)  评论(4编辑  收藏  举报