天天看點

分布式事務中間件Fescar—全局寫排它鎖解讀

本文被阿裡中間件技術團隊收錄:

前言

一般,資料庫事務的隔離級别會被設定成 讀已送出,已滿足業務需求,這樣對應在Fescar中的分支(本地)事務的隔離級别就是 讀已送出,那麼Fescar中對于全局事務的隔離級别又是什麼呢?如果認真閱讀了

分布式事務中間件Fescar-RM子產品源碼解讀

的同學應該能推斷出來:Fescar将全局事務的預設隔離定義成讀未送出。對于讀未送出隔離級别對業務的影響,想必大家都比較清楚,會讀到髒資料,經典的就是銀行轉賬例子,出現資料不一緻的問題。而對于Fescar,如果沒有采取任何其它技術手段,那會出現很嚴重的問題,比如:

如上圖所示,問最終全局事務A對資源R1應該復原到哪種狀态?很明顯,如果再根據UndoLog去做復原,就會發生嚴重問題:覆寫了全局事務B對資源R1的變更。那Fescar是如何解決這個問題呢?答案就是 Fescar的全局寫排它鎖解決方案,在全局事務A執行過程中全局事務B會因為擷取不到全局鎖而處于等待狀态。

對于Fescar的隔離級别,引用官方的一段話來作說明:

全局事務的隔離性是建立在分支事務的本地隔離級别基礎之上的。

在資料庫本地隔離級别 讀已送出 或以上的前提下,Fescar 設計了由事務協調器維護的 全局寫排他鎖,來保證事務間的 寫隔離,将全局事務預設定義在 讀未送出 的隔離級别上。

我們對隔離級别的共識是:絕大部分應用在 讀已送出 的隔離級别下工作是沒有問題的。而實際上,這當中又有絕大多數的應用場景,實際上工作在 讀未送出 的隔離級别下同樣沒有問題。

在極端場景下,應用如果需要達到全局的 讀已送出,Fescar 也提供了相應的機制來達到目的。預設,Fescar 是工作在 讀未送出 的隔離級别下,保證絕大多數場景的高效性。

下面,本文将深入到源碼層面對Fescar全局寫排它鎖實作方案進行解讀。Fescar全局寫排它鎖實作方案在TC(Transaction Coordinator)子產品維護,RM(Resource Manager)子產品會在需要鎖擷取全局鎖的地方請求TC子產品以保證事務間的寫隔離,下面就分成兩個部分介紹:TC-全局寫排它鎖實作方案、RM-全局寫排它鎖使用

一、TC—全局寫排它鎖實作方案

首先看一下TC子產品與外部互動的入口,下圖是TC子產品的main函數:

上圖中看出RpcServer處理通信協定相關邏輯,而對于TC子產品真實處理器是DefaultCoordiantor,裡面包含了所有TC對外暴露的功能,比如doGlobalBegin(全局事務建立)、doGlobalCommit(全局事務送出)、doGlobalRollback(全局事務復原)、doBranchReport(分支事務狀态上報)、doBranchRegister(分支事務注冊)、doLockCheck(全局寫排它鎖校驗)等,其中doBranchRegister、doLockCheck、doGlobalCommit就是全局寫排它鎖實作方案的入口。

/**
* 分支事務注冊,在注冊過程中會擷取分支事務的全局鎖資源
*/
@Override
protected void doBranchRegister(BranchRegisterRequest request, BranchRegisterResponse response,
                                RpcContext rpcContext) throws TransactionException {
    response.setTransactionId(request.getTransactionId());
    response.setBranchId(core.branchRegister(request.getBranchType(), request.getResourceId(), rpcContext.getClientId(),
            XID.generateXID(request.getTransactionId()), request.getLockKey()));
}
/**
* 校驗全局鎖能否被擷取到
*/
@Override
protected void doLockCheck(GlobalLockQueryRequest request, GlobalLockQueryResponse response, RpcContext rpcContext)
    throws TransactionException {
    response.setLockable(core.lockQuery(request.getBranchType(), request.getResourceId(),
        XID.generateXID(request.getTransactionId()), request.getLockKey()));
}
/**
* 全局事務送出,會将全局事務下的所有分支事務的鎖占用記錄釋放
*/
@Override
protected void doGlobalCommit(GlobalCommitRequest request, GlobalCommitResponse response, RpcContext rpcContext)
throws TransactionException {
   response.setGlobalStatus(core.commit(XID.generateXID(request.getTransactionId())));
}           

上述代碼邏輯最後會被代理到DefualtCore去做執行

如上圖,不管是擷取鎖還是校驗鎖狀态邏輯,最終都會被LockManger所接管,而LockManager的邏輯由DefaultLockManagerImpl實作,所有與全局寫排它鎖的設計都在DefaultLockManagerImpl中維護。

首先,就先來看一下全局寫排它鎖的結構:

private static final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentHashMap<Integer, Map<String, Long>>>> LOCK_MAP = new ConcurrentHashMap<~>();           

整體上,鎖結構采用Map進行設計,前半段采用ConcurrentHashMap,後半段采用HashMap,最終其實就是做一個鎖占用标記:在某個ResourceId(資料庫源ID)上某個Tabel中的某個主鍵對應的行記錄的全局寫排它鎖被哪個全局事務占用。下面,我們來看一下具體擷取鎖的源碼:

如上圖注釋,整個acquireLock邏輯還是很清晰的,對于分支事務需要的鎖資源,要麼是一次性全部成功擷取,要麼全部失敗,不存在部分成功部分失敗的情況。通過上面的解釋,可能會有兩個疑問:

  1. 為什麼鎖結構前半部分采用ConcurrentHashMap,後半部分采用HashMap?
前半部分采用ConcurrentHashMap好了解:為了支援更好的并發處理;疑問的是後半部分為什麼不直接采用ConcurrentHashMap,而采用HashMap呢?可能原因是因為後半部分需要去判斷目前全局事務有沒有占用PK對應的鎖資源,是一個複合操作,即使采用ConcurrentHashMap還是避免不了要使用Synchronized加鎖進行判斷,還不如直接使用更輕量級的HashMap。
  1. 為什麼BranchSession要存儲持有的鎖資源
這個比較簡單,在整個鎖的結構中未展現分支事務占用了哪些鎖記錄,這樣如果全局事務送出時,分支事務怎麼去釋放所占用的鎖資源呢?是以在BranchSession儲存了分支事務占用的鎖資源。

下圖展示校驗全局鎖資源能否被擷取邏輯:

下圖展示分支事務釋放全局鎖資源邏輯

以上就是TC子產品中全局寫排它鎖的實作原理:在分支事務注冊時,RM會将目前分支事務所需要的鎖資源一并傳遞過來,TC擷取負責全局鎖資源的擷取(要麼一次性全部成功,要麼全部失敗,不存在部分成功部分失敗);在全局事務送出時,TC子產品自動将全局事務下的所有分支事務持有的鎖資源進行釋放;同時,為減少全局寫排它鎖擷取失敗機率,TC子產品對外暴露了校驗鎖資源能否被擷取接口,RM子產品可以在在适當位置加以校驗,以減少分支事務注冊時失敗機率。

二、RM-全局寫排它鎖使用

在RM子產品中,主要使用了TC子產品全局鎖的兩個功能,一個是校驗全局鎖能否被擷取,一個是分支事務注冊去占用全局鎖,全局鎖釋放跟RM無關,由TC子產品在全局事務送出時自動釋放。分支事務注冊前,都會去做全局鎖狀态校驗邏輯,以保證分支注冊不會發生鎖沖突。

在執行Update、Insert、Delete語句時,都會在sql執行前後生成資料快照以組織成UndoLog,而生成快照的方式基本上都是采用Select...For Update形式,RM嘗試校驗全局鎖能否被擷取的邏輯就在執行該語句的執行器中:SelectForUpdateExecutor,具體如下圖:

基本邏輯如下:

  1. 執行Select ... For update語句,這樣本地事務就占用了資料庫對應行鎖,其它本地事務由于無法搶占本地資料庫行鎖,進而也不會去搶占全局鎖。
  2. 循環掌握校驗全局鎖能否被擷取,由于全局鎖可能會被先于目前的全局事務擷取,是以需要等之前的全局事務釋放全局鎖資源;如果這裡校驗能擷取到全局鎖,那麼由于步驟1的原因,在目前本地事務結束前,其它本地事務是不會去擷取全局鎖的,進而保證了在目前本地事務送出前的分支事務注冊不會因為全局鎖沖突而失敗。

注:細心的同學可能會發現,對于Update、Delete語句對應的UpdateExecutor、DeleteExecutor中會因擷取beforeImage而執行Select..For Update語句,進而會去校驗全局鎖資源狀态,而對于Insert語句對應的InsertExecutor卻沒有相關全局鎖校驗邏輯,原因可能是:因為是Insert,那麼對應插入行PK是新增的,全局鎖資源必定未被占用,進而在本地事務送出前的分支事務注冊時對應的全局鎖資源肯定是能夠擷取得到的。

接下來我們再來看看分支事務如何送出,對于分支事務中需要占用的全局鎖資源如何生成和儲存的。首先,在執行SQL完業務SQL後,會根據beforeImage和afterImage生成UndoLog,與此同時,目前本地事務所需要占用的全局鎖資源辨別也會一同生成,儲存在ContentoionProxy的ConnectionContext中,如下圖所示。

在ContentoionProxy.commit中,分支事務注冊時會将ConnectionProxy中的context内儲存的需要占用的全局鎖辨別一同傳遞給TC進行全局鎖的擷取。

以上,就是RM子產品中對全局寫排它鎖的使用邏輯,因在真正執行擷取全局鎖資源前會去循環校驗全局鎖資源狀态,保證在實際擷取鎖資源時不會因為鎖沖突而失敗,但這樣其實壞處也很明顯:在鎖沖突比較嚴重時,會增加本地事務資料庫鎖占用時長,進而給業務接口帶來一定的性能損耗。

三、總結

本文詳細介紹了Fescar為在 讀未送出 隔離級别下做到 寫隔離 而實作的全局寫排它鎖,包括TC子產品内的全局寫排它鎖的實作原理以及RM子產品内如何對全局寫排它鎖的使用邏輯。在了解源碼過程中,筆者也遺留了兩個問題:

  1. 全局寫排它鎖資料結構儲存在記憶體中,如果伺服器重新開機/當機了怎麼辦,即TC子產品的高可用方案是什麼呢?
  2. 一個Fescar管理的全局事務和一個非Fescar管理的本地事務之間發生鎖沖突怎麼辦?具體問題如下圖,問題是:全局事務A如何復原?

對于問題1有待繼續研究;對于問題2目前已有答案,但Fescar目前暫未實作,具體就是全局事務A復原時會報錯,全局事務A内的分支事務A1復原時會校驗afterImage與目前表中對應行資料是否一緻,如果一緻才允許復原,不一緻則復原失敗并報警通知對應業務方,由業務方自行處理。

參考

  1. Fescar官方介紹
  2. fescar鎖設計和隔離級别的了解
  3. 姊妹篇:分布式事務中間件Fescar—RM子產品源碼解讀