分佈式事務處理方式總結

在項目開發中,常常會須要處理分佈式事務。例如數據庫分庫分表以後,原來在一個單庫上的操做可能會跨越多個數據庫。系統服務化拆分以後,原來的在一個系統上的操做可能會跨越多個系統。就連咱們平時常用到的緩存(如redis、memcache等)也可能涉及分佈式事務,由於緩存和數據庫是兩個不一樣的實體,如何保證數據在緩存和數據庫間的一致性也是要重點考慮的。分佈式事務就是指事務要處理的資源分別位於分佈式系統中的不一樣節點之上的事務。
對於單機系統,一般咱們藉助數據庫實現本地事務,例以下面JDBC代碼實現了一個事務:redis

Connection con = datasource.getConnection();
con.setAutoCommit(false);
...
執行CRUD操做,可能會涉及到多個表
...
con.commit()/con.rollback()

因爲在分佈式系統中,多個系統沒法共用同一個數據庫連接,因此沒法簡單借用上面的處理方式實現分佈式事務。
下面將介紹幾種本人在實際開發中使用過的處理分佈式事務的方式,最後再引出分佈式事務的相關理論並進行總結。算法

避免出現分佈式事務

因爲分佈式事務比較難於處理,因此應該儘可能避免分佈式事務的發生。例如對於一個客戶信息系統,因爲註冊用戶數太多致使存儲的數據量過大,因此對其進行分庫分表存儲。而客戶信息模型又分爲多個子模型,對應數據庫中的多個表,例如客戶基本信息表、客戶登陸帳號表、客戶登陸密碼錶、客戶聯繫方式表等等。假設登陸帳號表和客戶基本信息表的關聯關係以下所示:數據庫

clipboard.png

user_id和login_id分別是兩個表的主鍵,user_id還做爲login_info表的外鍵使兩個表關聯。在用戶註冊時會自動生成user_id和login_id的值。user_info和login_info兩個表分別採用user_id和login_id計算分庫分表規則。假設咱們對每一個模型分十庫一百表存儲,即存在user_info_00 ~ user_info_99一百個表,其中user_info_00 ~ user_info_09屬於第一個庫,user_info_10 ~ user_info_19屬於第二個庫,依次類推。
在分庫分表以後,若是咱們不仔細考慮user_id和login_id的生成規則(例如隨意生成一個數字字符串或簡單使用遞增sequence),就可能致使同一個用戶的user_info信息和login_info信息被分別存儲到兩個不一樣的庫,這就會致使分佈式事務發生。
面對這種問題,最好的解決思路就是考慮如何避免分佈式事務的發生。只要想辦法讓跟一個用戶相關的全部模型數據所有存入到一個庫中,就能夠避免分佈式事務了。因爲每一個模型數據的分庫分表路由規則又是由各個表的主鍵id決定的(例如user_id、login_id),因此只要對各個表的主鍵生成規則進行定製,就能夠保證一個用戶的全部模型數據所有存到同一個庫。假設有下面的id生成規則:api

clipboard.png

  • 開始的兩位是標識模型位,例如user_id以01開頭,login_id以02開頭。
  • 接下來的11位是sequence遞增序列號,若是想要更多的ID能夠擴大這部分的位數,但對於存儲用戶信息而言,11位的長度足夠。
  • 接下來是分庫分表位,若是每一個模型的分庫分表算法都相同,那麼只要保證每一個模型的主鍵ID的分庫分表位都相同,就能保證一個用戶的全部模型數據都會存到同一個庫中。
  • 最後一位是id校驗位,這一位根據前面15位的內容生成,方便對一個id進行校驗。

根據這個思想,咱們能夠在用戶註冊的時候先生成user_id,user_id的分庫分表位能夠隨機生成。而後在爲其它模型生成主鍵id時(例如login_id),必須讓這個模型的主鍵id的分庫分表位與user_id的分庫分表位相同。另一點也要注意,一個表的查詢條件不必定只有主鍵id一個,若是有其它查詢條件列,那就要保證那一列的生成規則也要包含相同的分庫分表位,不然就不能使用該列進行查詢。
經過這種方式,就能夠保證一個用戶的全部模型數據所有存儲到同一個庫中,有效的避免分佈式事務的發生。緩存

事務補償

一般狀況下,應對高併發的一個主要手段就是增長分佈式緩存(如redis)以提升查詢性能。增長分佈式緩存後系統查詢數據的流程以下圖:網絡

clipboard.png

即先嚐試從緩存中查詢數據,若是緩存命中就直接返回結果,不然嘗試從DB中查詢數據。若是查詢DB命中則將數據補充到緩存,以便下次查詢時能夠命中緩存。
而在更新數據時,一般是先更新DB中的數據,DB寫入成功後再更新緩存中的數據。那麼就有一個問題,如何保證緩存和DB間數據的一致性?因爲緩存和DB是兩個不一樣的實體,寫入DB成功後再去更新緩存,若是緩存更新失敗(例如網絡抖動形成短暫的緩存不可用)就會形成緩存和DB的不一致。此時按照上圖的查詢邏輯,先查緩存就會查詢到「髒」的數據,就會嚴重影響業務。這也是一個典型的分佈式事務問題——緩存和DB要嘛同時更新成功,要嘛同時更新失敗。解決這個問題的一個較好方式就是事務補償。
咱們能夠在DB中建立一張事務補償表transaction_log,transaction_log表能夠和業務數據在一個庫中,也能夠在不一樣的庫。在更新數據前,先將要更新的模型數據記錄到transaction_log中。例如咱們更新user_info表中的數據,就將userId記錄到transaction_log中。
transaction_log記錄成功後,再去更新業務數據表user_info中的內容,最後更新緩存中的userInfo數據。緩存更新成功後,就能夠刪除transaction_log表中對應的記錄。
假設在更新完user_info表以後,因爲網絡抖動等緣由致使緩存更新失敗,則transaction_log表中對應的記錄就會一直存在,表示這個事務沒有完成的一種記錄。
應用會建立一個定時任務,週期性的掃描transaction_log表中的記錄(例如每隔2S掃描一次)。發現有符合條件的記錄,就嘗試執行補償邏輯。例如更新用戶信息時,DB中的user_info表更新成功,但緩存更新失敗,定時任務發現transaction_log表中對應的記錄沒有刪除且已經超過正常等待時間,就嘗試使緩存和DB一致(能夠刪除緩存中對應的數據,也能夠根據userId從新查詢DB再補充的緩存)。補償任務執行完成後,就能夠刪除transaction_log表中對應的記錄。若是補償任務執行再次失敗,就保留transaction_log表中的記錄,等待下個週期再次執行。
事務補償這種方式保證的是事務的最終一致性,即若是發生意外,會存在一個時間窗口(例如2S),在這個窗口內DB和緩存間是不一致的,但能保證最終二者的數據是一致的。至於定時任務週期的設定,要結合業務對「髒」數據的敏感程度以及系統的負載能力。併發

事務型消息

對於一個金融系統,假設有一個需求是用戶註冊成功後自動爲用戶建立一個帳戶。客戶的信息維護在客戶中心繫統,客戶的帳戶信息維護的帳務中心繫統,若是用戶註冊成功,必須保證客戶的帳戶在帳務系統建立成功。這顯然也是一個分佈式事務問題。
處理這個問題,顯然也能夠採用上一小節介紹的事務補償機制來處理。但註冊和開戶並不要求必定是同步完成,且須要感知用戶註冊成功事件的系統並不僅有帳務系統一個(例如營銷系統可能也須要感知用戶註冊成功的事件,給用戶發優惠券),因此使用消息機制異步通知更加合適。那麼問題就變成了「若是用戶註冊成功,必定要保證消息發送成功」。
應對這種場景,可使用事務型消息。但前提條件是使用的MQ中間件必須支持事務型消息,好比阿里的RocketMQ。目前市面上其它一些主流的MQ中間件都不支持事務型消息,好比Kafka和RabbitMQ都不支持。
下面的序列圖是事務型消息的執行流程:異步

clipboard.png

  • 相比於普通消息,發佈者發送消息後,MQ並非立刻將消息發送給訂閱者,而僅僅是將消息持久化存儲下來。
  • 發送消息成功以後,發佈者執行本地事務。例如咱們例子中提到的用戶註冊。
  • 根據本地事務執行是否成功,發佈者決定對以前已經發送的消息是commit仍是rollback。若是是rollback,MQ會刪除以前存儲的消息。假設咱們這裏發送commit。
  • MQ接收到發佈者發送的commit後,纔會將消息發送給訂閱者。以後,就能夠利用MQ的消息可靠傳輸特性促使訂閱者完成剩餘事務操做,例如上面例子中提到的開戶操做。

細心的小夥伴會發現,若是在上圖中的第5步發生問題致使發送commit失敗,不仍是會致使消息發佈者和消息訂閱者間事務的不一致嗎?爲了防止這種狀況的發生,增長MQ超時回調機制。
下面的序列圖是事務型消息commit失敗時的執行流程:分佈式

clipboard.png

當MQ長時間收不到發佈者的commit/rollback通知時,MQ會回調發布者應用詢問本地事務是否執行成功,是commit仍是rollback以前的消息。發佈者須要提供對應的callback,在callback中判斷本地事務是否執行成功。高併發

TCC兩階段提交

在某些場景下,一個分佈式事務可能會涉及到多個參與者,且每一個參與者須要根據本身當時的狀態對事務進行響應。
假設這樣一個場景,一個電商網站能夠容許用戶在支付時選擇多種支付方式。例如總共須要支付100元錢,用戶能夠選擇積分支付10元,帳戶餘額支付90元。用戶的積分由營銷系統負責,帳戶餘額由帳務系統負責,訂單的狀態管理由訂單系統負責。

  • 首先,要先確保事務的各個參與者知足條件才能執行事務。例如積分系統要確保用戶的積分超過10元錢,帳務系統要確保用戶的帳戶餘額大於90元錢才能發起此次交易。
  • 其次,就是要知足事務的原子性。這裏的用戶積分、用戶餘額、訂單狀態,要嘛所有處理成功,要嘛所有保持不變。

應對這種分佈式事務場景,能夠採用TCC兩階段提交的方式進行處理。關於TCC的詳細描述,你們也能夠參考下這篇博文,我覺的講的很好。
TCC將整個事務分紅兩個階段——try和commit/cancel。TCC整個流程具備三種角色——事務發起者、事務參與者、事務協調者。以上面的訂單支付爲例,採用TCC實現處理事務的流程以下:

clipboard.png

  • 第一階段try,訂單系統分別調用promotion和account兩個系統,詢問該用戶是否有足夠的積分和帳戶餘額。爲了防止資源爭搶,在這個階段會對資源進行鎖定,即營銷系統會鎖住用戶的10元積分,帳務系統會鎖住用戶的90元帳戶餘額。
  • 若是在try階段有任何一個參與者處理失敗(例如用戶積分不夠10元或者用戶的餘額不夠90元),則事務發起方(訂單系統)會通知事務協調組件,後者會通知全部的事務參與者cancel在try階段鎖定的資源。
  • 若是在try階段全部的參與者都處理成功,則事務發起方通知協調者commit這個事務,協調者會通知全部的參與者完成事務的commit。這時系統會完成真正的餘額和積分扣減。2.2步是假設訂單系統也要更新訂單的狀態。

但僅是這樣處理仍是有一致性問題,例如在第二階段commit時若是發生宕機、網絡抖動等異常狀況,就可能致使事務處於「非最終一致」狀態(參與者只執行了try階段,沒有執行第二階段。或部分參與者第二階段commit成功,部分參與者commit失敗)。爲了應對這種狀況,須要增長事務日誌,以便發生異常時恢復事務。
能夠利用DB這種可靠存儲來記錄事務日誌。日誌中應包含事務執行過程當中的上下文、事務執行狀態、事務的參與者等信息。事務日誌能夠由事務發起發負責記錄,也能夠交由事務協調方進行記錄。
事務日誌能夠由主事務記錄日誌和從事務記錄日誌組成:

  • 主事務記錄日誌用於記錄事務發起方信息以及事務執行的總體狀態。
  • 從事務記錄日誌用於記錄全部的事務參與者信息,以及每一個參與者所屬的從事務的執行狀態。與主事務記錄日誌是一對多的關係。

有了事務日誌後,就能夠週期性的不斷掃描事務日誌,找到異常中斷的事務。根據事務日誌中記錄的信息,推進剩餘的參與者commit或者cancel,以便使整個分佈式事務達到「最終一致性」。
下面是commit階段發生異常時的事務補償邏輯:

clipboard.png

TCC兩階段提交的實現須要注意以下事項:

  1. 事務中的任何一個參與者都要確保在try階段操做成功,在第二階段就必定能commit成功。
  2. 參與者在實現commit和cancel接口時要考慮冪等,對重複的commit/cancel請求要可以正確處理。
  3. 業務上要考慮對兩階段中間狀態(一階段已完成,二階段未開始)的處理。通常能夠經過一些特殊文案,好比顯示當前被凍結的帳戶餘額。
  4. 對於狀態型數據,當多個事務共同操做同一個資源時,要確保資源隔離。例如帳戶餘額,確保不一樣的事務操做的金額是隔離的,彼此互不影響。
  5. 因爲網絡丟包、亂序等因素的影響,可能會致使參與者接收到一階段try請求後,永遠收不到commit/cancel請求,致使參與者的資源一直被鎖定,永遠不會被釋放,這種狀況叫作事務懸掛。爲了防止事務懸掛的發生,能夠在第一階段try成功後,指定一個最大等待時間。超過這個最大等待時間就自動釋放被鎖定的資源。

總結

傳統的單機事務應知足A(原子性)、C(一致性)、I(隔離型)、D(持久性)四個特性,屬於剛性事務。因爲分佈式系統具備多個節點的特色,要求徹底知足ACID這四個規範會很是的困難。因此就誕生了柔性事務BASE理論(Basic availability、Soft state、Eventual consistency)。相比於單機事務,分佈式事務在A和D上仍可以嚴格保證,但在C和I上就要有必定程度的限制放寬(容許看到中間狀態數據、最終一致性)。

相關文章
相關標籤/搜索