錢付了,訂單還是未支付,使用者炸了!——聊聊如何防止支付掉單!

Weiwen發表於2022-10-11

今天分享一篇支付系統中,常見的掉單操作~

好好的支付,怎麼就掉單了?

我聽說過下單、買單、脫單……掉單是什麼東西?

所謂的掉單,就是使用者下單支付,在錢包裡完成了支付,結果回到電商APP一看,訂單還是未支付……

毫無疑問,使用者肯定會炸,結果不是客訴,就是差評。

圖片

使用者感覺受到了欺詐

那麼掉單是怎麼來的呢?

我們先來看看訂單支付的完整流程:

圖片

錢包支付的完整流程

  1. 使用者從電商應用點選支付,客戶端向服務端發起支付請求

  2. 支付服務會向第三方的支付渠道發起支付,支付渠道會響應對應的url

  3. 以APP為例,客戶端通常是會拉起對應的錢包,使用者跳到對應的錢包

  4. 使用者在錢包裡完成支付

  5. 使用者完成支付後,跳轉回對應的電商APP

  6. 客戶端輪詢訂單服務,獲取訂單狀態

  7. 支付渠道回撥支付服務,通知支付結果

  8. 支付服務通知訂單服務,更新訂單狀態

對於支付訂單而言,大概可以分為這麼幾個狀態:

圖片

支付狀態

  • 未支付:使用者在點選支付之後,支付服務請求支付渠道之前,處於未支付狀態

  • 支付中:使用者發起支付後,到跳轉到支付錢包,再到完成支付,支付服務獲取到最終支付結果之間,屬於支付中狀態,這個狀態下,可以說是一個迷霧狀態,電商系統對於使用者的支付是不確定

  • 支付成功/失敗/取消/關閉:電商系統最終確定了使用者在第三方錢包的支付最終結果

看起來沒什麼問題啊,怎麼就掉單了?簡單說,就是支付的狀態沒有同步到,或者沒有及時同步到。

圖片

掉單發生

  1. 支付渠道的支付回撥

    發生了一些異常,導致支付服務沒有收到支付渠道的回撥通知

  2. 支付服務通知訂單服務

    服務內部出現異常,導致支付狀態沒有同步到訂單服務

  3. 客戶端獲取訂單狀態

    客戶端通常是輪詢獲取狀態,可能會在輪詢時間內沒有獲取到訂單狀態,結果使用者看到未支付

其中1可以稱之為外部掉單,2和3可以稱之為內部掉單。

接下來我們看看,怎麼預防掉單問題。

我們先從系統內部的掉單說起,當然在系統內部,穩定性更容易保證,發生掉單的機率還是比較小的。

服務端防止掉單

支付服務和訂單服務之間防止掉單,關鍵就在於儘可能保證支付通知訂單支付結果成功,我們一般透過這兩種方式。

圖片

服務端防止掉單

  1. 同步呼叫重試機制

    支付服務呼叫訂單服務的時候,要進行失敗重試,防止網路抖動情況下的呼叫失敗。

  2. 非同步訊息可靠性投遞

    同步不穩妥,那就再加一個非同步。支付服務投遞一個支付成功訊息,訂單服務消費支付成功訊息,整個過程要儘可能保證可靠性,例如訂單服務要在完成訂單狀態更新後再確認完成訊息消費。

同步+非同步兩手策略,基本上可以防範服務端的內部掉單。

至於引入分散式事務(事務訊息、Seata)來保證狀態一致,我覺得也沒有必要。

客戶端如何防止掉單

使用者支付完成後,跳回電商系統,客戶端會輪詢一下訂單的狀態,通常兩三秒內,就會得到訂單完成支付的結果,這個過程出現問題的機率相比是非常低的。

但是也不排除,很小機率下,客戶端輪詢一段時間,還沒得到結果,那麼只能結束輪詢,給使用者展示未支付。

這種情況,通常問題也是出在服務端,沒有及時更新訂單的狀態,最主要的還是要處理服務端的掉單,保證服務端能及時同步支付訂單的狀態。

但是一旦服務端的訂單狀態變更了,也要儘可能同步到客戶端,不能讓使用者一直看到未支付。

客戶端和服務端之間,同步狀態,無非就是推和拉:

  1. 客戶端輪詢

    客戶端判斷使用者未支付之後,通常會進行訂單倒數計時。

    圖片

    倒數計時

    這裡再提一下?大家覺得這種倒數計時是怎麼實現的呢?純客戶端組元件倒數計時嗎?

    ——肯定不行,通常是客戶端元件倒數計時,定期向服務端請求,檢查倒數計時時間。同樣的,這種情況下,客戶端也可以檢查支付狀態。

  2. 服務端推送

    說真的,服務端推送,看上去是一種很美好的方案,Web端可以使用Websocket,APP端可以用自定義Push,大家可以看看我有 7種 實現web實時訊息推送的方案,7種!。但實際上,推送的成功率經常不那麼理想。

相比較內部掉單,外部掉單發生的機率就大很多,畢竟和外部渠道的對接,不可控的因素更多。

要防止外部掉單,核心就是四個字:“主動查詢”,如果只是等待第三方的回撥通知,風險還是比較大的,支付服務要主動向第三方查詢支付狀態,即使有什麼異常,也能及時感知到。

主動查詢,主要就是兩種形式:

定時任務查詢

毫無疑問,最簡單的肯定就是定時任務了,支付服務,定時查詢一段時間內支付中的支付訂單,向第三方渠道查詢支付結果,查詢到終態之後,就去更新支付訂單狀態、通知訂單服務:

圖片

定時查詢支付狀態

實現也很簡單,用xxl-job之類的定時任務框架,定時掃表,向第三方查詢就行了,大概程式碼如下:

    @XxlJob("syncPaymentResult")
    public ReturnT<String> syncPaymentResult(int hour) {
        //……
        //查詢一段之間支付中的流水
        List<PayDO> pendingList = payMapper.getPending(now.minusHours(hour));
        for (PayDO payDO : pendingList) {
            //……
            // 主動去第三方查
            PaymentStatusResult paymentStatusResult = paymentService.getPaymentStatus(paymentId);
            // 第三方支付中
            if (PaymentStatusEnum.PENDING.equals(paymentStatusResult.getPayStatus())) {
                continue;
            }
            //支付完成,獲取到終態
            //……
            // 1.更新流水
            payMapper.updatePayDO(payDO);
            // 2.通知訂單服務
            orderService.notifyOrder(notifyLocalRequestVO);
        }
        return ReturnT.SUCCESS;
    }

定時任務的最大好處肯定是簡單了,但是它也有一些問題:

  1. 查詢的結果不實時

    定時任務頻率的設定永遠是個不好確定的事情,間隔短對資料庫壓力大,間隔長了不實時,很容易出現,上面提到的使用者回到APP,結果輪詢不到支付成功狀態的情況。

    實際上,使用者跳轉錢包之後,通常會很快完成支付,如果短時間內沒有完成支付,那麼一般也不會再付了。所以其實,發起支付開始,從第三方查詢支付結果的頻率應該是遞減的。

  2. 對資料庫有壓力

    定時任務掃表,對資料庫肯定是會有壓力的,掃表的時候,經常會看到資料庫的監控出現一個小突刺,如果資料量大的話,可能影響更大。

    可以單獨建立一個支付中流水錶,定時任務掃描這張表,獲取到支付最終態之後,就刪除掉對應的記錄。

延時訊息查詢

定時任務存在一些問題,那麼有沒有什麼其它辦法呢?答案是延時訊息。

圖片

延時訊息查詢支付狀態

  • 在發起支付之後,傳送一個延時訊息,前面講到,使用者跳轉到錢包,通常很快會支付,所以我們希望查詢支付狀態這個步驟,符合這個規律,所以希望在10s、30s、1min、1min30s、2min、5min、7min……這種頻率去查詢支付訂單的狀態,這裡我們可以用一個佇列結構實現,佇列裡存放下一次查詢的時間間隔。

    大概程式碼如下:

         //……
            //控制查詢頻率的佇列,時間單位為s
            Deque<Integer> queue = new LinkedList<>();
            queue.offer(10);
            queue.offer(30);
            queue.offer(60);
            //……
            //支付訂單號
            PaymentConsultDTO paymentConsultDTO = new PaymentConsultDTO();
            paymentConsultDTO.setPaymentId(paymentId);
            paymentConsultDTO.setIntervalQueue(queue);
            //傳送延時訊息
            Message message = new Message();
            message.setTopic("PAYMENT");
            message.setKey(paymentId);
            message.setTag("CONSULT");
            message.setBody(toJSONString(paymentConsultDTO).getBytes(StandardCharsets.UTF_8));
            try {
                //第一個延時訊息,延時10s
                long delayTime = System.currentTimeMillis() + 10 * 1000;
                // 設定訊息需要被投遞的時間。
                message.setStartDeliverTime(delayTime);
                SendResult sendResult = producer.send(message);
                //……
            } catch (Throwable th) {
                log.error("[sendMessage] error:", th);
            }

    PS:這裡用的是RocketMQ雲伺服器版,支援任意級別的延時訊息,開源版的RocketMQ只支援固定級別的延時訊息,不得不感慨充錢才能變強。有實力的開發團隊,可以在開源基礎上,進行二次開發。

  • 在消費到延時訊息之後,向第三方查詢支付訂單的狀態,如果還在支付中,就繼續傳送下一個延時訊息,延時間隔從佇列結構中取。如果獲取到最終態,就去更新支付訂單狀態、通知訂單服務。

    @Component
    @Slf4j
    public class ConsultListener implements MessageListener {
        //消費者註冊,監聽器註冊
        //……
    
        @Override
        public Action consume(Message message, ConsumeContext context) {
            // UTF-8解析
            String body = new String(message.getBody(), StandardCharsets.UTF_8);
            PaymentConsultDTO paymentConsultDTO= JsonUtil.parseObject(body, new TypeReference<PaymentConsultDTO>() {
            });
            if (paymentConsultDTO == null) {
                return Action.ReconsumeLater;
            }
            //獲取支付流水
            PayDO payDO=payMapper.selectById(paymentConsultDTO.getPaymentId());
            //……
            //查詢支付狀態
            PaymentStatusResult paymentStatusResult=payService.getPaymentStatus(paymentStatusContext);
            //還在支付中,繼續投遞一個延時訊息
            if (PaymentStatusEnum.PENDING.equals(paymentStatusResult.getPayStatus())){
                //傳送延時訊息
                Message msg = new Message();
                message.setTopic("PAYMENT");
                message.setKey(paymentConsultDTO.getPaymentId());
                message.setTag("CONSULT");
               //下一個延時訊息的頻率
                Long delaySeconds=paymentConsultDTO.getIntervalQueue().poll();        message.setBody(toJSONString(paymentConsultDTO).getBytes(StandardCharsets.UTF_8));
                try {
                    Long delayTime = System.currentTimeMillis() + delaySeconds * 1000;
                    // 設定訊息需要被投遞的時間。
                    message.setStartDeliverTime(delayTime);
                    SendResult sendResult = producer.send(message);
                    //……
                } catch (Throwable th) {
                    log.error("[sendMessage] error:", th);
                }
                return Action.CommitMessage;
            }
            //獲取到最終態
            //更新支付訂單狀態
            //…… 
            //通知訂單服務
            //……
            return Action.CommitMessage;
        }
    }

    延時訊息的方案相對於定時輪詢方案來講:

    不過大家也看到,我這裡的實現是利用的是充錢版的RocketMQ,所以看起來不太複雜,但是如果用開源方案,那就沒那麼簡單。

    充錢就能解決

  • 時效性更好

  • 無需掃表,對資料庫壓力較小

這篇文章介紹了一個讓使用者炸毛,讓客服惱火,讓開發撓頭的問題——掉單,包括為什麼會掉單,怎麼防止掉單。

其中內部掉單,發生的機率相對較少,掉單最主要的原因還是所謂的外部掉單。

外部掉單解決的關鍵點是主動查詢,有兩種常用的方案:定時任務查詢延時訊息查詢,前者簡單一些,後者功能上更加出色。

本文轉自:
錢付了,訂單還是未支付,使用者炸了!——聊聊如何防止支付掉單!

本作品採用《CC 協議》,轉載必須註明作者和本文連結
最美的不是下雨天,而是和你一起躲過的屋簷!

相關文章