Seata分布式事务
支撑分布式事务的两个理论:CAP和BASE理论。
其精髓就是在一致性可用性和分区容错性中,在必须保证分区容错性的前提下,不可能既保证一致性又保证可用性,需在二者之间进行调和,这在以往的文章中有过介绍。
分布式事务中的两种事务:
- 全局事务:整个分布式事务
- 分支事务:分布式事务中的独立子事务
根据CAP和BASE理论产生的两种分布式事务处理思想:
- 最终一致思想(AP):分支事务分别进行提交,如有不一致再想办法恢复数据
- 强一致思想(CP):分支事务执行后不要提交,等待彼此结束,然后统一提交或回滚
事务协调者是用来协调所有分支事务,从而使全局事务满足CP或AP的组件
Seata
Seata是阿里巴巴开发的一款分布式事务管理框架,其中有三种角色
- 事务协调者(TC):协调全局事务和分支事务
- 事务管理器(TM):管理全局事务的边界,开启/提交/回滚全局事务
- 资源管理器(RM):注册和报告分支事务以及执行状态
整个的流程就是:
- TM代理你的全局事务,并在开始执行前向TC注册
- TM开始执行全局事务中的每个分支事务,RM向TC注册报告分支事务以及执行状态
- 分支事务执行完成,TM向TC发起提交或回滚全局事务的请求
安装以及整合Seata到Spring Cloud
Seata的部署稍微有点复杂,不过在官方文档中都有
- 从这里下载Seata的TC Server
- 解压后到
conf/registry.conf
里编写配置,目的是将Seata的TC Server添加到服务注册中心中,并且将配置文件也托管到其中以方便集群配置 - 在服务配置中心中编写Seata Server的配置,主要是配置它运行所需要的数据库
- 建立Seata所需要的数据库,对应的SQL可以在这里找到
- 通过
seata-server.bat
运行Seata Server - 在需要分布式事务管理的微服务中添加Seata相关依赖,并进行相关配置
conf/registry.conf
使用nacos作为服务注册中心和配置中心
registry {
type = "nacos"
nacos {
application = "seata-tc-server"
serverAddr = "localhost:8848"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "SH"
username = "nacos"
password = "nacos"
}
}
config {
type = "nacos"
nacos {
serverAddr = "localhost:8848"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
Seata TC Server配置
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://localhost:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=root
store.db.password=root
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
微服务相关配置
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>${seata.version}</version>
</dependency>
要注意,namespace, group, application, cluster
要与SeataServer所在的一致,而且配置cluster时是通过tx-service-group
间接进行配置
seata:
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: ""
group: DEFAULT_GROUP
application: seata-tc-server
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组名称
service:
vgroupMapping: # 事务组与cluster的映射关系
seata-demo: SH
分布式事务模式
XA模式(强一致)
一种X/Open
组织定义的分布式事务处理模式
XA模式中的事务提交分为两阶段
- 准备阶段:准备阶段TC通知各个RM可以开始执行了,但执行完不要提交,将执行结果返回给TC
- 提交或回滚阶段:如果RM的执行结果中有一个失败,第二阶段TC就通知所有RM回滚,否则就提交
XA模式的优点:
- 容易实现,因为大部分数据库都已经支持了XA事务,Seata只需要做简单的包装即可
- 强一致性
XA模式的缺点:
- 每个事务都需要等待所有事务处理完成,占用数据库锁,性能较差,可用性较低
- 如果数据库不支持XA事务就无法使用
Seata中的XA模式
Seata中多了一个TM角色,所以看起来稍显复杂
- TM告知TC开启全局事务
- TM调用RM执行分支事务
- RM向TC注册分支事务,开始执行
- RM执行结束,向TC报告事务状态
- TM感知到全局事务结束,通知TC,提交或回滚全局事务
- TC检查分支事务的状态,通知RM应该进行提交还是回滚
使用XA模式
在所有参与到全局事务中的微服务配置文件里配置使用XA模式:
seata:
data-source-proxy-mode: XA
在全局事务的入口处使用@GlobalTransactional
注解:
@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();
}
分支事务中由于其它事务执行失败而产生的提交和回滚:
AT模式(弱一致)
AT模式解决了XA模式的数据库锁占用问题
- TM通知TC开启全局事务
- TM调用RM分支执行事务
- RM向TC注册分支事务,记录执行前的快照,执行SQL并直接提交
- RM向TC报告自己的事务状态
- TM发现所有RM执行完成,报告TC,检查所有分支事务的状态
- 若有RM失败,TC进行回滚,从快照中恢复数据并删除快照
- 若无RM失败,TC进行提交(实际上就是删除快照数据)
快照的删除在提交和回滚中都存在,由于进行该操作时事务已经被提交或回滚了,所以这个操作可以异步进行。
注意,图中的
undo_log
并不是MySQL中MVCC机制的undo_log
,而是一张为支持AT模式所建立的普通的数据库表
AT模式优点:
- 无需等待所有事务执行完成才提交事务,不会过分占用数据库锁
- 偏向可用性
AT模式缺点:
- 实现较为复杂(但Seata已经帮我们实现好了)
- 无法保证强一致性
- AT模式中因为提前提交事务,所以全局事务已经没有隔离性可言了,所以会出现脏写,丢失更新等问题
AT模式用于解决隔离性问题的全局锁
AT模式中的全局事务隔离性丢失问题来自于在一个事务A在第一阶段提交事务后,其它事务能够对它所提交的数据进行读写,而对于A所在的全局事务来说当前并未处于提交状态,该数据有可能在稍后被回滚,也就是其它事务会读到脏数据
AT模式通过使用全局锁的概念来解决这个问题,即提供一个新的表,该表中的每一条记录代表一个事务对一个表中的一个行进行锁定。
首先明确两个概念:
- DB锁:数据库本身的锁,每个事务拥有一个(虽然在实践中可能被MVCC取代)
- 全局锁:即AT模式自己通过维护锁的数据库表来实现的锁
这样,一个事务在第一阶段中会经历:
- 获取DB锁,保存快照
- 执行SQL
- 获取全局锁
- 若成功,提交事务,释放DB锁
- 若失败,尝试几次后回滚该事务
第一阶段若成功获取全局锁,那么从那一刻开始,其它事务就都无法操作它所操作的那行数据了,所以,它可以放心大胆的提前提交而不用担心其它事务的脏读。
一个事务在第二阶段会经历:
- 获取DB锁
- 根据全局事务的成功失败状态,执行全局事务的提交/回滚操作
- 释放DB锁
加了全局锁的AT模式的锁粒度仍然比XA要小,因为它加的锁只在Seata内可见,而非数据库锁
Seata AT模式中的CAS
如果Seata的AT模式管理的事务碰上并非由Seata的AT模式管理的事务,并且它们操作同一条记录,那全局锁对于非AT模式不起作用,它根本就不会看那个锁表。
Seata的AT模式实际上是有一个类似CAS操作的,它在一阶段不仅记录更新前的快照,还记录更新后的快照,然后在二阶段,如果数据库中的数据和更新后的快照不匹配,代表中间有其它事务操作了这行数据,此时就发送警告,需要人工干预。
Seata AT模式测试
AT模式需要建立两张额外的表,快照表undo_log
和全局锁表lock_table
lock_table
放到TC Server的数据库中,undo_log
放到微服务的数据库中
开启AT模式:
seata:
data-source-proxy-mode: AT
重启微服务并测试:
可以看到在一个失败的全局事务里,分支事务通过undo_log
进行了回滚,并且在回滚后该undo_log
被删除。
我们在一个分支事务结束前打上一个断点,这样我们就能查看undo_log
和lock_table
表中的内容。
TCC模式(弱一致)
TCC模式和AT模式的思想几乎一样,它也在第一阶段直接提交,但它并不是记录快照,而是通过手动编码为每个事务来完成资源的预留功能,这可以让TCC模式在拥有AT模式的高性能的同时并不用维护一个全局锁,并发性更好。
TCC模式的名字来源于其中的三个操作:Try
、Confirm
、Cancel
TCC模式资源预留思想
所谓资源预留就是事务在一阶段提交时将它所操作的资源预留起来,比如事务要扣款30,这时的预留操作可以是在提交的同时保留30的冻结金额。
在预留操作中,你必须在某些地方(比如数据库表)记录(事务id, 预留行id, 预留值)
的元组,以便能够在该事务的二阶段找到它所预留的值。
在二阶段中,如果要提交,就把预留资源直接释放,如果要回滚,就使用预留资源来恢复原始表,来保证一致性。
看起来TCC资源预留和AT模式的快照没什么不一样的,但实际上TCC中每个事务保留了它一阶段操作的反向操作,在恢复时执行自己的反向操作(比如-30的反向操作是+30)。这和快照有本质区别,考虑在一个原本是100元的账户中扣款30的事务A,它的一阶段和二阶段之间事务B操作该账户的余额,资源预留所做的恢复操作是+30,而快照所做的恢复是重新设置成100元,资源预留并没有让事务B的更新丢失,而快照却让它丢失了。这也是资源预留并不用维护全局锁的原因。
Seata中的TCC模式
- TM通知TC开启全局事务
- TM调用每个RM分支事务开始执行
- RM向TC注册分支事务,预留资源(Try),执行SQL并直接提交
- RM将事务状态报告给TC
- TM发现所有分支事务执行完毕,通知TC提交或回滚全局事务
- TC判断所有RM的状态,若全部成功,则执行Confirm操作,释放预留资源,全局事务提交
- 若有一个失败,则全局事务回滚,执行Cancel操作,从预留资源恢复
TCC模式优点:
- 一阶段直接提交,无DB锁,无其它锁,性能好
- 预留和恢复逻辑由自己编写,不依赖数据库,可以用在非事务型数据库
TCC模式缺点:
- 编码复杂
- 弱一致
- 因为
Confirm
和Cancel
也可能失败,需要处理这个过程 - 有些业务并不适合TCC模式,比如下单操作是一个新增行的过程,没法也没必要使用TCC
空回滚和业务悬挂
在开始实现TCC模式之前要了解两个在Seata中可能发生的问题
空回滚:
假设某个事务阻塞了太久还没执行try
阶段,TM超时了,通知TC。此时TC会调用所有RM的cancel
阶段而不管它们是否try
了。
也就是说你的业务可能还没try
就被cancel
了,此时你需要检测到这种状况并直接返回,否则如果因为你在根本没有待恢复的值的情况下误操作数据库而导致抛出了异常,TC会再次调用你的cancel
。
业务悬挂:
假设在一个事务执行了空回滚后,它突然不阻塞了,又去执行try
,此时TC认为全局事务已经结束了,这个try
操作要执行的SQL已经脱离全局事务了,你需要检测到这种情况并不让这个try
操作发生。
实现TCC模式
创建冻结表
CREATE TABLE `account_freeze_tbl`(
`xid` VARCHAR(128) NOT NULL,
`user_id` VARCHAR(255) DEFAULT NULL,
`freeze_money` INT(11) UNSIGNED DEFAULT 0,
`state` INT(1) DEFAULT NULL,
PRIMARY KEY (`xid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
在冻结表中提供了四个字段:
xid
:发起冻结的事务iduser_id
:用于定位account表中的行,是account表的主键freeze_money
:冻结金额state
:事务的状态,用于实现confirm、cancel的幂等性,避免空回滚和业务悬挂(0: try, 1: confirm, 2: cancel)。
实体类:
@Data
@TableName("account_freeze_tbl")
public class AccountFreeze {
@TableId(type = IdType.INPUT)
private String xid;
private String userId;
private Integer freezeMoney;
private Integer state;
public static class State {
public final static int TRY = 0;
public final static int CONFIRM = 1;
public final static int CANCEL = 2;
}
/**
* 创建一个AccountFreeze,并自动填充xid
* xid使用Seata提供的RootContext.getXID获取
*/
public static AccountFreeze newAccountFreeze(String userId, Integer freezeMoney, Integer state) {
AccountFreeze freeze = new AccountFreeze();
freeze.setXid(RootContext.getXID());
freeze.setUserId(userId);
freeze.setFreezeMoney(freezeMoney);
freeze.setState(state);
return freeze;
}
}
创建用于冻结操作的Service
@LocalTCC
public interface AccountTCCService {
@TwoPhaseBusinessAction(name = "tryDeduct", commitMethod = "confirm", rollbackMethod = "cancel")
void tryDeduct(
@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money
);
boolean confirm(BusinessActionContext context);
boolean cancel(BusinessActionContext context);
}
对于TCC模式来说,
@TwoPhaseBusinessAction
标记的方法就是try
阶段,同时commitMethod
指定的方法就是confirm
阶段,rollbackMethod
指定的方法就是cancel
阶段。
被
@BusinessActionContextParameter
标注的参数会被添加到阶段二方法中的BusinessActionContext
中。
try阶段实现:
@Autowired
private AccountFreezeMapper freezeMapper;
@Autowired
private AccountMapper accountMapper;
@Override
public void tryDeduct(String userId, int money) {
// 业务悬挂判断
if (freezeMapper.selectById(RootContext.getXID()) != null) return;
// 尝试扣款,由于数据库表中的字段是unsigned的,所以不用做检测
accountMapper.deduct(userId, money);
// 插入冻结金额
freezeMapper.insert(AccountFreeze.newAccountFreeze(userId, money, AccountFreeze.State.TRY));
}
confirm阶段实现:
@Override
public boolean confirm(BusinessActionContext context) {
int effectedRows = freezeMapper.deleteById(context.getXid());
return effectedRows == 1;
}
cancel阶段实现:
@Override
public boolean cancel(BusinessActionContext context) {
Map<String, Object> actionContext = context.getActionContext();
AccountFreeze freeze = freezeMapper.selectById(context.getXid());
// 尚未执行try,空回滚
if (freeze == null) {
// 插入一个CANCEL状态的AccountFreeze信息
freeze = AccountFreeze.newAccountFreeze((String) actionContext.get("userId"), 0, AccountFreeze.State.CANCEL, context.getXid());
freezeMapper.insert(freeze);
return true;
}
// 幂等性保证,已经cancel过了
if (freeze.getState() == AccountFreeze.State.CANCEL) return true;
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setFreezeMoney(0);
int effectedRows = freezeMapper.updateById(freeze);
return effectedRows == 1;
}
测试&结果
在执行一次需要回滚的全局事务后,account_freeze_tbl
表中多了一条cancel数据。
Saga模式
略