分布式事务一致性2--seata分布式事务解决方案

505 阅读10分钟

1.理论依据

1.1 CAP定理

1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:

  • Constency(一致性)

  • Availability(可用性)

  • Partition tolerance (分区容错性)

Eric Brewer 说,分布式系统无法同时满足这三个指标。 这个结论就叫做 CAP 定理。

图片.png

1.2 BASE理论

BASE理论是对CAP的一种解决思路,包含三个思想:

  • Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。

  • Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。

  • Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

而分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论:

  • AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
  • CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。

2 Seata架构

Seata事务管理中有三个重要的角色:

  • TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
  • TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
  • RM (Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

图片.png

3 Seata 的4种分布式事务解决方案

Seata提供了四种不同的分布式事务解决方案优缺点如下:

  • XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入

  • TCC模式:最终一致的分阶段事务模式,有业务侵入

  • AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式

  • SAGA模式:长事务模式,有业务侵入

3.1 Seata XA模式

3.1.1 XA模式原理

图片.png RM一阶段的工作:

  1. 注册分支事务到TC
  2. 执行分支业务sql但不提交
  3. 报告执行状态到TC

TC二阶段的工作:

  1. TC检测各分支事务执行状态
  • 如果都成功,通知所有RM提交事务
  • 如果有失败,通知所有RM回滚事务
  1. RM二阶段的工作:
  • 接收TC指令,提交或回滚事务

3.1.2 XA模式配置

在上一章 分布式事务一致性1--seata部署和集成已经添加了基础集成配置 juejin.cn/post/706973… 现只添加XA模式配置。

Seata的starter已经完成了XA模式的自动装配,实现非常简单,步骤如下: 1.修改application.yml文件(每个参与事务的微服务),开启XA模式:

seata:
  data-source-proxy-mode: XA

2.给发起全局事务的入口方法添加@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.重启服务并测试

3.2 Seata AT模式

3.2.1 AT模式原理

图片.png 阶段一RM的工作:

  • 注册分支事务
  • 记录undo-log(数据快照)包含操作前的快照和操作后的快照
  • 执行业务sql并提交
  • 报告事务状态 阶段二提交时RM的工作:
  • 提交异步化 删除undo-log即可 阶段二回滚时RM的工作:
  • 根据undo-log恢复数据到更新前

3.2.2 AT模式写隔离

因为AT模式一阶段是分别提交释放了DB锁,如果在一阶段和二阶段之间有事务2获取到DB锁执行更新操作,而到二阶段如果回滚的话则会把事务2更新的数据覆盖掉,造成脏写。其过程如下:

图片.png

引入全局锁解决脏写问题:

图片.png

这时事务2提交时也要获取全局锁,全局锁被事务1拿着,这时事务1和事务2就会互相等待对方释放锁,事务2获取全局锁默认重试30次每次间隔10ms 即最长300ms就会回滚释放DB锁,事务1超时时间要长于事务2 故事务1会获取到DB锁完成回滚,事务2也没有写入成功,则避免了脏写问题。

上面事务2是被seata管理的全局事务中的分支事务。如果事务2不是被seata管理的事务就会出现问题:

图片.png

因为事务2不被seata管理就不需要获取全局事务可以直接提交事务,此时事务1发生回滚过程:

  1. 获取获取DB锁
  2. 比对现在数据和 after-image 快照中的数据是否一致
    • 一致,用befor-image中的数据记录恢复数据
    • 不一致,说明数据已被其他事务修改,记录异常发送警告需要人工介入

PS:这种情况并不常见,首先回滚的情况就比较少,回滚时其他事务刚好操作相同字段的概率更低。如果有类似情况可以将事务2也加入seata管理的全局事务解决。

3.2.3 AT读隔离

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

图片.png SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

3.2.4 AT模式配置

1.将 lock_table 表创建到seata-tc-server服务的数据库中,sql如下:

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;

2.将undo_log表创建到微服务系统的数据库中,sql如下:

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;

3.添加AT代理配置

seata:
  data-source-proxy-mode: AT

4.启动服务并测试

3.3 TCC模式

一个分布式的全局事务,整体是 两阶段提交 的模型。全局事务是由若干分支事务组成的,分支事务要满足 两阶段提交 的模型要求,即需要每个分支事务都具备自己的:

  • 一阶段 prepare 行为
  • 二阶段 commit 或 rollback 行为

图片.png TCC模式和AT模式各阶段对比:

AT 模式:

  • 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。
  • 二阶段 commit 行为:马上成功结束,自动 异步批量清理回滚日志。
  • 二阶段 rollback 行为:通过回滚日志,自动 生成补偿操作,完成数据回滚。

相应的,TCC 模式,不依赖于底层数据资源的事务支持:

  • 一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。
  • 二阶段 commit 行为:调用 自定义 的 commit 逻辑。
  • 二阶段 rollback 行为:调用 自定义 的 rollback 逻辑。

所谓 TCC 模式,是指支持把 自定义 的分支事务纳入到全局事务的管理中。

3.1.1 TCC模式空回滚、业务悬挂、幂等性

空回滚:

当某分支事务的prepare阶段阻塞时,可能导致全局事务超时而触发二阶段的rollback操作。在未执行prepare操作时先执行了rollback操作,这时rollback不能做回滚,就是空回滚。

业务悬挂:

对于已经空回滚的业务,如果此时阻塞的prepare阶段通了继续执行,就永远不可能commit或rollback,这就是业务悬挂。应当阻止执行空回滚后的prepare操作,避免悬挂

幂等性:

rollback行为由于网络或者超时等原因被执行多次,因此要验证幂等性,防止多次回滚。

3.1.2 TCC模式实现

实现一个扣减用户余额的业务。假设账户A原来余额是100,需要余额扣减30元

图片.png

为了实现空回滚、防止业务悬挂,以及幂等性要求定义一个account_freeze_tbl表 sql如下:

DROP TABLE IF EXISTS `account_freeze_tbl`;
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;

TCC 的prepare阶段,commit阶段,rollback阶段代码如下:

接口定义:

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;
/**
 * LocalTCC 注解表示实现该接口的类被 seata 来管理,seata 根据事务的状态,自动调用我们定义的方法,如果没问题则调用 Commit 方法,否则调用 Rollback 方法。
 */
@LocalTCC
public interface AccountTCCService {

    /**
     * @TwoPhaseBusinessAction 作用在prepare 方法上
     * name 为 tcc 方法的 bean 名称,需要全局唯一,一般写方法名即可
     * commitMethod 自然地写 Commit 方法的方法名
     * rollbackMethod 写 Rollback 方法的方法名
     * @BusinessActionContextParameter 该注解用来修饰 Try 方法的入参,被修饰的入参可以在 Commit 方法和 Rollback 方法中通过 BusinessActionContext 获取。
     */
    @TwoPhaseBusinessAction(name = "prepareTcc", commitMethod = "commitTcc", rollbackMethod = "rollbackTcc")
    void prepareTcc(@BusinessActionContextParameter(paramName = "userId") String userId,
                @BusinessActionContextParameter(paramName = "money")int money);

    boolean commitTcc(BusinessActionContext ctx);

    boolean rollbackTcc(BusinessActionContext ctx);
}

接口实现:

import com.chao.account.entity.AccountFreeze;
import com.chao.account.mapper.AccountFreezeMapper;
import com.chao.account.mapper.AccountMapper;
import com.chao.account.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
public class AccountTCCServiceImpl implements AccountTCCService {

    @Autowired
    private AccountMapper accountMapper;
    @Autowired
    private AccountFreezeMapper freezeMapper;

    @Override
    @Transactional
    public void prepareTcc(String userId, int money) {
        // 0.获取事务id
        String xid = RootContext.getXID();
        //判断freeze中是否有冻结记录,如果有,说明先执行了rollback 操作,此处要拒绝执行 防止业务悬挂
        AccountFreeze freeze1 = freezeMapper.selectById(xid);
        if(freeze1!=null){
            //已经执行过回滚,冻结金额操作不能在执行了
            return;
        }
        // 1.扣减可用余额
        accountMapper.deduct(userId, money);
        // 2.记录冻结金额,事务状态
        AccountFreeze freeze = new AccountFreeze();
        freeze.setUserId(userId);
        freeze.setFreezeMoney(money);
        freeze.setState(AccountFreeze.State.TRY);
        freeze.setXid(xid);
        freezeMapper.insert(freeze);
    }

    @Override
    public boolean commitTcc(BusinessActionContext ctx) {
        // 1.获取事务id
        String xid = ctx.getXid();
        // 2.根据id删除冻结记录
        int count = freezeMapper.deleteById(xid);
        return count == 1;
    }

    @Override
    public boolean rollbackTcc(BusinessActionContext ctx) {
        // 0.查询冻结记录
        String xid = ctx.getXid();
        AccountFreeze freeze = freezeMapper.selectById(xid);
        String userId= ctx.getActionContext("userId").toString();
        if(freeze==null){
            //证明没有执行prepare阶段 需要进行空回滚 并插入一条回滚数据
            freeze.setUserId(userId);
            freeze.setFreezeMoney(0);
            freeze.setState(AccountFreeze.State.CANCEL);
            freeze.setXid(xid);
            freezeMapper.insert(freeze);
            return true;
        }
        //幂等处理
        if(freeze.getState()==AccountFreeze.State.CANCEL){
            //已经被回滚过了
            return true;
        }
        // 1.恢复可用余额
        accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
        // 2.将冻结金额清零,状态改为CANCEL
        freeze.setFreezeMoney(0);
        freeze.setState(AccountFreeze.State.CANCEL);
        int count = freezeMapper.updateById(freeze);
        return count == 1;
    }
}

3.4 Saga模式

Saga模式是SEATA提供的长事务解决方案,在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。

图片.png

理论基础:Hector & Kenneth 发表论⽂ Sagas (1987)

** 适用场景:**

  • 业务流程长、业务流程多
  • 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口

优势:

  • 一阶段提交本地事务,无锁,高性能
  • 事件驱动架构,参与者可异步执行,高吞吐
  • 补偿服务易于实现

缺点:

  • 不保证隔离性

4.Seata 四种模式对比

图片.png