一次給女朋友轉賬引發我對分佈式事務的思考

2{icon} {views}

本文在個人技術博客不同步發布,詳情可用力戳
亦可掃描屏幕右側二維碼關注個人公眾號,公眾號內有個人聯繫方式,等你來撩…

   前两天發了工資,第一反應是想着要給遠方的女朋友一點驚喜!於是打開了平安銀行的APP給女朋友轉點錢!填寫上對方招商銀行卡的卡號、開戶名,一鍵轉賬!搞定!在我點擊的那瞬間,就收到了app的賬戶變動的提醒,並且出現了圖一所示的提示界面:“處理中,正在等待對方銀行返回結果…”。嗯!畢竟是跨行轉賬嘛,等個幾秒也正常!腦海開始浮現出女朋友收到轉賬后驚喜與感動的畫面!

  

   然而,一切並沒有那麼順利,剛過一會兒,app卻如圖二所示的提示我“由於收款人戶名不符”導致轉賬失敗!!!

  

   剛剛都已經從我卡里扣過錢了,現在卻提示我轉賬失敗,銀行會不會把我的錢給吞了?轉賬失敗的錢還能退換給我嗎?正在我緊張、焦慮、坐立不安之時又收到一條app沖正的消息,剛剛轉賬失敗的錢已經退還給我了,看來我多慮了……這也證明咱平安銀行的app還是比較安全靠譜的!

   為啥從我卡里扣錢那麼迅速,而對方卻要幾秒才能到賬?並且轉賬失敗后,扣除的錢還能及時的返還到我的卡里?萬一錢返還失敗怎麼辦?又或者我轉一次錢,對方卻收到了兩次轉賬的申請又該如何?帶着這些問題,我腦海中浮現出“事務”二字!

   在我們還在“牙牙學語”的時候,老師經常會通過轉賬的栗子來跟我們講解事務,但跟這裏場景不一樣的是,老師講的是本地事務,而這裏面對的是分佈式事務!我們先來簡單回顧一下本地事務!

本地事務

   談到本地事務,大家可能都很熟悉,因為這個數據庫引擎層面能支持的!所以也稱數據庫事務,數據庫事務四大特徵:原子性(A),一致性(C),隔離性(I)和持久性(D),而在這四大特性中,我認為一致性是最基本的特性,其它的三個特性都為了保證一致性而存在的!

   回到學生時代老師給我們舉的經典栗子,A賬戶給B賬戶轉賬100元(A、B處於同一個庫中),如果A的賬戶發生扣款,B的賬戶卻沒有到賬,這就出現了數據的不一致!為了保證數據的一致性,數據庫的事務機制會讓A賬戶扣款和B在賬戶到賬的兩個操作要麼同時成功,如果有一個操作失敗,則多個操作同時回滾,這就是事務的原子性,為了保證事務操作的原子性,就必須實現基於日誌的REDO/UNDO機制!但是,僅有原子性還不夠,因為我們的系統是運行在多線程環境下,如果多個事務并行,即使保證了每一個事務的原子性,仍然會出現數據不一致的情況。例如A賬戶原來有200元的餘額, A賬戶給B賬戶轉賬100元,先讀取A賬戶的餘額,然後在這個值上減去100元,但是在這兩個操作之間,A賬戶又給C賬戶轉賬100元,那麼最後的結果應該是A減去了200元。但事實上,A賬戶給B賬戶最終完成轉賬后,A賬戶只減掉了100元,因為A賬戶向C賬戶轉賬減掉的100元被覆蓋了!所以為了保證併發情況下的一致性,又引入的隔離性,即多個事務併發執行后的狀態,和它們串行執行后的狀態是等價的!隔離性又有多種隔離級別,為了實現隔離性(最終都是為了保證一致性)數據庫又引入了悲觀鎖、樂觀鎖等等……本文的主題是分佈式事務,所以本地事務就只是簡單回顧一下,需要記住的一點是,事務是為了保證數據的一致性

分佈式理論

  還記得剛畢業那年,帶着滿腔的熱血就去到了一家互聯網公司,領導給我的第一個任務就是在列表上增加一個修改數據的功能。這能難倒我?我分分鐘給你搞出來!不就是在列表上增加了一個“修改”按鈕,點擊按鈕彈出框修改后保存就好了么。然而一切不像我想象的那麼順利,點擊保存並刷新列表后,頁面上的數據還是显示的修改之前的內容,像沒有修改成功一樣!過一會兒再刷新列表,數據就能正常显示了!測試多次之後都是這樣!沒見過什麼大場面的我開始有點慌了,是我哪裡寫得不對么?最終,我不得不求助組內經驗比較豐富的前輩!他深吸了一口氣告訴我說:“畢竟是剛畢業的小伙子啊!我來跟你講講原因吧!我們的數據庫是做了讀寫分離的,部分讀庫與寫庫在不同的網絡分區。你的數據更新到了寫庫,而讀數據的時候是從讀庫讀取的。更新到寫庫的數據同步到讀庫是有一定的延遲的,也就是說讀庫與寫庫會有短暫的數據不一致”! “這樣不會體驗不好么?為什麼不能做到寫入的數據立馬能讀出來?那我這個功能該怎麼實現呢?” 面對我的一堆問題,同事有些不耐煩的說:“聽說過CAP理論嗎?你先自己去了解一下吧”!是我開始查閱各種資料去了解這個陌生的詞背後的秘密!

  CAP理論是由加州大學Eric Brewer教授提出來的,這個理論告訴我們,一個分佈式系統不可能同時滿足一致性(Consistency)、可用性(Availability)、分區容錯性(Partition tolerance)這三個基本需求,最多只能同時滿足其中兩項。
  一致性:這裏的一致性是指數據的強一致,也稱為線性一致性。是指在分佈式環境中,數據在多個副本之間是否能夠保持一致的特性。也就是說對某個數據進行寫操作后立馬執行讀操作,必須能讀取到剛剛寫入的值。(any read operation that begins after a write operation completes must return that value, or the result of a later write operation)
  可用性:任意被無故障節點接收到的請求,必須能夠在有限的時間內響應結果。(every request received by a non-failing node in the system must result in a response)
  分區容錯性:如果集群中的機器被分成了兩部分,這兩部分不能互相通信,系統是否能繼續正常工作。(the network will be allowed to lose arbitrarily many messages sent from one node to another)

  在分佈式系統中,分區容錯性是基本要保證的。也就是說只能在一致性和可用性之間進行取捨。一致性和可用性,為什麼不可能同時成立?回到之前修改列表的例子,由於數據會分佈在不同的網絡分區,必然會存在數據同步的問題,而同步會存在網絡延遲、異常等問題,所以會出現數據的不一致!如果要保證數據的一致性,那麼就必須在對寫庫進行操作時,鎖定其他讀庫的操作。只有寫入成功且完成數據同步后,才能重新放開讀寫,而這樣在鎖定期間,系統喪失了可用性。更詳細關於CAP理論可以參考這篇文章,該文章講得比較通俗易懂!

分佈式事務

   分佈式事務就是在分佈式的場景下,需要滿足事務的需求!上篇文章我們聊過了消息中間件,那這篇文章我們要聊的是分佈式事務,把兩者一結合,便有了基於消息中間件的分佈式事務解決方案!不管是本地事務,還是分佈式事務,都是為了解決數據的一致性問題!一致性這個詞咱們前面多次提及!與本地事務不同的是,分佈式事務需要保證的是分佈式環境下,不同數據庫表中的數據的一致性問題。分佈式事務的解決方案有多種,如XA協議、TCC三階段提交、基於消息隊列等等,本文只會涉及基於消息隊列的解決方案!

   本地事務講到了一致性,分佈式事務不可避免的面臨着一致性的問題!回到最開始跨行轉賬的例子,如果A銀行用戶向B銀行用戶轉賬,正常流程應該是:

1、A銀行對轉出賬戶執行檢查校驗,進行金額扣減。
2、A銀行同步調用B銀行轉賬接口。
3、B銀行對轉入賬戶進行檢查校驗,進行金額增加。
4、B銀行返回處理結果給A銀行。

  

   在正常情況對一致性要求不高的場景,這樣的設計是可以滿足需求的。但是像銀行這樣的系統,如果這樣實現大概早就破產了吧。我們先看看這樣的設計最主要的問題:

1、同步調用遠程接口,如果接口比較耗時,會導致主線程阻塞時間較長。
2、流量不能很好控制,A銀行系統的流量高峰可能壓垮B銀行系統(當然B銀行肯定會有自己的限流機制)。
3、如果“第1步”剛執行完,系統由於某種原因宕機了,那會導致A銀行賬戶扣款了,但是B銀行沒有收到接口的調用,這就出現了兩個系統數據的不一致。
4、如果在執行“第3步”后,B銀行由於某種原因宕機了而無法正確回應請求(實際上轉賬操作在B銀行系統已經執行且入庫),這時候A銀行等待接口響應會異常,誤以為轉賬失敗而回滾“第1步”操作,這也會出現了兩個系統數據的不一致。

   對於問題的1、2都很好解決,如果對消息隊列熟悉的朋友應該很快能想到可以引入消息中間件進行異步和削峰處理,於是又重新設計了一個方案,流程如下:

1、A銀行對賬戶進行檢查校驗,進行金額扣減。
2、將對B銀行的請求異步寫入隊列,主線程返回。
3、啟動後台程序從隊列獲取待處理數據。
4、後台程序對B銀行接口進行遠程調用。
5、B銀行對轉入賬戶進行檢查校驗,進行金額增加。
6、B銀行處理完成回調A銀行接口通知處理結果。

  

   通過上面的圖我們能看到,引入消息隊列后,系統的複雜性瞬間提升了,雖然彌補了我們第一種方案的幾個不足點,但也帶來了更多的問題,比如消息隊列系統本身的可用性、消息隊列的延遲等等!並且,這樣的設計依然沒有解決我們面臨的核心問題-數據的一致性

1、如果“第1步”剛執行完,系統由於某種原因宕機了,那會導致A銀行賬戶扣款了,但是寫入消息隊列失敗,無法進行B銀行接口調用,從而導致數據不一致。
2、如果B銀行在執行“第5步”時由於校驗失敗而未能成功轉賬,在回調A銀行接口通知回滾時網絡異常或者宕機,會導致A銀行轉賬無法完成回滾,從而導致數據不一致。

   面對上述問題,我們不得不對系統再次進行升級改造。為了解決“A銀行賬戶扣款了,但是寫入消息隊列失敗”的問題,我們需要藉助一個轉賬日誌表,或者叫轉賬流水表,該表簡單的設計如下:

字段名稱 字段描述
tId 交易流水id
accountNo 轉出賬戶卡號
targetBankNo 目標銀行編碼
targetAccountNo 目標銀行卡號
amount 交易金額
status 交易狀態(待處理、處理成功、處理失敗)
lastUpdateTime 最後更新時間

   這個流水表需要怎麼用呢?我們在“第1步”進行扣款時,同時往流水表寫入一條操作流水,狀態為“待處理”,並且這兩個操作必須是原子的,也就是說必須通過本地事務保證這兩個操作要麼同時成功,要麼同時失敗!這就保證了只要轉賬扣款成功,必定會記錄一條狀態為“待處理”的轉賬流水。如果在這一步失敗了,那自然就是轉賬失敗,沒有後續操作了。如果這步操作后系統宕機了導致沒有將消息成功寫入消息隊列(也就是“第2步”)也沒關係,因為我們的流水數據已經持久化了!這時候我們只需要加入一個後台線程進行補償,定期的從轉賬流水表中讀取狀態為“待處理”且最後更新的時間距當前時間大於某個閾值的數據,重新放入消息隊列進行補償。這樣,就保證了消息即使丟失,也會有補償機制!B銀行在處理完轉賬請求後會回調A銀行的接口通知轉賬的狀態,從而更新A銀行流水表中的狀態字段!這樣就完美解決了上一個方案中的兩個不足點。系統設計圖如下:
  

   到目前為止,我們很好的解決了消息丟失的問題,保證了只要A銀行轉賬操作成功,轉賬的請求就一定能發送到B銀行!但是該方案又引入了一個問題,通過後台線程輪詢將消息放入消息隊列處理,同一次轉賬請求可能會出現多次放入消息隊列而多次消費的情況,這樣B銀行會對同一轉賬多次處理導致數據出現不一致!那怎麼保證B銀行轉賬接口的冪等性呢?

   同樣的,我們可以在B銀行系統中需要增加一個轉賬日誌表,或者叫轉賬流水表,B銀行每次接收到轉賬請求,在對賬戶進行操作的時候同時往轉賬日誌表中插入一條轉賬日誌記錄,同樣這兩個操作也必須是原子的!在接收到轉賬請求后,首先根據唯一轉賬流水Id在日誌表中查找判斷該轉賬是否已經處理過,如果未處理過則進行處理,否則直接回調返回! 最終的架構圖如下:
  

   所以,我們這裏最核心的就是A銀行通過本地事務保證日誌記錄+後台線程輪詢保證消息不丟失。B銀行通過本地事務保證日誌記錄從而保證消息不重複消費!B銀行在回調A銀行的接口時會通知處理結果,如果轉賬失敗,A銀行會根據處理結果進行回滾。

   當然,分佈式事務最好的解決方案是盡量避免出現分佈式事務!

【精選推薦文章】

如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

想要讓你的商品在網路上成為最夯、最多人討論的話題?

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

不管是台北網頁設計公司台中網頁設計公司,全省皆有專員為您服務

想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師"嚨底家"!!