「每日分享」一個支付服務的最終一致性實踐案例

點擊上方"java全棧技術"關注,每天學習一個java知識點

“功夫貸”是一款線上貸款 APP,主要是給信用卡優質用戶提供純線上的信用貸款,以期限長、額度高、利息低為主要優勢(類似的業務模式主要有宜人貸)。

和任何一種分期貸款一樣,符合資質的用戶,在功夫貸成功貸款之後,需要在約定還款日還款。目前還款主要有以下這幾種方式:

  • 用戶在 APP 上主動還款;
  • 系統定時通過後臺任務扣款;
  • 催收人員通過內部作業系統,手動發起扣款;

真正的扣款操作(從銀行卡扣款)主要是通過第三方支付來完成,比如京東支付、通聯等。不同的第三方支付,支持的銀行列表和限額不同,費用和穩定性也不盡相同,我們會選擇出個最優通道、以及多層級備用通道,為此研發了支付路由系統,同時這些服務商的業務限制 / 出錯概率還不低,所以我們又要考慮業務上的一致性,這也是本文要介紹的主題。

扣款業務是比較複雜的,包括如下幾個主要步驟:

  1. 對業務表 (扣款任務表 / 還款計劃表等) 的數據庫操作
  2. 調用第三方支付
  3. 清算入賬

這多個子功能需要保證同時成功或者同時失敗,其中既有外部第三方調用,又有內部微服務的調用,所以這是個比較典型的分佈式事務的場景。由於外部的第三方支付服務有時不穩定、且部分交易可能很長時間才能確認成功。

因此 我們沒考慮兩階段提交的分佈式事務,而是選擇了最終一致性,而為了保證在狀態不一致這個時間窗口的準確性 (比如不能在該窗口對用戶重複扣款),我們也額外多做了很多的考慮。

主流程分析

扣款服務的主流程如下圖所示(在這裡僅舉“第三方支付渠道是同步返回扣款結果”作為例子,在實際情況中,各家第三方支付渠道的接口並不一致,有同步返回的、也有異步 + 輪詢方式的,這兩種形式,在我們這的處理邏輯上沒有明顯區別)。

「每日分享」一個支付服務的最終一致性實踐案例

為了避免對業務流程造成干擾,上圖中把同樣處於主執行路徑上的、起著日誌記錄作用的"log-x"這些步驟,在各自所處的位置以虛線表示,記得它們是主流程的一部分。這些“log-x”步驟在實現上,是建立一張日誌表,以持久化、結構化的方式來記錄,並不是 logback 之類的文件日誌,因為這些日誌在異常時的恢復,起著重要作用。

從上圖可以看出,由 1、2、3、4、6 這五個步驟,形成一個整體,我們需要保證的是,這 5 個步驟同時成功、或者同時失敗。其中包含幾類操作:

  • 本地 DB 的 SQL 執行,包括步驟 1、4;
  • 遠程 HTTP/RPC 調用,包括步驟 2;
  • 發送 MQ 消息,包括步驟 3;
  • 異步系統執行,包括步驟 6;

其中步驟 6 是另外一個服務(賬務服務),是在支付服務之外的,所以用虛線框來表示,但在邏輯上是整體不可分的一部分,需要共同成功 / 失敗。下面我們來看,在這些步驟中,會有哪些失敗場景和各自特點:

  • 本地 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 有丟失消息的風險,那仍然可能不一致)。

難題二:出現預期外的異常,如何保證最終一致性?

顧名思義,預期外的異常就是非程序提前感知到的,比如進程被強制 KILL、機器 CRASH,在這種情況下,程序執行到一半,突然結束了,這時怎麼保證最終一致性?

在這種情況下,只能是靠日誌表了,主流程或任何依賴內存記錄的恢復程序都無效了。

定時任務的目的是補償未能正常結束的扣款任務。一般來說,如果扣款任務未能正常結束,可能會有如下幾種原因:

  1. 系統意外退出(進程被 KILL、宕機等);
  2. 系統重啟——如當前某筆扣款記錄在輪詢第三方支付服務的扣款狀態,此時重啟也造成了流程中斷;
  3. 執行過程中出錯,如數據庫異常、調用超時、MQ 不可用等;

為了達到補償目標,需要設計若干張日誌表來輔助。我們設計了 2 張,如圖:

「每日分享」一個支付服務的最終一致性實踐案例

其一,“扣款途中日誌表”是用於標識扣款任務是否仍然在途中。在扣款開始之前,往該表插入記錄,扣款完成後 (成功或失敗) 更新狀態。該表主要目的是:可以方便地找出來,哪些扣款任務是沒有正常結束的。為什麼沒直接用業務表“還款記錄表”來查詢在途扣款呢? 主要是從便捷性和性能上考慮——業務表的數據是不能刪除的,而該日誌表可以定期將已完成的扣款任務清除掉,以控制該表其數據量,保證查詢效率;

其二,“扣款執行日誌表”是用於記錄扣款任務的執行過程。該表的記錄不更新,只插入。如果某個扣款任務需要恢復補償,則從該表中找到上次執行的“斷點”,繼續向後執行。上圖中舉了 3 組數據作為例子:黃色背景是一筆完成的、扣款成功的日誌;淺綠色背景是一筆完成的、扣款失敗的日誌;淺橙色背景是一筆進行中(正在執行調用第三方扣款)的日誌。

下面是定時補償任務的主流程:

「每日分享」一個支付服務的最終一致性實踐案例

  1. 在實踐中,一個正常的扣款任務在 1 分鐘內都應該結束了,時間主要花費在調用第三方扣款服務上,絕大部分 30 秒內結束,少量的會拖的時間比較長,甚至跨日;
  2. 定時任務 3 分鐘執行一次,每次掃描 3 分鐘前開始的、且當前未結束的任務。3 分鐘以內的任務不處理的原因是:它們可能仍然在自己的正常處理過程中,此時還不需要定時任務來接管;

偽代碼

為了便於讀者理解,這裡以偽代碼的形式把整個扣款過程寫出來,且分幾個迭代版本不斷增強。

版本一

「每日分享」一個支付服務的最終一致性實踐案例

  1. 在執行之前,注意要把數據庫事務設為自動提交,即不可把整個過程納入到一個事務裡——不僅是性能問題,更重要的是,如果過程中失敗了,日誌數據也被回滾掉了,無法恢復;
  2. 面對預期內的異常和預期外的異常,如詳細設計裡所述,或拋出異常結束、或 return 結束,後期由定時任務補償。在主流程中不做各種各樣繁雜的異常處理,既避免繁瑣,也避免出錯;
  3. 上面只是偽代碼,在實踐中應該打印出詳細的 Exception 信息、以及 log 文件日誌,以便於定位和查找問題;

版本一有 2 個問題

  1. 如果失敗了,都要等定時任務補償,那樣響應有些慢,畢竟定時任務幾分鐘才執行一次;
  2. 定時任務補償時,要判斷之前執行到哪,如果補償的起始階段不同、代碼邏輯也不一樣,這也比較麻煩;

基於此,有了版本 II,這裡取“調用第三方支付渠道扣款”的片段來說明。

版本二

「每日分享」一個支付服務的最終一致性實踐案例

  1. 紅色部分增加了日誌狀態的判斷。如果是補償性的,如該步驟以前已經成功了,則跳過這段調用第三方的邏輯;
  2. 藍色部分增加了先查詢的操作,不論是否已經調用過扣款;
  3. 褐色部分增加了後臺線程池輪詢,而不是單單等定時任務去觸發;這地方實踐中稍微控制下線程池數量、且最好有多路複用的模式,防止很多線程都掛在那輪詢;
  4. 綠色部分,其實是出現異常的話,上面這些步驟可以再來一遍;

不難看出,該版本主要是增加各個邏輯段的冪等性,既使其能安全執行、又使代碼邏輯簡潔。

版本二還可以更為嚴謹一點——拿下面這個代碼段紅框裡的來說,如果在兩段 SQL 之間失敗了,有造成不一致的可能(概率很小)。

「每日分享」一個支付服務的最終一致性實踐案例

版本三

「每日分享」一個支付服務的最終一致性實踐案例

通過事務保證邏輯段能同時成功或同時失敗。雖然概率很小,但如果線上發生了,很難找到原因。

上面這些偽碼是本人用 markdown 純粹手敲的,並不是生產代碼,沒有經過嚴格測試,所以如果有些地方寫的筆誤或邏輯有漏洞,請讀者諒解。

通過上面分析,我們看到有多個地方可能會對同一筆還款記錄扣款,包括:

  1. 正常執行扣款;
  2. 提交到後臺線程池的重試 / 輪詢;
  3. 定時任務補償;
  4. 人工執行扣款

所以針對單筆還款記錄的扣款操作,我們需要使用鎖定,實踐中我們採用的是 redission 來做的分佈式鎖,這比較簡單,這裡不多敘述,不忽略這一點就好。

兜底方案

上面我們分析了很多,對主流程中的分支都做了很多的考慮,但仍然有這兩個風險:

  1. 有些異常分支沒有考慮到;
  2. 隨著業務的發展,新加進來的邏輯,或者新人進來,很可能有些新的分支點沒有被充分考慮;

所以從嚴謹的角度,我們需要個兜底方案——主動檢查 + 對賬,以主動識別任何異常現象。從實踐上看,由於業務的複雜性以及持續變化,可能很難完全梳理清楚所有的異常點,因此“主動檢查 + 對賬”可能更為重要。

主動檢查

我們創建了個 Thread,定時查詢還款計劃表中,處於”扣款中“的異常數據,進行檢查,如果有問題,自動修正或者通知出來人工干預。比如某條還款記錄,從“還款中”的狀態到現在,已經過去了 1 個小時了,這種情況就會被判定為可疑現象,需要人工介入。

對賬

仍然有一些情況,是系統所覆蓋不到的,需要雙方對賬 (我們和第三方支付對賬、第三方支付和銀行對賬)。主要有以下這些場景:

  • 跨日——雙方把訂單歸到不同日期。比如 23:59 的訂單,我們歸到今天,第三方支付那邊可能歸到第二天;
  • 第三方支付開始告訴我們是成功的,我們已經結束操作了,後來對賬時,第三方支付說支付失敗了(可能它的信息是來自於銀行);
  • 我這邊還款 1 筆,第三方支付那邊搞成了 2 筆(可能是它們自己的原因,也可能是銀行的原因);

對賬主要是根據“訂單號”、“狀態”、“日期”,主要是看狀態和日期,是否對的。金額之類的,一般是不核對的,因為它不會出錯。

兜底方案雖然好,但往往需要人工介入,成本高、反饋慢,如果能夠系統自動就識別並修正,保證系統一致,那麼在用戶體驗和成本角度考慮,都是很合適的。所以兜底方案和系統一致性是相互補充、各自取長補短的事情。

總結

上面以我們的支付服務,作為一個最終一致性的例子。雖然場景不是很複雜,但寫的比較細緻,需要考慮的點也還是不少,希望能幫助到讀者,將來在處理類似問題時,能夠有比較清晰的思路。


分享到:


相關文章: