分散式事務的一種實現方式--狀態流轉

發表於2017-11-08

關於分散式事務,參考了網上提到的一些辦法,比如利用訊息佇列實現分散式事務,補償事務,TCC,最大努力送達,等等。這裡給出自己的一些理解和實現。可以稱之為狀態流轉的實現。

一些要點

  • 大事務拆分成多個小事務,每個小事務都是單機上的事務
  • 要支援冪等,即每個小事務多次執行時結果要相同。
    • 如何達到冪等,一般是加一個狀態,如一個帶有狀態欄位的行,在寫入業務資料時同時修改狀態為“已做”,“已撤銷”之類的。下次再做時,先檢查狀態的值,如果存在且為“已做”,則不用再做了;如果不存在,說明是“未做”,這時需要寫入業務資料。
  • 對應一個大事務的多個小事務如何保證全部成功或者全部失敗。
    • 先分析如何保證全部成功,就是多做幾次,直到全部成功。由於有冪等的保證,過程中某些小事務多做了幾次,是不影響資料的正確性的。
    • 同理可以分析全部失敗即需要回滾的情況,也是在冪等保證的前提下多做幾次,直到全部回滾完成。
    • 如何保證多做幾次,辦法應該很多,一種方法是在第一個小事務執行前先發一個延時訊息,這個訊息中包含預先生成的對應整個大事務流程的id。這樣就算當前事務流程中在某個步驟失敗,以後也有再次檢查執行的機會。
    • 其他的一些技巧。使用唯一索引保證操作的唯一性以及防併發操作。使用CAS(compare and set)來防併發修改操作,如專設一個rowver欄位。

分析一個簡單的例子,轉賬業務。

  • 相關的表如下
    • Account(userId,amount,rowver)
    • AccountTransferState(acsId,idForPart,state,fromUserId,toUserId,amount,rowver)
      • 注意AccountChangeState有2份記錄,只是idForPart不同(注意其他相同,如acsId等相同),分別為fromUserId和toUserId。這樣與Account是一一對應的,並且分割槽方式相同,這樣方便事務處理。

先分析流程中的正常情況。

  • 0,預先生成acsId,併傳送訊息到佇列,訊息內容包括AccountTransferState的幾乎所有欄位 以及 msgType=checkTransfer(在別的情況下還可以msgType=newTransfer,相當於非同步進行轉賬操作)等。
    • 利用訊息佇列是自動處理,還可以人工干預。比如,使用者(這裡指fromUser)可以查詢一個AccountTransferState的列表,按時間從晚到早排序。如果哪條記錄未完成,可以點按鈕觸發操作。
    • 綜上2種情況,在預先生成acsId後,都有辦法拿到這個acsId以及其他相關資料(如fromUserId等等)。
  • 1,在單機事務中,先檢查fromAccount的amount,
    • 如果不夠,則直接返回,整個大事務以fail結束。
    • 如果夠,減少fromAccount 並 新建AccountTransferState(acsId為預先生成,idForPart=fromUserId,state=didOut)。
  • 2,在單機事務中 增加toAccount 並 新建AccountChangeState(idForPart=toUserId,state=finish)
  • 3,在單機事務中 修改idForPart=fromUserId的AccountTransferState,使state=finish。

再分析流程中的異常情況。

在1,2,3中每一步都可能出異常。(第0步出異常,處理很簡單,參考下面的1B步驟即可)
由於第0步存在,總有辦法拿到具體的id和必要資料。從而可以分析檢查重做的過程,如下。

  • 1B,在單機事務中,使用acsId和idForPart=fromUserId查詢AccountTransferState記錄,
    • 如果不存在,說明第1步的事務由於各種原因失敗,從而是整個流程失敗,不用管了。(另外也可以考慮再次轉賬,不過在這裡暫不分析,以避免引入一些不必要的複雜性)
    • 如果存在,看AccountTransferState記錄內容,
      • 如果state=finish,則說明是在第3步事務完成後出了異常,此時整個流程已經成功完成,不用在這裡做什麼。
      • 如果state=didOut,說明第1步已經完成,由下一步來處理,見2B.
      • 如果state=其他,按業務邏輯分析不應該出現,給開發人員報bug。
  • 2B, 在單機事務中,使用acsId和idForPart=toUserId查詢AccountTransferState記錄,
    • 如果不存在,說明第2步尚未開始或未能成功開始,只需簡單做前面的2、3步中的內容
    • 如果存在,看AccountTransferState記錄內容,
      • 如果state=finish,說明第2步已經成功完成,只需簡單做前面的第3步中的內容
      • 如果state=其他,按業務邏輯分析不應該出現,給開發人員報bug。
  • 3B,同前面的第3步
    • 這步比較簡單。根據上面的分析,如果能夠到這步,只有當前面2步都成功時才到。所以無需判斷什麼條件,只需簡單執行第3步的內容即可。

分析一個比較複雜的例子,使用積分和餘額付款,積分和餘額在不同的庫中。

由於在不同的庫中,還可以把一個小事務抽象成一個遠端呼叫。

相關的表如下
這裡AccountTransferRequest與4個Account一般是5片。

注意AccountXyzChangeState有2份記錄,分別與2個AccountXyz對應,且分割槽方式相同,這樣保證單機事務

初次執行所有事務時(第一次也稱建立階段)

1,建立轉賬請求 AccountTransferRequest(state=Init)
在事務前預先生成atrId,(還考慮先儲存到導致這個動作的根動作上,這樣便於追查狀態)。
第一次執行暫且不用分散式鎖,因為atrId是新生成的,沒有別的地方爭用。除非是鎖根動作的id。
傳送延時訊息到佇列(1minute,msgType=checkTransfer)。訊息內容包括AccountTransferRequest的幾乎所有欄位,重點的有atrId,fromUserId,msgType=checkTransfer(當非同步進行轉賬操作,還可以msgType=newTransfer)。
另一方面,使用者(這裡是fromUser),可以by fromUserId查詢一個AccountTransferRequest的列表,按時間從晚到早排序。如果哪條記錄未完成,可以點按鈕觸發操作。(或者從某個根動作得到atrId,並進一步取到資料)
綜上2種情況,都可以拿到atrId和fromUserId。
另外,toUser暫時認為沒有查詢AccountTransferRequest的列表的必要。

  • 一方面,沒轉賬成功,toUser不必知道這個請求狀態的中間過程(畢竟現在的電商網站也沒有提供這種查詢)。
  • 另一方面,還可以查AccountChangeState作為列表,至於join問題,可以冗餘幾個欄位或一個json字串欄位,或者等這個資料流轉到異構資料庫來解決查詢問題。

2,處理 AccountJiFen 的轉出部分邏輯
先檢查fromAccountJiFen的amount夠不夠:

夠 ,則 減少fromAccountJiFen 並 新建AccountJiFenChangeState(fromUserId,atrId,state=didOut,atrDetailJson~)

不夠,則 不寫fromAccountJiFen和AccountJiFenChangeState的資料。

  • 此時需要回滾,先傳送延時訊息到佇列,訊息內容包括AccountTransferRequest的幾乎所有欄位,重點的有atrId,msgType=toRollback。
  • 如果或就算在傳送訊息前出異常停掉。下次通過訊息進入檢查處理流程,會重試步驟2,可以走夠與不夠2個分支,但不會導致資料不一致等錯誤。
  • 然後修改AccountTransferRequest的state=toRollback,並修改shuoming欄位為失敗原因,這裡是積分不足。然後返回失敗原因提示使用者或者發訊息通知使用者。
  • 然後執行B處的回滾邏輯,而不是執行下面的邏輯。考慮到通用性,B處的回滾邏輯包括多處資料的處理。
  • 注意這裡由於有分支路徑,需要避免併發。因為不排除在某些情況下,相同的兩個訊息由2個執行緒併發處理,而且2個執行緒走的分支不一樣,比如先執行的發現amount不夠需要回滾,而後執行的發現amount夠了(比如之前amount被另一個執行緒增加了)而繼續執行正常分支。這需要一把分散式鎖作用在大事務上來避免併發。

3,處理 AccountYuE 的轉出部分邏輯
先檢查fromAccountYuE的amount夠不夠:
夠 ,則 減少fromAccountYuE 並 新建AccountYuEChangeState(fromUserId,atrId,state=didOut,atrDetailJson~)
不夠,則 不寫fromAccountYuE和AccountYuEChangeState的資料。

  • 而是修改AccountTransferRequest的state=toRollback,並修改shuoming欄位為失敗原因,這裡是餘額不足。然後返回失敗原因提示使用者或者發訊息通知使用者。
  • 然後執行B處的回滾邏輯,而不是執行下面的邏輯。考慮到通用性,B處的回滾邏輯包括多處資料的處理。
  • 注意這裡由於有分支路徑,需要避免併發。

4,處理 AccountJiFen 的轉入部分邏輯

在事務中 增加toAccountJiFen 並 新建AccountJiFenChangeState(toUserId,atrId,state=didIn,atrDetailJsonStr~)
此處不會出現業務邏輯問題而需要回滾,可以設想對應系統停機維護,此時出網路錯不應該回滾。所以只需一直重試直到成功即可,當然重試機制需要細化考慮。
5,處理 AccountYuE 的轉入部分邏輯

在事務中 增加toAccountYuE 並 新建AccountYuEChangeState(toUserId,atrId,state=didIn,atrDetailJsonStr~)
此處不會出現業務邏輯問題而需要回滾,可以設想對應系統停機維護,此時出網路錯不應該回滾。所以只需一直重試直到成功即可,當然重試機制需要細化考慮。
6,修改轉賬請求AccountTransferRequest的 state=finish
7,有無必要修改4個AccountChangeState的state=finish,待定,暫不考慮
—-

B 回滾邏輯

B0 先加一個分散式鎖,利用Redisson,以atrId為key。這樣可以防止意外的併發執行。
B1 先傳送延時訊息到佇列,訊息內容包括AccountTransferRequest的幾乎所有欄位,重點的有atrId,msgType=toRollback。

以保證下面步驟出異常時,還能繼續做回滾操作直到完成。

  • 如果AccountTransferRequest的state!=toRollback,而是state=Init,則修改AccountTransferRequest的state=toRollback。
  • 如果state=finish OR fail ,本來是不做任何事情,但是為何能有這條路線是很奇怪的,需要讓開發調查是否存在bug。

B2 from方的 AccountJiFen的回滾
根據 atrId 和 fromUserId 查詢 AccountJiFenChangeState 記錄

如果沒查到記錄,說明沒做轉出動作,不用回滾,即不做任何操作。

如果查到1條記錄,(不可能多條記錄,否則給開發報bug)

  • 如果state=didOut,則需要回滾。增加fromAccountJiFen 並 修改AccountJiFenChangeState的state=didRollback 。
  • 如果state=didRollback,說明已經回滾了,不用再做任何操作了。

B3 from方的 AccountYuE的回滾
根據 atrId 和 fromUserId 查詢 AccountYuEChangeState 記錄
如果沒查到記錄,說明沒做轉出動作,不用回滾,即不做任何操作。
如果查到1條記錄,(不可能多條記錄,否則給開發報bug)

  • 如果state=didOut,則需要回滾。增加fromAccountYuE 並 修改AccountYuEChangeState的state=didRollback 。
  • 如果state=didRollback,說明已經回滾了,不用再做任何操作了。

B4 to方的 AccountJiFen的回滾
根據 atrId 和 toUserId 查詢 AccountJiFenChangeState 記錄
如果沒查到記錄,說明沒做轉入動作,不用回滾,即不做任何操作。
如果查到1條記錄,(不可能多條記錄,否則給開發報bug)

  • 如果state=didIn,則需要回滾。減少toAccountJiFen 並 修改AccountJiFenChangeState的state=didRollback 。
  • 如果state=didRollback,說明已經回滾了,不用再做任何操作了。

B5 to方的 AccountYuE的回滾
根據 atrId 和 toUserId 查詢 AccountYuEChangeState 記錄
如果沒查到記錄,說明沒做轉入動作,不用回滾,即不做任何操作。
如果查到1條記錄,(不可能多條記錄,否則給開發報bug)

  • 如果state=didIn,則需要回滾。減少toAccountYuE 並 修改AccountYuEChangeState的state=didRollback 。
  • 如果state=didRollback,說明已經回滾了,不用再做任何操作了。

B6 最後修改AccountTransferRequest的state=fail
B7 釋放分散式鎖
—-
由於在1–6中每一步都可能失敗或異常,研究處理失敗情況。
由於可以拿到具體的id,所以先分2種情況,一種是完全新增,此時對應上面的步驟,簡單。
一種是某個步驟失敗後,通過某種方式重啟這個轉賬流程,在下面討論。

0C,先加一個分散式鎖,利用Redisson,以atrId為key。這樣可以防止意外的併發執行。至於為何要避免併發在前面有地方分析過。
1C,使用atrId查詢AccountTransferRequest記錄
如果不存在,說明第1步就沒成功,可以以全新方式重做上面的1–6步。暫不考慮已經返回錯誤資訊給使用者,由使用者決定,而不用做任何動作的情況。
如果存在,看AccountTransferRequest記錄內容,

  • 如果state=finish,此時整個流程已經成功完成,不用做什麼,只需消費掉這條訊息即可。
  • 如果state=fail,整個流程已經失敗,也不用管了。
  • 如果state=toRollback,轉入 B 回滾邏輯 的處理流程
  • 如果state=Init,說明至少第1步已經完成,由第2步來檢查處理,見2C.
  • 如果state=其他,按業務邏輯分析不應該出現,給開發人員報錯提醒是bug。

2C, 使用atrId和idForPart=fromUserId查詢AccountJiFenChangeState記錄
如果不存在,說明第2步尚未開始或未能成功開始,簡單做2–6步。這裡有可能之前是第2步檢查失敗,但是未能修改AccountTransferRequest記錄狀態。注意可能出現以前檢查失敗,現在檢查可以成功的情況。雖然單獨做沒有問題,但是併發做則有隱患,需要防止併發。
如果存在,看AccountJiFenChangeState記錄內容:

  • 如果state=didOut,說明第2步已經成功完成,由第3步來檢查處理,見3C.
  • 如果state=didRollback,說明應該回滾了,轉入 B 回滾邏輯 的處理流程
  • 如果state=其他,按業務邏輯分析不應該出現,給開發人員報錯提醒是bug。

3C, 使用atrId和idForPart=fromUserId查詢AccountYuEChangeState記錄
如果不存在,說明第3步尚未開始或未能成功開始,簡單做3–6步。這裡有可能之前是第3步檢查失敗,但是未能修改AccountTransferRequest記錄狀態。注意可能出現以前檢查失敗,現在檢查可以成功的情況。雖然單獨做沒有問題,但是併發做則有隱患,需要防止併發。
如果存在,看AccountYuEChangeState記錄內容:

  • 如果state=didOut,說明第3步已經成功完成,由第4步來檢查處理,見4C.
  • 如果state=didRollback,說明應該回滾了,轉入 B 回滾邏輯 的處理流程
  • 如果state=其他,按業務邏輯分析不應該出現,給開發人員報錯提醒是bug。

4C, 使用atrId和idForPart=toUserId查詢AccountJiFenChangeState記錄
如果不存在,說明第4步尚未開始或未能成功開始,簡單做4–6步
如果存在,看AccountJiFenChangeState記錄內容:

  • 如果state=didOut,說明第4步已經成功完成,由第5步來檢查處理,見5C.
  • 如果state=didRollback,說明應該回滾了,轉入 B 回滾邏輯 的處理流程
  • 如果state=其他,按業務邏輯分析不應該出現,給開發人員報錯提醒是bug。

5C, 使用atrId和idForPart=toUserId查詢AccountYuEChangeState記錄
如果不存在,說明第5步尚未開始或未能成功開始,簡單做5–6步
如果存在,看AccountYuEChangeState記錄內容:

  • 如果state=didOut,說明第5步已經成功完成,由第6步來檢查處理,見6C.
  • 如果state=didRollback,說明應該回滾了,轉入 B 回滾邏輯 的處理流程
  • 如果state=其他,按業務邏輯分析不應該出現,給開發人員報錯提醒是bug。

6C,同第6步
7C,釋放分散式鎖


小結

這種狀態流轉的分散式事務的實現的好處,我認為最大的好處就是直觀。
所有的資料處理,都可以合在一個大的函式裡面,可以看到整個處理過程的全貌。
這裡也給出本人對前面的2個例子的具體程式碼實現,在 https://github.com/zlywq/tmycat1


相關文章