詳解 Seata AT 模式事務隔離級別與全局鎖設計
Seata AT 模式是一種非侵入式的分布式事務解決方案,Seata 在內部做了對數(shù)據庫操作的代理層,我們使用 Seata AT 模式時,實際上用的是 Seata 自帶的數(shù)據源代理 DataSourceProxy,Seata 在這層代理中加入了很多邏輯,比如插入回滾 undo_log 日志,檢查全局鎖等。
為什么要檢查全局鎖呢,這是由于 Seata AT 模式的事務隔離是建立在支事務的本地隔離級別基礎之上的,在數(shù)據庫本地隔離級別讀已提交或以上的前提下,Seata 設計了由事務協(xié)調器維護的全局寫排他鎖,來保證事務間的寫隔離,同時,將全局事務默認定義在讀未提交的隔離級別上。
Seata 事務隔離級別解讀
在講 Seata 事務隔離級之前,我們先來回顧一下數(shù)據庫事務的隔離級別,目前數(shù)據庫事務的隔離級別一共有 4 種,由低到高分別為:
- Read uncommitted:讀未提交
- Read committed:讀已提交
- Repeatable read:可重復讀
- Serializable:序列化
數(shù)據庫一般默認的隔離級別為讀已提交,比如 Oracle,也有一些數(shù)據的默認隔離級別為可重復讀,比如 Mysql,一般而言,數(shù)據庫的讀已提交能夠滿足業(yè)務絕大部分場景了。
我們知道 Seata 的事務是一個全局事務,它包含了若干個分支本地事務,在全局事務執(zhí)行過程中(全局事務還沒執(zhí)行完),某個本地事務提交了,如果 Seata 沒有采取任務措施,則會導致已提交的本地事務被讀取,造成臟讀,如果數(shù)據在全局事務提交前已提交的本地事務被修改,則會造成臟寫。
由此可以看出,傳統(tǒng)意義的臟讀是讀到了未提交的數(shù)據,Seata 臟讀是讀到了全局事務下未提交的數(shù)據,全局事務可能包含多個本地事務,某個本地事務提交了不代表全局事務提交了。
在絕大部分應用在讀已提交的隔離級別下工作是沒有問題的,而實際上,這當中又有絕大多數(shù)的應用場景,實際上工作在讀未提交的隔離級別下同樣沒有問題。
在極端場景下,應用如果需要達到全局的讀已提交,Seata 也提供了全局鎖機制實現(xiàn)全局事務讀已提交。但是默認情況下,Seata 的全局事務是工作在讀未提交隔離級別的,保證絕大多數(shù)場景的高效性。
全局鎖實現(xiàn)
AT 模式下,會使用 Seata 內部數(shù)據源代理 DataSourceProxy,全局鎖的實現(xiàn)就是隱藏在這個代理中。我們分別在執(zhí)行、提交的過程都做了什么。
1、執(zhí)行過程
執(zhí)行過程在 StatementProxy 類,在執(zhí)行過程中,如果執(zhí)行 SQL 是 select for update,則會使用 SelectForUpdateExecutor 類,如果執(zhí)行方法中帶有 @GlobalTransactional or @GlobalLock注解,則會檢查是否有全局鎖,如果當前存在全局鎖,則會回滾本地事務,通過 while 循環(huán)不斷地重新競爭獲取本地鎖和全局鎖。
- public T doExecute(Object... args) throws Throwable {
- Connection conn = statementProxy.getConnection();
- // ... ...
- try {
- // ... ...
- while (true) {
- try {
- // ... ...
- if (RootContext.inGlobalTransaction() || RootContext.requireGlobalLock()) {
- // Do the same thing under either @GlobalTransactional or @GlobalLock,
- // that only check the global lock here.
- statementProxy.getConnectionProxy().checkLock(lockKeys);
- } else {
- throw new RuntimeException("Unknown situation!");
- }
- break;
- } catch (LockConflictException lce) {
- if (sp != null) {
- conn.rollback(sp);
- } else {
- conn.rollback();
- }
- // trigger retry
- lockRetryController.sleep(lce);
- }
- }
- } finally {
- // ...
- }
2、提交過程
提交過程在 ConnectionProxy#doCommit方法中。
1)如果執(zhí)行方法中帶有@GlobalTransactional注解,則會在注冊分支時候獲取全局鎖:
- 請求 TC 注冊分支
- private void register() throws TransactionException {
- if (!context.hasUndoLog() || !context.hasLockKey()) {
- return;
- }
- Long branchId = DefaultResourceManager.get().branchRegister(BranchType.AT, getDataSourceProxy().getResourceId(),
- null, context.getXid(), null, context.buildLockKeys());
- context.setBranchId(branchId);
- }
- TC 注冊分支的時候,獲取全局鎖
- protected void branchSessionLock(GlobalSession globalSession, BranchSession branchSession) throws TransactionException {
- if (!branchSession.lock()) {
- throw new BranchTransactionException(LockKeyConflict, String
- .format("Global lock acquire failed xid = %s branchId = %s", globalSession.getXid(),
- branchSession.getBranchId()));
- }
- }
2)如果執(zhí)行方法中帶有@GlobalLock注解,在提交前會查詢全局鎖是否存在,如果存在則拋異常:
io.seata.rm.datasource.ConnectionProxy#processLocalCommitWithGlobalLocks
- private void processLocalCommitWithGlobalLocks() throws SQLException {
- checkLock(context.buildLockKeys());
- try {
- targetConnection.commit();
- } catch (Throwable ex) {
- throw new SQLException(ex);
- }
- context.reset();
- }
GlobalLock 注解說明
從執(zhí)行過程和提交過程可以看出,既然開啟全局事務 @GlobalTransactional注解可以在事務提交前,查詢全局鎖是否存在,那為什么 Seata 還要設計多處一個 @GlobalLock注解呢?
因為并不是所有的數(shù)據庫操作都需要開啟全局事務,而開啟全局事務是一個比較重的操作,需要向 TC 發(fā)起開啟全局事務等 RPC 過程,而@GlobalLock注解只會在執(zhí)行過程中查詢全局鎖是否存在,不會去開啟全局事務,因此在不需要全局事務,而又需要檢查全局鎖避免臟讀臟寫時,使用@GlobalLock注解是一個更加輕量的操作。
如何防止臟寫
先來看一下使用 Seata AT 模式是怎么產生臟寫的:
注:分支事務執(zhí)行過程省略其它過程。
業(yè)務一開啟全局事務,其中包含分支事務A(修改 A)和分支事務 B(修改 B),業(yè)務二修改 A,其中業(yè)務一執(zhí)行分支事務 A 先獲取本地鎖,業(yè)務二則等待業(yè)務一執(zhí)行完分支事務 A 之后,獲得本地鎖修改 A 并入庫,業(yè)務一在執(zhí)行分支事務時發(fā)生異常了,由于分支事務 A 的數(shù)據被業(yè)務二修改,導致業(yè)務一的全局事務無法回滾。
如何防止臟寫?
1、業(yè)務二執(zhí)行時加 @GlobalTransactional注解:
注:分支事務執(zhí)行過程省略其它過程。
業(yè)務二在執(zhí)行全局事務過程中,分支事務 A 提交前注冊分支事務獲取全局鎖時,發(fā)現(xiàn)業(yè)務業(yè)務一全局鎖還沒執(zhí)行完,因此業(yè)務二提交不了,拋異常回滾,所以不會發(fā)生臟寫。
2、業(yè)務二執(zhí)行時加 @GlobalLock注解:
注:分支事務執(zhí)行過程省略其它過程。
與 @GlobalTransactional注解效果類似,只不過不需要開啟全局事務,只在本地事務提交前,檢查全局鎖是否存在。
2、業(yè)務二執(zhí)行時加 @GlobalLock 注解 + select for update語句:
注:分支事務執(zhí)行過程省略其它過程。
如果加了select for update語句,則會在 update 前檢查全局鎖是否存在,只有當全局鎖釋放之后,業(yè)務二才能開始執(zhí)行 updateA 操作。
如果單單是 transactional,那么就有可能會出現(xiàn)臟寫,根本原因是沒有 Globallock 注解時,不會檢查全局鎖,這可能會導致另外一個全局事務回滾時,發(fā)現(xiàn)某個分支事務被臟寫了。所以加 select for update 也有個好處,就是可以重試。
如何防止臟讀
Seata AT 模式的臟讀是指在全局事務未提交前,被其它業(yè)務讀到已提交的分支事務的數(shù)據,本質上是Seata默認的全局事務是讀未提交。
那么怎么避免臟讀現(xiàn)象呢?
業(yè)務二查詢 A 時加 @GlobalLock 注解 + select for update語句:
注:分支事務執(zhí)行過程省略其它過程。
加select for update語句會在執(zhí)行 SQL 前檢查全局鎖是否存在,只有當全局鎖完成之后,才能繼續(xù)執(zhí)行 SQL,這樣就防止了臟讀。
本文轉載自微信公眾號「后端進階」,可以通過以下二維碼關注。轉載本文請聯(lián)系后端進階公眾號。