本地事务,也就是传统的单机事务。在传统数据库事务中,必须要满足四个原则,即ACID:
ACID,是指数据库管理系统(DBMS)在写入或更新资料的过程中,为保证事务(transaction)是正确可靠的,所必须具备的四个特性:原子性(atomicity,或称不可分割性)、一致性(consistency)、隔离性(isolation,又称独立性)、持久性(durability)。
原子性:事务中的所有操作,要么全部成功,要么全部失败。
一致性:要保证数据库内部完整性约束、声明性约束。
隔离性:对同一资源操作的事务不能同时发生。
持久性:对数据库做的一切修改将永久保存,不管是否出现故障。
分布式事务,就是指不是在单个服务或单个数据库架构下产生的事务,例如:
在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。例如电商行业中比较常见的下单付款案例,包括下面几个行为:
完成上面的操作需要访问三个不同的微服务和三个不同的数据库。

订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证 ACID 原则。
但是,当我们把三件事情看作一个“业务”,要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务了。
此时 ACID 难以满足,这是分布式事务要解决的问题。
代码下载地址:https://gitee.com/taurusGitee/springcloud-seata
1)创建 seata_demo 数据库,并执行下面的 SQL 语句。
create database seata_demo;
use seata_demo;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `account_tbl`;
CREATE TABLE `account_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`money` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
INSERT INTO `account_tbl` VALUES (1, 'user202103032042012', 1000);
DROP TABLE IF EXISTS `order_tbl`;
CREATE TABLE `order_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) NULL DEFAULT 0,
`money` int(11) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
DROP TABLE IF EXISTS `storage_tbl`;
CREATE TABLE `storage_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `commodity_code`(`commodity_code`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
INSERT INTO `storage_tbl` VALUES (1, '100202003032041', 10);
SET FOREIGN_KEY_CHECKS = 1;
2)将代码导入到 IDEA 中

其中:
seata-demo:父工程,负责管理项目依赖
3)启动 Nacos,启动所有的微服务
4)测试下单功能,发出Post请求:
请求如下:
curl --location --request POST 'http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=20&money=200'
如图:

此时订单创建成功,账户及库存扣减成功。
订单表:

账户表:
原 money 数为 1000,扣减 200 后为 800。

原库存数为 10,扣减 2 后为 8。

修改订单数,将 count 改为 10,超出现有库存数 8,重新测试:

请求没有成功。


但是,经过测试发现,当库存不足时,如果余额已经扣减,并不会回滚,出现了分布式事务问题。

首先,每一个服务都是独立的,库存服务抛出异常,账户服务并不知道。另外,由于每一个服务都是独立的,所以它们的事务也都是独立的,订单服务和账户服务在执行完业务之后,事务完成提交,无法回滚。所以,当库存服务抛出异常时,就无法达成事务的一致性。
在分布式系统下,一个业务跨越多个服务或数据源,每个服务都是一个分支事务,要保证所有分支事务最终状态一致,这样的事务就是分布式事务。
解决分布式事务问题,需要一些分布式系统的基础知识作为理论指导。
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。
- Consistency(一致性)
- Availability(可用性)
- Partition tolerance (分区容错性)

它们的第一个字母分别是 C、A、P。
Eric Brewer 说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。
Consistency(一致性):一致性是指写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。
比如,现在包含两个节点,假设 node01 是主节点,node02 是从节点,其中的初始数据是一致的:

当我们修改其中一个节点的数据时,两者的数据产生了差异:

要想保持一致性,就必须实现 node01 到 node02 的数据同步:

如何实现一致性?
1、写入主数据库后要将数据同步到从数据库。
2、写入主数据库后,在向从数据库同步期间要将从数据库锁定,待同步完成后再释放锁,以免在新数据写入成功后,向从数据库查询到旧的数据。
分布式系统一致性的特点:
1、由于存在数据同步的过程,写操作的响应会有一定的延迟。
2、为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。
3、如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据(为了保证数据一致性,从任意结点读取到的数据都必须是最新的状态)。
Availability(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝。
如图,有三个节点的集群,访问任何一个都可以及时得到响应:

当有部分节点因为网络故障或其他原因无法访问时,代表节点不可用:

数据同步会导致被同步的数据被锁定,而无法查询。
如何实现可用性?
1、写入主数据库后要将数据同步到从数据库。
2、由于要保证从数据库的可用性,不可将从数据库中的资源进行锁定。
3、即时数据还没有同步过来,从数据库也要返回要查询的数据,哪怕是旧数据,如果连旧数据也没有则可以按照约定返回一个默认信息,但不能返回错误或响应超时。
分布式系统可用性的特点:
1、 所有请求都有响应,且不会出现响应超时或响应错误。
Partition(分区):因为网络故障或其他原因导致分布式系统中的部分节点与其他节点失去连接,形成独立分区。

Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务。
如何实现分区容忍性?
1、尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。
2、添加从数据库结点,其中一个从结点挂掉其它从结点提供服务。
分布式分区容忍性的特点:
1、分区容忍性分是布式系统具备的基本能力
在分布式系统中,系统间的网络不能 100% 保证健康,一定会有故障的时候,而服务又必须对外保证服务。因此 Partition Tolerance 不可避免。
当节点接收到新的数据变更时,就会出现问题了:

如果此时要保证一致性,就必须等待网络恢复,完成数据同步后,整个集群才对外提供服务,服务处于阻塞状态,不可用。
如果此时要保证可用性,就不能等待网络恢复,那 node01、node02 与 node03 之间就会出现数据不一致。
也就是说,在 P 一定会出现的情况下,A 和 C 之间只能实现一个。
BASE 理论是对 CAP 的一种解决思路,包含三个思想:
分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴 CAP 定理和 BASE 理论,有两种解决思路:
但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个事务协调者(TC):

这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务。
Seata是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
官网地址:http://seata.io/,其中的文档、播客中提供了大量的使用说明、源码分析。
Seata 事务管理中有三个重要的角色:
整体的架构如图:

Seata 基于上述架构提供了四种不同的分布式解决方案:
无论哪种方案,都离不开 TC,也就是事务的协调者。
首先我们要下载seata-server包,地址在https://seata.io/zh-cn/blog/download.html

在非中文目录解压缩这个zip包,其目录结构如下:

修改conf目录下的 application.yml 文件:

具体配置如下:
server:
port: 7091
spring:
application:
name: seata-server
logging:
config: classpath:logback-spring.xml
file:
path: ${user.home}/logs/seata
extend:
logstash-appender:
destination: 127.0.0.1:4560
kafka-appender:
bootstrap-servers: 127.0.0.1:9092
topic: logback_to_logstash
console:
user:
username: seata
password: seata
seata:
config:
# support: nacos, consul, apollo, zk, etcd3
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace:
group: SEATA_GROUP
username: nacos
password: nacos
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key: ""
#secret-key: ""
data-id: seataServer.properties
registry:
# support: nacos, eureka, redis, zk, consul, etcd3, sofa
type: nacos
nacos:
application: seata-tc-server
server-addr: 127.0.0.1:8848
group: DEFAULT_GROUP
namespace:
cluster: SH
username: nacos
password: nacos
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key: ""
#secret-key: ""
store:
# support: file 、 db 、 redis
mode: db
db:
datasource: druid
db-type: mysql
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
user: mysql
password: 123
min-conn: 5
max-conn: 100
global-table: global_table
branch-table: branch_table
lock-table: lock_table
distributed-lock-table: distributed_lock
query-limit: 100
max-wait: 5000
# server:
# service-port: 8091 #If not configured, the default is '${server.port} + 1000'
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login
特别注意,为了让tc服务的集群可以共享配置,我们选择了nacos作为统一配置中心。因此服务端配置文件seataServer.properties文件需要在nacos中配好。
格式如下:

配置内容:
# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=mysql
store.db.password=123
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
# 事务、日志等配置
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
server.maxRollbackRetryTimeout=-1
server.rollbackRetryTimeoutUnlockEnable=false
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
# 客户端与服务端传输方式
transport.serialization=seata
transport.compressor=none
# 关闭metrics功能,提高性能
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898
其中的数据库地址、用户名、密码都需要修改成你自己的数据库信息。
如果此处自己不想手动添加配置,可以打开解压的下面的 config.txt 文件,修改其中的数据库配置。


然后执行 nacos 目录下的 nacos-config.sh,执行结束后,nacos 中会自动添加好相应的配置。
特别注意:tc服务在管理分布式事务时,需要记录事务相关数据到数据库中,你需要提前创建好这些表。
新建一个名为seata的数据库,运行/seata/script/server/db 目录下的 mysql.sql 文件:

这些表主要记录全局事务、分支事务、全局锁信息:
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- 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_status_gmt_modified` (`status` , `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- 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 = utf8mb4;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_status` (`status`),
KEY `idx_branch_id` (`branch_id`),
KEY `idx_xid_and_branch_id` (`xid` , `branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `distributed_lock`
(
`lock_key` CHAR(20) NOT NULL,
`lock_value` VARCHAR(20) NOT NULL,
`expire` BIGINT,
primary key (`lock_key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);
进入bin目录,运行其中的 seata-server.sh 即可:

启动成功后,seata-server应该已经注册到nacos注册中心了。
打开浏览器,访问nacos地址:http://localhost:8848,然后进入服务列表页面,可以看到seata-tc-server的信息:

首先,我们需要在微服务中引入seata依赖:
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-seataartifactId>
<exclusions>
<exclusion>
<artifactId>seata-spring-boot-starterartifactId>
<groupId>io.seatagroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>io.seatagroupId>
<artifactId>seata-spring-boot-starterartifactId>
<version>${seata.version}version>
dependency>
需要修改application.yml文件,添加一些配置:
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
# 参考tc服务自己的registry.conf中的配置
type: nacos
nacos: # tc
server-addr: 127.0.0.1:8848
namespace: ""
group: DEFAULT_GROUP
application: seata-tc-server # tc服务在nacos中的服务名称
cluster: SH
tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称
service:
vgroup-mapping: # 事务组与TC服务cluster的映射关系
seata-demo: SH

nacos 服务名称组成包括:namespace + group + serviceName + cluster
seata 客户端获取 tc 的 cluster 的名称方式:以 tx-group-service 的值为 key 到 vgroupMapping 中查找
注意 order-service、storage-service、account-service 都需要修改。
修改完成后,将服务启动。
XA 规范是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范描述了全局 TM 与局部的 RM 之间的接口,几乎所有主流的数据库都对 XA 规范提供了支持。
XA是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交(2PC)。
什么是 2PC?
2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commit phase),2是指两个阶段,P是指准备阶段,C是指提交阶段。
整个事务过程由事务管理器和参与者组成,事务管理器负责决策整个分布式事务的提交和回滚,事务参与者负责自己本地事务的提交和回滚。
在计算机中部分关系数据库如Oracle、MySQL支持两阶段提交协议,如下图:
正常情况:

异常情况:

一阶段:
二阶段:
- 如果一阶段都成功,则通知所有事务参与者,提交事务
- 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
Seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图:

RM(资源管理器) 一阶段的工作:
① 注册分支事务到 TC(事务协调者)
② 执行分支业务 SQL 但不提交
③ 报告执行状态到 TC
TC 二阶段的工作:
XA 模式的优点:
XA 模式的缺点:
Seata 的 starter 已经完成了 XA 模式的自动装配,实现非常简单,步骤如下:
1)修改 application.yml 文件(每个参与事务的微服务),开启 XA 模式:
seata:
data-source-proxy-mode: XA
2)给发起全局事务的入口方法添加 @GlobalTransactional 注解:
本例中是 OrderServiceImpl 中的 create 方法。
@Override
@GlobalTransactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
try {
// 扣用户余额
accountClient.deduct(order.getUserId(), order.getMoney());
// 扣库存
storageClient.deduct(order.getCommodityCode(), order.getCount());
} catch (FeignException e) {
log.error("下单失败,原因:{}", e.contentUTF8(), e);
throw new RuntimeException(e.contentUTF8(), e);
}
return order.getId();
}
3)重启服务并测试
重启order-service,再次测试,发现无论怎样,三个微服务都能成功回滚。

账户表:

订单表:

库存表:

AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。
基本流程图:

阶段一 RM 的工作:
阶段二提交时 RM 的工作:
阶段二回滚时 RM 的工作:
我们用一个真是的业务来梳理下 AT 模式的原理。
比如,现在有一个数据库表,记录用户余额:
| id | money |
|---|---|
| 1 | 100 |
其中一个分支业务要执行的 SQL 为:
update tb_account set money = money - 10 where id = 1
AT 模式下,当前分支事务执行流程如下:
一阶段:
1)TM 发起并注册全局事务到 TC
2)TM 调用分支事务
3)分支事务准备执行业务 SQL
4)RM 拦截业务 SQL,根据 where 条件查询原始数据,形成快照。
{
"id": 1, "money": 100
}
5)RM 执行业务 SQL,提交本地事务,释放数据库锁,此时 money=90
6)RM 报告本地事务状态给 TC
二阶段:
1)TM 通知 TC 事务结束
2)TC 检查分支事务状态
{"id": 1, "money": 100}),将快照恢复到数据库。此时数据库再次恢复为 100。流程图:

在多线程并发访问AT模式的分布式事务时,有可能出现脏写问题,如图:

假设线程 A 开启了事务 1,事务开启后,首先 RM 会拦截业务 SQL 的执行,获取数据库锁,接着形成快照 {"id": 1, "money": 100}。RM在拿到快照之后,事务 1 便开始执行业务 SQLset money=90 ,紧接着提交本地事务,并释放数据库锁。至此,一阶段执行结束,二阶段准备开始执行。而恰巧此时,线程 B 开启了另一个事务 2,来执行该业务。首先,事务 2 需要先获取数据库锁,但由于事务 1 拿着数据库锁,只能等待事务 1 释放锁。当事务 1 释放锁后,事务 2 拿到锁,接着 RM 就会去形成快照{"id": 1, "money": 90}。快照形成后,事务 2 便开始执行业务 SQLset money=80 ,紧接着提交本地事务,并释放数据库锁。此时事务 1 正在等待事务 2 释放数据库锁。在事务 2 释放数据库锁且事务 1 拿到锁后,事务 1 便会立即执行二阶段回滚操作,RM 便会根据一阶段形成的快照来恢复数据,但是事务1的快照是 {"id": 1, "money": 100},而在事务 2 执行结束后,money 的值已经变成了 80,通过事务 1 的快照恢复,就会直接将 80 覆盖为 100。
解决思路就是引入了全局锁的概念。在释放DB锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据。
所谓全局锁,就是由 TC 记录当前正在操作某行数据的事务,该事务持有全局锁,具备执行权。那么 TC 是如何记录当前正在操作某行数据的事务?会有一张数据库表,表中会记录当前执行事务的 id,操作哪张表 table,以及哪一行数据(pk 数据的主键值)

假设线程 A 开启了事务 1,事务开启后,首先 RM 会拦截业务 SQL 的执行,获取数据库锁,接着形成快照 {"id": 1, "money": 100}。RM在拿到快照之后,事务 1 便开始执行业务 SQLset money=90 ,业务 SQL 执行结束但尚未提交事务之前,事务 1 会去获取全局锁,获取成功后,会将当前正在操作的数据的事务信息记录下来,然后 RM 便可以放心大胆地去提交本地事务,并释放数据库锁。至此,一阶段执行结束,二阶段准备开始执行。而恰巧此时,线程 B 开启了另一个事务 2,来执行该业务。首先,事务 2 需要先获取数据库锁,但由于事务 1 拿着数据库锁,只能等待事务 1 释放锁。当事务 1 释放锁后,事务 2 拿到锁,接着 RM 就会去形成快照{"id": 1, "money": 90}。快照形成后,事务 2 便开始执行业务 SQLset money=80 ,紧接着事务 2 也尝试获取全局锁,但是由于此时事务 1 持有着全局锁,导致事务 2 不断去重复获取全局锁,直至事务 1 释放全局锁。而此时事务 1 开始执行二阶段回滚操作,需要先获取数据库锁,但是此时数据库锁被事务 2 所持有,导致事务 1 也在等待事务 2 释放数据库锁。由于事务 1 和事务 2 彼此之间相互等待对方释放锁,也就产生了死锁。为了解决这种情况,Seata 设置了重试获取全局锁的次数,默认为 30 次,间隔 10 毫秒。而由于获取 DB 锁的等待时间往往比较长,所以事务 2 最终会由于超时而回滚,并释放数据库锁。接着事务 1 便会获取到数据库锁,根据快照恢复数据。
全局锁是由 Seata 来进行管理的,它与数据库锁是有区别的。假设有两个事务来同时更新 money 值,此时事务 2 为非 Seata 管理的全局事务,这样就会重新产生脏写的问题。

假设线程 A 开启了事务 1,事务开启后,首先 RM 会拦截业务 SQL 的执行,获取数据库锁,接着形成快照 {"id": 1, "money": 100}。RM在拿到快照之后,事务 1 便开始执行业务 SQLset money=90 ,业务 SQL 执行结束但尚未提交事务之前,事务 1 会去获取全局锁,获取成功后,会将当前正在操作的数据的事务信息记录下来,然后 RM 便可以放心大胆地去提交本地事务,并释放数据库锁。至此,一阶段执行结束,二阶段准备开始执行。而恰巧此时,线程 B 开启了另一个事务 2,该事务并不受 Seata 管理,是一个普通事务,同样也是执行该业务。由于事务 1 释放了数据库锁,使得事务 2 获取到了数据库锁,事务 2 执行业务 SQL set money=80,然后提交事务,释放数据库锁。在事务 2 执行的过程中,事务 1 一直在等待事务 2 释放数据库锁。事务 1 在重新获取到数据库锁后,执行二阶段回滚事务。当事务 1 准备恢复数据时,除了会获取更新前的快照(用于做数据恢复),还会获取更新后的快照,获取更新后的快照是为了与当前数据库中的数据进行对比,如果一样,证明在一阶段与二阶段之间,没有其他事务修改过数据,如果不一样,则说明数据发生过修改。这个时候,Seata 就会停止数据恢复,记录下异常,发送警告,进行人工介入。
AT 模式的优点
AT 模式的缺点:
AT 模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
只不过,AT 模式需要一个表来记录全局锁、另一张表来记录数据快照 undo_log。
1)导入数据库表,记录全局锁
将 lock_table 导入到 TC 服务关联的数据库,undo_log 表导入到微服务关联的数据库。
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (
`row_key` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`xid` varchar(96) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`branch_id` bigint(20) NOT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`table_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`pk` varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`row_key`) USING BTREE,
INDEX `idx_branch_id`(`branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'global transaction id',
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci 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(6) NOT NULL COMMENT 'create datetime',
`log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = Compact;

2)修改 application.yml 文件,将事务模式修改为 AT 模式即可:
seata:
data-source-proxy-mode: AT # 默认就是AT
3)重启服务,并测试
Account_service 的后台日志:

二阶段执行时,先是rm handle branch roollback,进行了数据回滚,接着删除 undo_log。
如果向查看 undo_log 中的数据,可以在程序执行过程中打上断点。
TCC 模式与 AT 模式非常相似,每阶段都是独立事务,不同的是 TCC 通过人工编码来实现数据恢复。需要实现三个方法:
举例,一个扣减用户余额的业务。假设账户 A 原来余额是 100,需要余额扣减 30 原。
阶段一(Try):检查余额是否充足,如果充足则冻结金额增加 30 元,可用余额扣除 30
初始余额:

余额充足,可以冻结:

此时,总金额 = 冻结金额 + 可用余额,数量依然是 100 不变。事务直接提交无需等待其他事务。
阶段二(Confirm):加入要提交(Confirm),则冻结金额扣减 30
确认可以提交,不过之前可用金额已经扣减过了,这里只要清除冻结金额就好了:

此时,总金额 = 冻结金额 + 可用余额 = 0 + 70 = 70 元
阶段二(Cancel):如果要回滚(Cancel),则冻结金额扣减 30,可用余额增加 30
需要回滚,那么就要释放冻结金额,恢复可用余额。

TCC 模式在一阶段完成资源预留以后,在二阶段,不管是 Confirm 还是 Cancel,都是操作的自己预留的这部分资源。而正是因为这种特性,导致了 TCC 与 AT 模式有很大的区别。我们来对比一下两者的隔离性和一致性。从一致性上来看,在第一阶段,两种模式都是分支事务各自提交各自的事务,在事务提交完成后,就会释放掉数据库锁,性能上都非常好。但是也正是由于这个原因,一阶段也有可能会执行失败,这时就会出现状态不一致的情况,只有在二阶段完成了回滚或者提交后,才能保证数据的最终一致性。从隔离性上来看,AT 模式需要获取全局锁来实现隔离,保证一二阶段在执行过程中其他事务无法操作。但是在 TCC 模式下,是不需要隔离的,因为在一阶段执行时冻结了一部分余额,假设同时有另一个事务要冻结一部分余额,而该事务要冻结的部分与之前已经冻结的部分是不相关的。而在二阶段中,如果要恢复数据,只需要恢复各自冻结的部分即可。事务之间没有影响,因此也就不需要加锁。TCC 通过资源预留的方式,在二阶段,每个事务各自操作各自预留的资源,互不影响,不需要加锁,自然也就实现了隔离效果。所以,TCC 的性能要优于 AT模式。
Seata 中的 TCC 模型依然延续之前的事务架构,如图:

(1)TM 发起并注册全局事务到 TC
(2)TM 调用分支事务
(3)RM 拦截分支事务,并将分支事务注册到 TC
(4)注册成功后,RM 开始执行分支事务,进行资源预留(Try),资源预留会直接提交事务。
(5)事务提交完成后,RM 会将事务的状态报告给 TC(一阶段结束)
(6)TM 通知 TC 事务执行结束,TC 会进行事务状态的判断,检查各分支事务资源是否足够
(7)如果足够,则执行 Confirm,进行提交;如果不够,则执行 Cancel,进行回滚。
TCC 模式的每个阶段是做什么的?
TCC 的优点是什么?
TCC 的缺点是什么?
案例:改造 account-service 服务,利用 TCC 实现分布式事务。
需求如下:
幂等性就是无论接口调用多少次,最终达成的效果应该是一样的,不会因为重复调用而出现问题。
当某分支事务的 try 阶段阻塞时,可能导致全局事务超时而触发二阶段的 Cancel 操作。在未执行 try 操作时限执行了 Cancel 操作,这是 Cancel 不能做回滚,就是空回滚。由于在一阶段并没有做资源预留,所以没办法进行回滚,同时,也不能报错,如果报错,seata 会认为 Cancel 出现问题,会进行重试。

执行cancel操作时,应当判断try是否已经执行,如果尚未执行,则应该空回滚。
对于已经空回滚的业务,之前被阻塞的 try 操作恢复,继续执行 try,就永远不可能 Confirm 或 Cancel,事务一直处于中间状态,这就是业务悬挂。
执行 try 操作时,应当判断 Cancel 是否已经执行过了,如果已经执行,应当阻止空回滚后的 try 操作,避免悬挂。
为了实现空回滚、防止业务悬挂,以及幂等性要求。我们必须在数据库记录冻结金额的同时,记录当前事务 id 和执行状态,为此我们设计了一张表:
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) NOT NULL,
`user_id` varchar(255) DEFAULT NULL COMMENT '用户id',
`freeze_money` int(11) unsigned DEFAULT '0' COMMENT '冻结金额',
`state` int(1) DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
其中:
那此时,我们的业务该怎么做呢?
- 记录冻结金额和事务状态 account_freeze 表
- 扣减 account 表可用金额
- 根据 xid 删除 account_freeze 表的冻结记录
- 修改 account_freeze 表,冻结金额置为 0,state 置为 2
- 修改 account 表,恢复可用金额
- Cancel 业务中,根据 xid 查询 account_freeze,如果为 null 则说明 try 还没做,需要空回滚
- try 业务中,根据 xid 查询 account_freeze,如果已经存在则证明 Cancel 已经执行,拒绝执行 try 业务。
接下来,我们改造 account-service,利用 TCC 实现余额扣减功能。
1)准备工作
创建 account_freeze_tbl 表,用于记录用户的冻结金额。
DROP TABLE IF EXISTS `account_freeze_tbl`;
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`freeze_money` int(11) UNSIGNED NULL DEFAULT 0,
`state` int(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
SET FOREIGN_KEY_CHECKS = 1;
2)声明TCC接口
TCC 的 Try、Confirm、Cancel 方法都需要在接口中基于注解来声明,语法如下:

我们在account-service项目中的cn.itcast.account.service包中新建一个接口,声明TCC三个接口:
@LocalTCC
public interface AccountTCCService {
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
boolean confirm(BusinessActionContext context);
boolean cancel(BusinessActionContext context);
}
3)编写实现类
@Service
public class AccountTccServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
// 获取全局事务 id
String xid = RootContext.getXID();
// 业务悬挂处理:判断 freeze 表中是否冻结记录,如果有,一定是 Cancel 执行过,拒绝执行try业务
AccountFreeze oldFreeze = accountFreezeMapper.selectById(xid);
if(oldFreeze != null){
return;
}
// 扣减可用金额
accountMapper.deduct(userId, money);
// 记录冻结金额
AccountFreeze accountFreeze = new AccountFreeze();
accountFreeze.setUserId(userId);
accountFreeze.setFreezeMoney(money);
accountFreeze.setState(AccountFreeze.State.TRY);
accountFreeze.setXid(xid);
accountFreezeMapper.insert(accountFreeze);
}
@Override
public boolean confirm(BusinessActionContext context) {
// 获取事务id
String xid = context.getXid();
// 根据id删除冻结记录
int i = accountFreezeMapper.deleteById(xid);
return i == 1;
}
@Override
public boolean cancel(BusinessActionContext context) {
// 查询冻结记录
String xid = context.getXid();
String userId = context.getActionContext("userId").toString();
AccountFreeze accountFreeze = accountFreezeMapper.selectById(xid);
// 空回滚判断,判断 accountFreeze 是否为null,为 null 证明 try 没执行,需要做空回滚
if(accountFreeze == null){
accountFreeze = new AccountFreeze();
accountFreeze.setUserId(userId);
accountFreeze.setFreezeMoney(0);
accountFreeze.setState(AccountFreeze.State.CANCEL);
accountFreeze.setXid(xid);
accountFreezeMapper.insert(accountFreeze);
return true;
}
// 幂等判断
if(accountFreeze.getState() == AccountFreeze.State.CANCEL){
// 如果相等,说明已经处理过一次 CANCEL 了,无需重复处理
return true;
}
// 恢复可用余额
accountMapper.refund(accountFreeze.getUserId(), accountFreeze.getFreezeMoney());
// 将冻结金额清空,状态改为 CANCEL
accountFreeze.setFreezeMoney(0);
accountFreeze.setState(AccountFreeze.State.CANCEL);
int i = accountFreezeMapper.updateById(accountFreeze);
return i == 1;
}
}
4)测试
测试订单数为 20

控制台信息:

account_freeze_tbl 表

其理论基础是Hector & Kenneth 在1987年发表的论文Sagas。
Seata官网对于Saga的指南:https://seata.io/zh-cn/docs/user/saga.html
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,一次执行各参与者的正向操作,如果所有整箱操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,是分布式事务回到初始状态。

Saga 模式是 Seata 提供的长事务解决方案,也分为两个阶段:
Saga 模式与 TCC 模式的区别:
TCC 模式一阶段不是真正的在处理事务,而是对资源的预留,而 Saga 模式则是直接提交事务,不会对资源进行预留。以 4.3.4 下单业务逻辑为例,TCC 模式在一阶段,除了扣减可用余额外,还会向冻结表中插入冻结的金额,而 Saga 模式则是直接扣减可用余额。
TCC 二阶段成功,则需要完成业务执行和事务提交,如 4.3.4 中的需要删除冻结表中的冻结金额,而 Saga 则什么都不做。在回滚时,TCC 模式需要完成预留资源的释放,而Saga则是通过编写补偿业务来回滚。
优点:
缺点:
我们从以下几个方面来对比四种实现:
| XA | AT | TCC | SAGA | |
|---|---|---|---|---|
| 一致性 | 强一致 | 弱一致 | 弱一致 | 最终一致 |
| 隔离性 | 完全隔离 | 基于全局锁隔离 | 基于资源预留隔离 | 无隔离 |
| 代码侵入 | 无 | 无 | 有,要编写三个接口 | 有,要编写状态机和补偿业务 |
| 性能 | 差 | 好 | 非常好 | 非常好 |
| 场景 | 对一致性、隔离性有高要求的业务 | 基于关系型数据库的大多数分布式事务场景都可以 | 对性能要求较高的事务。有非关系型数据局要参与的事务 | 业务流程长、业务流程多。参与者包含其他公司或遗留系统服务,无法提供 TCC 模式要求的三个接口 |