SpringCloud进阶--Seata与分布式事务
Seata与分布式事务
先回顾下数据库事务吧:
- 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性:事务开始之前和事务结束之后,数据的完整性没有被破坏。
- 隔离性:数据库允许多个并发事务同时对其数据进行读写操作,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括:读未提交、读已提交、可重复读和串行化。
- 持久性:事务处理结束后,对数据的修改就是永久的,即使系统故障也不会丢失。
在分布式环境下,可能会出现这样的问题,比如下单购物,先调用库存服务减掉库存数量-》订单服务开始下单-》账户服务进行扣款。如果订单服务出现问题,就会导致库存扣了,但是没有生成订单,用户也没付款!导致货物丢失!
因此必须实现分布式事务,SPring Cloud Alibaba提供了分布式事务组件Seata

Seata时一款开源的分布式事务解决方案,Seata将为用户提供AT、TCC、SAGA、XA事务模式。
实际上就是多了一个中间人(TC)来协调所有服务的事务。
分布式事务的解决方案
- XA分布式事务协议-2PC(两阶段提交实现)
这里的PC指的时Prepare和Commit,也就是说它分为两个阶段,一个时准备阶段,一个时提交阶段,整个过程的参与者一共有两个角色,一个是事务的执行者,一个是事务的协调者,实际上整个分布式事务的运作都需要依靠协调者来维持:
-
准备阶段:一个分布式事务是由协调者来开启的
首先,协调者会向所有的事务执行者发送事务内容,等待所有的事务执行者答复
各个事务执行者开始执行事务操作,但不提交,并将undo和redo信息记录到事务日志中
如果事务执行者执行事务成功,那么告诉协调者yes,否则告诉协调者失败No,不能提交事务。
-
提交阶段:所有执行者都反馈完成后进入第二阶段
协调者检查各个执行者反馈的内容,如果都反馈成功,那么告诉所有执行者可以提交事务了,最后再释放锁资源
如果至少有一个执行者返回失败或者超时,那么就让所有执行者回滚,分布式事务执行失败。
这种方式看起来比较简单,但是存在以下几个问题:
-
事务协调者是核心角色,一旦出现问题,整个分布式事务都不能正常运行。
-
如果提交阶段发生网络问题,导致某些事务执行者没有收到协调者发来的提交命令。将导致这些执行者有些提交有些没提交,这样会发生错误!
-
XA分布式事务协议-3PC(三阶段提交实现)
三阶段提交是在二阶段提交基础上的改进版本,主要加入了超时机制,同时在协调者和执行者中都引入了超时机制!
三个阶段分别为:
-
CanCommit阶段:
协调者向执行者发送CanCommit请求,询问是否可以执行事务提交操作,然后等待执行者响应
执行者接收到请求后,如果其自身认为可以顺利执行事务,则返回yes,并进入预备状态,否则返回No
-
PreCommit阶段:
协调者根据执行者的反应情况来决定是否可以进入第二阶段PreCommit
如果所有执行者都返回yes,则协调者向所有执行者发送PreCommit请求,并进入PreCommit阶段,执行者收到请求后,会执行事务操作。并将undo和redo信息记录到事务日志中,如果执行成功,则返回成功响应
如果至少有一个执行者返回No,则协调者向所有执行者发送abort请求,所有执行者在收到请求或超过一段时间没收到任何请求时,会直接中断事务。
-
DoCommit阶段:
该阶段进行真正的事务提交。
协调者收到所有执行者发送的成功响应,那么就进入DoCommit状态,并向所有执行者发送DoCommit请求,
执行者收到DoCommit请求后,开始提交事务,并在完成事务提交后释放所有事务资源,并向协调者发送确认响应,协调者接收到所有执行者的确认之后,完成事务。(如果因为网络问题没收到DoCommit请求,执行者会在超时之后直接提交事务,虽然执行者只是猜测协调者返回的是DoCommit请求,但是因为前面的阶段都正常执行,所有能够在一定程度上认为本次事务是成功的,因此会直接提交)
协调者只要接收到一个失败的响应(或者响应超时),就会执行中断事务,协调者向所有执行者发送abort请求,执行者收到abort请求后,利用其在PreCommit阶段记录的undo信息来执行事务回滚,在回滚完成后释放所有的事务资源,并向协调者发送确认信息,协调者接收到反馈后执行事务中断。
相比两阶段提交,三阶段的优势显而易见,但是也有缺点:
- 3PC在2PC的第一阶段和第二阶段中插入一个准备阶段,保证了在最后提交阶段之前各参与节点的状态一致。
- 一旦执行者无法及时收到协调者的提交信息,会默认执行commit。这样就不会因为协调者单方面故障导致全局出现问题
- 但是,超时之后的commit决策本质上是一个赌注,如果此时协调者发送的是abort请求但是超时未接收,那么会导致数据一致性问题
- TCC(补偿事务)
TCC(Try Confirm Cancel)对业务有侵入性,一共分为三个阶段:
-
Try阶段:
比如在借书时,将书籍库存-1,用户剩余借阅量-1,这个操作除了直接对库存和剩余借阅量进行修改之外,还要将减去的值,单独存放到冻结表中,但是此时不会创建借阅信息,它只是预先把关键的信息处理了,预留业务资源出来。
-
Confirm阶段:
如果Try阶段成功,则进入Confirm阶段。此时开始创建借阅信息,只能使用Try阶预留的业务资源,如果创建成功,那么就对Try阶段冻结的值进行解冻,这个流程就执行完了;如果失败了,就进入Cancel阶段
-
Cancel阶段:
此时把冻结的值返还回去,这个借阅没有成功!
跟XA协议相比,TCC没有协调者角色,而是自主执行上一阶段的执行情况来确保正常,充分利用了集群优势,性能有很大提升。
但是缺点也很明显,它与业务有关联性,需要开发者编写更多的补偿代码,并不适合所有的业务流程!
Seata 机制

-
RM(Resource Manager)资源管理器:用于直接执行本地事务的提交和回滚。
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
-
TM(Transaction Manager)事务管理器:分布式事务的核心管理者。比如现在需要在借阅服务中开启全局事务,来让自身、图书服务、用户服务都参与进来,也就是说,TM一般是全局事务发起者。
定义全局事务的范围:开始全局事务、提交或回滚全局事务。
-
TC(Transaction Coordinator)事务协调者:Seata服务器,用于全局控制,比如在XA模式下就是一个协调者角色,而一个分布式事务的启动就是由TM向TC发起请求,TC再来与其他RM进行协调操作。
维护全局和分支事务的状态,驱动全局事务提交或回滚。
TM请求TC开启一个全局事务----TC会生成一个XID作为该全局事务的编号,XID会在服务调用链路中传播,保证将多个微服务的子事务关联在一起;
RM请求TC将本地事务注册未全局事务的分支事务,通过全局事务的XID进行关联;
TM询问TC,XID对应的全局事务应该提交还是回滚?
TC驱动RM将XID对应的本地事务进行提交或回滚。
Seata支持4种事务模式:
-
AT:本质上是2PC的升级版,在AT模式下,用户只关心自己的“业务sql”
-
一阶段:
- Seata 会拦截" 业务SQL",解析SQL 语义
- 查询 “业务SQL” 要更新的业务数据,在业务数据被更新前,将其保存成 “before image”
- 执行 “业务SQL” ,更新业务数据
- 查询更新后的数据,将其保存成 “after image”
- 将 before image 和 after image 保存至 Undo Log 表中
- 生成行锁
以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
-
二阶段(提交):因为 “业务SQL” 在一阶段已经提交至数据库,所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
二阶段(回滚):
- 首先要校验脏写,对比“数据库当前业务数据”和 “after image”
- 如果两份数据完全一致就说明没有脏写,可以还原业务数据。
- 如果不一致就说明有脏写,出现脏写就需要转人工处理。
- 用“before image”还原业务数据
- 删除快照数据和行锁
- TCC:同上
- XA:同上,但是要求数据库本身支持这种模式才可以
- Saga:用于处理长事务,每个执行者需要实现事务的正向操作和补偿操作:
以AT模式为例,如何才能做到不侵入业务的情况下完成分布式事务?
Seata客户端是通过对数据源进行代理实现的,使用的是DataSourceProxy类。我们只需要将对应的代理类注册为Bean即可。
使用file模式部署
-
先下载Seata(下载地址:Releases · apache/incubator-seata · GitHub)。Seata支持本地部署和基于注册中心部署(比如Nacos、Eureka),这里以本地部署为例,不需要对Seata的配置文件做任何修改。
Seata存在事务分组机制:
- 事务分组:seata的资源逻辑,可以按照微服务需要,在应用程序(客户端)对事务进行分组,并对分组命名
- 集群:Seata-Server服务端一个或多个节点组成的集群cluster。应用程序(客户端)需要指定事务分组和Seata服务端集群(默认default)的映射关系。
为什么要通过事务分组映射到集群,为什么不直接指定集群呢?
这样设计后,事务分组可以作为资源的逻辑隔离单位,集群出现故障时可以快速failover,只切换对应分组,就可以把故障减到服务级别,前提是有足够多的server集群节点。
- 为各个微服务引入依赖:
- 首先要校验脏写,对比“数据库当前业务数据”和 “after image”
-
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
-
添加配置:
seata: service: vgroup-mapping: #对事务组做映射,默认分组为应用名称-seata-service-group,将其映射到default集群 borrow-service-seata-service-group: default grouplist: default: localhost:8868然后启动服务,这时只是单纯的连接上Seata。但是没开启分布式事务。
-
要开启分布式事务。首先在启动类加注解@EnableAutoDataSourceProxy。此注解会添加一个后置处理器将数据源封装为支持分布式事务的代理源;
@SpringBootApplication @EnableAutoDataSourceProxy public class AppBorrow { public static void main( String[] args ) { SpringApplication.run(AppBorrow.class, args); } } -
然后在业务方法上添加@GlobalTransactional注解开启分布式事务
@GlobalTransactional @Override public Boolean doBorrow(int uid, int bid) { //1.判断图书和用户是否都可以借阅 if (bookClient.bookRemain(bid)<1){ throw new RuntimeException("图书数量不足!"); } if (userClient.userRemain(uid)<1){ throw new RuntimeException("用户剩余借阅量不足!"); } // 2. 先将图书数量-1 if (!bookClient.bookBorrow(bid)){ throw new RuntimeException("借阅图书时出错!"); } // 3. 添加借阅信息 if (borrowMapper.getBorrow(uid,bid)!=null){ throw new RuntimeException("此书籍已被此用户借阅了!"); } if (borrowMapper.addBorrow(uid,bid)<=0){ throw new RuntimeException("录入借阅信息时出错!"); } // 4. 用户可借阅-1 if (!userClient.userBorrow(uid)){ throw new RuntimeException("借阅时出现错误!"); } // 5.完成 return true; } -
由于Seata会分析修改数据的sql,同时生成对应的反向回滚sql。这个回滚记录会存放在undo_log表。所以要求每一个client都有一个undo_log表(每个服务连接的数据库都要创建这样一个表,由于我的三个服务都用的同一个数据库,所以只用在这个数据库创建一个undo_log表。)
DROP TABLE IF EXISTS `undo_log`; CREATE TABLE `undo_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `branch_id` bigint(20) NOT NULL, `xid` varchar(100) NOT NULL, `context` varchar(128) NOT NULL, `rollback_info` longblob NOT NULL, `log_status` int(11) NOT NULL, `log_created` datetime NOT NULL, `log_modified` datetime NOT NULL, `ext` varchar(100) default null, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; -
此时就配置完成了!
使用nacos模式部署
- 先单独为Seata配置一个命名空间

-
修改Seata 中的/conf/registry.conf文件
registry { # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa type = "nacos" nacos { application = "seata-server" serverAddr = "127.0.0.1:8848" group = "SEATA_GROUP" # 使用我们刚创建的命名空间 namespace = "74cc7e01-083a-48ca-9991-19c93d298ccd" cluster = "default" username = "nacos" password = "nacos" } ... config { # file、nacos 、apollo、zk、consul、etcd3 type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "74cc7e01-083a-48ca-9991-19c93d298ccd" group = "SEATA_GROUP" username = "nacos" password = "nacos" dataId = "seataServer.properties" } ...-
微服务的配置修改成
-
seata: registry: type: nacos nacos: namespace: 74cc7e01-083a-48ca-9991-19c93d298ccd username: nacos password: nacos config: type: nacos nacos: namespace: 74cc7e01-083a-48ca-9991-19c93d298ccd username: nacos password: nacos
-
默认的数据存储方式是file。现在修改成数据库的存储方式:
修改file.conf文件:
store {
## store mode: file、db、redis
mode = "db"
...
## 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.cj.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 = "mysql"
password = "mysql"
minConn = 5
maxConn = 100
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
maxWait = 5000
}
创建seata数据库,并创建表
-- 1. 全局事务表
CREATE TABLE IF NOT EXISTS `global_table` (
`xid` varchar(128) NOT NULL,
`transaction_id` bigint DEFAULT NULL,
`status` tinyint NOT NULL,
`application_id` varchar(32) DEFAULT NULL,
`transaction_service_group` varchar(32) DEFAULT NULL,
`transaction_name` varchar(128) DEFAULT NULL,
`timeout` int DEFAULT NULL,
`begin_time` bigint DEFAULT NULL,
`application_data` varchar(2000) DEFAULT NULL,
`gmt_create` datetime DEFAULT NULL,
`gmt_modified` datetime DEFAULT NULL,
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status`,`gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 分支事务表
CREATE TABLE IF NOT EXISTS `branch_table` (
`branch_id` bigint NOT NULL,
`xid` varchar(128) NOT NULL,
`transaction_id` bigint DEFAULT NULL,
`resource_group_id` varchar(32) DEFAULT NULL,
`resource_id` varchar(256) DEFAULT NULL,
`branch_type` varchar(8) DEFAULT NULL,
`status` tinyint DEFAULT NULL,
`client_id` varchar(64) DEFAULT NULL,
`application_data` varchar(2000) DEFAULT NULL,
`gmt_create` datetime DEFAULT NULL,
`gmt_modified` datetime DEFAULT NULL,
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. 锁表
CREATE TABLE IF NOT EXISTS `lock_table` (
`row_key` varchar(128) NOT NULL,
`xid` varchar(128) DEFAULT NULL,
`transaction_id` bigint DEFAULT NULL,
`branch_id` bigint DEFAULT NULL,
`resource_id` varchar(256) DEFAULT NULL,
`table_name` varchar(32) DEFAULT NULL,
`pk` varchar(36) DEFAULT NULL,
`gmt_create` datetime DEFAULT NULL,
`gmt_modified` datetime DEFAULT NULL,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `undo_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`branch_id` bigint NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
然后重启seata,即完成部署!
本文来自博客园,作者:NE_STOP,转载请注明原文链接:https://www.cnblogs.com/alineverstop/p/19774989
浙公网安备 33010602011771号