SpringCloud Alibaba - Seata 四种分布式事务解决方案(TCC、Saga)+ 实践部署(下)

目录

[一、Seata 分布式解决方案](#一、Seata 分布式解决方案)

[1.1、TCC 模式](#1.1、TCC 模式)

[1.1.1、TCC 模式理论](#1.1.1、TCC 模式理论)

[对比 TCC 和 AT 模式的一致性和隔离性](#对比 TCC 和 AT 模式的一致性和隔离性)

[TC 的工作模型](#TC 的工作模型)

[1.2.2、TCC 模式优缺点](#1.2.2、TCC 模式优缺点)

[1.2.3、TCC 模式注意事项:空回滚](#1.2.3、TCC 模式注意事项:空回滚)

[1.2.4、TCC 模式注意事项:业务悬挂](#1.2.4、TCC 模式注意事项:业务悬挂)

[1.2.5、实现 TCC 模式](#1.2.5、实现 TCC 模式)

[案例:在用户余额扣减服务中,实现 TCC 模式.](#案例:在用户余额扣减服务中,实现 TCC 模式.)

实现案例

[a)TCC 的 try、confirm、cancel 方法都需要在接口中基于注解来声明](#a)TCC 的 try、confirm、cancel 方法都需要在接口中基于注解来声明)

b)建表

[c)对应刚刚上述所描述的实现思路,可以基本实现(未考虑空回滚 和 业务悬挂)](#c)对应刚刚上述所描述的实现思路,可以基本实现(未考虑空回滚 和 业务悬挂))

d)考虑空回滚

e)幂等问题

f)业务悬挂问题

g)到此,整个业务完成.

[1.2、Saga 模式](#1.2、Saga 模式)

[1.2.1、Saga 模式理论](#1.2.1、Saga 模式理论)

[1.2.2、saga 模式优缺点](#1.2.2、saga 模式优缺点)


一、Seata 分布式解决方案


1.1、TCC 模式

1.1.1、TCC 模式理论

TCC 模式和 AT 模式很相似,第一阶段都是独立事务,执行完了直接提交,不同的是 TCC 模式不用去加锁,也不用生成快照,因此性能上会更好.

TCC 模式的第二阶段是基于人工编码的方式来实现数据恢复的,不像 AT 是自动实现的.

人工编码的方式需要实现三个方法,分别是 try、confirm、cancel.

  • try:用来进行资源的检测和预留. 也就是说我需要修改某个资源的时候,先把这个资源预留下来,等到第二阶段的时候在对这个资源进行一个具体的操作.
  • cancel:如果第二阶段需要进行回滚,就执行 cancel. cancel 是对预留资源的一个释放(可以理解为对 try 的反向操作).
  • confirm:完成资源业务操作(这里要求 try 成功,那么 confirm 一定要成功).

例如现在我的账户余额是 100 元,现在要扣掉 30 元. 如果分成 try、cancel、confirm 这三个阶段.

  1. try 阶段:做资金的检测和预留. 检测就是判断余额够不够(当前余额为 100,要扣 30 肯定是够的). 预留就是说如果余额充足,就先把要扣的 30 元金额冻结起来,也就是说可用余额扣减 30,冻结金额增加 30,而总金额是没有变化的.
  2. confirm 阶段:如果 try 阶段执行成功了,就会执行 confirm 进行提交. 这里就提交就是直接把 try 阶段冻结的 30 元金额直接扣掉,因此总金额就变成 70 元了.
  3. cancel 阶段:如果有人在执行 try 阶段失败了,就要执行 cancel 进行回滚. 这里就是对 try 阶段的一个反向操作. 在 try 阶段冻结的30元余额解冻,可用余额也就增加了 30. 因此从 try 到 cancel 可以看到总金额是没有变化的,变化的仅仅是冻结的部分.
对比 TCC 和 AT 模式的一致性和隔离性

**一致性:**首先第一阶段两个模式都是各自提交各自的事务,因此两种模式都有可能出现提交成功和失败的情况,导致状态不一致,需要通过第二阶段来调整. 也就是说这两种模式都是最终一致性.

**隔离性:**AT 模式是需要通过加锁实现隔离(在第一、第二阶段持有全局锁),而 TCC 模式下不需要加锁隔离,因为在第一阶段是通过冻结来实现隔离(冻结了一部分金额),就算此时有另一个事务也要冻结金额,那就直接从可用余额中取一部分冻结,所以事务之间都没有任何影响,不需要加锁,那么 TCC 模式的性能就要比 AT 模式好很多了.

TC 的工作模型

第一阶段:

这里大部分都和 AT 很像,一开始都是由 TM 去开启全局事务并注册到 TC 上面,然后 TM 去通知每一个分支事务去执行,然后请求被 RM 拦截,RM 就会先去注册分之十五,然后去执行 try 预留资源,执行完后直接提交,随后向 TC 报告事务的状态(资源预留执行成功了?还是失败了).

第二阶段:

TM 通知 TC 事务结束了,那么 TC 就要对事务的状态做判断了. 如果分支预留资源成功了,就直接执行 confirm 提交即可;如果发现其中任意一个有问题,就要执行 cancel 逻辑.

1.2.2、TCC 模式优缺点

优点:

性能高:第一阶段执行完直接提交事务,并且既不用生成快照,也不用使用全局锁. 可以认为是所有分布式事务模型中性能最好的.

不依赖数据库:不需要依赖于事务性的数据库,因为是靠预留资源来做代偿的. 也就是说不仅可以使用 mysql 这种关系型数据库,也可以使用 redis 这种非关系型数据库去实现 TCC 模式.

缺点:

代码侵入高:try、confirm、cancel 这三个方法需要人工编写.

软状态,最终一致:第一阶段执行完后,直接提交事务.

考虑幂等:将来 confirm 和 cancel 可能会执行失败,Seata 看到失败了就会重试,就可能造成死循环. 因此要考虑各种健壮性.

1.2.3、TCC 模式注意事项:空回滚

问题:

在将执行某个分支事务的时候,发现执行分支事务的请求因为某种原因(网络抖动)阻塞住了,一旦阻塞的时间超过了超时时间,就会将超时的错误报告给 TC,然后 TC 就会告诉这个分支事务的 RM:"那你去回滚吧",此时 RM 就会去执行 cancel 的业务.

这就导致本身你没有执行 try 预留资源,现在却要执行 cancel 去释放预留资源. 比方说 try 的业务就是去冻结 30 元的余额,但是在没有进行 try 之前却要进行释放 30 元冻结余额的业务,这不就出事了吗?

解决方案:

因此这里需要做一个空回滚.

在 try 执行请求因为某种原因阻塞时,可能会导致全局事务超时,从而先触发了 cancel 逻辑,此时根本就没有做资源预留,就不能回滚,并且也不能报错(不然 Seata 会以为 cancel 出问题了,会重试,最后导致死循环). 那么空回滚只需要我们返回一个正常结束即可.

1.2.4、TCC 模式注意事项:业务悬挂

问题:

在执行完空回滚之后,try 逻辑的请求阻塞突然通畅,就会去执行资源预留业务,但是资源预留了之后就没有后续了(已经执行过 cancel 中的空回滚了),既没有 cancel,也没有 confirm,业务只执行了一半. 这就是业务悬挂.

比如说我本来有 100 元余额,执行完空回滚后,try逻辑突然通常,冻结了我 30 元的可用余额,然后也没有后续业务了,就导致我这 30 元有是有,但是却一直用不了.

解决办法:

在执行 try 的时候,先判断一下是否回滚过,如果回滚过了 try 就不能执行了. 同样在执行 cancel 的时候,需要判断一下,try 是不是已经执行了,如果 try 没有执行,就去做一个空回滚.

怎么知道 try 到底有没有执行过呢?这就需要在数据库中在创建一个表,用来记录事务的状态(记录上一步是执行了 try 呢?还是cancel?还是confirm?).

1.2.5、实现 TCC 模式

案例:在用户余额扣减服务中,实现 TCC 模式.

那么实现的思路如下:

  1. try:扣减可用余额,添加冻结金额.
  2. confirm:删除冻结金额.
  3. cancel:恢复可用金额,删除冻结金额.
  4. 注意事项:保证 confirm、cancel 接口的幂等性,注意 空回滚 和 业务悬挂.
实现案例
a)TCC 的 try、confirm、cancel 方法都需要在接口中基于注解来声明

语法如下:

java 复制代码
@LocalTCC
public interface TCCService {
    /**
     * Try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
     * commitMethod 用来指定 confirm 逻辑,值必须对应自己实现的方法名. rollbackMethod 表示 cancel 逻辑,值必须对应自己实现的方法名.
     */
    @TwoPhaseBusinessAction(name = "prepare", commitMethod = "confirm", rollbackMethod = "cancel")
    void prepare(@BusinessActionContextParameter(paramName = "param") String param);
    /**
     * 二阶段confirm确认方法、可以另命名,但要保证与commitMethod一致 
     *
     * @param context 上下文,可以传递try方法的参数
     * @return boolean 执行是否成功
     */
    boolean confirm (BusinessActionContext context);
    /**
     * 二阶段回滚方法,要保证与rollbackMethod一致
     */
    boolean cancel (BusinessActionContext context);
}

根据上述语法,就可以编写用户余额冻结服务的接口 AccountTCCService ,如下

java 复制代码
@LocalTCC
public interface AccountTCCService {

    /**
     * try:冻结指定余热
     * @param userId
     * @param money
     */
    @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
    void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
                @BusinessActionContextParameter(paramName = "money") int money);

    /**
     * 删除冻结余额
     * @param ctx
     * @return
     */
    boolean confirm(BusinessActionContext ctx);

    /**
     * 删除冻结余额,恢复可用余额
     * @param ctx
     * @return
     */
    boolean cancel(BusinessActionContext ctx);

}
b)建表

这里我们已经有了用户金额表,如下:

这里我们还需要创建 用户冻结金额表 ,如下:

java 复制代码
CREATE TABLE `account_freeze_tbl`  (
  `xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `freeze_money` int(11) UNSIGNED NULL DEFAULT 0,
  `state` int(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
  PRIMARY KEY (`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;

对应实体类如下:

java 复制代码
@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 abstract class State {
        public final static int TRY = 0;
        public final static int CONFIRM = 1;
        public final static int CANCEL = 2;
    }
}
c)对应刚刚上述所描述的实现思路,可以基本实现(未考虑空回滚 和 业务悬挂)

AccountTCCService 接口,如下:

java 复制代码
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {

    @Autowired
    private AccountMapper accountMapper;

    @Autowired
    private AccountFreezeMapper freezeMapper;


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

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

    @Override
    public boolean cancel(BusinessActionContext ctx) {
        //1.查询冻结记录
        String xid = RootContext.getXID();
        AccountFreeze freeze = freezeMapper.selectById(xid);
        //2.恢复可用余额
        accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
        //3.清理冻结余额,状态修改为 cancel
        freeze.setFreezeMoney(0);
        freeze.setState(AccountFreeze.State.CANCEL);
        int count = freezeMapper.updateById(freeze);
        return count == 1;
    }

}
d)考虑空回滚

考虑在执行 try 逻辑阻塞超时,执行了 cancel 逻辑,那么就需要考虑空回滚. 主要记录 cancel 状态即可.

java 复制代码
    @Override
    public boolean cancel(BusinessActionContext ctx) {
        //1.查询冻结记录
        String xid = RootContext.getXID();
        AccountFreeze freeze = freezeMapper.selectById(xid);

        //a. 空回滚判断
        if (freeze == null) {
            //这里主要记录当前的 cancel 状态
            freeze = new AccountFreeze();
            //这里能拿到 userId 和 money 是因为在 AccountTCCService 接口中通过 BusinessActionContextParameter 注解注册了
            String userId = ctx.getActionContext("userId").toString();
            freeze.setUserId(userId);
            freeze.setXid(xid);
            freeze.setFreezeMoney(0);
            freeze.setState(AccountFreeze.State.TRY);
            freezeMapper.insert(freeze);
        }

        //2.恢复可用余额
        accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
        //3.清理冻结余额,状态修改为 cancel
        freeze.setFreezeMoney(0);
        freeze.setState(AccountFreeze.State.CANCEL);
        int count = freezeMapper.updateById(freeze);
        return count == 1;
    }
e)幂等问题

第一次超时了,进行空回滚(添加 freeze,设置状态为 cancel),第二次又超时了,freeze 不为空,就会进行恢复金额逻辑. 这就出问题了,不能进行恢复金额操作,因此,这里需要进行判断,如果处理过了,直接返回 true 即可.

java 复制代码
    @Override
    public boolean cancel(BusinessActionContext ctx) {
        //1.查询冻结记录
        String xid = RootContext.getXID();
        AccountFreeze freeze = freezeMapper.selectById(xid);

        //a. 空回滚判断
        if (freeze == null) {
            //这里主要记录当前的 cancel 状态
            freeze = new AccountFreeze();
            //这里能拿到 userId 和 money 是因为在 AccountTCCService 接口中通过 BusinessActionContextParameter 注解注册了
            String userId = ctx.getActionContext("userId").toString();
            freeze.setUserId(userId);
            freeze.setXid(xid);
            freeze.setFreezeMoney(0);
            freeze.setState(AccountFreeze.State.TRY);
            freezeMapper.insert(freeze);
        }

        //b.幂等问题:第一次超时了,进行空回滚,第二次又超时了,freeze 不为空,就会进行恢复金额逻辑(这就出问题了).
        if(freeze.getState() == AccountFreeze.State.CANCEL) {
            //已经处理过依次 cancel 了,无需重复处理
            return true;
        }

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

confirm 为什么不考虑幂等了?

因为 confirm 逻辑是删除冻结记录,底层就是 sql 调用 delete. 因此即使操作多次,也无妨.

f)业务悬挂问题

处理过 cancel 之后,就没必要再处理 try 了,因此这里只需要判断 freeze 是否存在冻结记录,如果有,拒绝即可.

java 复制代码
    @Override
    @Transactional
    public void deduct(String userId, int money) {
        //1.获取事务 id
        String xid = RootContext.getXID();

        //a. 业务悬挂问题处理:判断 freeze 中是否有冻结记录,如果有,一定是 cancel 执行过,要拒绝业务
        AccountFreeze oldFreeze = freezeMapper.selectById(xid);
        if(oldFreeze != null) {
            return;
        }

        //2.扣减可用余额
        accountMapper.deduct(userId, money);
        //3.增加冻结金额,并记录当前事务的状态
        AccountFreeze freeze = new AccountFreeze();
        freeze.setUserId(userId);
        freeze.setXid(xid);
        freeze.setFreezeMoney(money);
        freeze.setState(AccountFreeze.State.TRY);
        freezeMapper.insert(freeze);
    }
g)到此,整个业务完成.

全代码如下:

java 复制代码
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {

    @Autowired
    private AccountMapper accountMapper;

    @Autowired
    private AccountFreezeMapper freezeMapper;


    @Override
    @Transactional
    public void deduct(String userId, int money) {
        //1.获取事务 id
        String xid = RootContext.getXID();

        //a. 业务悬挂问题处理:判断 freeze 中是否有冻结记录,如果有,一定是 cancel 执行过,要拒绝业务
        AccountFreeze oldFreeze = freezeMapper.selectById(xid);
        if(oldFreeze != null) {
            return;
        }

        //2.扣减可用余额
        accountMapper.deduct(userId, money);
        //3.增加冻结金额,并记录当前事务的状态
        AccountFreeze freeze = new AccountFreeze();
        freeze.setUserId(userId);
        freeze.setXid(xid);
        freeze.setFreezeMoney(money);
        freeze.setState(AccountFreeze.State.TRY);
        freezeMapper.insert(freeze);
    }

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

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

        //a. 空回滚判断
        if (freeze == null) {
            //这里主要记录当前的 cancel 状态
            freeze = new AccountFreeze();
            //这里能拿到 userId 和 money 是因为在 AccountTCCService 接口中通过 BusinessActionContextParameter 注解注册了
            String userId = ctx.getActionContext("userId").toString();
            freeze.setUserId(userId);
            freeze.setXid(xid);
            freeze.setFreezeMoney(0);
            freeze.setState(AccountFreeze.State.TRY);
            freezeMapper.insert(freeze);
        }

        //b.幂等问题:第一次超时了,进行空回滚,第二次又超时了,freeze 不为空,就会进行恢复金额逻辑(这就出问题了).
        if(freeze.getState() == AccountFreeze.State.CANCEL) {
            //已经处理过依次 cancel 了,无需重复处理
            return true;
        }

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

}

1.2、Saga 模式

1.2.1、Saga 模式理论

Saga模式是SEATA提供的长事务解决方案。也分为两个阶段:

第一阶段:

与 AT 一样,直接提交本地事务.

第二阶段:

如果第一阶段大家都成功了,就什么也不做.

如果第一阶段有失败的,那么他会反向做一个补偿逻辑去回滚. 这里确实和 tcc 优点像,但不完全一样,因为 tcc 再第一阶段中不是处理事务,只是做资源预留.

比如 扣余额业务,TCC 就直接冻结了,而 saga 是直接把余额扣掉了,如果 saga 第一阶段出现问题,第二阶段就是把扣掉的余额增加回来,实现回滚逻辑的.

1.2.2、saga 模式优缺点

缺点:

没有隔离性:因为一二阶段既没有全局锁,也没有预留资源,所有事务与事务之间可能存在脏写问题.

软状态持续时间不确定:saga 模式是按顺序执行每一个事务,如果有任何一个出现问题,就会立刻反向补偿. 因此这个不一致的时间不确定.

优点:

吞吐能力高:基于事件驱动实现异步调用,也就是一个事务完成了,自己执行下一个事务,无需阻塞等待.

性能高:第一阶段无需上锁,性能高.

实现简单:不用像 TCC 那样编写三个阶段,实现简单.

1.2.3、补充说明

Ps:由于这种模式的使用场景极少,因此就不演示了.

相关推荐
知兀9 小时前
【Spring/SpringBoot】<dependencyManagement> + import 导入能继承父maven项目的所有依赖,类似parent
spring boot·spring·maven
源码宝9 小时前
企业项目级医院随访系统源码,患者随访管理系统,技术框架:Java+Spring boot,Vue,Ant-Design+MySQL5
java·vue.js·spring·程序·医院管理系统·随访·随访系统源码
千禧皓月11 小时前
【C++】基于C++的RPC分布式网络通信框架(二)
c++·分布式·rpc
A.说学逗唱的Coke11 小时前
【观察者模式】深入 Spring 事件驱动模型:从入门到微服务整合实战
spring·观察者模式·微服务
lzjava202411 小时前
Spring AI使用知识库增强对话功能
人工智能·python·spring
ToPossessLight090212 小时前
Spring 容器的基本实现
spring
杂家12 小时前
Zookeeper完全分布式部署(超详细)
大数据·分布式·zookeeper
雨点保护雪花13 小时前
15、RabbitMQ
分布式·rabbitmq
程序定小飞15 小时前
基于springboot的学院班级回忆录的设计与实现
java·vue.js·spring boot·后端·spring
郝开16 小时前
Spring Boot 2.7.18(最终 2.x 系列版本)1 - 技术选型:连接池技术选型对比;接口文档技术选型对比
java·spring boot·spring