使用分布式事务 Seata 的 TCC 模式

发布时间 2023-12-05 22:49:42作者: 乔京飞

Seata 的 TCC 模式需要通过人工编码来实现数据的回滚恢复,有点麻烦,但是性能最高。TCC 是 3 个方法的首字母缩写,即 Try 方法、Confirm 方法、Cancel 方法。Try 方法进行资源的检查和冻结,Confirm 方法是当所有事务都成功后调用的方法,Cancel 方法是当整体事务中某个分支事务失败时调用的数据回滚恢复方法,相当于是 Try 方法的反向操作。

在一个项目中的 Seata 事务中,AT 模式和 TCC 模式可以并存。TCC 模式是有使用场景的,对于金额扣除和库存扣除,能够实现金额冻结和库存冻结,因此可以使用 TCC 模式。对于下单操作来说,只能进行添加或删除回滚操作,没有冻结的场景,因此只能使用 AT 模式,无法使用 TCC 模式。本篇博客仍然使用上篇博客的 Demo 进行改造,仅对金额的扣除实现 TCC 模式进行演示。


一、搭建工程

复制一份上篇博客的 Demo,为了区分,我将工程的名字改为 springcloud_seata_tcc,如下所示:

image

在 application.yml 中,由于 data-source-proxy-mode 只能配置两种值:XA 和 AT,没有 TCC 这种值,因此还是配置为 AT,对于 OrderService 和 StockService 仍然使用 AT 模式,对于 AccountService 虽然配置为 AT,但是代码中我们会使用 @LocalTCC 注解编写一个新的 Service 类,表示使用 TCC 模式。

image


二、代码实现

由于需要冻结金额,因此需要在我们自己的业务数据库 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 调用下单接口,传入的金额能够满足,库存量大一些,不能满足要求,此时就会下单失败,数据回滚:

image

此时看一下 Account 服务的日志,可以看到 TCC 模式的回滚日志:

image

在我们自己的业务数据库中,记录冻结金额和事务状态的表 tb_account_freeze 表中多了一条记录:

image

由于数据进行了回滚恢复,所以该记录中金额修改为 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