Seata AT模式學習

坏男银發表於2024-05-02

官方文件

Seata是目前國內最流行的一個分散式事務的元件,支援以下4種模式

AT模式:對業務程式碼無侵入,只要在業務的資料庫加上一個UNDO_LOG表,在配置檔案中配置好Seata的服務端,在需要開啟全域性事務的地方加上註解就行

TCC模式:即Try-Commit-Cancel,自定義prepare邏輯、commit邏輯及回滾的邏輯,程式碼侵入性大、靈活、對開發要求高

SAGA模式:主要用於分散式長事務

XA模式:即XA協議的實現,經典的二階段提交

這裡我主要學習一下最常用的AT模式

大致工作流程:

由兩階段提交協議演化而來,也是分為兩個階段,如下

一階段:解析update SQL和執行業務的UPDATE語句,將回滾的補償放入undo_log,直接提交本地事務

二階段(成功):清理undo_log相關的補償資訊

二階段(失敗):根據undo_log種的補償資訊對資料進行反向補償

三種角色:

TC(Transaction Manager):事務協調者,維護全域性和分支事務的狀態,驅動全域性事務提交或回滾。

TM(Transaction Manager): 事務管理器,定義全域性事務的範圍,開始全域性事務、提交或回滾全域性事務。相當於加了@GlobalTransaction註解那個才是TM。

RM(Resource Manager ) :資源管理器,管理分支事務處理的資源( Resource ),與 TC 交談以註冊分支事務和報告分支事務的狀態,並驅動分支事務提交或回滾。被TM呼叫的服務會有RM。

其中,TC工作在Seata服務端,TM和RM工作在Seata的客戶端即業務的微服務端

使用方式:

1. 部署Seata服務端,單機用於學習,生產上一般是要以叢集的形式,並配置一個資料庫(儲存全域性的事務id),並將自身註冊到服務註冊中心,被其他微服務使用

2. 業務端改造,

在每個相關的業務端的資料庫中加上一個undo_log的表,

使用方式:只需要將註解@GlobalTransactional加在需要開啟全域性事務的那個方法上,而被呼叫的微服務的方法上只需要加上本地事務註解 @Transactional

public class BusinessServiceImpl implements BusinessService {

    private StorageService storageService;

    private OrderService orderService;

@GlobalTransactional public void purchase(String userId, String commodityCode, int orderCount) { storageService.deduct(commodityCode, orderCount); orderService.create(userId, commodityCode, orderCount); } }

@GlobalTransactional這個直接會標識開啟全域性事務,這個應該就是TM,開啟全域性事務、提交全域性事務和回滾全域性事務

那為什麼被呼叫的微服務的方法不需要加全域性事務的註解呢?

我大膽猜測一下,因為被呼叫的微服務方法是具體的幹活的,是RM,會將DataSource進行一層代理,在執行UPDATE SQL的前後會進行解析UPDATE SQL和生成補償資訊到undo_log中

那RM怎麼知道某個方法被全域性事務的進行管理了呢?

我再來猜測一下,微服務間的呼叫肯定也做了手腳,會在HTTP呼叫或者RPC呼叫的時候將全域性事務的事務xid作為引數或者header傳過來

關於資料來源的代理做了什麼事參考官方文件

關於如何傳遞全域性事務id,需要使用改造過的feign/rpc

兩個主要的註解:

@GlobalTransactional 宣告全域性事務(會隱式的獲取、持有、釋放全域性鎖)

@GlobalLock 檢查是否有全域性鎖(配合select for update 能進行多次嘗試獲取全域性鎖,否則監測到有全域性鎖就丟擲異常)

以下是一階段和二階段的詳細過程

一階段

  1. 解析 SQL:得到 SQL 的型別(UPDATE),表(product),條件(where name = 'TXC')等相關的資訊。
  2. 查詢前映象:根據解析得到的條件資訊,生成查詢語句,定位資料。
  3. 執行業務 SQL:更新這條記錄的 name 為 'GTS'。
  4. 查詢後映象:根據前映象的結果,透過 主鍵 定位資料。
  5. 插入回滾日誌:把前後映象資料以及業務 SQL 相關的資訊組成一條回滾日誌記錄,插入到 UNDO_LOG 表中。
  6. 提交前,向 TC 註冊分支:申請 product 表中,主鍵值等於 1 的記錄的 全域性鎖 。
  7. 本地事務提交:業務資料的更新和前面步驟中生成的 UNDO LOG 一併提交。
  8. 將本地事務提交的結果上報給 TC。

二階段-提交

  1. 收到 TC 的分支提交請求,把請求放入一個非同步任務的佇列中,馬上返回提交成功的結果給 TC。
  2. 非同步任務階段的分支提交請求將非同步和批次地刪除相應 UNDO LOG 記錄。

二階段-回滾

  1. 收到 TC 的分支回滾請求,開啟一個本地事務,執行如下操作。
  2. 透過 XID 和 Branch ID 查詢到相應的 UNDO LOG 記錄。
  3. 資料校驗:拿 UNDO LOG 中的後鏡與當前資料進行比較,如果有不同,說明資料被當前全域性事務之外的動作做了修改。這種情況,需要根據配置策略來做處理,詳細的說明在另外的文件中介紹。
  4. 根據 UNDO LOG 中的前映象和業務 SQL 的相關資訊生成並執行回滾的語句:
  5. 提交本地事務。並把本地事務的執行結果(即分支事務回滾的結果)上報給 TC。

關於髒讀問題

Seata中的分散式事務,都有各自的 XID,每個 XID 都會把 “行鎖”(也叫全域性鎖)註冊到 TC 裡面

問題原因:由於AT模式是一階段就直接提交,所以如果另一個不相關的方法去查詢對應的那行資料,是有可能讀到髒資料(即還未完成全域性事務的資料)

解決辦法:使用select for update和@GlobalLock註解,如下圖,

select for update會等待全域性事務中的一階段結束後才能拿到本地鎖,然後去獲取全域性鎖,此時全域性鎖被全域性事務給佔用了(TC處有記錄),導致全域性鎖獲取不到,從而無法繼續下去進行讀取操作,直到全域性事務二階段提交或者回滾

關於髒寫問題

問題原因:同樣的,由於AT模式是一階段就直接提交,所以如果另一個不相關的方法(只帶了@Transactional,甚至不帶)去修改對應的那行資料,是可以在一階段結束後,二階段提交回滾前將資料改掉,導致如果全域性事務失敗,無法正確回滾(補償)

解決辦法一:在其他的update請求的方法上也加上@GlobalTransactional,同樣開啟全域性事務,確保修改的時候能夠因為全域性鎖而被擋住

解決辦法二:在其他的update請求的方法上加上@GlobalLock+@Transactional,並在update前使用selectForUpdate(這步可以不做,下面解釋)。 會先去嘗試拿本地鎖(直到拿到),然後做修改,再去獲取全域性鎖,此時另一個全域性事務還未提交,則會霸佔著全域性鎖,這裡取不到全域性鎖,會釋放本地鎖,然後丟擲獲取全域性鎖失敗的異常。

這裡不使用select for update也能防止髒寫,但是加了能帶來以下的好處:

  • 鎖衝突更“溫柔”些。如果只有@GlobalLock,檢查到全域性鎖,則立刻丟擲異常,也許再“堅持”那麼一下,全域性鎖就釋放了,丟擲異常豈不可惜了。
  • updateA()中可以透過select for update獲得最新的A,接著再做更新。

關於髒讀髒寫的原因以及解決辦法

寫隔離以及是否會死鎖

不會死鎖,雖然是可能形成兩個全域性事務相互持有鎖(比如一個事務持有本地鎖並嘗試獲取全域性鎖,一個事務持有全域性鎖並嘗試獲取本地鎖),不過拿全域性鎖是會進行有限次數的嘗試,拿不到的話會放棄並釋放另一個鎖,所以並不會形成真正的死鎖

如下圖官網的例子:

兩個全域性事務tx1和tx2,分別對a表的一個欄位m進行更新,

tx1先開始 -》 開啟本地事務 -》 拿到本地鎖 -》 更新操作 -》 拿到全域性鎖 -》 提交併釋放本地鎖 -》 等待二階段的提交或者回滾

tx2後開始 -》 開啟本地事務 -》 拿到本地鎖(如果在tx1釋放本地鎖前嘗試拿的話會等待)-》 更新操作 -》 嘗試多次去拿全域性鎖(全域性鎖被tx1持有)

如果tx1最後執行的是二階段提交,則tx1釋放全域性鎖,tx2獲取到全域性鎖,tx2也能完成一階段的工作,並繼續往下執行

如果tx1最後執行的是二階段回滾,(tx1仍然持有全域性鎖)tx1還需要重新獲取本地鎖,但是本地鎖已經被tx2持有了,這時候就是相互持有對方的鎖(tx1持有全域性鎖並嘗試獲取本地鎖,tx2持有本地鎖並嘗試獲取全域性鎖),不過由於嘗試獲取全域性鎖是有嘗試次數限制的(預設最多10次),所以tx2最終會獲取全域性鎖超時失敗,並釋放本地鎖,然後tx1得到本地鎖從而完成二階段的回滾(補償)

讀隔離

Seata(AT 模式)的預設全域性隔離級別是 讀未提交(Read Uncommitted)

如果業務確實需要讀已提交,就需要使用@GlobalLock註解並使用select for update, Seata的AT模式對當前讀(select for update)進行了代理,如果加了@GlobalLock註解和使用select for update, 會進行獲取全域性鎖的獲取的重試。

出於總體效能上的考慮,Seata 目前的方案並沒有對所有 SELECT 語句都進行代理,僅針對 FOR UPDATE 的 SELECT 語句。

下面有待補充具體的實操

相關文章