前言
前幾天,在家裡研究了下阿里巴巴開源的分散式事務中介軟體Seata
,並記錄了一下過程。
不過光有實戰不行,我們多少也得把原理搞搞清楚,不然出了問題不知咋解決豈不是很尷尬。
一、原理
首先,設想一個傳統的單體應用,通過 3 個 模組,在同一個資料來源上更新資料來完成一項業務。
很自然的,整個業務過程的資料一致性由本地事務來保證。
隨著業務需求和架構的變化,單體應用被拆分為微服務。原來的3個模組被拆分為3個獨立的服務,分別使用獨立的資料。
業務過程將通過RPC的服務呼叫來完成。
那麼這個時候,每一個服務內部的資料一致性仍由本地事務來保證。
而整個業務層面的全域性資料一致性和完整性要如何保障呢?這就是微服務架構下面臨的,典型的分散式事務需求。
1、原理和設計
Seata
把一個分散式事務理解成一個包含了若干 分支事務 的 全域性事務 。全域性事務 的職責是協調其下管轄的 分支事務 達成一致,要麼一起成功提交,要麼一起失敗回滾。此外,通常 分支事務 本身就是一個滿足 ACID 的 本地事務。
Seata
定義了3個元件來協議分散式事務的處理過程。
- Transaction Coordinator (TC): 事務協調器,維護全域性事務的執行狀態,負責協調並驅動全域性事務的提交或回滾。
- Transaction Manager (TM): 控制全域性事務的邊界,負責開啟一個全域性事務,並最終發起全域性提交或全域性回滾的決議。
- Resource Manager (RM): 控制分支事務,負責分支註冊、狀態彙報,並接收事務協調器的指令,驅動分支(本地)事務的提交和回滾。
一個典型的分散式事務過程:
- TM 向 TC 申請開啟一個全域性事務,全域性事務建立成功並生成一個全域性唯一的 XID。
- XID 在微服務呼叫鏈路的上下文中傳播。
- RM 向 TC 註冊分支事務,將其納入 XID 對應全域性事務的管轄。
- TM 向 TC 發起針對 XID 的全域性提交或回滾決議。
- TC 排程 XID 下管轄的全部分支事務完成提交或回滾請求。
2、AT模式
Seata有4種分散式事務解決方案,分別是 AT 模式、TCC 模式、Saga 模式和 XA 模式。<後兩種實現還在官方計劃版本中>
我們的示例專案中,所用到的就是AT模式。在 AT 模式下,使用者只需關注自己的“業務 SQL”,使用者的 “業務 SQL” 作為一階段,Seata 框架會自動生成事務的二階段提交和回滾操作。
- 一階段:
在一階段,Seata 會攔截“業務 SQL”,首先解析 SQL 語義,找到“業務 SQL”要更新的業務資料,在業務資料被更新前,將其儲存成“before image”,然後執行“業務 SQL”更新業務資料,在業務資料更新之後,再將其儲存成“after image”,最後生成行鎖。以上操作全部在一個資料庫事務內完成,這樣保證了一階段操作的原子性。
- 二階段提交:
二階段如果是提交的話,因為“業務 SQL”在一階段已經提交至資料庫,所以Seata 框架只需將一階段儲存的快照資料和行鎖刪掉,完成資料清理即可。
- 二階段回滾:
二階段如果是回滾的話,Seata 就需要回滾一階段已經執行的“業務 SQL”,還原業務資料。回滾方式便是用“before image”還原業務資料。
下面我們從原始碼中來看看這整個流程是怎麼串起來的。
二、本地環境搭建
為了方便看原始碼,首先就得把除錯環境搞起,方便Debug。
Seata 原始碼:github.com/seata/seata 。
目前的版本是0.7.0-SNAPSHOT
,然後通過mvn install
將專案打包到本地。
我們的SpringBoot+Seata
測試專案就可以引入這個依賴。
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
<version>0.7.0-SNAPSHOT</version>
</dependency>
複製程式碼
為啥要這樣幹呢?因為Seata
不同元件之間的通訊都是Netty
來完成的,在除錯的時候,往往會因為超時而斷開連線。
引入了本地版本,我們就可以把心跳檢測時間加長或者索性去掉,隨便搞~
1、服務端啟動
找到io.seata.server.Server
,直接執行main
方法,就啟動了Seata服務,so easy~
我們上面說Seata
定義了三個元件,其中有一個叫TC的事務協調器,就是指這個服務端。
我們看看它具體幹了些啥。
public class Server {
public static void main(String[] args) throws IOException {
//初始化引數解析器
ParameterParser parameterParser = new ParameterParser(args);
//初始化RpcServer ,設定伺服器引數
RpcServer rpcServer = new RpcServer(WORKING_THREADS);
rpcServer.setHost(parameterParser.getHost());
rpcServer.setListenPort(parameterParser.getPort());
UUIDGenerator.init(1);
//從檔案或者資料庫中載入Session
SessionHolder.init(parameterParser.getStoreMode());
//初始化預設的協調器
DefaultCoordinator coordinator = new DefaultCoordinator(rpcServer);
coordinator.init();
rpcServer.setHandler(coordinator);
//註冊鉤子程式 清理協調器相關資源
ShutdownHook.getInstance().addDisposable(coordinator);
//127.0.0.1 and 0.0.0.0 are not valid here.
if (NetUtil.isValidIp(parameterParser.getHost(), false)) {
XID.setIpAddress(parameterParser.getHost());
} else {
XID.setIpAddress(NetUtil.getLocalIp());
}
XID.setPort(rpcServer.getListenPort());
//啟動RPC服務
rpcServer.init();
System.exit(0);
}
}
複製程式碼
這裡的RpcServer
是通過Netty實現的一個RPC服務端,用來接收並處理TM和RM的訊息。本文的重點不在服務端,所以我們先有一個大致的印象即可。
三、客戶端配置
在專案中,我們配置了SeataConfiguration
,其中的重點是配置全域性事務掃描器和資料來源代理。所以,我們先來看看為啥要配置它們,它們具體又做了什麼事。
1、事務掃描器
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("springboot-order", "my_test_tx_group");
}
複製程式碼
按照規矩,我們看一個類,先看它的結構。比如它是誰的兒子,從哪裡來,欲往何處去?
public class GlobalTransactionScanner extends AbstractAutoProxyCreator
implements InitializingBean, ApplicationContextAware,DisposableBean {
}
複製程式碼
這裡我們看到它是AbstractAutoProxyCreator
的子類,又實現了InitializingBean
介面。
這倆哥們都是Spring大家族的成員,一個用於Spring AOP生成代理;一個用於呼叫Bean的初始化方法。
- InitializingBean
Bean的初始化方法有三種方式,按照先後順序是,@PostConstruct、afterPropertiesSet、init-method
。
在這裡,它的初始化方法中,主要就幹了三件事。
private void initClient() {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Initializing Global Transaction Clients ... ");
}
//init TM 初始化事務管理器
TMClient.init(applicationId, txServiceGroup);
//init RM 初始化資源管理器
RMClient.init(applicationId, txServiceGroup);
//註冊鉤子程式,用於TM、RM的資源清理
registerSpringShutdownHook();
}
複製程式碼
到目前為止,Seata定義的三個元件都已經浮出水面了。
TMClient.init
主要是初始化事務管理器的客戶端,建立與RPC服務端的連線,同時向事務協調器註冊。
RMClient.init
也是一樣過程,初始化資源管理器,建立與RPC服務端的連線,同時向事務協調器註冊。
同時,它們都是通過定時任務來完成連線的,所以斷線之後可以自動重連。
timerExecutor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
clientChannelManager.reconnect(getTransactionServiceGroup());
}
}, 5, 5, TimeUnit.SECONDS);
複製程式碼
最後,註冊鉤子程式,用於清理這兩個元件中的資源。
- AbstractAutoProxyCreator
它實際上是一個Bean的後置處理器,在Bean初始化之後,呼叫postProcessAfterInitialization
方法。
public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
if (bean != null) {
Object cacheKey = this.getCacheKey(bean.getClass(), beanName);
if (this.earlyProxyReferences.remove(cacheKey) != bean) {
return this.wrapIfNecessary(bean, beanName, cacheKey);
}
}
return bean;
}
複製程式碼
然後在GlobalTransactionScanner.wrapIfNecessary()
裡它幹了些什麼呢?
就是檢查Bean的方法上是否包含GlobalTransactional
和GlobalLock
註解,然後生成代理類。
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey){
if (disableGlobalTransaction) {
return bean;
}
//已經生成了代理,直接返回
if (PROXYED_SET.contains(beanName)) {
return bean;
}
interceptor = null;
//檢查是不是TCC的代理
if (TCCBeanParserUtils.isTccAutoProxy(bean, beanName, applicationContext)) {
//TCC interceptor, proxy bean of sofa:reference/dubbo:reference, and LocalTCC
interceptor = new TccActionInterceptor(TCCBeanParserUtils.getRemotingDesc(beanName));
} else {
Class<?> serviceInterface = SpringProxyUtils.findTargetClass(bean);
Class<?>[] interfacesIfJdk = SpringProxyUtils.findInterfaces(bean);
//判斷類方法上是否包含GlobalTransactional註解和GlobalLock註解
if (!existsAnnotation(new Class[] {serviceInterface})
&& !existsAnnotation(interfacesIfJdk)) {
return bean;
}
//建立攔截器
if (interceptor == null) {
interceptor = new GlobalTransactionalInterceptor(failureHandlerHook);
}
}
//如果不是AOP代理,則建立代理;如果是代理,則將攔截器加入到Advisor
if (!AopUtils.isAopProxy(bean)) {
bean = super.wrapIfNecessary(bean, beanName, cacheKey);
} else {
AdvisedSupport advised = SpringProxyUtils.getAdvisedSupport(bean);
Advisor[] advisor = buildAdvisors(beanName, getAdvicesAndAdvisorsForBean(null, null, null));
for (Advisor avr : advisor) {
advised.addAdvisor(0, avr);
}
}
PROXYED_SET.add(beanName);
return bean;
}
複製程式碼
至此,我們已經確定了一件事。我們ServiceImpl
實現類上帶有GlobalTransactional
註解的方法,會生成一個代理類。
在呼叫方法時,實際會呼叫的就是代理類的攔截器方法invoke()
。
public class GlobalTransactionalInterceptor implements MethodInterceptor {
@Override
public Object invoke(final MethodInvocation methodInvocation) throws Throwable {
//獲取目標類
Class<?> targetClass = AopUtils.getTargetClass(methodInvocation.getThis());
//獲取呼叫的方法
Method specificMethod = ClassUtils.getMostSpecificMethod(methodInvocation.getMethod(), targetClass);
final Method method = BridgeMethodResolver.findBridgedMethod(specificMethod);
//獲取方法上的註解
final GlobalTransactional globalTransactionalAnnotation = getAnnotation(method, GlobalTransactional.class);
final GlobalLock globalLockAnnotation = getAnnotation(method, GlobalLock.class);
//處理全域性事務
if (globalTransactionalAnnotation != null) {
return handleGlobalTransaction(methodInvocation, globalTransactionalAnnotation);
} else if (globalLockAnnotation != null) {
return handleGlobalLock(methodInvocation);
} else {
return methodInvocation.proceed();
}
}
}
複製程式碼
可以看到,這裡是開始處理全域性事務的地方。這裡我們先不深究,接著往下看。
2、資料來源代理
除了上面建立方法的代理,還要建立資料來源的代理;然後把這個代理物件設定到SqlSessionFactory
。
@Bean
public DataSourceProxy dataSourceProxy(DataSource dataSource) {
return new DataSourceProxy(dataSource);
}
@Bean
public SqlSessionFactory sqlSessionFactoryBean(DataSourceProxy dataSourceProxy) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSourceProxy);
sqlSessionFactoryBean.setTransactionFactory(new JdbcTransactionFactory());
return sqlSessionFactoryBean.getObject();
}
複製程式碼
這裡的重點是建立了DataSourceProxy
,並把它設定到Mybatis
中的SqlSessionFactory
。
我們知道,在Mybatis
執行方法的時候,最終要建立PreparedStatement
物件,然後執行ps.execute()
返回SQL結果。
這裡有兩點我們需要注意:
- PreparedStatement的建立
PreparedStatement
物件是從Connection
物件建立而來的,也許我們都寫過:
PreparedStatement pstmt = conn.prepareStatement(insert ........)
複製程式碼
- Connection的建立
Connection
又是從哪裡來的呢?這個我們不必遲疑,當然從資料來源中才能拿到一個連線。
不過我們已經把資料來源DataSource
物件已經被替換成了Seata
中的DataSourceProxy
物件。
所以,Connection和PreparedStatement
在建立的時候,都被搞成了Seata
中的代理物件。
不信你看嘛:
public class DataSourceProxy extends AbstractDataSourceProxy implements Resource {
public ConnectionProxy getConnection() throws SQLException {
Connection targetConnection = targetDataSource.getConnection();
return new ConnectionProxy(this, targetConnection);
}
}
複製程式碼
然後呼叫AbstractDataSourceProxy
來建立PreparedStatement
。
public abstract class AbstractConnectionProxy implements Connection {
@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
PreparedStatement targetPreparedStatement = getTargetConnection().prepareStatement(sql);
return new PreparedStatementProxy(this, targetPreparedStatement, sql);
}
}
複製程式碼
看到這裡,我們應該明白一件事。
在執行ps.execute()
的時候,則會呼叫到PreparedStatementProxy.execute()
。
理清了配置檔案後面的邏輯,也許就掌握了它的脈絡,再看程式碼的時候,可以知道從哪裡下手。
四、方法的執行
上面已經說到,ServiceImpl
已經是一個代理類,所以我們直接看GlobalTransactionalInterceptor.invoke()
。
它會呼叫到TransactionalTemplate.execute()
,TransactionalTemplate
是業務邏輯和全域性事務的模板。
public class TransactionalTemplate {
public Object execute(TransactionalExecutor business) throws Throwable {
// 1. 建立一個全域性事務
GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();
// 1.1 獲取事務的屬性 比如超時時間、事務名稱
TransactionInfo txInfo = business.getTransactionInfo();
if (txInfo == null) {
throw new ShouldNeverHappenException("transactionInfo does not exist");
}
try {
// 2. 開始事務
beginTransaction(txInfo, tx);
Object rs = null;
try {
// 執行業務邏輯
rs = business.execute();
} catch (Throwable ex) {
// 3.回滾
completeTransactionAfterThrowing(txInfo,tx,ex);
throw ex;
}
// 4. 提交
commitTransaction(tx);
return rs;
} finally {
//5. 清理資源
triggerAfterCompletion();
cleanUp();
}
}
}
複製程式碼
這裡的程式碼很清晰,事務的流程也一目瞭然。
- 建立全域性事務,並設定事務屬性
- 開啟一個事務
- 執行業務邏輯
- 如果發生異常,則回滾事務;否則提交事務
- 清理資源
下面我們看看具體它是怎麼做的。
1、開啟事務
從客戶端的角度來看,開啟事務就是告訴伺服器說:我要開啟一個全域性事務了,請事務協調器TC先生分配一個全域性事務ID給我。
TC先生會根據應用名稱、事務分組、事務名稱等建立全域性Session,並生成一個全域性事務XID。
然後客戶端記錄當前的事務狀態為Begin
,並將XID繫結到當前執行緒。
2、執行業務邏輯
開啟事務之後,開始執行我們自己的業務邏輯。這就涉及到了資料庫操作,上面我們說到Seata
已經將PreparedStatement
物件做了代理。所以在執行的時候將會呼叫到PreparedStatementProxy.execute()
。
public class PreparedStatementProxy{
public boolean execute() throws SQLException {
return ExecuteTemplate.execute(this, new StatementCallback<Boolean, PreparedStatement>() {
@Override
public Boolean execute(PreparedStatement statement, Object... args) throws SQLException {
return statement.execute();
}
});
}
}
複製程式碼
在這裡它會先根據SQL的型別生成不同的執行器。比如是一個INSERT INTO
語句,那麼就是InsertExecutor
執行器。
然後判斷是不是自動提交的,執行相應方法。那麼接著看executeAutoCommitFalse()
public abstract class AbstractDMLBaseExecutor{
protected T executeAutoCommitFalse(Object[] args) throws Throwable {
TableRecords beforeImage = beforeImage();
T result = statementCallback.execute(statementProxy.getTargetStatement(), args);
TableRecords afterImage = afterImage(beforeImage);
prepareUndoLog(beforeImage, afterImage);
return result;
}
}
複製程式碼
這裡就是AT模式一階段所做的事,攔截業務SQL,在資料儲存前將其儲存為beforeImage
;然後執行業務SQL,在資料更新後再將其儲存為afterImage
。這些操作全部在一個本地事務中完成,保證了一階段操作的原子性。
我們以INSERT INTO
為例,看看它是怎麼做的。
- beforeImage
由於是新增操作,所以在執行之前,這條記錄還沒有,beforeImage只是一個空表記錄。
- 業務SQL
執行原有的SQL語句,比如INSERT INTO ORDER(ID,NAME)VALUE(?,?)
- afterImage
它要做的事就是,把剛剛新增的那條記錄從資料庫中再查出來。
protected TableRecords afterImage(TableRecords beforeImage) throws SQLException {
//查詢主鍵ID的值
List<Object> pkValues = containsPK() ? getPkValuesByColumn() : getPkValuesByAuto();
//根據主鍵ID查詢記錄
TableRecords afterImage = getTableRecords(pkValues);
return afterImage;
}
複製程式碼
然後將beforeImage
和afterImage
構建成UndoLog
物件,儲存到資料庫。重要的是,這些操作都是在同一個本地事務中進行的。我們看它的sqlList也能看出來。
最後,我們看一下UndoLog
在資料庫中的記錄是長這樣的:
{
"@class": "io.seata.rm.datasource.undo.BranchUndoLog",
"xid": "192.168.216.1:8091:2016493467",
"branchId": 2016493468,
"sqlUndoLogs": ["java.util.ArrayList", [{
"@class": "io.seata.rm.datasource.undo.SQLUndoLog",
"sqlType": "INSERT",
"tableName": "t_order",
"beforeImage": {
"@class": "io.seata.rm.datasource.sql.struct.TableRecords$EmptyTableRecords",
"tableName": "t_order",
"rows": ["java.util.ArrayList", []]
},
"afterImage": {
"@class": "io.seata.rm.datasource.sql.struct.TableRecords",
"tableName": "t_order",
"rows": ["java.util.ArrayList", [{
"@class": "io.seata.rm.datasource.sql.struct.Row",
"fields": ["java.util.ArrayList", [{
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "id",
"keyType": "PrimaryKey",
"type": 4,
"value": 116
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "order_no",
"keyType": "NULL",
"type": 12,
"value": "c233d8fb-5e71-4fc1-bc95-6f3d86312db6"
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "user_id",
"keyType": "NULL",
"type": 12,
"value": "200548"
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "commodity_code",
"keyType": "NULL",
"type": 12,
"value": "HYD5620"
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "count",
"keyType": "NULL",
"type": 4,
"value": 10
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "amount",
"keyType": "NULL",
"type": 8,
"value": 5000.0
}]]
}]]
}
}]]
}
複製程式碼
3、提交
如果執行業務沒有異常,就進入二階段提交。客戶端向伺服器傳送Commit事件,同時將XID解綁。
伺服器端回覆確認提交後,客戶端將本地UndoLog資料清除。
這裡重要在AsyncWorker.init()
方法,它會啟動一個定時任務來執行doBranchCommits
,來清除Log資料。
4、回滾
如果發生異常,則進行二階段回滾。
先通過xid和branchId 找到UnDoLog這條記錄,然後在解析裡面的資料生成反向SQL,將剛才的執行結果給撤銷。
這塊程式碼較長,大家自行參考UndoLogManager.undo()
和AbstractUndoExecutor.executeOn()
方法。
5、如何關聯Dubbo
只有一個事務管理器TM才會開啟全域性事務,那麼其他服務參與者是如何自動納入到全域性事務中去的呢?
首先,Seata
給Dubbo搞了個Filter過濾器叫做TransactionPropagationFilter
。
它會在Dubbo RPC
上下文中設定XID,這樣在其他服務中也可以獲取這個XID。
然後,我們知道,Seata已經代理了PreparedStatement
。在執行資料操作的時候,就有個判斷。
if (!RootContext.inGlobalTransaction() && !RootContext.requireGlobalLock()) {
//如果不包含XID,就執行原始方法
return statementCallback.execute(statementProxy.getTargetStatement(), args);
}
複製程式碼
這裡的意思就是,如果當前執行緒不包含XID,就執行原始方法;如果包含呢,就繼續往下執行事務方法。
五、總結
本文大概闡述了Seata TA模式下,客戶端的工作原理。還有一部分Seata服務端的邏輯,本文並沒有深入涉及。
原因在於筆者還沒有完全的吃透這部分內容,沒辦法通俗的寫出來,等以後再補~
如若文中有不準確的地方,也希望朋友們不吝賜教,謝謝。