概述 隨著互聯網的發展,軟體系統由原來的單體應用轉變為分散式應用。分散式系統把一個單體應用拆分為可獨立部署的多個服務,因此需要服務與服務之間遠程協作才能完成事務操作。這種分散式系統下不同服務之間通過遠程協作完成的事務稱之為分散式事務,例如用戶註冊送積分事務、創建訂單減庫存事務,銀行轉賬事務等都是分佈 ...
概述
隨著互聯網的發展,軟體系統由原來的單體應用轉變為分散式應用。分散式系統把一個單體應用拆分為可獨立部署的多個服務,因此需要服務與服務之間遠程協作才能完成事務操作。這種分散式系統下不同服務之間通過遠程協作完成的事務稱之為分散式事務,例如用戶註冊送積分事務、創建訂單減庫存事務,銀行轉賬事務等都是分散式事務
舉個例子,使用傳統本地事務完成轉賬邏輯,任一步驟出問題都會回滾
begin transaction;
// 1.本地資料庫操作:張三減少金額
// 2.本地資料庫操作:李四增加金額
commit transation;
但在分散式系統下,就變成這樣
begin transaction;
// 1.本地資料庫操作:張三減少金額
// 2.遠程調用:讓李四增加金額
commit transation;
如果執行到第二步,遠程調用成功了,李四增加了金額,但因為網路延遲沒能及時響應,那麼本地系統就會認為事務失敗,從而回滾張三減少金額的操作
分散式事務產生的場景
1. 微服務架構
典型的場景就是微服務之間通過遠程調用完成事務操作,比如:訂單服務和庫存服務,下單的同時,訂單服務請求庫存服務減庫存
2. 單體系統訪問多個資料庫實例
當單體系統需要訪問多個資料庫時就會產生分散式事務,比如:用戶信息和訂單信息分別在兩個資料庫存儲,用戶管理系統刪除用戶信息,需要分別刪除用戶信息及用戶訂單信息,由於數據分佈在不同的資料庫,需要通過不同的資料庫鏈接操作數據,產生分散式事務
3. 多服務訪問同一個資料庫
比如:訂單服務和庫存服務訪問同一個資料庫也會產生分散式事務,兩個服務持有不同的資料庫鏈接進行操作,產生分散式事務
2PC(兩階段提交)
Two-Phase Commit,兩階段提交,指將整個事務流程分為兩個階段,準備階段(prepare-phase)、提交階段(commit-phase)
舉例:張三和李四聚餐,飯店老闆要求先買單,才能出票,兩人就商議 AA。只有張三和李四都付款,老闆才能出票安排就餐
- 準備階段:老闆要求張三付款,張三付款,老闆要求李四付款,李四付款
- 提交階段:老闆出票,兩人拿票就餐
該例形成一個事務,若張三或李四其中一人拒絕付款,或錢不夠,老闆都不會出票,並且會把已收款退回。整個事務過程由事務管理器和參與者組成,老闆就是事務管理器,張三和李四就是事務參與者。事務管理器負責
決策整個分散式事務的提交和回滾,事務參與者負責自己本地事務的提交和回滾
部分關係資料庫如 Oracle、MySQL 都支持兩階段提交協議:
- 準備階段:事務管理器給每個參與者發送 Prepare 消息,每個資料庫參與者在本地執行事務,並寫本地的 Undo/Redo 日誌,此時事務沒有提交(Undo 日誌記錄修改前的數據,用於資料庫回滾,Redo 日誌記錄修改後的數據,用於提交事務後寫入數據文件)
- 提交階段:如果事務管理器收到了參與者的執行失敗或者超時消息,直接給每個參與者發送回滾消息;否則,發送提交消息;參與者根據事務管理器的指令執行提交或者回滾操作,並釋放事務處理過程中使用的鎖資源
XA 規範
2PC 提供瞭解決分散式事務的方案,但不同的資料庫實現卻不一樣。為了統一標準,國際開放標準組織 Open Group 定義分散式事務的模型(DTP)和 分散式事務協議(XA)
DTP 模型由以下元素組成:
- AP(Application Program):應用程式,可以理解為使用 DTP 分散式事務的程式
- RM(Resource Manager):資源管理器,可以理解為事務的參與者,一般指一個資料庫實例,通過資源管理器控制資料庫
- TM(Transaction Manager):事務管理器,負責協調和管理事務,事務管理器控制全局事務,管理事務生命周期,並協調各個 RM
XA 規範定義了 RM(資源管理器)與 TM(事務管理器)的交互介面,另外,XA 規範還對 2PC 做了優化,執行流程如下:
- 應用程式(AP)持有用戶庫和積分庫兩個數據源
- 應用程式(AP)通過 TM 通知用戶庫 RM 新增用戶,同時通知積分庫 RM 為該用戶新增積分,此時 RM 並未提交事務,用戶和積分資源鎖定
- TM 收到執行回覆,只要有一方失敗則分別向其他 RM 發起回滾事務,回滾完畢,資源鎖釋放
- TM 收到執行回覆,全部成功,此時向所有 RM 發起提交事務,提交完畢,資源鎖釋放
MySQL 從 5.0.3 開始支持 XA 分散式事務協議,且只有 InnoDB 存儲引擎支持,這裡通過 JDBC 來演示如何通過 TM 控制多個 RM 完成 2PC 分散式事務
import com.mysql.jdbc.jdbc2.optional.MysqlXAConnection;
import com.mysql.jdbc.jdbc2.optional.MysqlXid;
import javax.sql.XAConnection;
import javax.transaction.xa.XAException;
import javax.transaction.xa.XAResource;\
import javax.transaction.xa.Xid;import java.sql.*;
public class MysqlXAConnectionTest {
public static void main(String[] args) throws SQLException {
// true 表示列印 XA 語句, 用於調試
boolean logXaCommands = true;
// 獲得資源管理器操作介面實例 RM1
Connection conn1 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "root");
XAConnection xaConn1 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn1, logXaCommands);
XAResource rm1 = xaConn1.getXAResource();
// 獲得資源管理器操作介面實例 RM2
Connection conn2 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "root");
XAConnection xaConn2 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn2, logXaCommands);
XAResource rm2 = xaConn2.getXAResource();
// AP(應用程式)請求 TM(事務管理器) 執行一個分散式事務, TM 生成全局事務 ID
byte[] gtrid = "distributed_transaction_id_1".getBytes();
int formatId = 1;
try {
// TM 生成 RM1 上的事務分支 ID
byte[] bqual1 = "transaction_001".getBytes();Xid xid1 = new MysqlXid(gtrid, bqual1, formatId);
// 執行 RM1 上的事務分支
rm1.start(xid1, XAResource.TMNOFLAGS);
PreparedStatement ps1 = conn1.prepareStatement("INSERT into user(name) VALUES ('jack')");
ps1.execute();
rm1.end(xid1,XAResource.TMSUCCESS);
// TM 生成 RM2 上的事務分支 ID
byte[] bqual2 = "transaction_002".getBytes();Xid xid2 = new MysqlXid(gtrid, bqual2, formatId);
// 執行 RM2 上的事務分支
rm2.start(xid2, XAResource.TMNOFLAGS);
PreparedStatement ps2 = conn2.prepareStatement("INSERT into user(name) VALUES ('rose')");
ps2.execute();
rm2.end(xid2, XAResource.TMSUCCESS);
// phase1: 詢問所有的RM 準備提交事務分支
int rm1_prepare = rm1.prepare(xid1);
int rm2_prepare = rm2.prepare(xid2);
// phase2: 提交所有事務分支
if (rm1_prepare == XAResource.XA_OK && rm2_prepare == XAResource.XA_OK) {
// 所有事務分支都 prepare 成功, 提交所有事務分支
rm1.commit(xid1, false);rm2.commit(xid2, false);
} else {
// 如果有事務分支沒有成功, 則回滾
rm1.rollback(xid1);rm1.rollback(xid2);
}
} catch (XAException e) {
e.printStackTrace();
}
}
}
AT 模式
2PC 原理簡單,實現方便,但也有缺點:
- 需要本地資料庫支持 XA 協議
- 資源鎖需要等到兩個階段結束才釋放,性能較差
Seata 是由阿裡團隊研發的開源的分散式事務框架,是工作在應用層的中間件,主要優點是性能較好,且不長時間占用連接資源,以高效並且對業務零侵入的方式解決微服務場景下的分散式事務問題。它提供的 AT 模式在傳統 2PC 的基礎上進行改進,並解決 2PC 方案面臨的問題
Seata 把一個分散式事務理解成一個包含了若幹分支事務的全局事務,全局事務的職責是協調其下管理的分支事務達成一致,要麼一起成功提交,要麼一起失敗回滾
與傳統 2PC 類似,Seata 定義了三個組件來協議分散式事務的處理過程:
- TC(Transaction Coordinator):事務協調器,它是獨立的中間件,需要獨立部署運行,它維護全局事務的運行狀態,接收 TM 指令發起全局事務的提交與回滾,負責與 RM 通信協調各各分支事務的提交或回滾
- TM(Transaction Manager):事務管理器,TM 需要嵌入應用程式中工作,它負責開啟一個全局事務,並最終向 TC 發起全局提交或全局回滾的指令
- RM(Resource Manager):控制分支事務,負責分支註冊、狀態彙報,並接收事務協調器 TC 的指令,驅動分支(本地)事務的提交和回滾
拿新用戶註冊送積分舉例:
- 用戶服務的 TM 向 TC 申請開啟一個全局事務,全局事務創建成功並生成一個全局唯一的 XID
- 用戶服務的 RM 向 TC 註冊分支事務,該分支事務在用戶服務執行新增用戶邏輯,並將其納入 XID 對應全局事務的管轄
- 用戶服務執行分支事務,向用戶表插入一條記錄
- 邏輯執行到遠程調用積分服務時(XID 在微服務調用鏈路的上下文中傳播),積分服務的 RM 向 TC 註冊分支事務,該分支事務執行增加積分的邏輯,並將其納入 XID 對應全局事務的管轄
- 積分服務執行分支事務,向積分記錄表插入一條記錄,執行完畢後,返回用戶服務
- 用戶服務分支事務執行完畢
- TM 向 TC 發起針對 XID 的全局提交或回滾決議
- TC 調度 XID 下管轄的全部分支事務完成提交或回滾請求
傳統 2PC 的 RM 實際上是在資料庫層,RM 本質上就是資料庫自身,通過 XA 協議實現,而 Seata 的 RM 是以 jar 包的形式作為中間件層部署在應用程式這一側的。傳統 2PC 無論第二階段的決議是 commit 還是 rollback,事務性資源的鎖都要保持到 Phase2 完成才釋放,而 Seata 的做法是在 Phase1 就將本地事務提交,這樣就可以省去 Phase2 持鎖的時間,整體提高效率
有關使用 Seata 實現 2PC 方案可以參考:https://www.cnblogs.com/Yee-Q/p/17744259.html
TCC
TCC 是Try、Confirm、Cancel 三個單詞的縮寫,TCC 要求每個分支事務實現三個操作:預處理 Try、確認 Confirm、撤銷 Cancel。Try 操作做業務檢查及資源預留,Confirm 做業務確認操作,Cancel 實現一個與 Try 相反的操作即回滾操作。TM 首先發起所有的分支事務的 try 操作,任何一個分支事務的 try 操作執行失敗,TM 就會發起所有分支事務的 Cancel 操作。若 Try 操作全部成功,TM 就會發起所有分支事務的 Confirm 操作,其中 Confirm/Cancel 操作若執行失敗,TM 會進行重試
分支事務失敗的情況:
TCC 分為三個階段:
- Try 階段是做業務檢查及資源預留,此階段僅是一個初步操作,它和後續的 Confirm 一起才能真正構成一個完整的業務邏輯
- Confirm 階段是做確認提交,Try 階段所有分支事務執行成功後開始執行 Confirm,通常情況下,採用 TCC 就認為只要 Try 成功,Confirm 就一定成功,若 Confirm 階段真的出錯了,需引入重試機制或人工處理
- Cancel 階段是在業務執行錯誤需要回滾的狀態下執行分支事務的業務取消,預留資源釋放,通常情況下,採用 TCC 就認為 Cancel 也是一定成功的,若 Cancel 階段真的出錯了,需引入重試機制或人工處理
TM 事務管理器可以實現為獨立的服務,也可以讓全局事務發起方充當 TM 的角色。TM 在發起全局事務時會生成全局事務記錄,全局事務 ID 貫穿整個分散式事務調用鏈條,用來記錄事務上下文,追蹤和記錄狀態
TCC 需要註意三種異常處理:
- 空回滾:是當一個分支事務所在服務宕機或網路異常,分支事務調用記錄為失敗,這個時候沒有執行 Try 方法。當故障恢復後,分散式事務進行回滾會調用 Cancel 方法,從而形成空回滾。解決思路是識別出這個空回滾,即需要知道一階段是否執行。如果執行了,那就是正常回滾;如果沒執行,那就是空回滾。可以根據全局事務 ID 和分支事務 ID 新建一張記錄表。執行 Try 方法時在表中插入一條記錄,表示一階段執行了。Cancel 介面里讀取該記錄,如果該記錄存在,則正常回滾,如果該記錄不存在,則是空回滾
- 冪等:為了避免 TCC 的提交重試機制引發數據不一致,要求 TCC 的 Try、Confirm 和 Cancel 介面保證冪等,這樣不會重覆使用或者釋放資源。解決思路和空回滾類似,每次執行前都查詢狀態
- 懸掛:RPC 調用分支事務 Try 時,如果發生網路擁堵,RPC 調用超時,TM 就調用分支事務 Cancel 回滾,可能回滾完成後,之前的 Try 請求才真正到達並執行行,而 Try 方法預留的業務資源,只有該分散式事務才能使用,而分散式事務又已經回滾,即該業務資源後續沒法處理了,對於這種情況就稱為懸掛。解決思路和空回滾類似,每次執行前都查詢狀態
如果拿 TCC 事務的處理流程與 2PC 兩階段提交做比較,2PC 通常都是在跨庫的 DB 層面,而 TCC 則在應用層面的處理,需要通過業務邏輯來實現。TCC 的優勢在於,可以讓應用自己定義數據操作的粒度,降低鎖衝突、提高吞吐量。不足之處則在於對應用的侵入性非常強,業務邏輯的每個分支都需要實現 try、confirm、cancel 三個操作。此外,其實現難度也比較大,需要按照網路狀態、系統故障等不同的失敗原因實現不同的回滾策略
可靠消息最終一致性
可靠消息最終一致性方案是指,事務發起方(消息發送者)執行完成本地事務,並將消息發給消息中間件,事務參與方(消息消費者)從消息中間件接收消息,並執行完成本地事務
因為事務發起/參與方和消息中間件都是通過網路通信,網路通信的不確定性有可能導致問題,因此可靠消息最終一致性方案要解決以下幾個問題:
-
本地事務與消息發送的原子性問題:
事務發起方在本地事務執行成功後消息必鬚髮出去,否則就丟棄消息,即本地事務和消息發送要麼都成功,要麼都失敗
先嘗試以下操作,先發送消息,再操作資料庫:
begin transaction; // 1. 發送 MQ // 2. 資料庫操作 commit transation;
這種情況無法保證資料庫操作與發送消息的一致性,因為可能發送消息成功,資料庫操作失敗
如果先進行資料庫操作,再發送消息
begin transaction; // 1. 資料庫操作 // 2. 發送 MQ commit transation;
這種情況貌似沒有問題,如果發送 MQ 消息失敗,就會拋出異常,導致資料庫事務回滾。但如果是超時異常,資料庫回滾,但 MQ 其實已經正常發送了,同樣會導致不一致
-
事務參與方接收消息的可靠性:事務參與方必須能夠從消息隊列接收到消息,如果接收消息失敗可以重覆接收消息
-
消息重覆消費的問題:若某一個節點消費成功但超時了,此時消息中間件會重覆投遞此消息,導致消息的重覆消費,因此要實現事務參與方的方法冪等性
下麵討論具體的解決方案
1. 本地消息表
通過本地事務保證業務數據的一致性,然後通過定時任務將消息發送至消息中間件,待確認消息發送給消費方成功再將消息刪除
以註冊送積分為例來說明,用戶服務負責添加用戶,積分服務負責增加積分
交互流程如下:
- 用戶服務在本地事務新增用戶和增加 積分消息日誌(用戶表和消息表通過本地事務保證一致)
- 可以啟動獨立的線程,定時對消息日誌表的消息進行掃描併發送至消息中間件,消息中間件反饋發送成功後刪除該消息日誌,否則等待定時任務下一周期重試
2. RocketMQ 事務消息方案
RocketMQ 事務消息設計主要是為瞭解決 Producer 端的消息發送與本地事務執行的原子性問題
還以註冊送積分的例子來描述,執行流程如下:
- Producer(MQ 發送方)發送事務消息至 MQ Server,MQ Server 將消息狀態標記為 Prepared(預備狀態),註意此時這條消息消費者(MQ 訂閱方)是無法消費的
- MQ Server 接收到 Producer 發送給的消息則回應發送成功,表示 MQ 已接收到消息
- Producer 端執行業務邏輯,即執行添加用戶操作,通過本地資料庫事務控制
- 若 Producer 本地事務執行成功自動向 MQ Server 發送 commit 消息,MQ Server 接收到 commit 消息後將“增加積分消息”狀態標記為可消費,此時 MQ 訂閱方(積分服務)正常消費消息;若 Producer 本地事務執行失敗則自動向 MQ Server 發送 rollback 消息,MQ Server 接收到 rollback 消息後 將刪除“增加積分消息”
- MQ 訂閱方(積分服務)消費消息,消費成功則向 MQ 回應 ack,否則將重覆接收消息,這裡 ack 預設自動回應,即程式執行正常則自動回應 ack
如果執行 Producer 端本地事務過程中,執行端掛掉,或者超時,MQ Server 會不停的詢問同組的其他 Producer 來獲取事務執行狀態,這個過程叫事務回查,MQ Server 會根據事務回查結果來決定是否投遞消息。以上主幹流程已由 RocketMQ 實現,對用戶側來說,用戶需要分別實現本地事務執行以及本地事務回查方法,因此只需關註本地事務的執行狀態即可
最大努力通知
最大努力通知也是一種解決分散式事務的方案,下邊是一個是充值的例子:
交互流程如下:
- 賬戶系統調用充值系統介面
- 充值系統完成支付處理,向賬戶系統發起充值結果通知,若通知失敗,則充值系統發起重試
- 賬戶系統接收到充值結果通知修改充值狀態
- 賬戶系統未接收到通知,會主動調用充值系統的介面查詢充值結果
最大努力通知方案的核心在於,發起通知方通過一定的機制最大努力將業務處理結果通知到接收方,具體包括:
- 消息重覆通知機制:因為接收通知方可能沒接收到通知,此時要有一定的機制保證重試通知
- 消息校對機制:如果盡最大努力也沒有通知到接收方,或者接收方消費消息後要再次消費,此時可由接收方主動向通知方查詢消息信息來滿足需求
最大努力通知與可靠消息一致性有什麼不同?
- 解決方案思想不同:可靠消息一致性,消息發送方需要保證將消息發出去,並且將消息發到消息接收方,消息的可靠性關鍵由消息發送方保證。最大努力通知,發起通知方盡最大的努力將業務處理結果告知接收通知方,但可能消息接收不到,此時需要接收通知方主動調用發起通知方的介面查詢業務處理結果,通知的可靠性關鍵在發起通知方
- 兩者的業務應用場景不同:可靠消息一致性關註的是交易過程的事務一致,以非同步的方式完成交易。最大努力通知關註的是交易後的通知事務,即將交易結果可靠的通知出去
- 技術解決方向不同:可靠消息一致性要解決消息從發出到接收的一致性,即消息發出並且被接收到。最大努力通知無法保證消息從發出到接收的一致性,只提供消息接收的可靠性機制。可靠機制是,最大努力的將消息通知給接收方,當消息無法被接收方接收時,由接收方主動查詢消息(業務處理結果)
通過對最大努力通知的理解,採用 MQ 的 ack 機制可以實現最大努力通知
方案一:利用 MQ 的 ack 機制由 MQ 向接收通知方發送通知
- 發起通知方將通知發給 MQ,如果消息沒有發出去可由接收通知方主動請求發起通知方查詢業務執行結果
- 接收通知方監聽 MQ 接收消息,業務處理完成回應 ack,若接收通知方沒有回應 ack 則 MQ 會重覆通知
- 接收通知方可通過消息校對介面來校對消息的一致性
方案二:也是利用 MQ 的 ack 機制,與方案一不同的是由通知程式向接收通知方發送通知
- 發起通知方將通知發給 MQ
- 通知程式監聽 MQ,接收 MQ 的消息,通知程式若沒有回應 ack 則 MQ 會重覆通知
- 通知程式通過互聯網介面協議(如 http、webservice)調用接收通知方案介面,完成通知
- 接收通知方可通過消息校對介面來校對消息的一致性
方案一和方案二的不同點:
- 方案一中接收通知方案監聽 MQ,此方案是業務應用與內部應用之間的通知
- 方案二中通知程式監聽 MQ,收到 MQ 的消息後由通知程式通過互聯網介面協議調用接收通知方,此方案是業務應用與外部應用之間的通知,例如支付寶、微信的支付結果通知