Seata分布式事务
一、创建项目
首先附上Seata 官方文档:Seata 是什么
我们创建一个新项目,并使用Seata提供分布式事务的解决方案,分别会创建三个服务,account-server账户服务,order-server订单服务,storage-server库存服务。具体调用逻辑,订单服务创建订单,然后调用账户服务扣除金额功能,接着调用库存服务扣除库存功能。
附上项目源码地址
二、搭建Seata服务器
Seata 官方已经给我们备好了可执行的安装文件,你可以到 Seata Github 地址的Release 页面下载。为了避免各种兼容性问题,我推荐下载seata-server-1.4.2这个版本。下载好之后在本地解压,然后我们需要对其中的配置文件做一番更改。
更改持久化配置我们打开 Seata 安装目录下的 conf 文件夹,找到 file.conf.example 文件,把里面的内容复制一下并且 Copy 到file.conf里。我们需要在 file.conf 文件里更改两个地方。
第一个改动点是持久化模式。Seata 支持本地文件和数据库两种持久化模式,前者只能用在本地开发阶段,因为基于本地文件的持久化方案并不具备高可用能力。我们这里需要把 store 节点下的 mode 属性改成“db”。
## transaction log store, only used in seata-server
store {
## store mode: file、db、redis
mode = "db"
第二个改动点就是 DB 的连接方式。我们需要把本地的 connection 配置到 store 节点下的 db 节点里。你可以参考下面的代码。
store {
## store mode: file、db、redis
mode = "db"
## rsa decryption public key
publicKey = ""
## database store property
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc.
datasource = "druid"
## mysql/oracle/postgresql/h2/oceanbase etc.
dbType = "mysql"
driverClassName = "com.mysql.jdbc.Driver"
## if using mysql to store the data, recommend add rewriteBatchedStatements=true in jdbc connection param
url = "jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=true"
user = "root"
password = ""
minConn = 5
maxConn = 100
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
maxWait = 5000
}
}
然后创建seata的数据库,分别添加三张表globalTable、branchTable 和 lockTable,这是 Seata Server 用来保存全局事务、分支事务还有事务锁定状态的表。
另外,在Server 端的 DB tables 创建完成之后,你还得为每个微服务背后的数据库创建一个特殊的表,叫做 undo_log,这个表是做什么用的呢?在 Seata 的 AT 模式下(下节会提到AT实现细节 ),Seata Server 发起一个 Rollback 指令后,微服务作为 Client 端要负责执行一段 Rollback 脚本,这个脚本所要执行的回滚逻辑就保存在 undo_log 中。
在 seata-server-1.4.2 的安装目录下有一个 lib 目录,里面包含了 Seata Server 运行期所需要用到的 jar 文件,这其中就包括了 JDBC driver。进入到 lib 目录下的 jdbc 文件夹,你会看到两个内置的 JDBC driver 的 jar 包,分别是 mysql-connector-java-5.1.35.jar 和 mysql-connector-java-8.0.19.jar。选择对应的jdbc版本从jdbc 文件夹复制到lib下。
开启服务发现
Seata Server 的搭建只剩下最后一步了,那就是将 Seata Server 作为一个微服务注册到 Nacos 中。打开 Seata 安装目录下的 conf/registry.conf 文件,找到 registry 节点,这就是用来配置服务注册的地方了。在 registry 节点下有一个 type 属性,它表示服务注册的类型。Seata 支持的注册类型有 file 、nacos 、eureka、redis、zk、consul、etcd3、sofa,可见大部分主流的注册中心都在支持列表中,默认情况下注册类型为 file(即本地文件),我们这里需要将其改为“nacos”。接下来,你还需要修改 registry.nacos 里的内容,我把主要的几个配置信息整理成了一个表格,你可以对照表格了解一下代码中配置项背后的含义。
registry {
# 【改动点01】 - type变成nacos
type = "nacos"
# 【改动点02】 - 更换
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "myGroup"
namespace = "dev"
cluster = "default"
username = ""
password = ""
}
}
现在我们已经万事俱备了,你只要直接运行 bin 目录的下的 seata-server.sh 或者 seata-server.bat,就可以启动 Seata Server 了。如果一切正常,你会看到命令行打印出 Server started 和监听端口 8091。
三、Seata AT 底层原理
在开始之前,我需要先花点时间带你认识下 Seata 框架的三个重要角色,TC、TM 和 RM。
TC 全称是 Transaction Coordinator(Seata Server)。TC 扮演了一个中心化的事务协调者的角色,负责协调全局事务的提交和回滚,并维护全局和分支事务的状态。
TM 全称是 Transaction Manager,它是事务管理器,主要作用是发起一个全局事务,对全局事务的提交和回滚做出决议。在 AT 方案中,TM 通常是由发起全局事务的那个微服务所扮演,比如“下单功能”中,TM 的扮演者就是 order-server。
RM 全称是 Resource Manager,它是资源管理器,向 TC 注册分支事务并上报事务状态,同时负责对当前分支事务进行提交和回滚。每一个分支事务都是全局事务的参与者,这些分支事务的所属应用扮演了 RM 的角色。
Seata AT 的业务流程分为两个阶段来执行。
-
一阶段:执行核心业务逻辑(即代码中的 CRUD 操作)。Seata 会根据 DB 操作自动生成相应的回滚日志,并将回滚日志添加到 RM 对应的 undo_log 表中。执行业务代码和添加回滚日志这两步都是在同一个本地事务中提交的。
-
二阶段:如果全局事务的最终决议是 Commit,则更新分支事务状态并清空回滚日志;如果最终决议是 Rollback,则根据 undo_log 中的回滚日志进行 rollback 操作。二阶段是以异步化的方式来执行的。
从这两个阶段可以看出,Seata AT 方案的核心在于这个 undo_log。正是有了这个记录回滚日志的 undo_log 表,我们才能将一阶段和二阶段剥离成两个独立的本地事务来执行。而 Seata AT 之所以执行效率高,主要原因有两个。一是核心业务逻辑可以在一阶段得到快速提交,DB 资源被快速释放;二是全局事务的 Commit 和 Rollback 是异步执行
首先,Customer 服务作为分布式事务的起点,扮演了一个 TM 的角色,它会向 TC 注册并发起一个全局事务。全局事务会生成一个 XID,它是全局唯一的 ID 标识,所有分支事务都会和这个 XID 进行绑定。XID 在服务内部(非跨服务调用)的传播机制是基于 ThreadLocal 构建的,即 XID 在当前线程的上下文中进行透传,对于跨服务调用来说,则依赖 seata-all 组件内置的各个适配器(如 Interceptor 和 Filter)将 XID 传递给对象服务。
然后,Customer 服务调用了 Template 服务进行模板注销流程,Template 服务的 RM 开启了一个分支事务,并注册到 TC。在执行分支事务的过程中,RM 还会生成回滚日志并提交到 undo_log 表中。除此之外,RM 还需要获取到两个特殊的 Lock。其中一个是 Local Lock(本地锁),另一个是 Global Lock(全局锁)。
Lock 信息存放在 lock_table 这张表里,它会记录待修改的资源 ID 以及它的全局事务和分支事务 ID 等信息。无论是一阶段提交还是二阶段回滚,RM 都需要获取待修改记录的本地锁,然后才会去执行 CRUD 操作。而在 RM 提交一阶段事务之前,它还会尝试获取 Global Lock(全局锁),目的是防止多个分布式事务对同一条记录进行修改。假设有两个不同的分布式事务想要修改记录 A,那么只有同时获取到 Local Lock 和 Global Lock 的事务才能正常提交一阶段事务。
本地锁会随一阶段事务的提交 / 回滚而释放,而全局锁只有等到全局事务提交 / 回滚之后才会被释放。在一阶段中,如果某一个事务在一定的尝试次数后仍然无法获取全局锁,它会知难而退,执行本地事务回滚操作。而如果在二阶段回滚的时候,RM 无法获取本地锁,它会原地打转不停重试,直到成功获取本地锁并完成重试。
接下来,Template 服务调用成功,Customer 服务开始执行自己的本地事务,流程都大同小异就不说了。TM 端根据业务的执行情况,最终做出二阶段决议,Commit 或 Rollback。最后,TC 向各个分支下达了二阶段决议。如果最终决议是 Commit,那么各个 RM 会执行一段异步操作,删除 undo_log;如果最终决议是 Rollback,那么 RM 端会根据 undo_log 中记录的回滚日志做反向补偿。
四、Seata实践
添加依赖项
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
声明数据源代理
Seata AT 之所以能够实现无感知的编程体验,其中的一个秘诀就在这个数据源代理上了。我们在项目中使用的数据源通常是 JDBC driver 的底层 DataSource,或者是 hikari 之类的连接池。但在分布式事务的场景上,为了能够在分支事务开启 / 提交等关键节点上做一番手脚(比如向 Seata 注册分支事务、生成 undo_log 等),我们需要用 Seata 特有的数据源“接管”项目原有的数据源。我在项目中创建了一个 SeataConfiguration的类,用来声明一个 Seata 特有的数据源,作为当前项目的 DataSrouce 代理。
@Configuration
public class SeataConfiguration {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
@Bean("dataSource")
@Primary
public DataSource dataSourceDelegation(DruidDataSource druidDataSource) {
return new DataSourceProxy(druidDataSource);
}
}
在上面的代码中,我先是创建了一个 DruidDataSource 作为数据源连接池,并指定其读取 spring.datasoource 下的数据库连接信息。Druid 也是 alibaba 出品的一个开源数据库连接池方案,在阿里系内部应用也非常广泛。
在 dataSourceDelegation 方法中,我声明了一个 DataSourceProxy 的类,并接收 DruidDataSource 作为构造器初始化参数。DataSourceProxy 是由 Seata 框架提供的一个数据源代理类,为了确保 Spring 上下文使用 DataSourceProxy 而不是其它三方数据源,我在 dataSourceDelegation 方法上添加了 @Primary 注解,将其作为 javax.sql.DataSource 的默认代理类。数据源代理改造完成之后,我们可以去添加 seata 的配置项了。
添加 Seata 配置项
Seata 的配置项定义在 application.yml 文件中,分为上下两部分,一部分在 spring.cloud.alibaba 节点下面,它指定了当前应用的事务分组;另一部分在根节点 seata 下面,定义了连接 Seata Server 的方式。
spring:
cloud:
alibaba:
seata:
tx-service-group: seata-server-group
seata:
application-id: account-server
registry:
type: nacos
nacos:
application: seata-server
server-addr: localhost:8848
namespace: dev
group: myGroup
cluster: default
service:
vgroup-mapping:
seata-server-group: default
在 seata.registry 节点下,我通过 type 属性指定了本地服务和 Seata Server 之间基于 Nacos 服务发现来获取地址信息,而且我还在 seata.registry.nacos 节点下配置了 Nacos 的地址、命名空间、group 等信息。
spring.cloud.alibaba.seata.tx-service-group 节点定义了事务服务的分组名称,你可以随意写一个名称,比如我这里写的是 seata-server-group。唯一要注意的一点是,tx-service-group 中的分组名称一定要和 seata.service.vgroup-mapping 中定义的分组名称一致,我为 seata-server-group 分组所指定的值是 default,这个值会被用来获取 Seata Server 地址。在项目启动的时候,Seata 框架会尝试从 Nacos 获取 Seata Server 的地址信息,执行这个操作的类是 NacosRegistryServiceImpl。在这个类的 lookup 方法中,Seata 使用了下面这行代码查找 seata-server 服务,其中 clusters 参数的值就来自于 seata.service.vgroup-mapping.seata-server-group 所对应的值.
List<Instance> firstAllInstances = getNamingInstance()
.getAllInstances(getServiceName(), getServiceGroup(), clusters);
seata异常回滚测试
关于 Seata AT 的所有准备工作到这里就完成了,接下来就是测试了,代码就不贴了,后面会附上源码地址,大致流程及就是:order-server创建订单,然后调用account-server扣除金额功能,接着调用storage-server扣除库存功能。
正常情况下没什么问题,主要测试一下异常时,是否会回滚,在account-server的decrease方法中加上一段异常代码。
//模拟异常
System.out.println(1/0);
在account-server中执行扣款金额时,加入异常,事务将回滚,并在undo_log中添加上记录。
测试时发现的问题
//rollback_info是longblob,可以通过CONVERT后查看
SELECT CONVERT (rollback_info USING utf8) FROM `undo_log`
-
rollback_info数据为空:主要是由于rollback_info数据比较大,而回滚后rollback_info作用不大,所以最后只使用“{}”代替,可以通过io.seata.rm.datasource.undo.mysql.MySQLUndoLogManager的insertUndoLogWithGlobalFinished方法确认,如果想看完整的rollback_info信息,可以在io.seata.rm.datasource.undo.AbstractUndoLogManager的undo方法打个断点查看。
-
整个测试过程中seata-server端的三张表,都没有发现数据,可能是断点位置不对(问题待研究)。
-
回滚的日志数据undo_log表只在stroage库中有,测试时也发现DataSourceProxy代理的是storage库,问题是其他的库也有回滚,为什么undo_log表中为什么没有数据(问题待研究)。