1、什么是分布式事务
分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器**「分别位于不同的分布式系统的不同节点之上」**。
一个大的操作由N多的小的操作共同完成。而这些小的操作又分布在不同的服务上。针对于这些操作,「要么全部成功执行,要么全部不执行」。
事务必须具有的四个特性分别是:原子性(atomicity)、一致性(consistency)、隔离性(isolation,又称独立性)以及持久性(durability)。这就是事务的ACID原则。

2、为什么会有分布式事务?
举个例子:

转账是最经典的分布式事务场景,假设用户 A 使用银行 app 发起一笔跨行转账给用户 B,银行系统首先扣掉用户 A 的钱,然后增加用户 B 账户中的余额。
如果其中某个步骤失败,此时就有可能会出现 2 种**「异常」**情况:
-
1.用户 A 的账户扣款成功,用户 B 账户余额增加失败
-
2.用户 A 账户扣款失败,用户 B 账户余额增加成功。
对于银行系统来说,以上 2 种情况都是**「不允许发生」**,此时就需要事务来保证转账操作的成功。
在**「单体应用」** 中,我们只需要贴上@Transactional注解就可以开启事务来保证整个操作的**「原子性」**。
但是看似以上简单的操作,在实际的应用架构中,不可能是单体的服务,我们会把这一系列操作交给**「N个服务」** 去完成,也就是拆分成为**「分布式微服务架构」**。

比如下订单服务,扣库存服务等等,必须要**「保证不同服务状态结果的一致性」**,于是就出现了分布式事务。
3、分布式理论
3.1、CAP定理
在一个分布式系统中,以下三点特性无法同时满足,「鱼与熊掌不可兼得」
一致性(C):
在分布式系统中的所有数据备份,「在同一时刻是否拥有同样的值」。(等同于所有节点访问同一份最新的数据副本)
可用性(A):在集群中一部分节点**「故障」** 后,集群整体**「是否还能响应」**客户端的读写请求。(对数据更新具备高可用性)
分区容错性(P):即使出现**「单个组件无法可用,操作依然可以完成」**。
具体地讲在分布式系统中,在任何数据库设计中,一个Web应用**「至多只能同时支持上面的两个属性」**。显然,任何横向扩展策略都要依赖于数据分区。因此,设计人员必须在一致性与可用性之间做出选择。
3.2、BASE理论
在分布式系统中,我们往往追求的是可用性,它的重要程序比一致性要高,那么如何实现高可用性呢?
前人已经给我们提出来了另外一个理论,就是BASE理论,它是用来对CAP定理进行进一步扩充的。BASE理论指的是:
-
「Basically Available(基本可用)」
-
「Soft state(软状态)」
-
「Eventually consistent(最终一致性)」
BASE理论是对CAP中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
3.3、分布式事务问题
项目工程是seata-demo,主要包含以下三个服务,分别是order-service(订单服务)、account-service(账户服务)以及storage-service(库存服务):

项目的初始化数据库脚本如下所示,或者也可以直接下载seata-init.sql文件,内容是一样的。
sql
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- account_tbl表
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, 'user202109132032012', 1000);
-- order_tbl表
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;
-- storage_tbl表
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, '100202109032041', 10);
SET FOREIGN_KEY_CHECKS = 1;
本地使用的数据库地址是jdbc:mysql://localhost:3306/seata_demo。项目的主要业务逻辑是,用户通过订单服务创建订单,然后订单服务会调用账户服务进行用户余额扣减,还会调用库存服务进行商品库存扣减。

执行完初始化数据库脚本后,三个表的数据情况如下所示:

然后依次启动三个微服务项目:

启动成功后,使用工具访问http://localhost:8082/order/create,请求方式为POST,请求体内容如下:
javascript
{"userId":"user202109132032012","commodityCode":"100202109032041","count":2,"money":200}
说明 :
userId是用户id,commodityCode是商品编码,count是商品数量,money是商品金额。
出现如下结果,表示订单创建成功:

成功创建订单后数据库中各个表的数据情况如下:

以上演示的是正常情况。
下面再来演示下异常情况:
在上面提供的初始化数据库脚本中,storage_tbl表的count字段使用了UNSIGNED属性,该属性意思是count字段的值不能为负数,那么我们只要在创建订单时使商品数量大于当前数据库的库存数量即可抛出异常。
再次调用创建订单的http://localhost:8082/order/create接口,将商品数量设置为20,详细请求体如下所示:
javascript
{"userId":"user202109132032012","commodityCode":"100202109032041","count":20,"money":200}
返回的应答如下所示:

由返回的应答可知,创建订单接口显然已经调用异常了,这时候我们再看看数据库中各个表的数据情况:

由数据库中各个表的数据可知,在微服务项目中,其中一个服务出现了问题,并不会让所以服务都进行回滚,所以就出现了订单创建失败,但是用户余额依然进行扣减了的情况。而分布式事务就是用来解决以上问题的。
在分布式系统下,一个业务跨越多个服务或数据源,每个服务都是一个分支事务,要保证所有分支事务最终状态一致,这样的事务就是分布式事务。
4、分布式事务理论基础
4.1、两阶段提交(2PC)
熟悉mysql的同学对两阶段提交应该颇为熟悉,mysql的事务就是通过**「日志系统」**来完成两阶段提交的。
两阶段协议可以用于单机集中式系统,由事务管理器协调多个资源管理器;也可以用于分布式系统,「由一个全局的事务管理器协调各个子系统的局部事务管理器完成两阶段提交」。

这个协议有**「两个角色」**,
A节点是事务的协调者,B和C是事务的参与者。
事务的提交分成两个阶段
第一个阶段是**「投票阶段」**
-
1.协调者首先将命令**「写入日志」**
-
- **「发一个prepare命令」**给B和C节点这两个参与者
-
3.B和C收到消息后,根据自己的实际情况,「判断自己的实际情况是否可以提交」
-
4.将处理结果**「记录到日志」**系统
-
5.将结果**「返回」**给协调者

第二个阶段是**「决定阶段」**
当A节点收到B和C参与者所有的确认消息后
-
「判断」 所有协调者**「是否都可以提交」**
-
如果可以则**「写入日志」**并且发起commit命令
-
有一个不可以则**「写入日志」**并且发起abort命令
-
-
参与者收到协调者发起的命令,「执行命令」
-
将执行命令及结果**「写入日志」**
-
**「返回结果」**给协调者
可能会存在哪些问题?
- 同步阻塞:参与者在等待协调者的指令时,其实是在等待其他参与者的响应,在此过程中,参与者是无法进行其他操作的,也就是阻塞了其运行。 倘若参与者与协调者之间网络异常导致参与者一直收不到协调者信息,那么会导致参与者一直阻塞下去。
- 单点障碍:在 2PC 中,一切请求都来自协调者,所以协调者的地位是至关重要的,如果协调者宕机,那么就会使参与者一直阻塞并一直占用事务资源。
- 数据不一致:Commit 事务过程中,Commit 请求 或 Rollback 请求可能会因为协调者宕机或协调者与参与者网络问题丢失,那么就导致了部分参与者没有收到 Commit/Rollback 请求,而其他参与者则正常收到执行了 Commit/Rollback 操作,没有收到请求的参与者则继续阻塞。这时,参与者之间的数据就不再一致了。
- 环境可靠性依赖:协调者 Prepare 请求发出后,等待响应,然而如果有参与者宕机或与协调者之间的网络中断,都会导致协调者无法收到所有参与者的响应,那么在 2PC 中,协调者会等待一定时间,然后超时后,会触发事务中断,在这个过程中,协调者和所有其他参与者都是出于阻塞的。这种机制对网络问题常见的现实环境来说太苛刻了。
4.2、三阶段提交(3PC)
三阶段提交又称3PC,相对于2PC来说增加了CanCommit阶段和超时机制。如果段时间内没有收到协调者的commit请求,那么就会自动进行commit,解决了2PC单点故障的问题。
但是性能问题和不一致问题仍然没有根本解决。下面我们还是一起看下三阶段流程的是什么样的?
-
第一阶段:**「CanCommit阶段」**这个阶段所做的事很简单,就是协调者询问事务参与者,你是否有能力完成此次事务。
-
如果都返回yes,则进入第二阶段
-
有一个返回no或等待响应超时,则中断事务,并向所有参与者发送abort请求
-
-
第二阶段:**「PreCommit阶段」**此时协调者会向所有的参与者发送PreCommit请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈"Ack"表示我已经准备好提交了,并等待协调者的下一步指令。
-
第三阶段:**「DoCommit阶段」**在阶段二中如果所有的参与者节点都可以进行PreCommit提交,那么协调者就会从"预提交状态"转变为"提交状态"。然后向所有的参与者节点发送"doCommit"请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈"Ack"消息,协调者收到所有参与者的Ack消息后完成事务。相反,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。
5、分布式事务解决方案
5.1、补偿事务(TCC)
5.1.1、TCC模式原理
TCC(Try-Confirm-Cancel)模式与AT(Automatic Transaction)模式非常相似,每阶段都是独立事务,它们不同的是TCC通过人工编码来实现数据恢复,不用像AT模式那样先生成快照,然后在提交或回滚的时候再删除快照,减少了性能的损耗。TCC模式使用起来需要实现三个方法:
Try:资源的检测和预留;Confirm:完成资源操作业务,要求Try成功Confirm一定要能成功;Cancel:预留资源释放,可以理解为Try的反向操作。
比如说,有一个扣减用户余额的业务。假设账户A原来余额是100元,需要余额扣减30元。

阶段一(Try):检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30元。

阶段二:假如要提交(Confirm),则扣减掉冻结金额的30元,可用余额变成70元。

阶段二:如果要回滚(Cancel ),则扣减掉冻结金额的30元,增加到可用余额中,可用余额增加30元后再次变回100元。

TCC的工作模型图如下所示:

5.1.2、TCC的优缺点
TCC模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能好;
- 相比AT模式,无需生成快照,无需使用全局锁,性能最强;
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库。
TCC模式的缺点:
- 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦;
- 软状态,事务是最终一致;
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理。
5.1.3、空回滚和业务悬挂
当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作的时候却先执行了cancel操作,这时cancel做回滚,就是空回滚。对于已经空回滚的业务,如果以后继续执行try操作,就永远不可能confirm或cancel,这就是业务悬挂。应当阻止执行空回滚后的try操作,避免悬挂。
5.1.4、实现TCC模式
为了应对空回滚、防止业务悬挂以及实现幂等性要求,我们必须在数据库记录冻结金额的同时,记录当前事务id和执行状态,为此我们设计了一张account_freeze_tbl表,直接在我们的微服务所在的seata_demo库中执行即可:
sql
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;
然后下面我们做一个业务分析,如下所示:

TCC的Try、Confirm、Cancel方法都需要在接口中基于注解来声明,语法如下:

下面编写详细的操作步骤:
TCC模式和AT模式是可以共存的,所以虽然现在本地项目中已经配置并使用了AT模式,但是依然还可以使用TCC模式。首先我们需要创建上面account_freeze_tbl表对应的实体类,在account-service服务中新增相关实体类,如下所示:
java
package com.account.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
@Data
@TableName("account_freeze_tbl")
public class AccountFreeze implements Serializable {
@TableId(type = IdType.INPUT)
private String xid;
private String userId;
private Integer freezeMoney;
private Integer state;
//定义一个内部类,当枚举使用
public static abstract class State {
public final static int TRY = 0;
public final static int CONFIRM = 1;
public final static int CANCEL = 2;
}
}
为了方便操作数据库,还需要定义Mapper,如下所示:
java
package com.account.mapper;
import com.account.entity.AccountFreeze;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* @author gongsl
*/
public interface AccountFreezeMapper extends BaseMapper<AccountFreeze> {
}
然后编写一个Try、Confirm、Cancel相关方法对应的AccountTCCService接口:
java
package com.account.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* @Author: gongsl
* @Date: 2021-11-18 21:40
*/
@LocalTCC
public interface AccountTCCService {
/**
* Try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
*/
@TwoPhaseBusinessAction(name = "prepare",
commitMethod = "confirm",
rollbackMethod = "cancel")
void prepare(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
/**
* 二阶段Confirm确认方法,可以另命名,但要保证和上面commitMethod的值一致
* @param context 上下文,可以传递try方法的参数
* @return boolean 执行是否成功
*/
boolean confirm(BusinessActionContext context);
/**
* 二阶段回滚方法,方法名要和上面rollbackMethod的值一致
*/
boolean cancel(BusinessActionContext context);
}
之后就是编写以上接口对应的AccountTCCServiceImpl实现类:
java
package com.account.service.impl;
import com.account.entity.AccountFreeze;
import com.account.mapper.AccountFreezeMapper;
import com.account.mapper.AccountMapper;
import com.account.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* @Author: gongsl
* @Date: 2021-11-18 21:55
*/
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void prepare(String userId, int money) {
//1.获取事务id
String xid = RootContext.getXID();
//判断表中是否已经有冻结记录,如果有,一定是CANCEL执行过,这时要拒绝业务避免业务悬挂
AccountFreeze freeze = freezeMapper.selectById(xid);
if (freeze != null) {
//CANCEL已经执行过了,所以这里要拒绝业务
return;
}
//2.扣减可用余额
accountMapper.deduct(userId, money);
//3.记录冻结金额、事务状态
AccountFreeze accountFreeze = new AccountFreeze();
accountFreeze.setXid(xid);
accountFreeze.setUserId(userId);
accountFreeze.setFreezeMoney(money);
accountFreeze.setState(AccountFreeze.State.TRY);
freezeMapper.insert(accountFreeze);
}
/**
* 到这里,说明第一阶段已经执行成功,既然已经成功,所以这里只需要删除冻结记录即可
* @param context
* @return
*/
@Override
public boolean confirm(BusinessActionContext context) {
//我们也可以通过上下文对象获取事务id
String xid = context.getXid();
//返回值为1说明删除成功
int count = freezeMapper.deleteById(xid);
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext context) {
String xid = context.getXid();
//查询冻结记录
AccountFreeze freeze = freezeMapper.selectById(xid);
//空回滚判断,如果freeze为null,说明try没执行,需要空回滚
if (freeze == null) {
freeze = new AccountFreeze();
/*
接口中我们已经通过@BusinessActionContextParameter注解将
userId放到上下文对象中了,所以这里直接通过上下文对象获取即可
*/
String userId = context.getActionContext("userId").toString();
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
//幂等判断
if (freeze.getState() == AccountFreeze.State.CANCEL) {
//已经处理过一次CANCEL了,无需重复处理
return true;
}
//恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
//将冻结金额清零,状态改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
最后我们需要修改AccountController类,之前该类扣款的方法是调用的AccountService接口中的扣款方法,现在需要改成调用AccountTCCService接口中的prepare方法,如下所示:
java
package com.account.web;
import com.account.service.AccountService;
import com.account.service.AccountTCCService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* @author gongsl
*/
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
private AccountService accountService;
@Autowired
private AccountTCCService accountTCCService;
/**
* 扣款
* @param userId
* @param money
* @return
*/
@PostMapping("/{userId}/{money}")
public void deduct(@PathVariable("userId") String userId,
@PathVariable("money") Integer money){
// accountService.deduct(userId, money);
//修改成调用下面的方法
accountTCCService.prepare(userId, money);
}
}
修改完成之后,重启服务进行测试即可。我们还是将购买的数量设置的超过库存数量,以便报错。然后我们查看数据库相关数据时会发现,所有服务都进行了回滚,通过控制台打印的日志也可以看出来:

而且account_freeze_tbl表中还会多出下面这样一条数据:

说明 :表里的
state字段的值为2就说明,TCC模式在回滚的时候确实执行了cancel方法。
5.2、XA模式
5.2.1、XA模式入门
XA规范是X/Open组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA规范描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对XA规范提供了支持,它一般包含两个阶段。

第一阶段只是做一个准备就绪的工作,并不会提交事务,第一阶段如果都成功了,才会在第二阶段进行事务的提交。但是如果第一阶段有任何一个服务是失败的,那么在第二阶段,其余服务也会进行回滚。
以上规范只是一个标准,在具体实现的时候,可能会有差别。Seata的XA模式在实现上与上面大体相似,但是也做了一些调整,主要是增加了一个事务管理器(TM):

5.2.2、XA模式的优缺点

5.2.3、实现XA模式
Seata的starter已经完成了XA模式的自动装配,所以实现非常简单,步骤如下:
1、修改所有参与事务的微服务的application.yml文件,开启XA模式:
XML
seata:
data-source-proxy-mode: XA # 开启数据源代理的XA模式
2、给发起全局事务的入口类或方法添加@GlobalTransactional注解,我这边就直接将order-service服务中的全局事务入口类OrderServiceImpl之前的@Transactional注解换成了@GlobalTransactional注解:

3、重启服务并测试:
测试前数据库表数据情况如下所示:

然后以POST方式访问http://localhost:8082/order/create接口,请求体内容如下:
javascript
{"userId":"user202109132032012","commodityCode":"100202109032041","count":20,"money":200}
由于我们将商品数量设置成了20,已经超过了库存数量,所以请求一定会报错,接口调用完成之后我们再次查看数据库中各个表的数据时发现,三个表的数据都没有变化,说明分布式事务已经起作用了,并完成了回滚。我们从控制台中account-service服务打印的日志也可以看到回滚的日志:

说明:至此,Seata的XA模式就演示完成了。XA模式默认是等待和其他微服务一起提交事务,所以比较消耗性能,我们一般不使用这种模式,除非是对强一致性要求比较高的服务。
5.3、AT模式
5.3.1、AT模式原理
AT模式同样是分阶段提交的事务模型,不过却弥补了XA模型中资源锁定周期过长的缺陷。详细步骤如下所示:

阶段一RM的工作:
- 注册分支事务;
- 记录undo-log(数据快照);
- 执行业务sql并提交;
- 报告事务状态。
阶段二提交时RM的工作:
- 删除undo-log。
阶段二回滚时RM的工作:
- 根据undo-log恢复数据到更新前。
举个例子,比如一个分支业务的SQL是这样的:update tb_account set money = money - 10 where id = 1,那么两个阶段的执行逻辑就如下所示:

说明:在AT模式中,记录、删除或者恢复快照等操作,都是由框架自动完成的,是不用我们手动操作的。
AT模式和XA模式的区别:
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
- XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
- XA模式是强一致;AT模式是最终一致。
5.3.2、脏写问题及解决方案
由于AT模式的事务都是各自提交各自的,并不是像XA模式那样等待着一起提交,所以在并发场景下可能会存在一些问题。还是以业务执行update account set money = money - 10 where id = 1这样一条SQL为例:

解释说明:如上图所示,线程一在第一阶段首先会记录快照,即money等于100,执行完业务sql并提交后,money的值就变成了90。提交后就会释放数据库锁,假设此时线程二过来抢到了锁,由于此时money的值是90,所以就将此时的值作为线程二的快照进行了保存,然后就开始执行业务sql并提交,执行完之后money的值就变成了80。线程二提交后会释放数据库锁,假设线程一又抢到了锁,然后就开始进行第二阶段的操作,可是如果第二阶段出错了,就会根据之前记录的快照进行回滚,然后money的值就会变成100了。但是数据库中money的值已经被线程二改过一次了,线程一失败后还是将money的值回滚成100显然是有问题的。
为了解决以上提及的问题,Seata引入了全局锁的概念。持有全局锁的事务会被记录到一张表里,这个表里面会有事务的名称、事务正在操作的表的表名以及该表的主键等信息,具体的操作流程如下所示:

解释说明:同样是对account表的money字段进行操作,线程一在执行完业务sql后会先获取全局锁,然后才会提交事务并释放数据库锁。假设这时线程二获取到了数据库锁,然后也执行了业务sql,执行完后就会去尝试获取全局锁,但是由于此时线程一整个阶段还没执行完,全局锁还没有被释放,所以线程二无法获取到全局锁。这时就会出现线程一等待获取数据库锁,线程二等待获取全局锁的局面。不过最多300毫秒后,线程二还没获取到全局锁的话,就会超时回滚,然后释放数据库锁。这时线程一获取到数据库锁后会继续执行第二阶段的流程,一旦执行失败,就会根据之前的快照进行回滚,然后释放全局锁。由于线程一全程持有全局锁,所以不会出现脏写的情况。
注意:AT模式和XA模式一样,都使用锁来保证数据的一致性,但是AT模式的性能却比XA模式要高,是因为它们的锁是有本质区别的。XA模式使用的是数据库锁,一旦锁定,所有对数据库中相关数据的操作都要等待,所以性能较差。但是AT模式的全局锁不同,它是Seata框架提供的锁,所以不使用该框架对数据库的操作是不受全局锁的影响的,而且如果操作的是同一行数据,只要和获取到全局锁的事务操作的不是同一个字段,也是不受影响的。
正是由于不通过Seata框架直接对数据库的操作不受全局锁影响,所以虽然概率很低,但是也还是可能出现某个事务和使用了Seata框架中的事务操作同一个表的同一行的相同字段的情况。如果真出现了这种情况,Seata框架的解决办法就是触发警告,然后通过人工接入的方式来解决,如下图所示:

解释说明:其实Seata在保存快照的时候,是会保存两份的,即不仅会把更新前的数据保存为快照,还会把更新后的数据保存为快照。所以在事务一获取到全局锁后进行数据库操作时,如果还有一个非Seata管理的事务也对同样的数据进行了操作的话,一旦事务一操作有问题要回滚,Seata就会比对之前执行业务sql后的快照,看看数据库中的数据是不是和快照的一致,一致就回滚,不一致就说明自己在操作期间还有别的事务对数据库中相同的数据进行了操作,那就发出警告通知人工介入来进行解决。
5.3.3、AT模式的优缺点
AT模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能比较好;
- 利用全局锁实现读写隔离;
- 没有代码侵入,框架自动完成回滚和提交。
AT模式的缺点:
- 两阶段之间属于软状态,属于最终一致;
- 框架的快照功能会影响性能,但也比XA模式要好很多。
5.3.4、实现AT模式
AT模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
1、在我们自己微服务关联的数据库(库名为seata_demo)中执行以下sql:创建 UNDO_LOG 表 每一个 微服务数据库都需要创建
java
CREATE TABLE IF NOT EXISTS `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`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;
2、修改所有参与分布式事务的服务的application.yml文件,将事务模式修改为AT模式:
XML
seata:
data-source-proxy-mode: AT # 开启数据源代理的AT模式
说明 :这里不设置
seata.data-source-proxy-mode属性也可以,因为Seata默认使用的就是AT模式。
3、给发起全局事务的入口类或方法添加@GlobalTransactional注解:

4、重启服务进行测试:
可以发现,当我们多个服务中有一个因为报错而回滚时,其他服务也会回滚。从account-service服务的控制台日志也可以看出确实已经进行了回滚操作:

然后undo_log表里面也会增加快照的信息,不过不管事务最终是成功提交还是回滚,该表中的数据都会被立即删除掉,所以我们在表里面最终是看不到内容的。通过某种方式,我在表数据被删除前查询出来了数据,如下所示:
说明 :以上undo_log表中rollback_info字段的内容我给放到rollback_info.json文件中了,该文件中的
beforeImage属性和afterImage属性的内容对应的就是执行业务sql前和执行业务sql后的快照信息。
本地消息表

执行流程:
-
消息生产方,需要额外建一个消息表,并**「记录消息发送状态」**。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。
- 如果消息发送失败,会进行重试发送。
-
消息消费方,需要**「处理」** 这个**「消息」**,并完成自己的业务逻辑。
- 如果是**「业务上面的失败」** ,可以给生产方**「发送一个业务补偿消息」**,通知生产方进行回滚等操作。
-
此时如果本地事务处理成功,表明已经处理成功了
-
如果处理失败,那么就会重试执行。
-
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。
消息事务
消息事务的原理是将两个事务**「通过消息中间件进行异步解耦」**,和上述的本地消息表有点类似,但是是通过消息中间件的机制去做的,其本质就是'将本地消息表封装到了消息中间件中'。
执行流程:
-
发送prepare消息到消息中间件
-
发送成功后,执行本地事务
-
如果事务执行成功,则commit,消息中间件将消息下发至消费端
-
如果事务执行失败,则回滚,消息中间件将这条prepare消息删除
-
-
消费端接收到消息进行消费,如果消费失败,则不断重试
这种方案也是实现了**「最终一致性」** ,对比本地消息表实现方案,不需要再建消息表,「不再依赖本地数据库事务」 了,所以这种方案更适用于高并发的场景。目前市面上实现该方案的**「只有阿里的 RocketMQ」**。
最大努力通知
最大努力通知的方案实现比较简单,适用于一些最终一致性要求较低的业务。
执行流程:
-
系统 A 本地事务执行完之后,发送个消息到 MQ;
-
这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口;
-
要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。
Sagas 事务模型
Saga事务模型又叫做长时间运行的事务
Saga模式是Seata提供的长事务解决方案。也分为两个阶段:
- 一阶段:直接提交本地事务;
- 二阶段:成功则什么都不做,失败则通过编写补偿业务来回滚。
Saga的优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高;
- 一阶段直接提交事务,无锁,性能好;
- 不用编写TCC中的三个阶段,实现简单。
Saga的缺点:
- 软状态持续时间不确定,时效性差;
- 没有锁,没有事务隔离,会有脏写

Seata框架中一个分布式事务包含3种角色:
「Transaction Coordinator (TC)」 :事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。「Transaction Manager (TM)」 :控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。「Resource Manager (RM)」:控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
seata框架**「为每一个RM维护了一张UNDO_LOG表」**,其中保存了每一次本地事务的回滚数据。
具体流程:
1.首先TM 向 TC 申请**「开启一个全局事务」** ,全局事务**「创建」** 成功并生成一个**「全局唯一的 XID」**。
2.XID 在微服务调用链路的上下文中传播。
3.RM 开始执行这个分支事务,RM首先解析这条SQL语句,「生成对应的UNDO_LOG记录」。下面是一条UNDO_LOG中的记录,UNDO_LOG表中记录了分支ID,全局事务ID,以及事务执行的redo和undo数据以供二阶段恢复。
4.RM在同一个本地事务中**「执行业务SQL和UNDO_LOG数据的插入」** 。在提交这个本地事务前,RM会向TC**「申请关于这条记录的全局锁」**。如果申请不到,则说明有其他事务也在对这条记录进行操作,因此它会在一段时间内重试,重试失败则回滚本地事务,并向TC汇报本地事务执行失败。
6.RM在事务提交前,「申请到了相关记录的全局锁」 ,然后直接提交本地事务,并向TC**「汇报本地事务执行成功」**。此时全局锁并没有释放,全局锁的释放取决于二阶段是提交命令还是回滚命令。
7.TC根据所有的分支事务执行结果,向RM**「下发提交或回滚」**命令。
-
RM如果**「收到TC的提交命令」** ,首先**「立即释放」** 相关记录的全局**「锁」**,然后把提交请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。异步队列中的提交请求真正执行时,只是删除相应 UNDO LOG 记录而已。
-
RM如果**「收到TC的回滚命令」**,则会开启一个本地事务,通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。将 UNDO LOG 中的后镜与当前数据进行比较,
-
如果不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理。
-
如果相同,根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句并执行,然后提交本地事务达到回滚的目的,最后释放相关记录的全局锁。
-

总结
本文介绍了分布式事务的一些基础理论,并对常用的分布式事务方案进行了讲解。
分布式事务本身就是一个技术难题,业务中具体使用哪种方案还是需要不同的业务特点自行选择,但是我们也会发现,分布式事务会大大的提高流程的复杂度,会带来很多额外的开销工作,「代码量上去了,业务复杂了,性能下跌了」。
所以,当我们真实开发的过程中,能不使用分布式事务就不使用。