- 推荐阅读 Seata TCC 分布式事务源码分析
- 公众号 Young_Blog
- 什么是 Seata AT 模式
- Seata AT 的使用方法
- Seata AT 的工作流程
- Seata AT 模式源码模块拆解
- Seata AT 模式客户端部分
- Seata AT 模式服务端部分
- Seata AT 模式的全局锁
- Seata AT 模式潜在优化点
- 全文总结
什么是 Seata AT 模式
AT
模式是 Seata
主推的分布式事务解决方案,最早来源于阿里中间件团队发布的 TXC
服务,后来成功上云改名 GTS
。Seata
官方文档中有关于 AT
模式的详细介绍 —— AT Mode,它使得应用代码可以像使用本地事务一样使用分布式事务,完全屏蔽了底层细节,它和笔者之前介绍过的 Seata TCC
模式的区别有以下几点:
- 使用上,
TCC
依赖于用户自行实现的三个方法成本较大;AT
依赖全局事务注解和代理数据源,其余代码基本不需要改动,对业务无侵入、接入成本极小 TCC
的作用范围在应用层,本质上是实现针对某种业务逻辑的正向和反向方法;AT
模式的作用范围在于底层数据源,通过保存操作行记录的前后快照和生成反向 SQL 语句进行补偿操作,实现难度较大,优点是对上层应用透明TCC
仅try
阶段加锁,后续补偿逻辑事务间各自独立;AT
需要借助于全局锁和GlobalLock
注解来解决不同全局事务间的写冲突问题,如果一阶段分支事务成功则二阶段一开始全局锁即被释放,否则需要夯住直到分支事务二阶段回滚完成才能释放全局锁
Seata AT 的使用方法
我们先了解一下如何在应用里使用 AT
模式,流程非常简单,Seata
也提供了 Seata-Samples 方便大家了解如何使用该项目。
第一步,增加全局事务注解
首先依赖 Seata
的客户端 SDK
,然后在整个分布式事务发起方的业务方法上增加 @GlobalTransactional
注解,下面的例子来源于 Seata-Samples dubbo
案例,purchase
是事务发起方的业务方法,通过 RPC
调用了下游库存服务和订单服务提供的接口:
@Override
@GlobalTransactional(timeoutMills = 300000, name = "dubbo-demo-tx")
public void purchase(String userId, String commodityCode, int orderCount) {
LOGGER.info("purchase begin ... xid: " + RootContext.getXID());
// RPC 调用库存服务
storageService.deduct(commodityCode, orderCount);
// RPC 调用订单服务
orderService.create(userId, commodityCode, orderCount);
throw new RuntimeException("xxx");
}
第二步,配置代理数据源
以 MySQL
为例:
// 配置数据源
<bean name="accountDataSource" class="com.alibaba.druid.pool.DruidDataSource"
init-method="init" destroy-method="close">
// …… …… 省略数据源配置
</bean>
// 关键步骤,配置 Seata 的代理数据源,代理之前配置的 accountDataSource
<bean id="accountDataSourceProxy" class="io.seata.rm.datasource.DataSourceProxy">
<constructor-arg ref="accountDataSource" />
</bean>
// 配置 applicationId 和 txServiceGroup,这主要是来标识应用和服务端集群的
<bean class="io.seata.spring.annotation.GlobalTransactionScanner">
<constructor-arg value="dubbo-demo-account-service"/>
<constructor-arg value="my_test_tx_group"/>
</bean>
// 省略一些 dubbo 服务注册配置和 jdbcTemplate 配置
第三步,新建 undo_log 表
在事务链涉及的服务的数据库中新建 undo_log
表用来存储 UndoLog
信息,用于二阶段回滚操作,表中包含 xid
、branchId
、rollback_info
等关键字段信息。
Seata AT 的工作流程
工作流程总览
概括来讲,AT
模式的工作流程分为两阶段。一阶段进行业务 SQL
执行,并通过 SQL
拦截、SQL
改写等过程生成修改数据前后的快照(Image
),并作为 UndoLog
和业务修改在同一个本地事务中提交。
如果一阶段成功那么二阶段仅仅异步删除刚刚插入的 UndoLog
;如果二阶段失败则通过 UndoLog
生成反向 SQL
语句回滚一阶段的数据修改。其中关键的 SQL 解析和拼接工作借助了 Druid Parser 中的代码,这部分本文并不涉及,感兴趣的小伙伴可以去翻看源码,并不是很复杂。
图解 AT 模式一阶段流程
一阶段中分支事务的具体工作有:
- 根据需要执行的
SQL
(UPDATE
、INSERT
、DELETE
)类型生成相应的SqlRecognizer
- 进而生成相应的
SqlExecutor
- 接着便进入核心逻辑查询数据的前后快照,例如图中标红的部分,拿到修改数据行的前后快照之后,将二者整合生成
UndoLog
,并尝试将其和业务修改在同一事务中提交。
整个流程的流程图如下:
值得注意的是,本地事务提交前必须先向服务端注册分支,分支注册信息中包含由表名和行主键组成的全局锁,如果分支注册过程中发现全局锁正在被其他全局事务锁定则抛出全局锁冲突异常,客户端需要循环等待,直到其他全局事务释放锁之后该本地事务才能提交。Seata 以这样的机制保证全局事务间的写隔离。
图解二阶段 Commit 流程
对服务端来说,等到一阶段完成未抛异常,全局事务的发起方会向服务端申请提交这个全局事务,服务端根据 xid
查询出该全局事务后加锁并关闭这个全局事务,目的是防止该事务后续还有分支继续注册上来,同时将其状态从 Begin
修改为 Committing
。
紧接着,判断该全局事务下的分支类型是否均为 AT
类型,若是则服务端会进行异步提交,因为 AT
模式下一阶段完成数据已经落地。服务端仅仅修改全局事务状态为 AsyncCommitting
,然后会有一个定时线程池去存储介质(File
或者 Database
)中查询出待提交的全局事务日志进行提交,如果全局事务提交成功则会释放全局锁并删除事务日志。整个流程如下图所示:
对客户端来说,先是接收到服务端发送的 branch commit
请求,然后客户端会根据 resourceId
找到相应的 ResourceManager
,接着将分支提交请求封装成 Phase2Context
插入内存队列 ASYNC_COMMIT_BUFFER
,客户端会有一个定时线程池去查询该队列进行 UndoLog
的异步删除。
一旦客户端提交失败或者 RPC
超时,则服务端会将该全局事务状态置位 CommitRetrying
,之后会由另一个定时线程池去一直重试这些事务直至成功。整个流程如下图所示:
图解二阶段 Rollback 流程
回滚相对复杂一些,如果发起方一阶段抛异常会向服务端请求回滚该全局事务,服务端会根据 xid
查询出这个全局事务,加锁关闭事务使得后续不会再有分支注册上来,并同时更改其状态 Begin
为 Rollbacking
,接着进行同步回滚以保证数据一致性。除了同步回滚这个点外,其他流程同提交时相似,如果同步回滚成功则释放全局锁并删除事务日志,如果失败则会进行异步重试。整个流程如下图所示:
客户端接收到服务端的 branch rollback
请求,先根据 resourceId
拿到对应的数据源代理,然后根据 xid
和 branchId
查询出 UndoLog
记录,反序列化其中的 rollback
字段拿到数据的前后快照,我们称该全局事务为 A
。
根据具体 SQL
类型生成对应的 UndoExecutor
,校验一下数据 UndoLog
中的前后快照是否一致或者前置快照和当前数据(这里需要 SELECT
一次)是否一致,如果一致说明不需要做回滚操作,如果不一致则生成反向 SQL
进行补偿,在提交本地事务前会检测获取数据库本地锁是否成功,如果失败则说明存在其他全局事务(假设称之为 B
)的一阶段正在修改相同的行,但是由于这些行的主键在服务端已经被当前正在执行二阶段回滚的全局事务 A
锁定,因此事务 B 的一阶段在本地提交前尝试获取全局锁一定是失败的,等到获取全局锁超时后全局事务 B
会释放本地锁,这样全局事务 A
就可以继续进行本地事务的提交,成功之后删除本地 UndoLog
记录。整个流程如下图所示:
本节小结
我们通过流程图分析了一下 Seata AT
模式两阶段的工作流程,这里提一句,官方文档针对 AT
模式的工作流程提供了一个非常易懂的例子 —— AT 模式工作机制。
笔者强烈建议感兴趣的同学阅读过后,再看下文的源码分析。
Seata AT 模式源码模块拆解
通过上面的文字和图解,相信大家已经了解了 Seata AT
模式的基本工作原理,那么本节开始我们正式进入相关源码的分析阶段。第一步,由于 Seata
模块不算少,我们先对整个 Seata
项目的模块进行拆解,挑出其中需要重点关注的模块,忽略那些次要的。
下文的源码分析均基于
Seata v0.6.1
版本
相比于之前笔者对 Seata TCC
实现的分析,AT
模式的源码就要复杂很多了,基本上大多数模块均有涉及,因此在阅读源码之前,我们先对模块的优先级进行筛选,包括下文会叙述哪些模块和忽略哪些模块。
首先,seata-tcc
与 AT
的功能无关可以不用看;seata-common
、seata-core
、seata-config
、seata-discovery
这些只看名字也能知道大致的功能,后续阅读代码期间经常会看到其中的类,因此都可以暂时忽略;seata-tm
、seata-rm
这两者都是封装的与 seata-server
进行通信的方法和步骤,这部分笔者已经在上一篇关于 TCC
的文章中叙述过了,不再赘述;seata-spring
主要是注解、切面织入、方法拦截等功能的实现,关键点包括全局事务的开启,但是由于 AT
和 TCC
在全局事务开启部分的逻辑是一致的,因此本文也不再赘述。
一通排查下来,和 AT
核心功能有关的模块仅剩下 seata-rm-datasource
和 seata-server
,仔细一想这也很合理,因为 Seata 中分支事务才是真正执行数据修改和补偿的部分,因此对于 TCC
模式来说,TwoPhaseBusinessAction
注解的实现类是分支事务,对 AT
模式来说,代理数据源正是分支事务,因此核心逻辑必然在 seata-rm-datasource
模块中,而 TC
集群是协调整个全局事务的指挥者,自然 seata-server
模块也是我们需要特别关注的,但是由于服务端逻辑和 TCC 部分高度相似,除了 v0.6.1 中新增了 DB 模式作为日志存储介质外,因此下文先选取客户端 AT 模式相关源码进行深入分析,最后简要分析下与 AT 模式相关的服务端源码。
Seata AT 模式客户端部分
数据源代理部分 —— 三类 Proxy
下图来源于 Seata 官方文档:
Seata
中主要针对 java.sql
包下的 DataSource
、Connection
、Statement
、PreparedStatement
四个接口进行了再包装,包装类分别为 DataSourceProxy
、ConnectionProxy
、StatementProxy
、PreparedStatementProxy
,很好一一对印,其功能是在 SQL
语句执行前后、事务 commit
或者 rollbakc
前后进行一些与 Seata
分布式事务相关的操作,例如分支注册、状态回报、全局锁查询、快照存储、反向 SQL 生成等。
ExecuteTemplate.execute
AT
模式下,真正分支事务开始是在 StatementProxy
和 PreparedStatementProxy
的 execute
、executeQuery
、executeUpdate
等具体执行方法中,这些方法均实现自 Statement
和 PreparedStatement
的标准接口,而方法体内调用了 ExecuteTemplate.execute
做方法拦截,下面我们来看看这个方法的实现:
public static <T, S extends Statement> T execute(SQLRecognizer sqlRecognizer,
StatementProxy<S> statementProxy,
StatementCallback<T, S> statementCallback,
Object... args) throws SQLException {
// 如果不是处于全局事务中,即上游没有 xid 传递下来
// 或者没有 GlobalLock 修饰,该数据操作不需要纳入 Seata 框架下进行管理
// 则直接执行这个 SQL
if (!RootContext.