分散式事務中介軟體 Fescar
前言
一般,資料庫事務的隔離級別會被設定成 讀已提交,已滿足業務需求,這樣對應在Fescar中的分支(本地)事務的隔離級別就是 讀已提交,那麼Fescar中對於全域性事務的隔離級別又是什麼呢?如果認真閱讀了 分散式事務中介軟體Txc/Fescar-RM模組原始碼解讀 的同學應該能推斷出來:Fescar將全域性事務的預設隔離定義成讀未提交。對於讀未提交隔離級別對業務的影響,想必大家都比較清楚,會讀到髒資料,經典的就是銀行轉賬例子,出現數據不一致的問題。而對於Fescar,如果沒有采取任何其它技術手段,那會出現很嚴重的問題,比如:
如上圖所示,問最終全域性事務A對資源R1應該回滾到哪種狀態?很明顯,如果再根據UndoLog去做回滾,就會發生嚴重問題:覆蓋了全域性事務B對資源R1的變更。那Fescar是如何解決這個問題呢?答案就是 Fescar的全域性寫排它鎖解決方案
對於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邏輯還是很清晰的,對於分支事務需要的鎖資源,要麼是一次性全部成功獲取,要麼全部失敗,不存在部分成功部分失敗的情況。通過上面的解釋,可能會有兩個疑問:
- 為什麼鎖結構前半部分採用ConcurrentHashMap,後半部分採用HashMap?
前半部分採用ConcurrentHashMap好理解:為了支援更好的併發處理;疑問的是後半部分為什麼不直接採用ConcurrentHashMap,而採用HashMap呢?可能原因是因為後半部分需要去判斷當前全域性事務有沒有佔用PK對應的鎖資源,是一個複合操作,即使採用ConcurrentHashMap還是避免不了要使用Synchronized加鎖進行判斷,還不如直接使用更輕量級的HashMap。
- 為什麼BranchSession要儲存持有的鎖資源
這個比較簡單,在整個鎖的結構中未體現分支事務佔用了哪些鎖記錄,這樣如果全域性事務提交時,分支事務怎麼去釋放所佔用的鎖資源呢?所以在BranchSession儲存了分支事務佔用的鎖資源。
下圖展示校驗全域性鎖資源能否被獲取邏輯:
下圖展示分支事務釋放全域性鎖資源邏輯
以上就是TC模組中全域性寫排它鎖的實現原理:在分支事務註冊時,RM會將當前分支事務所需要的鎖資源一併傳遞過來,TC獲取負責全域性鎖資源的獲取(要麼一次性全部成功,要麼全部失敗,不存在部分成功部分失敗);在全域性事務提交時,TC模組自動將全域性事務下的所有分支事務持有的鎖資源進行釋放;同時,為減少全域性寫排它鎖獲取失敗概率,TC模組對外暴露了校驗鎖資源能否被獲取介面,RM模組可以在在適當位置加以校驗,以減少分支事務註冊時失敗概率。
二、RM-全域性寫排它鎖使用
在RM模組中,主要使用了TC模組全域性鎖的兩個功能,一個是校驗全域性鎖能否被獲取,一個是分支事務註冊去佔用全域性鎖,全域性鎖釋放跟RM無關,由TC模組在全域性事務提交時自動釋放。分支事務註冊前,都會去做全域性鎖狀態校驗邏輯,以保證分支註冊不會發生鎖衝突。
在執行Update、Insert、Delete語句時,都會在sql執行前後生成資料快照以組織成UndoLog,而生成快照的方式基本上都是採用Select...For Update形式,RM嘗試校驗全域性鎖能否被獲取的邏輯就在執行該語句的執行器中:SelectForUpdateExecutor,具體如下圖:
基本邏輯如下:
- 執行Select ... For update語句,這樣本地事務就佔用了資料庫對應行鎖,其它本地事務由於無法搶佔本地資料庫行鎖,進而也不會去搶佔全域性鎖。
- 迴圈掌握校驗全域性鎖能否被獲取,由於全域性鎖可能會被先於當前的全域性事務獲取,因此需要等之前的全域性事務釋放全域性鎖資源;如果這裡校驗能獲取到全域性鎖,那麼由於步驟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模組內如何對全域性寫排它鎖的使用邏輯。在瞭解原始碼過程中,筆者也遺留了兩個問題:
- 全域性寫排它鎖資料結構儲存在記憶體中,如果伺服器重啟/宕機了怎麼辦,即TC模組的高可用方案是什麼呢?
- 一個Fescar管理的全域性事務和一個非Fescar管理的本地事務之間發生鎖衝突怎麼辦?具體問題如下圖,問題是:全域性事務A如何回滾?
對於問題1有待繼續研究;對於問題2目前已有答案,但Fescar目前暫未實現,具體就是全域性事務A回滾時會報錯,全域性事務A內的分支事務A1回滾時會校驗afterImage與當前表中對應行資料是否一致,如果一致才允許回滾,不一致則回滾失敗並報警通知對應業務方,由業務方自行處理。