詳解 Seata AT 模式事務(wù)隔離級(jí)別與全局鎖設(shè)計(jì)
Seata AT 模式是一種非侵入式的分布式事務(wù)解決方案,Seata 在內(nèi)部做了對(duì)數(shù)據(jù)庫(kù)操作的代理層,我們使用 Seata AT 模式時(shí),實(shí)際上用的是 Seata 自帶的數(shù)據(jù)源代理 DataSourceProxy,Seata 在這層代理中加入了很多邏輯,比如插入回滾 undo_log 日志,檢查全局鎖等。
為什么要檢查全局鎖呢,這是由于 Seata AT 模式的事務(wù)隔離是建立在支事務(wù)的本地隔離級(jí)別基礎(chǔ)之上的,在數(shù)據(jù)庫(kù)本地隔離級(jí)別讀已提交或以上的前提下,Seata 設(shè)計(jì)了由事務(wù)協(xié)調(diào)器維護(hù)的全局寫排他鎖,來(lái)保證事務(wù)間的寫隔離,同時(shí),將全局事務(wù)默認(rèn)定義在讀未提交的隔離級(jí)別上。
Seata 事務(wù)隔離級(jí)別解讀
在講 Seata 事務(wù)隔離級(jí)之前,我們先來(lái)回顧一下數(shù)據(jù)庫(kù)事務(wù)的隔離級(jí)別,目前數(shù)據(jù)庫(kù)事務(wù)的隔離級(jí)別一共有 4 種,由低到高分別為:
- Read uncommitted:讀未提交
- Read committed:讀已提交
- Repeatable read:可重復(fù)讀
- Serializable:序列化
數(shù)據(jù)庫(kù)一般默認(rèn)的隔離級(jí)別為讀已提交,比如 Oracle,也有一些數(shù)據(jù)的默認(rèn)隔離級(jí)別為可重復(fù)讀,比如 Mysql,一般而言,數(shù)據(jù)庫(kù)的讀已提交能夠滿足業(yè)務(wù)絕大部分場(chǎng)景了。
我們知道 Seata 的事務(wù)是一個(gè)全局事務(wù),它包含了若干個(gè)分支本地事務(wù),在全局事務(wù)執(zhí)行過(guò)程中(全局事務(wù)還沒(méi)執(zhí)行完),某個(gè)本地事務(wù)提交了,如果 Seata 沒(méi)有采取任務(wù)措施,則會(huì)導(dǎo)致已提交的本地事務(wù)被讀取,造成臟讀,如果數(shù)據(jù)在全局事務(wù)提交前已提交的本地事務(wù)被修改,則會(huì)造成臟寫。
由此可以看出,傳統(tǒng)意義的臟讀是讀到了未提交的數(shù)據(jù),Seata 臟讀是讀到了全局事務(wù)下未提交的數(shù)據(jù),全局事務(wù)可能包含多個(gè)本地事務(wù),某個(gè)本地事務(wù)提交了不代表全局事務(wù)提交了。
在絕大部分應(yīng)用在讀已提交的隔離級(jí)別下工作是沒(méi)有問(wèn)題的,而實(shí)際上,這當(dāng)中又有絕大多數(shù)的應(yīng)用場(chǎng)景,實(shí)際上工作在讀未提交的隔離級(jí)別下同樣沒(méi)有問(wèn)題。
在極端場(chǎng)景下,應(yīng)用如果需要達(dá)到全局的讀已提交,Seata 也提供了全局鎖機(jī)制實(shí)現(xiàn)全局事務(wù)讀已提交。但是默認(rèn)情況下,Seata 的全局事務(wù)是工作在讀未提交隔離級(jí)別的,保證絕大多數(shù)場(chǎng)景的高效性。
全局鎖實(shí)現(xiàn)
AT 模式下,會(huì)使用 Seata 內(nèi)部數(shù)據(jù)源代理 DataSourceProxy,全局鎖的實(shí)現(xiàn)就是隱藏在這個(gè)代理中。我們分別在執(zhí)行、提交的過(guò)程都做了什么。
1、執(zhí)行過(guò)程
執(zhí)行過(guò)程在 StatementProxy 類,在執(zhí)行過(guò)程中,如果執(zhí)行 SQL 是 select for update,則會(huì)使用 SelectForUpdateExecutor 類,如果執(zhí)行方法中帶有 @GlobalTransactional or @GlobalLock注解,則會(huì)檢查是否有全局鎖,如果當(dāng)前存在全局鎖,則會(huì)回滾本地事務(wù),通過(guò) while 循環(huán)不斷地重新競(jìng)爭(zhēng)獲取本地鎖和全局鎖。
- 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、提交過(guò)程
提交過(guò)程在 ConnectionProxy#doCommit方法中。
1)如果執(zhí)行方法中帶有@GlobalTransactional注解,則會(huì)在注冊(cè)分支時(shí)候獲取全局鎖:
- 請(qǐng)求 TC 注冊(cè)分支
- 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 注冊(cè)分支的時(shí)候,獲取全局鎖
- 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注解,在提交前會(huì)查詢?nèi)宙i是否存在,如果存在則拋異常:
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 注解說(shuō)明
從執(zhí)行過(guò)程和提交過(guò)程可以看出,既然開(kāi)啟全局事務(wù) @GlobalTransactional注解可以在事務(wù)提交前,查詢?nèi)宙i是否存在,那為什么 Seata 還要設(shè)計(jì)多處一個(gè) @GlobalLock注解呢?
因?yàn)椴⒉皇撬械臄?shù)據(jù)庫(kù)操作都需要開(kāi)啟全局事務(wù),而開(kāi)啟全局事務(wù)是一個(gè)比較重的操作,需要向 TC 發(fā)起開(kāi)啟全局事務(wù)等 RPC 過(guò)程,而@GlobalLock注解只會(huì)在執(zhí)行過(guò)程中查詢?nèi)宙i是否存在,不會(huì)去開(kāi)啟全局事務(wù),因此在不需要全局事務(wù),而又需要檢查全局鎖避免臟讀臟寫時(shí),使用@GlobalLock注解是一個(gè)更加輕量的操作。
如何防止臟寫
先來(lái)看一下使用 Seata AT 模式是怎么產(chǎn)生臟寫的:
注:分支事務(wù)執(zhí)行過(guò)程省略其它過(guò)程。
業(yè)務(wù)一開(kāi)啟全局事務(wù),其中包含分支事務(wù)A(修改 A)和分支事務(wù) B(修改 B),業(yè)務(wù)二修改 A,其中業(yè)務(wù)一執(zhí)行分支事務(wù) A 先獲取本地鎖,業(yè)務(wù)二則等待業(yè)務(wù)一執(zhí)行完分支事務(wù) A 之后,獲得本地鎖修改 A 并入庫(kù),業(yè)務(wù)一在執(zhí)行分支事務(wù)時(shí)發(fā)生異常了,由于分支事務(wù) A 的數(shù)據(jù)被業(yè)務(wù)二修改,導(dǎo)致業(yè)務(wù)一的全局事務(wù)無(wú)法回滾。
如何防止臟寫?
1、業(yè)務(wù)二執(zhí)行時(shí)加 @GlobalTransactional注解:
注:分支事務(wù)執(zhí)行過(guò)程省略其它過(guò)程。
業(yè)務(wù)二在執(zhí)行全局事務(wù)過(guò)程中,分支事務(wù) A 提交前注冊(cè)分支事務(wù)獲取全局鎖時(shí),發(fā)現(xiàn)業(yè)務(wù)業(yè)務(wù)一全局鎖還沒(méi)執(zhí)行完,因此業(yè)務(wù)二提交不了,拋異常回滾,所以不會(huì)發(fā)生臟寫。
2、業(yè)務(wù)二執(zhí)行時(shí)加 @GlobalLock注解:
注:分支事務(wù)執(zhí)行過(guò)程省略其它過(guò)程。
與 @GlobalTransactional注解效果類似,只不過(guò)不需要開(kāi)啟全局事務(wù),只在本地事務(wù)提交前,檢查全局鎖是否存在。
2、業(yè)務(wù)二執(zhí)行時(shí)加 @GlobalLock 注解 + select for update語(yǔ)句:
注:分支事務(wù)執(zhí)行過(guò)程省略其它過(guò)程。
如果加了select for update語(yǔ)句,則會(huì)在 update 前檢查全局鎖是否存在,只有當(dāng)全局鎖釋放之后,業(yè)務(wù)二才能開(kāi)始執(zhí)行 updateA 操作。
如果單單是 transactional,那么就有可能會(huì)出現(xiàn)臟寫,根本原因是沒(méi)有 Globallock 注解時(shí),不會(huì)檢查全局鎖,這可能會(huì)導(dǎo)致另外一個(gè)全局事務(wù)回滾時(shí),發(fā)現(xiàn)某個(gè)分支事務(wù)被臟寫了。所以加 select for update 也有個(gè)好處,就是可以重試。
如何防止臟讀
Seata AT 模式的臟讀是指在全局事務(wù)未提交前,被其它業(yè)務(wù)讀到已提交的分支事務(wù)的數(shù)據(jù),本質(zhì)上是Seata默認(rèn)的全局事務(wù)是讀未提交。
那么怎么避免臟讀現(xiàn)象呢?
業(yè)務(wù)二查詢 A 時(shí)加 @GlobalLock 注解 + select for update語(yǔ)句:
注:分支事務(wù)執(zhí)行過(guò)程省略其它過(guò)程。
加select for update語(yǔ)句會(huì)在執(zhí)行 SQL 前檢查全局鎖是否存在,只有當(dāng)全局鎖完成之后,才能繼續(xù)執(zhí)行 SQL,這樣就防止了臟讀。
本文轉(zhuǎn)載自微信公眾號(hào)「后端進(jìn)階」,可以通過(guò)以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系后端進(jìn)階公眾號(hào)。