【SpirngCloud】分布式事务解决方案
文章目录
- 【SpirngCloud】分布式事务解决方案
- 1. 理论基础
- 1.1 CAP 理论
- 1.2 BASE 理论
- 1.3 分布式事务模型
- 2. Seata 架构
- 2.1 项目引入 Seata
- 3. 强一致性分布式事务解决方案
- 3.1 XA 模式
- 3.1.1 seata的XA模式
- 3.1.2 XA 模式实践
- 3.1.3 总结
- 4. 最终一致性分布式事务解决方案
- 4.1 AT 模式
- 4.1.1 AT 模式实践
- 4.1.2 总结
- 4.2 TCC 模式
- 4.2.1 TCC 模式实践
- 4.2.2 总结
- 4.3 Saga 模式
- 5. 四种模式总结
1. 理论基础
1.1 CAP 理论
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:
- Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致
- Availability(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝
- Partition tolerance(分区容错性)
- Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。
- Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务
分布式无法同时满足这三个指标,这个结论就叫做 CAP理论
。凡是分布式系统就一定会出现分区,集群又必须对外提供服务,那么就认为 P
一定要实现,那么就 C
和 A
之间就要做出取舍,要嘛 CP
要嘛 AP
。
1.2 BASE 理论
BASE
理论是对 CAP
的一种解决思路,包含三个思想:
- Basically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
- Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
而分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴 CAP定理
和 BASE理论
:
- AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
- CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。
1.3 分布式事务模型
解决分布式事务,各个子系统之间必须能感知到彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个事务的参与者(子系统事务)。
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务
2. Seata 架构
Seata事务中有三个重要的角色:
- TC (Transaction Coordinator) - **事务协调者:**维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) - **事务管理器:**定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - **资源管理器:**管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
Seata 提供了四种不同的分布式事务解决方案:
- XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- TCC模式:最终一致的分阶段事务模式,有业务侵入
- AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
- SAGA模式:长事务模式,有业务侵入
2.1 项目引入 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>
<!--seata starter 采用1.4.2版本-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>${seata.version}</version>
</dependency>
项目配置:
seata:
registry:
type: nacos # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
# 参考tc服务自己的registry.conf中的配置,
# 包括:地址、namespace、group、application-name 、cluster
nacos:
server-addr: 127.0.0.1:8848
namespace: "" #空就是public
group: DEFAULT_GROUP
application: seata-tc-server
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称
service:
vgroup-mapping: # 事务组与TC服务cluster的映射关系
seata-demo: SH
3. 强一致性分布式事务解决方案
3.1 XA 模式
XA模式原理:XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持。
如果事务都成功执行,那么如下图所示:
如果任意一个事务执行失败,那么如下图所示:
3.1.1 seata的XA模式
seata的XA模式做了一些调整,但大体上相似:
RM一阶段的工作:
- 注册分支事务到TC
- 执行分支业务sql但不提交
- 报告执行状态到TC
TC二阶段的工作:
- TC检测各分支事务执行状态
- 如果都成功,通知所有
RM
提交事务 - 如果有失败,通知所有
RM
回滚事务
- 如果都成功,通知所有
RM二阶段的工作:
- 接收TC指令,提交或回滚事务
3.1.2 XA 模式实践
Seata的starter已经完成了XA模式的自动装配,实现非常简单,步骤如下:
- 修改每个微服务中的
application.yml
配置文件,开启XA模式:
seata:
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: "" #空就是public
group: DEFAULT_GROUP
application: seata-tc-server
username: nacos
password: nacos
tx-service-group: seata-demo #事务组名称
service:
vgroup-mapping: #事务组与cluster的映射关系
seata-demo: SH
data-source-proxy-mode: XA #开启数据源代理的XA模式
- 给发起全局事务的入口方法添加
@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.1.3 总结
XA 模式的优点是什么?
- 事务的强一致性,满足
ACID
原则。 - 常用数据库都支持,实现简单,并且没有代码侵入。
XA 模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差。
- 依赖关系型数据库实现事务
4. 最终一致性分布式事务解决方案
4.1 AT 模式
AT模式同样是分阶段提交的事务模型,不过却弥补了 XA 模型中资源锁定周期过长的缺陷。
阶段一 RM
的工作:
- 注册分支事务
- 记录
undo-log
(数据快照) - 执行业务sql并提交
- 报告事务状态
阶段二提交时 RM
的工作:
- 删除
undo-log
即可
阶段二回滚时 RM
的工作:
- 根据
undo-log
恢复数据到更新之前
AT模式有一个缺陷就是“写丢失”,如下图所示:
为了解决“写丢失”问题,AT模式引入了“全局锁”概念,由TC记录当前正在操作某行数据的事务,该事务持有全局锁,具备执行权。
但是这种方法还是有一种问题,如果一个不是由seata管理的事务去修改money值,那么还是会导致“写丢失”,如下图所示:
那么我们最好杜绝这种情况的发生,让包含修改money值的事务都受到seata的管理;或是像上图一样利用乐观锁的思想。
4.1.1 AT 模式实践
AT模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
- 在TC服务关联的数据库中导入
lock_table
,在微服务相关的数据库中导入undo_log
。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for undo_log
-- ----------------------------
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;
-- ----------------------------
-- Records of undo_log
-- ----------------------------
-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
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;
SET FOREIGN_KEY_CHECKS = 1;
- 修改配置文件,将事务模式修改为AT模式:
seata:
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: "" #空就是public
group: DEFAULT_GROUP
application: seata-tc-server
username: nacos
password: nacos
tx-service-group: seata-demo #事务组名称
service:
vgroup-mapping: #事务组与cluster的映射关系
seata-demo: SH
data-source-proxy-mode: AT #开启数据源代理的AT模式
- 给发起全局事务的入口方法添加
@GlobalTransactional
注解。
4.1.2 总结
简述AT模式和XA模式的最大区别是什么?
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
- XA模式依赖数据库机制实现回滚,AT模式利用数据快照实现数据回滚。
- XA模式强一致,AT模式最终一致
AT模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能比较好
- 利用全局锁实现读写隔离(XA模式锁定数据库资源的情况下读写都被阻塞)
- 没有代码侵入,框架自动完成回滚和提交
AT模式的缺点:
- 两阶段之间属于软状态,属于最终一致
- 框架的快照功能会影响性能,但是比XA模式要好很多
4.2 TCC 模式
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:
- Try:资源的检测和预留。
- Confirm:完成资源操作业务,要求 Try 成功 Confirm 一定要能成功。
- Cancel:预留资源释放,可以理解为try的反向操作。
举例,一个扣减用户余额的业务。假设账户A原来余额是100,需要余额扣减30元。
- 阶段一(Try):检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30
- 阶段二:假如要提交(Confirm),则冻结金额扣减30
- 阶段二:如果要回滚(Cancel),则冻结金额30,可用余额增加30
TCC的工作模型图如下所示:
4.2.1 TCC 模式实践
需求如下:
- 修改account-service,编写try、confirm、cancel逻辑
- try业务:添加冻结金额,扣减可用金额
- confirm业务:删除冻结金额
- cancel业务:删除冻结金额,恢复可用金额
- 保证confirm、cancel接口的幂等性
- 允许空回滚
- 拒绝业务悬挂
当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时cancel不能做回滚,就是空回滚。对于已经空回滚的业务,如果以后继续执行try,就永远不可能confirm或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;
业务分析:
声明TCC接口:
TCC的Try、Confirm、Cancel方法都需要在接口中基于注解来声明,语法如下:
@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);
}
原业务方法如下所示:
@Slf4j
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
log.info("开始扣款");
try {
accountMapper.deduct(userId, money);
} catch (Exception e) {
throw new RuntimeException("扣款失败,可能是余额不足!", e);
}
log.info("扣款成功");
}
}
使用 TCC 模式后业务方法如下所示:
@Slf4j
@Service
public class AccountTccServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
//1.获取事务id
String xid = RootContext.getXID();
//业务悬挂
//判断 accountFreeze 中是否有冻结记录,如果有,一定是 CANCEL 执行过,我要拒绝业务
AccountFreeze accountFreeze = accountFreezeMapper.selectById(xid);
if (accountFreeze != null) {
//CANCEL 执行过,拒绝业务
return;
}
//2.扣减可用余额
accountMapper.deduct(userId, money);
//3.记录冻结金额,事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
accountFreezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
//1.获取事务id
String xid = ctx.getXid();
//2.根据id删除冻结记录
int count = accountFreezeMapper.deleteById(xid);
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
//0.查询冻结记录
String xid = ctx.getXid();
String userId = ctx.getActionContext("userId").toString();
AccountFreeze accountFreeze = accountFreezeMapper.selectById(xid);
//1.空回滚的判断,判断 accountFreeze 是否为null,为null证明try没执行,需要空回滚
if (accountFreeze == null) {
//为null证明try没执行,需要空回滚
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
accountFreezeMapper.insert(freeze);
return true;
}
//2.幂等判断,cancel方法超时会重试
if (accountFreeze.getState() == AccountFreeze.State.CANCEL) {
//已经处理过一次 CANCEL 了,无需重复处理
return true;
}
//3.恢复可用余额
accountMapper.refund(accountFreeze.getUserId(), accountFreeze.getFreezeMoney());
//4.将冻结金额清零,状态改为 CANCEL
accountFreeze.setFreezeMoney(0);
accountFreeze.setState(AccountFreeze.State.CANCEL);
int count = accountFreezeMapper.updateById(accountFreeze);
return count == 1;
}
}
AT模式 和 TCC模式可以混用。
4.2.2 总结
TCC模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
TCC的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC的缺点是什么?
- 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理
4.3 Saga 模式
Saga模式是SEATA提供的长事务解决方案。也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
Saga模式的优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
Saga模式的缺点:
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写