分布式事务 Seata

Seata 学习笔记

分布式事务

单体应用被拆分成微服务应用,原来的多个模块被分成多个独立的应用,分别使用多个独立的数据源,业务操作需要调用多个服务来完成。此时每个服务内部的数据一致性由本地事务来保证,但是全局的数据一致性问题没有办法保证。

一句话总结:一次业务操作需要跨多个数据源或需要跨多个服务进行远程调用,就会产生分布式事务问题

本地事务

本地事务,也就是传统的单机事务。在传统数据库事务中,必须满足四个原则:

  • 原子性
  • 一致性
  • 隔离性
  • 持久性

分布式事务

分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务,例如:

  • 跨数据源的分布式事务
  • 跨服务的分布式事务
  • 综合情况

在数据库水平拆分、服务垂直拆分后,一个业务操作通常要跨多个数据库、服务才能完成。例如电商行业中比较常见的下单付款案例,包括下面几个行为:

  • 创建新订单
  • 扣减商品库存
  • 从用户账户余额扣除金额

完成上面的操作需要访问三个不同的微服务和三个不同的数据库。

订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。

但是当我们把三件事情看做一个”业务”,要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务了。

此时ACID难以满足,这是分布式事务要解决的问题

理论基础

解决分布式事务问题,需要一些分布式的基础知识作为理论指导

CAP 定理

分布式系统有三个指标:

  • Consistency(一致性)
  • Avaliability(可用性)
  • Partition tolerance(分区容错性)
CAP 定理由 `Eric Brewer` 提出,他还说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。

一致性

Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。

比如:

当修改其中一个节点数据,两者数据产生差异:

要想保证一致性,就必须实现 node1 到 node2 节点的数据同步:

可用性

Availability(可用性):用户访问集群的任意健康节点,必须能得到响应,而不是超时或拒绝。

比如,有三个节点的集群,访问任何一个都可以得到响应:

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

分区容错

Partition(分区):因为网络故障或其他原因导致分布式系统中的部分节点与其他节点失去连接,形成独立分区。

Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务。

矛盾

在分布式系统中,系统间的网络不能保证百分百健康,一定会有故障的时候,而服务又必须对外保证服务。因此 Partition Tolerance 不可避免。

当节点接受到新的数据变更时,就会出现问题了:

如果此时要保证一致性,就必须等待网络恢复,完成数据同步后,整个集群才对外提供服务,服务处于阻塞状态,不可用。

如果此时要保证可用性,就不能等待网络恢复,那 node1、node2和 node3 之间就会出现数据不一致。

也就是说,在 P 一定会出现的情况下,A 和 C 之间只能选一个。

BASE 理论

BASE 理论是对 CAP 的一种解决思路,包含三个思想:

  • Basically Available(基本可用):分布式系统再出现故障时,允许损失部分可用性,即保证核心可用。
  • Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
  • Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

解决分布式事务问题的思路

分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴 CAP 定理和 BASE 理论,有两种解决思路:

  • AP 模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
  • CP 模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于若可用状态。

但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个**事务协调者(TC)**:

这里的子系统事务,成为分支事务;有关联的各个分支事务在一起成为全局事务

初始 Seata

简介

Seata是一款开源的分布式事务解决方案,旨在解决分布式事务场景下的数据一致性问题。Seata 提供了一系列技术手段。如 TC(事务协调者)、TM(事务管理器)、RM(资源管理器)等,来确保分布式事务的正确性和一致性。

Seata 的工作原理是通过 TC 来协调各个 RM,实现全局事务的管理。当一个分布式事务开始时,TM 会向 TC 注册一个全局事务,然后 TC 会向各个 RM 注册分支事务。在分支事务执行完成后,RM 会将事务执行结果通知 TC,TC 会根据所有分支事务的执行结果来决定是否提交或回滚全局事务。

Seata 支持多种分布式事务模式,如 AT 模式、TCC 模式、Saga 模式等。同时 Seata还提供了一些高级功能,如分布式锁、分布式 ID、分布式事务日志等,以满足不同场景下的需要:

相关术语

一个典型的分布式事务过程,可以用分布式处理过程的一 ID+三组件来描述。

一 ID(全局唯一的事务 ID):Transaction ID XID,在这个事务ID下的所有事物会被统一控制。

三组件:

  • TC(Transaction Coordinator,事务协调者):维护全局事务的运行状态,负责协调全局事务的提交或回滚;(Server 端,为单独服务器部署)
  • TM(Transaction Manager,事务管理器):定义全局事务的范围、开始全局事务、提交或回滚全局事务。
  • RM (Resource Manager,资源管理器):管理分支事务处理的资源,与 TC 交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

架构图:

Seata 基于上述架构提供了四种不同分布式事务解决方案:

  • XA 模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入。
  • TCC 模式:最终一致的分阶段事务模式,有业务侵入。
  • AT 模式:最终一致的分阶段事务模式,无业务侵入,也是 Seata 的默认模式。
  • SAGA 模式:长事务模式,有业务侵入。

业务侵入:我粗浅的理解为是否需要编写额外的代码。

微服务集成 Seata

引入依赖:

<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!--版本较低,1.3.0,因此排除-->
<exclusion>
<artifactId>seata-spring-boot-starter</artifactId>
<groupId>io.seata</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<!--seata starter 采用1.4.2版本-->
<version>${seata.version}</version>
</dependency>

配置TC地址

在对应服务的application.yml中,配置 TC 服务信息,通过注册中心 Nacos,结合服务名称获取 TC 地址:

seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 127.0.0.1:8848 # nacos地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-tc-server # seata服务名称
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组名称
service:
vgroup-mapping: # 事务组与cluster的映射关系
seata-demo: SH

微服务如何根据这些配置寻找 TC 的地址呢?我们知道注册到 Nacos 中的微服务,确定一个具体实例需要四个信息:

  • namespace:命名空间
  • group:分组
  • application:服务名
  • cluster:集群名

namespace 如果为空,则是默认的 public

结合起来,TC 服务的信息就是:public@DEFAULT_GROUP@seata-server@SH,这样就能确定 TC 服务集群了。然后就可以去 Nacos 拉去对应的实例信息了。

四种事务模式

XA 模式

两阶段提交

XA 是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。

正常情况:

异常情况:

一阶段:

  • 事务协调者通知每个事务参与者执行本地事务
  • 本地事务执行完成后报告十五执行状态给事务协调者,此时事务不提交,继续持有数据库锁

二阶段:

  • 事务协调者基于一阶段的报告来判断下一步操作
    • 如果一阶段都成功,则通知所有事务参与者,提交事务
    • 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务

Seata 的 XA 模型

RM一阶段的工作:

​ ① 注册分支事务到TC

​ ② 执行分支业务sql但不提交

​ ③ 报告执行状态到TC

TC二阶段的工作:

  • TC检测各分支事务执行状态

    a.如果都成功,通知所有RM提交事务

    b.如果有失败,通知所有RM回滚事务

RM二阶段的工作:

  • 接收TC指令,提交或回滚事务

优缺点

XA 模式优点:

  • 事务的强一致性,满足 ACID 原则。
  • 常用数据库都支持,实现简单,并且没有代码侵入。

XA 模式缺点:

  • 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差。
  • 以来关系型数据库实现事务

实现 XA 模式

1)修改 application.yml ,开启 XA 模式:

seata:
data-source-proxy-mode: XA

2)给发起全局事务的入口方法添加@GlobalTransactional 注解:

如 创建订单:

AT 模式

AT 模式同样是分阶段提交的事务模型,不过弥补了 XA 模型中资源锁定时间过长的缺陷。

Seata 的 AT 模型

阶段一RM 的工作:

  • 注册分支事务
  • 记录 undo-log(数据快照)
  • 执行业务 sql 并提交
  • 报告事务状态

阶段二提交时 RM 的工作:

  • 删除 undo-log 即可

阶段二回滚时 RM 的工作:

  • 根据 undo-log 恢复数据到更新前

流程图:

AT 与 XA 的区别

  • XA 模式一阶段不提交事务,锁定资源;AT 模式一阶段直接提交,不锁定资源。
  • XA 模式依赖数据库机制实现回滚;AT 模式利用数据快照实现数据回滚。
  • XA 模式强一致;AT 模式最终一致。

脏写问题

在多线程并发访问

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

注意:每个事务都有自己的undo log,最终恢复的依据是当前事务的undo log

解决思路就是引入了全局锁的概念。在释放DB锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据。

AT 模式优缺点

AT 模式优点:

  • 一阶段完成直接提交,释放数据库资源,性能较好
  • 利用全局锁实现读写隔离
  • 没有代码侵入,框架自动完成回滚和提交

AT 模式缺点:

  • 两阶段之间属于软状态,属于最终一致
  • 框架的快照功能会影响性能,但比 XA 模式要好很多

实现 AT 模式

AT 模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。

不过 AT 模式需要一个表来记录全局锁、另一张表来记录数据快照 undo-log。

1)导入数据库,记录全局锁

lock_table 导入到 TC 服务关联的数据库,undo_log 表导入到微服务关联的数据库。

2)修改 application.yml 文件,将事务模式修改为 AT

seata:
data-source-proxy-mode: AT #默认就是 AT

TCC 模式

TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:

  • Try:资源的检测和预留;

  • Confirm:完成资源操作业务;要求 Try 成功 Confirm 一定要能成功。

  • Cancel:预留资源释放,可以理解为try的反向操作。

流程分析

举例,一个扣减用户余额的业务。假设账户A原来余额是100,需要余额扣减30元。

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

初始余额:

余额充足,可以冻结:

此时,总金额 = 冻结金额 + 可用金额,数量依然是100不变。事务直接提交无需等待其它事务。

  • **阶段二(Confirm)**:假如要提交(Confirm),则冻结金额扣减30

确认可以提交,不过之前可用金额已经扣减过了,这里只要清除冻结金额就好了:

此时,总金额 = 冻结金额 + 可用金额 = 0 + 70 = 70元

  • **阶段二(Canncel)**:如果要回滚(Cancel),则冻结金额扣减30,可用余额增加30

需要回滚,那么就要释放冻结金额,恢复可用金额:

Seata 的 TCC 模型

架构图:

TCC 模式优缺点

TCC 的优点:

  • 一阶段完成直接提交事务,释放数据库资源,性能好
  • 相比AT模型,无需生成快照,无需使用全局锁,性能最强
  • 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库

TCC 的缺点:

  • 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
  • 软状态,事务是最终一致
  • 需要考虑Confirm和Cancel的失败情况,做好幂等处理

事务悬挂和空回滚

空回滚

当分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时cancel不能做回滚,就是空回滚

执行cancel操作时,应当判断try是否已经执行,如果尚未执行,则应该空回滚。

业务悬挂

对于已经回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel,事务一直处于中间状态,这就是业务悬挂

执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂。

实现 TCC 模式

解决空回滚和业务悬挂问题;必须要记录当前事务状态,是在 try、还是 cancle?

思路分析

定义一张表:

CREATE TABLE `account_freeze_tbl` (
  `xid` varchar(128NOT NULL,
  `user_id` varchar(255DEFAULT NULL COMMENT '用户id',
  `freeze_money` int(11) unsigned DEFAULT '0' COMMENT '冻结金额',
  `state` int(1DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
  PRIMARY KEY (`xid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
  • xid:是全局事务 id
  • freeze_money:用来记录用户冻结金额
  • state:用来记录事务状态

业务:

  • Try业务:
    • 记录冻结金额和事务状态到 account_freeze表
    • 扣减 account 表可用金额
  • Confirm业务:
    • 根据 xid 删除 account_freeze 表的冻结记录
  • Cancel 业务:
    • 修改 account_freeze 表,冻结金额为 0,state 为 2
    • 修改 account 表,恢复可用金额
  • 如何判断是否空回滚
    • cancel 业务中,根据 xid 查询 account_freeze,如果为 null 则说明 try 还没做,需要空回滚
  • 如何避免业务悬挂
    • try 业务中,根据 xid 查询 account_freeze,如果已存在则证明 Cancel 已经执行,拒绝执行 try 业务

声明TCC接口

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

在accoun-service 服务中新建一个接口,声明 TCC 三个接口:

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;

@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 ctx);

boolean cancel(BusinessActionContext ctx);
}

编写实现类

@Service
@Slf4j
public class AccountTCCServiceImpl implements AccountTCCService {

@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;

@Override
@Transactional
public void deduct(String userId, int money) {
// 0.获取事务id
String xid = RootContext.getXID();
// 1.扣减可用余额
accountMapper.deduct(userId, money);
// 2.记录冻结金额,事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}

@Override
public boolean confirm(BusinessActionContext ctx) {
// 1.获取事务id
String xid = ctx.getXid();
// 2.根据id删除冻结记录
int count = freezeMapper.deleteById(xid);
return count == 1;
}

@Override
public boolean cancel(BusinessActionContext ctx) {
// 0.查询冻结记录
String xid = ctx.getXid();
AccountFreeze freeze = freezeMapper.selectById(xid);

// 1.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
// 2.将冻结金额清零,状态改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}

SAGA 模式

原理

在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。

分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。

Saga也分为两个阶段:

  • 一阶段:直接提交本地事务
  • 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚

4.4.2.优缺点

优点:

  • 事务参与者可以基于事件驱动实现异步调用,吞吐高
  • 一阶段直接提交事务,无锁,性能好
  • 不用编写TCC中的三个阶段,实现简单

缺点:

  • 软状态持续时间不确定,时效性差
  • 没有锁,没有事务隔离,会有脏写

4.5.四种模式对比

我们从以下几个方面来对比四种实现:

  • 一致性:能否保证事务的一致性?强一致还是最终一致?
  • 隔离性:事务之间的隔离性如何?
  • 代码侵入:是否需要对业务代码改造?
  • 性能:有无性能损耗?
  • 场景:常见的业务场景