「功夫貸」是一款線上貸款 APP,主要是給信用卡優質用戶提供純線上的信用貸款,以期限長、額度高、利息低爲主要優點(相似的業務模式主要有宜人貸)。html
和任何一種分期貸款同樣,符合資質的用戶,在功夫貸成功貸款以後,須要在約定還款日還款。目前還款主要有如下這幾種方式:redis
用戶在 APP 上主動還款;數據庫
系統定時經過後臺任務扣款;安全
催收人員經過內部做業系統,手動發起扣款;服務器
真正的扣款操做(從銀行卡扣款)主要是經過第三方支付來完成,好比京東支付、通聯等。不一樣的第三方支付,支持的銀行列表和限額不一樣,費用和穩定性也不盡相同,咱們會選擇出個最優通道、以及多層級備用通道,爲此研發了支付路由系統,同時這些服務商的業務限制 / 出錯機率還不低,因此咱們又要考慮業務上的一致性,這也是本文要介紹的主題。markdown
扣款業務是比較複雜的,包括以下幾個主要步驟:網絡
這多個子功能須要保證同時成功或者同時失敗,其中既有外部第三方調用,又有內部微服務的調用,因此這是個比較典型的分佈式事務的場景。因爲外部的第三方支付服務有時不穩定、且部分交易可能很長時間才能確認成功。多線程
所以 咱們沒考慮兩階段提交的分佈式事務,而是選擇了最終一致性,而爲了保證在狀態不一致這個時間窗口的準確性 (好比不能在該窗口對用戶重複扣款),咱們也額外多作了不少的考慮。異步
扣款服務的主流程以下圖所示(在這裏僅舉「第三方支付渠道是同步返回扣款結果」做爲例子,在實際狀況中,各家第三方支付渠道的接口並不一致,有同步返回的、也有異步 + 輪詢方式的,這兩種形式,在咱們這的處理邏輯上沒有明顯區別)。分佈式
爲了不對業務流程形成干擾,上圖中把一樣處於主執行路徑上的、起着日誌記錄做用的"log-x"這些步驟,在各自所處的位置以虛線表示,記得它們是主流程的一部分。這些「log-x」步驟在實現上,是創建一張日誌表,以持久化、結構化的方式來記錄,並非 logback 之類的文件日誌,由於這些日誌在異常時的恢復,起着重要做用。
從上圖能夠看出,由 一、二、三、四、6 這五個步驟,造成一個總體,咱們須要保證的是,這 5 個步驟同時成功、或者同時失敗。其中包含幾類操做:
本地 DB 的 SQL 執行,包括步驟 一、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,在這種狀況下,程序執行到一半,忽然結束了,這時怎麼保證最終一致性?
在這種狀況下,只能是靠日誌表了,主流程或任何依賴內存記錄的恢復程序都無效了。
定時任務的目的是補償未能正常結束的扣款任務。通常來講,若是扣款任務未能正常結束,可能會有以下幾種緣由:
系統意外退出(進程被 KILL、宕機等);
系統重啓——如當前某筆扣款記錄在輪詢第三方支付服務的扣款狀態,此時重啓也形成了流程中斷;
執行過程當中出錯,如數據庫異常、調用超時、MQ 不可用等;
爲了達到補償目標,須要設計若干張日誌表來輔助。咱們設計了 2 張,如圖:
其一,「扣款途中日誌表」是用於標識扣款任務是否仍然在途中。在扣款開始以前,往該表插入記錄,扣款完成後 (成功或失敗) 更新狀態。該表主要目的是:能夠方便地找出來,哪些扣款任務是沒有正常結束的。爲何沒直接用業務表「還款記錄表」來查詢在途扣款呢? 主要是從便捷性和性能上考慮——業務表的數據是不能刪除的,而該日誌表能夠按期將已完成的扣款任務清除掉,以控制該表其數據量,保證查詢效率;
其二,「扣款執行日誌表」是用於記錄扣款任務的執行過程。該表的記錄不更新,只插入。若是某個扣款任務須要恢復補償,則從該表中找到上次執行的「斷點」,繼續向後執行。上圖中舉了 3 組數據做爲例子:黃色背景是一筆完成的、扣款成功的日誌;淺綠色背景是一筆完成的、扣款失敗的日誌;淺橙色背景是一筆進行中(正在執行調用第三方扣款)的日誌。
下面是定時補償任務的主流程:
在實踐中,一個正常的扣款任務在 1 分鐘內都應該結束了,時間主要花費在調用第三方扣款服務上,絕大部分 30 秒內結束,少許的會拖的時間比較長,甚至跨日;
定時任務 3 分鐘執行一次,每次掃描 3 分鐘前開始的、且當前未結束的任務。3 分鐘之內的任務不處理的緣由是:它們可能仍然在本身的正常處理過程當中,此時還不須要定時任務來接管;
爲了便於讀者理解,這裏以僞代碼的形式把整個扣款過程寫出來,且分幾個迭代版本不斷加強。
在執行以前,注意要把數據庫事務設爲自動提交,即不可把整個過程歸入到一個事務裏——不只是性能問題,更重要的是,若是過程當中失敗了,日誌數據也被回滾掉了,沒法恢復;
面對預期內的異常和預期外的異常,如詳細設計裏所述,或拋出異常結束、或 return 結束,後期由定時任務補償。在主流程中不作各類各樣繁雜的異常處理,既避免繁瑣,也避免出錯;
上面只是僞代碼,在實踐中應該打印出詳細的 Exception 信息、以及 log 文件日誌,以便於定位和查找問題;
版本一有 2 個問題
若是失敗了,都要等定時任務補償,那樣響應有些慢,畢竟定時任務幾分鐘才執行一次;
定時任務補償時,要判斷以前執行到哪,若是補償的起始階段不一樣、代碼邏輯也不同,這也比較麻煩;
基於此,有了版本 II,這裏取「調用第三方支付渠道扣款」的片斷來講明。
紅色部分增長了日誌狀態的判斷。若是是補償性的,如該步驟之前已經成功了,則跳過這段調用第三方的邏輯;
藍色部分增長了先查詢的操做,不管是否已經調用過扣款;
褐色部分增長了後臺線程池輪詢,而不是單單等定時任務去觸發;這地方實踐中稍微控制下線程池數量、且最好有多路複用的模式,防止不少線程都掛在那輪詢;
綠色部分,實際上是出現異常的話,上面這些步驟能夠再來一遍;
不難看出,該版本主要是增長各個邏輯段的冪等性,既使其能安全執行、又使代碼邏輯簡潔。
版本二還能夠更爲嚴謹一點——拿下面這個代碼段紅框裏的來講,若是在兩段 SQL 之間失敗了,有形成不一致的可能(機率很小)。
經過事務保證邏輯段能同時成功或同時失敗。雖然機率很小,但若是線上發生了,很難找到緣由。
上面這些僞碼是本人用 markdown 純粹手敲的,並非生產代碼,沒有通過嚴格測試,因此若是有些地方寫的筆誤或邏輯有漏洞,請讀者諒解。
經過上面分析,咱們看到有多個地方可能會對同一筆還款記錄扣款,包括:
正常執行扣款;
提交到後臺線程池的重試 / 輪詢;
定時任務補償;
人工執行扣款
因此針對單筆還款記錄的扣款操做,咱們須要使用鎖定,實踐中咱們採用的是 redission 來作的分佈式鎖,這比較簡單,這裏很少敘述,不忽略這一點就好。
兜底方案
上面咱們分析了不少,對主流程中的分支都作了不少的考慮,但仍然有這兩個風險:
有些異常分支沒有考慮到;
隨着業務的發展,新加進來的邏輯,或者新人進來,極可能有些新的分支點沒有被充分考慮;
因此從嚴謹的角度,咱們須要個兜底方案——主動檢查 + 對帳,以主動識別任何異常現象。從實踐上看,因爲業務的複雜性以及持續變化,可能很難徹底梳理清楚全部的異常點,所以「主動檢查 + 對帳」可能更爲重要。
主動檢查
咱們建立了個 Thread,定時查詢還款計劃表中,處於」扣款中「的異常數據,進行檢查,若是有問題,自動修正或者通知出來人工干預。好比某條還款記錄,從「還款中」的狀態到如今,已通過去了 1 個小時了,這種狀況就會被斷定爲可疑現象,須要人工介入。
對帳
仍然有一些狀況,是系統所覆蓋不到的,須要雙方對帳 (咱們和第三方支付對帳、第三方支付和銀行對帳)。主要有如下這些場景:
跨日——雙方把訂單歸到不一樣日期。好比 23:59 的訂單,咱們歸到今天,第三方支付那邊可能歸到次日;
第三方支付開始告訴咱們是成功的,咱們已經結束操做了,後來對帳時,第三方支付說支付失敗了(可能它的信息是來自於銀行);
我這邊還款 1 筆,第三方支付那邊搞成了 2 筆(多是它們本身的緣由,也多是銀行的緣由);
對帳主要是根據「訂單號」、「狀態」、「日期」,主要是看狀態和日期,是否對的。金額之類的,通常是不覈對的,由於它不會出錯。
兜底方案雖然好,但每每須要人工介入,成本高、反饋慢,若是可以系統自動就識別並修正,保證系統一致,那麼在用戶體驗和成本角度考慮,都是很合適的。因此兜底方案和系統一致性是相互補充、各自取長補短的事情。
總結
上面以咱們的支付服務,做爲一個最終一致性的例子。雖然場景不是很複雜,但寫的比較細緻,須要考慮的點也仍是很多,但願能幫助到讀者,未來在處理相似問題時,可以有比較清晰的思路。
原文連接:http://www.10tiao.com/html/773/201806/2247487977/1.html