今天分享一篇支付系統中,常見的掉單操作~
好好的支付,怎麼就掉單了?
我聽說過下單、買單、脫單……掉單是什麼東西?
所謂的掉單,就是使用者下單支付,在錢包裡完成了支付,結果回到電商APP一看,訂單還是未支付……
毫無疑問,使用者肯定會炸,結果不是客訴,就是差評。
使用者感覺受到了欺詐
那麼掉單是怎麼來的呢?
我們先來看看訂單支付的完整流程:
錢包支付的完整流程
使用者從電商應用點選支付,客戶端向服務端發起支付請求
支付服務會向第三方的支付渠道發起支付,支付渠道會響應對應的url
以APP為例,客戶端通常是會拉起對應的錢包,使用者跳到對應的錢包
使用者在錢包裡完成支付
使用者完成支付後,跳轉回對應的電商APP
客戶端輪詢訂單服務,獲取訂單狀態
支付渠道回撥支付服務,通知支付結果
支付服務通知訂單服務,更新訂單狀態
對於支付訂單而言,大概可以分為這麼幾個狀態:
支付狀態
未支付:使用者在點選支付之後,支付服務請求支付渠道之前,處於未支付狀態
支付中:使用者發起支付後,到跳轉到支付錢包,再到完成支付,支付服務獲取到最終支付結果之間,屬於支付中狀態,這個狀態下,可以說是一個迷霧狀態,電商系統對於使用者的支付是不確定
支付成功/失敗/取消/關閉:電商系統最終確定了使用者在第三方錢包的支付最終結果
看起來沒什麼問題啊,怎麼就掉單了?簡單說,就是支付的狀態沒有同步到,或者沒有及時同步到。
掉單發生
支付渠道的支付回撥
發生了一些異常,導致支付服務沒有收到支付渠道的回撥通知
支付服務通知訂單服務
服務內部出現異常,導致支付狀態沒有同步到訂單服務
客戶端獲取訂單狀態
客戶端通常是輪詢獲取狀態,可能會在輪詢時間內沒有獲取到訂單狀態,結果使用者看到未支付
其中1可以稱之為外部掉單,2和3可以稱之為內部掉單。
接下來我們看看,怎麼預防掉單問題。
我們先從系統內部的掉單說起,當然在系統內部,穩定性更容易保證,發生掉單的機率還是比較小的。
服務端防止掉單
支付服務和訂單服務之間防止掉單,關鍵就在於儘可能保證支付通知訂單支付結果成功,我們一般透過這兩種方式。
服務端防止掉單
同步呼叫重試機制
支付服務呼叫訂單服務的時候,要進行失敗重試,防止網路抖動情況下的呼叫失敗。
非同步訊息可靠性投遞
同步不穩妥,那就再加一個非同步。支付服務投遞一個支付成功訊息,訂單服務消費支付成功訊息,整個過程要儘可能保證可靠性,例如訂單服務要在完成訂單狀態更新後再確認完成訊息消費。
同步+非同步兩手策略,基本上可以防範服務端的內部掉單。
至於引入分散式事務(事務訊息、Seata)來保證狀態一致,我覺得也沒有必要。
客戶端如何防止掉單
使用者支付完成後,跳回電商系統,客戶端會輪詢一下訂單的狀態,通常兩三秒內,就會得到訂單完成支付的結果,這個過程出現問題的機率相比是非常低的。
但是也不排除,很小機率下,客戶端輪詢一段時間,還沒得到結果,那麼只能結束輪詢,給使用者展示未支付。
這種情況,通常問題也是出在服務端,沒有及時更新訂單的狀態,最主要的還是要處理服務端的掉單,保證服務端能及時同步支付訂單的狀態。
但是一旦服務端的訂單狀態變更了,也要儘可能同步到客戶端,不能讓使用者一直看到未支付。
客戶端和服務端之間,同步狀態,無非就是推和拉:
客戶端輪詢
客戶端判斷使用者未支付之後,通常會進行訂單倒數計時。
倒數計時
這裡再提一下?大家覺得這種倒數計時是怎麼實現的呢?純客戶端組元件倒數計時嗎?
——肯定不行,通常是客戶端元件倒數計時,定期向服務端請求,檢查倒數計時時間。同樣的,這種情況下,客戶端也可以檢查支付狀態。
服務端推送
說真的,服務端推送,看上去是一種很美好的方案,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;
}
定時任務的最大好處肯定是簡單了,但是它也有一些問題:
查詢的結果不實時
定時任務頻率的設定永遠是個不好確定的事情,間隔短對資料庫壓力大,間隔長了不實時,很容易出現,上面提到的使用者回到APP,結果輪詢不到支付成功狀態的情況。
實際上,使用者跳轉錢包之後,通常會很快完成支付,如果短時間內沒有完成支付,那麼一般也不會再付了。所以其實,發起支付開始,從第三方查詢支付結果的頻率應該是遞減的。
對資料庫有壓力
定時任務掃表,對資料庫肯定是會有壓力的,掃表的時候,經常會看到資料庫的監控出現一個小突刺,如果資料量大的話,可能影響更大。
可以單獨建立一個支付中流水錶,定時任務掃描這張表,獲取到支付最終態之後,就刪除掉對應的記錄。
延時訊息查詢
定時任務存在一些問題,那麼有沒有什麼其它辦法呢?答案是延時訊息。
延時訊息查詢支付狀態
在發起支付之後,傳送一個延時訊息,前面講到,使用者跳轉到錢包,通常很快會支付,所以我們希望查詢支付狀態這個步驟,符合這個規律,所以希望在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 協議》,轉載必須註明作者和本文連結