Seata分散式事務TA模式原始碼解讀

清幽之地發表於2019-07-11

前言

前幾天,在家裡研究了下阿里巴巴開源的分散式事務中介軟體Seata,並記錄了一下過程。

SpringBoot+Dubbo+Seata分散式事務實戰

不過光有實戰不行,我們多少也得把原理搞搞清楚,不然出了問題不知咋解決豈不是很尷尬。

一、原理

首先,設想一個傳統的單體應用,通過 3 個 模組,在同一個資料來源上更新資料來完成一項業務。

很自然的,整個業務過程的資料一致性由本地事務來保證。

Seata分散式事務TA模式原始碼解讀

隨著業務需求和架構的變化,單體應用被拆分為微服務。原來的3個模組被拆分為3個獨立的服務,分別使用獨立的資料。

業務過程將通過RPC的服務呼叫來完成。

Seata分散式事務TA模式原始碼解讀

那麼這個時候,每一個服務內部的資料一致性仍由本地事務來保證。

而整個業務層面的全域性資料一致性和完整性要如何保障呢?這就是微服務架構下面臨的,典型的分散式事務需求。

1、原理和設計

Seata把一個分散式事務理解成一個包含了若干 分支事務全域性事務全域性事務 的職責是協調其下管轄的 分支事務 達成一致,要麼一起成功提交,要麼一起失敗回滾。此外,通常 分支事務 本身就是一個滿足 ACID 的 本地事務。

Seata定義了3個元件來協議分散式事務的處理過程。

  • Transaction Coordinator (TC): 事務協調器,維護全域性事務的執行狀態,負責協調並驅動全域性事務的提交或回滾。
  • Transaction Manager (TM): 控制全域性事務的邊界,負責開啟一個全域性事務,並最終發起全域性提交或全域性回滾的決議。
  • Resource Manager (RM): 控制分支事務,負責分支註冊、狀態彙報,並接收事務協調器的指令,驅動分支(本地)事務的提交和回滾。

一個典型的分散式事務過程:

  1. TM 向 TC 申請開啟一個全域性事務,全域性事務建立成功並生成一個全域性唯一的 XID。
  2. XID 在微服務呼叫鏈路的上下文中傳播。
  3. RM 向 TC 註冊分支事務,將其納入 XID 對應全域性事務的管轄。
  4. TM 向 TC 發起針對 XID 的全域性提交或回滾決議。
  5. TC 排程 XID 下管轄的全部分支事務完成提交或回滾請求。

Seata分散式事務TA模式原始碼解讀

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的方法上是否包含GlobalTransactionalGlobalLock註解,然後生成代理類。

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. 建立全域性事務,並設定事務屬性
  2. 開啟一個事務
  3. 執行業務邏輯
  4. 如果發生異常,則回滾事務;否則提交事務
  5. 清理資源

下面我們看看具體它是怎麼做的。

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;
}
複製程式碼

然後將beforeImageafterImage構建成UndoLog物件,儲存到資料庫。重要的是,這些操作都是在同一個本地事務中進行的。我們看它的sqlList也能看出來。

Seata分散式事務TA模式原始碼解讀

最後,我們看一下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服務端的邏輯,本文並沒有深入涉及。

原因在於筆者還沒有完全的吃透這部分內容,沒辦法通俗的寫出來,等以後再補~

如若文中有不準確的地方,也希望朋友們不吝賜教,謝謝。

相關文章