分布式事务Seata
三大模块
TC (Transaction Coordinator) - 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。
TM (Transaction Manager) - 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。
RM (Resource Manager) - 资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
Seata 实现分布式事务,设计了一个关键角色
UNDO_LOG
(回滚日志记录表),我们在每个应用分布式事务的业务库中创建这张表,这个表的核心作用就是,将业务数据在更新前后的数据镜像组织成回滚日志,备份在UNDO_LOG
表中,以便业务异常能随时回滚。
Seata分TC、TM和RM三个角色,TC(Server端)为单独服务端部署,TM和RM(Client端)由业务系统集成。
Seata
也是从两段提交演变而来的一种分布式事务解决方案,提供了 AT
、TCC
、SAGA
和 XA
等事务模式,这里重点介绍 AT
模式。
既然 Seata
是两段提交,那我们看看它在每个阶段都做了点啥?下边我们还以下单扣库存、扣余额举例。
先介绍 Seata
分布式事务的几种角色:
Transaction Coordinator(TC)
: 全局事务协调者,用来协调全局事务和各个分支事务(不同服务)的状态, 驱动全局事务和各个分支事务的回滚或提交。Transaction Manager™
: 事务管理者,业务层中用来开启/提交/回滚一个整体事务(在调用服务的方法中用注解开启事务)。Resource Manager(RM)
: 资源管理者,一般指业务数据库代表了一个分支事务(Branch Transaction
),管理分支事务与TC
进行协调注册分支事务并且汇报分支事务的状态,驱动分支事务的提交或回滚。
Seata 实现分布式事务,设计了一个关键角色
UNDO_LOG
(回滚日志记录表),我们在每个应用分布式事务的业务库中创建这张表,这个表的核心作用就是,将业务数据在更新前后的数据镜像组织成回滚日志,备份在UNDO_LOG
表中,以便业务异常能随时回滚。
第一个阶段
比如:下边我们更新 user
表的 name
字段。
update user set name = '小富最帅' where name = '程序员内点事'
首先 Seata 的 JDBC
数据源代理通过对业务 SQL 解析,提取 SQL 的元数据,也就是得到 SQL 的类型(UPDATE
),表(user
),条件(where name = '程序员内点事'
)等相关的信息。
第一个阶段的流程图
先查询数据前镜像,根据解析得到的条件信息,生成查询语句,定位一条数据。
select name from user where name = '程序员内点事'
数据前镜像
紧接着执行业务 SQL,根据前镜像数据主键查询出后镜像数据
select name from user where id = 1
数据后镜像
把业务数据在更新前后的数据镜像组织成回滚日志,将业务数据的更新和回滚日志在同一个本地事务中提交,分别插入到业务表和 UNDO_LOG
表中。
回滚记录数据格式如下:包括 afterImage
后镜像、beforeImage
前镜像、 branchId
分支事务ID、xid
全局事务ID
{
"branchId":641789253,
"xid":"xid:xxx",
"undoItems":[
{
"afterImage":{
"rows":[
{
"fields":[
{
"name":"id",
"type":4,
"value":1
}
]
}
],
"tableName":"product"
},
"beforeImage":{
"rows":[
{
"fields":[
{
"name":"id",
"type":4,
"value":1
}
]
}
],
"tableName":"product"
},
"sqlType":"UPDATE"
}
]
}
这样就可以保证,任何提交的业务数据的更新一定有相应的回滚日志。
在本地事务提交前,各分支事务需向
全局事务协调者
TC 注册分支 (Branch Id
) ,为要修改的记录申请 全局锁 ,要为这条数据加锁,利用SELECT FOR UPDATE
语句。而如果一直拿不到锁那就需要回滚本地事务。TM 开启事务后会生成全局唯一的XID
,会在各个调用的服务间进行传递。
有了这样的机制,本地事务分支(Branch Transaction
)便可以在全局事务的第一阶段提交,并马上释放本地事务锁定的资源。相比于传统的 XA
事务在第二阶段释放资源,Seata
降低了锁范围提高效率,即使第二阶段发生异常需要回滚,也可以快速 从UNDO_LOG
表中找到对应回滚数据并反解析成 SQL 来达到回滚补偿。
最后本地事务提交,业务数据的更新和前面生成的 UNDO LOG 数据一并提交,并将本地事务提交的结果上报给全局事务协调者 TC。
第二个阶段
第二阶段是根据各分支的决议做提交或回滚:
如果决议是全局提交,此时各分支事务已提交并成功,这时 全局事务协调者(TC)
会向分支发送第二阶段的请求。收到 TC 的分支提交请求,该请求会被放入一个异步任务队列中,并马上返回提交成功结果给 TC。异步队列中会异步和批量地根据 Branch ID
查找并删除相应 UNDO LOG
回滚记录。
如果决议是全局回滚,过程比全局提交麻烦一点,RM
服务方收到 TC
全局协调者发来的回滚请求,通过 XID
和 Branch ID
找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。
注意:这里删除回滚日志记录操作,一定是在本地业务事务执行之后
上边说了几种分布式事务各自的优缺点,下边实践一下分布式事务中间 Seata 感受一下。
Seata的执行流程
- A 服务【订单微服务】的 TM[ 事务发起者 ] 向 TC[seata 服务端 ] 申请开启一个全局事务, TC 就会创建一个全局事务并返回一个唯一的 XID
- A 服务开始远程调用 B 服务【账户微服务】,此时 XID 会在微服务的调用链上传播
- B 服务的 RM 向 TC 注册分支事务,并将其纳入 XID 对应的全局事务的管辖
- B 服务执行分支事务,向数据库做操作
- 全局事务调用链处理完毕, TM 根据有无异常向 TC 发起全局事务的提交或者回滚
- TC 协调其管辖之下的所有分支事务, 决定是否回滚
Seata 实践
Seata 是一个需独立部署的中间件,所以先搭 Seata Server,这里以最新的 seata-server-1.4.0
版本为例,下载地址:https://seata.io/en-us/blog/download.html
解压后的文件我们只需要关心 \seata\conf
目录下的 file.conf
和 registry.conf
文件。具体查看官方文档
Seata Server
file.conf
file.conf
文件用于配置持久化事务日志的模式,目前提供 file
、db
、redis
三种方式。
file.conf 文件配置
注意:在选择 db
方式后,需要在对应数据库创建 globalTable
(持久化全局事务)、branchTable
(持久化各提交分支的事务)、 lockTable
(持久化各分支锁定资源事务)三张表。
-- the table to store GlobalSession data
-- 持久化全局事务
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
-- the table to store BranchSession data
-- 持久化各提交分支的事务
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
-- the table to store lock data
-- 持久化每个分支锁表事务
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(96),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
registry.conf
registry.conf
文件设置 注册中心 和 配置中心:
目前注册中心支持 nacos
、eureka
、redis
、zk
、consul
、etcd3
、sofa
七种,这里我使用的 eureka
作为注册中心 ;配置中心支持 nacos
、apollo
、zk
、consul
、etcd3
五种方式。
registry.conf 文件配置
配置完以后在 \seata\bin
目录下启动 seata-server
即可,到这 Seata
的服务端就搭建好了。
Seata Client
Seata Server
环境搭建完,接下来我们新建三个服务 order-server
(下单服务)、storage-server
(扣减库存服务)、account-server
(账户金额服务),分别服务注册到 eureka
。
每个服务的大体核心配置如下:
spring:
application:
name: storage-server
cloud:
alibaba:
seata:
tx-service-group: my_test_tx_group
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://47.93.6.1:3306/seat-storage
username: root
password: root
# eureka 注册中心
eureka:
client:
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:8761/eureka/
instance:
hostname: 47.93.6.5
prefer-ip-address: true
业务大致流程:用户发起下单请求,本地 order 订单服务创建订单记录,并通过 RPC
远程调用 storage
扣减库存服务和 account
扣账户余额服务,只有三个服务同时执行成功,才是一个完整的下单流程。如果某个服执行失败,则其他服务全部回滚。
Seata 对业务代码的侵入性非常小,代码中使用只需用 @GlobalTransactional
注解开启一个全局事务即可。
@Override
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public void create(Order order) {
String xid = RootContext.getXID();
LOGGER.info("------->交易开始");
//本地方法
orderDao.create(order);
//远程方法 扣减库存
storageApi.decrease(order.getProductId(), order.getCount());
//远程方法 扣减账户余额
LOGGER.info("------->扣减账户开始order中");
accountApi.decrease(order.getUserId(), order.getMoney());
LOGGER.info("------->扣减账户结束order中");
LOGGER.info("------->交易结束");
LOGGER.info("全局事务 xid: {}", xid);
}
前边说过 Seata AT 模式实现分布式事务,必须在相关的业务库中创建 undo_log
表来存数据回滚日志,表结构如下:
-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
到这环境搭建的工作就完事了,完整案例会在后边贴出
GitHub
地址,就不在这占用篇幅了。
测试 Seata
项目中的服务调用过程如下图:
服务调用过程
启动各个服务后,我们直接请求下单接口看看效果,只要 order
订单表创建记录成功,storage
库存表 used
字段数量递增、account
余额表 used
字段数量递增则表示下单流程成功。
原始数据
请求后正向流程是没问题的,数据和预想的一样
下单数据
而且发现 TM
事务管理者 order-server
服务的控制台也打印出了两阶段提交的日志
控制台两次提交
那么再看看如果其中一个服务异常,会不会正常回滚呢?在 account-server
服务中模拟超时异常,看能否实现全局事务回滚。
全局事务回滚
发现数据全没执行成功,说明全局事务回滚也成功了
那看一下 undo_log
回滚记录表的变化情况,由于 Seata
删除回滚日志的速度很快,所以要想在表中看见回滚日志,必须要在某一个服务上打断点才看的更明显。
回滚记录
注意:
1,异常需要层层往上抛,如果你在子服务将异常处理的话(比如全局异常处理GlobalExceptionHandler
),seata
会认为你已经手动处理了异常。
2,出现事务失效的情况下,优先检查 RootContext.getXID()
,xid是否传递且一致。
3,主服务加上@GlobalTransactional
注解即可,被调用服务不用加@GlobalTransactional
和@Transactional
.
4,@GlobalTransactional(rollbackFor = Exception.class)
最好加上rollbackFor = Exception.class
,表示遇到Exception
都回滚,不然遇到有些异常(如自定义异常)则不会回滚。
参考:
面试被问分布式事务(2PC、3PC、TCC),这样解释没毛病!
对比 5 种分布式事务方案,还是宠幸了阿里的 Seata(原理 + 实战)
分布式事务 SEATA-1.4.1 AT模式 配合NACOS 应用