使用分布式事务 Seata 的 TCC 模式
Seata 的 TCC 模式需要通过人工编码来实现数据的回滚恢复,有点麻烦,但是性能最高。TCC 是 3 个方法的首字母缩写,即 Try 方法、Confirm 方法、Cancel 方法。Try 方法进行资源的检查和冻结,Confirm 方法是当所有事务都成功后调用的方法,Cancel 方法是当整体事务中某个分支事务失败时调用的数据回滚恢复方法,相当于是 Try 方法的反向操作。
在一个项目中的 Seata 事务中,AT 模式和 TCC 模式可以并存。TCC 模式是有使用场景的,对于金额扣除和库存扣除,能够实现金额冻结和库存冻结,因此可以使用 TCC 模式。对于下单操作来说,只能进行添加或删除回滚操作,没有冻结的场景,因此只能使用 AT 模式,无法使用 TCC 模式。本篇博客仍然使用上篇博客的 Demo 进行改造,仅对金额的扣除实现 TCC 模式进行演示。
一、搭建工程
复制一份上篇博客的 Demo,为了区分,我将工程的名字改为 springcloud_seata_tcc,如下所示:
在 application.yml 中,由于 data-source-proxy-mode 只能配置两种值:XA 和 AT,没有 TCC 这种值,因此还是配置为 AT,对于 OrderService 和 StockService 仍然使用 AT 模式,对于 AccountService 虽然配置为 AT,但是代码中我们会使用 @LocalTCC 注解编写一个新的 Service 类,表示使用 TCC 模式。
二、代码实现
由于需要冻结金额,因此需要在我们自己的业务数据库 seatatest 中创建一张记录冻结金额和事务状态的表
CREATE TABLE `tb_account_freeze` (
`xid` varchar(250) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '事务id',
`user_id` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户id',
`freeze_money` int(11) UNSIGNED NULL DEFAULT 0 COMMENT '冻结金额',
`state` int(1) NULL DEFAULT NULL COMMENT '事务状态,1:try,0:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
为了对该表进行增删改查,因此就需要创建对应的实体类和 mapper 文件:
package com.jobs.pojo;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@TableName("tb_account_freeze")
public class AccountFreeze {
@TableId(type = IdType.INPUT)
private String xid;
private String userId;
private Integer freezeMoney;
//1-try状态,0-cancel状态
private Integer state;
}
package com.jobs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jobs.pojo.AccountFreeze;
import org.apache.ibatis.annotations.Mapper;
//由于使用 mybatis plus 框架,因此这里就只需要集成 BaseMapper传入实体类即可生成相应的增删改查方法
@Mapper
public interface AccountFreezeMapper extends BaseMapper<AccountFreeze> {
}
最后我们新建一个全新的 Service 类:AccountTccService,专门用来实现 Seata 的 TCC 模式:
package com.jobs.service;
import com.jobs.mapper.AccountFreezeMapper;
import com.jobs.mapper.AccountMapper;
import com.jobs.pojo.AccountFreeze;
import io.seata.core.context.RootContext;
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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
//TCC 是事务是 3 中操作的首字母缩写,即 try(执行操作),confirm(确认提交),cancel(数据回滚)
@LocalTCC
@Slf4j
@Service
public class AccountTccService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
//该注解配置了 tcc 事务的 3 个方法:
//name 配置 try 方法
//commitMethod 配置 confirm 方法
//rollbackMethod 配置 cancel 方法
@TwoPhaseBusinessAction(name = "minusMoney",
commitMethod = "confirm", rollbackMethod = "cancel")
public void minusMoney(
//使用该注解指定的参数,
//参数值可以在 confirm 方法和 cancel 方法的 BusinessActionContext 参数中获取到
@BusinessActionContextParameter(paramName = "uid") String uid,
@BusinessActionContextParameter(paramName = "money") int money) {
//获取事务id
String xid = RootContext.getXID();
//为了防止业务悬挂,需要判断是否有冻结记录,如果有的话,就不能再执行 try 操作了
AccountFreeze oldfreeze = freezeMapper.selectById(xid);
if (oldfreeze != null) {
return;
}
//减钱
accountMapper.minusMoney(uid, money);
//记录冻结的金额和事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(uid);
freeze.setFreezeMoney(money);
// 1 表示 try 状态,0 表示 cancel 状态
freeze.setState(1);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}
//事务成功提交的方法,此时需要删除冻结记录即可
public boolean confirm(BusinessActionContext bac) {
//获取事务id
String xid = bac.getXid();
//根据id删除冻结记录
int count = freezeMapper.deleteById(xid);
return true;
}
//数据回滚方法,此时需要恢复金额,更改冻结记录的状态
public boolean cancel(BusinessActionContext bac) {
//通过事务id查询冻结记录中的金额
String xid = bac.getXid();
AccountFreeze freeze = freezeMapper.selectById(xid);
//如果 freeze 为 null,表示之前没有执行过 try,
//此时需要空回滚,向 tb_account_freeze 表示添加一条 cancel 状态的记录
if (freeze == null) {
freeze = new AccountFreeze();
//由于在 try 方法(也就是 minusMoney 方法)的参数 uid,
//使用了 @BusinessActionContextParameter 注解,
//因此这里使用 BusinessActionContext.getActionContext("uid")
//就能够获取到 uid 传入的参数值,也就是用户id的值
String uid = bac.getActionContext("uid").toString();
freeze.setUserId(uid);
freeze.setFreezeMoney(0);
// 1 表示 try 状态,0 表示 cancel 状态
freeze.setState(0);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
//为了防止 cancel 方法被调用了多次,这里需要幂等性判断
//如果获取到的冻结记录,状态本身已经是 cancel 状态,则不再进行处理
if (freeze.getState() == 0) {
return true;
}
//恢复余额
accountMapper.addMoney(freeze.getUserId(), freeze.getFreezeMoney());
//将冻结金额清零,状态改为 cancel
//1 表示 try 状态,0 表示 cancel 状态
freeze.setFreezeMoney(0);
freeze.setState(0);
freezeMapper.updateById(freeze);
return true;
}
}
最后在 AccountController 类中,使用 AccountTccService 方法来进行扣钱即可:
package com.jobs.controller;
import com.jobs.service.AccountService;
import com.jobs.service.AccountTccService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/account")
@RestController
public class AccountController {
@Autowired
private AccountTccService accountTccService;
@GetMapping("/minus/{uid}/{money}")
public ResponseEntity<String> minusMoney(@PathVariable("uid") String uid,
@PathVariable("money") Integer money) {
accountTccService.minusMoney(uid, money);
return ResponseEntity.ok("减钱成功");
}
}
三、验证效果
当我们调用下单接口,传入的金额和库存量都满足的情况下,能够正常下单,这种情况就不演示了。
我们使用 Postman 调用下单接口,传入的金额能够满足,库存量大一些,不能满足要求,此时就会下单失败,数据回滚:
此时看一下 Account 服务的日志,可以看到 TCC 模式的回滚日志:
在我们自己的业务数据库中,记录冻结金额和事务状态的表 tb_account_freeze 表中多了一条记录:
由于数据进行了回滚恢复,所以该记录中金额修改为 0,状态修改为 0 (cancel 状态)
四、TCC 模式的存在问题和优缺点
TCC 模式并非所有场景都适用,如本篇博客的 Demo 中,下单就不适合适用 TCC 模式,只有能够实现资源冻结的情况,才可以使用 TCC 模式,比如本篇博客中的金额和库存量的增减场景,就可以使用 TCC 模式。
另外需要注意的是:
- 在使用 TCC 模式实现 Try 方法时,需要考虑业务悬挂的情况。所谓业务悬挂是指由于网络原因,本分支的事务 Try 方法还没来得及执行,其它分支事务失败了,然后导致本分支事务进行了提前进行了 cancel 回滚操作,此时 Try 方法由于网络恢复执行了,导致资源冻结,但是本分支事务早已结束,后续永远不会再进行 Confirm 或 Cancel 方法的执行,此时冻结的资源就永远无法释放了。
- 在使用 TCC 模式实现 Cancel 方法是,需要考虑空回滚的情况。所谓空回滚跟上面的业务悬挂场景相同,就是由于网络原因,本分支的事务 Try 方法还没来得及执行,其它分支事务失败了,然后导致本分支事务进行了提前进行了 cancel 回滚操作。此时的回滚操作不能进行金额的恢复操作,需要进行空回滚。
以上两种情况,本篇博客的 Demo 中在 AccountTccService 方法中都有考虑和实现。
TCC 模式的优点是:
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC 模式的缺点是:
- 有代码侵入,需要人为编写 try、Confirm 和 Cancel接口,比较麻烦
- 事务执行过程属于软状态,事务是最终一致
- 需要考虑 Confirm 和 Cancel 的失败情况,做好幂等处理
OK,以上就是有关 Seata 的 TCC 模式的介绍,可以下载源代码进行运行验证结果。
本篇博客的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/springcloud_seata_tcc.zip