分布式事务理论基础

两阶段提交(2PC)

两阶段提交协议(Two Phase Commitment Protocol)中,涉及到两种角色

  • 一个事务协调者(coordinator)

    负责协调多个参与者进行事务投票及提交或者回滚

  • 多个事务参与者(participants)

    本地事务执行者

两个处理步骤

  • 投票阶段(voting phase)

    协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。参与者将告知协调者自己的决策:

    • 同意

      事务参与者成功执行本地事务,但未提交

    • 取消

      本地事务遇到故障执行失败

  • 提交阶段 (commit phase)

    收到参与者的通知后,协调者再向参与者发出通知,根据反馈情况决定各参与者是否要提交还是回滚

如图,1-2为第一阶段,2-3为第二阶段

如果任一资源管理器在第一阶段返回准备失败,那么事务管理器会要求所有资源管理器在第二阶段执行回滚操作。通过事务管理器的两阶段协调,最终所有资源管理器要么全部提交,要么全部回滚,最终状态都是一致的

TCC

基本原理

TCC将事务提交分为Try-Confirm-Cancel 3个操作。其和两阶段提交有点类似,Try为第一阶段,Confirm-Cancel为第二阶段,是一种应用层面侵入业务的两阶段提交

操作方法 含义
Try 预留业务资源/数据校验
Confirm 确认执行业务操作,实际提交数据,不做任何业务检查,try成功,confirm必定成功,需保证幂等
Cancel 取消执行业务操作,实际回滚数据,需保证幂等

其核心在于将业务分为两个操作步骤完成。不依赖RM对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务

下面以银行转账例子来说明

假设用户user表中有两个字段:可用余额(available_money)、冻结余额(frozen_money)
A扣钱对应服务A(ServiceA)
B加钱对应服务B(ServiceB)
转账订单服务(OrderService)
业务转账方法服务(BusinessService)

ServiceA,ServiceB,OrderService都需分别实现try(),confirm(),cancel()方法,方法对应业务逻辑如下

ServiceA ServiceB OrderService
try() 校验余额(并发控制)
冻结余额+1000
余额-1000
冻结余额+1000 创建转账订单,状态待转账
confirm() 冻结余额-1000 冻结余额-1000
余额+1000
状态变为转账成功
cancel() 冻结余额-1000
余额+1000
冻结余额-1000 状态变为转账失败

其中业务调用方BusinessService中就需要调用
ServiceA.try()
ServiceB.try()
OrderService.try()

1、 当所有try方法均执行成功时,对全局事务进行提交,及由事务管理器调用每个微服务的confirm()方法

2、当任意一个方法try()失败(预留资源不足,抑或网络异常等任何异常),由事务管理器调用每个服务的cancel()方法对全局事务进行回滚

TCC原理参考图

幂等控制

使用TCC时要注意Try-Confirm-Cancel 3个操作的幂等控制,网络原因,或者重试操作都有可能导致这几个操作重复执行

业务实现过程中需重点关注幂等实现,讲到幂等,以上诉TCC转账例子中的confirm()方法来说明

在confirm()方法中,余额-1000,冻结余额-1000,这一步是实现幂等的关键,你会怎么做?

常规逻辑:

1
2
3
4
5
6
7
//根据userId查到账户
Account account = accountMapper.selectById(userId);
//取出当前资金
int availableMoney = account.getAvailableMoney();
account.setAvailableMoney(availableMoney-1000);
//更新剩余资金
accountMapper.update(account);

看起来是没有问题的,但是这是一个读-改-写的过程,其过程非原子性,在并发情况下会出现数据不一致的情况

最简单的做法是利用数据库行锁特性解决

1
update account set available_money = available_money-1000 where user_id=#{userId}

但是TCC中,单纯使用这个方法是不行的,如上,他不能解决多次操作带来的多次扣减问题,如果执行2次,那么用户账户就减少了2000

这是我们可以引入转账订单状态来做判断,若订单状态为已支付,则停止后续扣减操作

1
2
3
if( order!=null && order.getStatus().equals("转账成功")){
return;
}

空回滚

TCC服务在未收到Try请求的情况下收到Cancel请求,这种场景被称为空回滚

如下图所示,事务协调器再调用TCC服务的一阶段try操作时,可能会出现丢包而导致的网络超时,此时事务协调器会触发二阶段回滚,调用TCC的cancel操作

TCC服务在实现时应当允许空回滚的执行

以上诉案例为例,如果try方法没执行,那么订单一定没创建,所以在cancel方法中可以以此为依据进行判断,如果订单不存在,不处理后续逻辑,且正常执行,不要抛出异常

1
2
3
if(orderNo==null || order==null){
return;
}

防悬挂

如下图所示,事务协调器在调用TCC服务的一阶段Try操作时,可能会出现因网络拥堵而导致的超时,此时事务协调器会触发二阶段回滚,调用TCC服务的Cancel操作;在此之后,拥堵在网络上的一阶段Try数据包被TCC服务收到,出现了二阶段Cancel请求比一阶段Try请求先执行的情况;

用户在实现TCC服务时,应当允许空回滚,但是要拒绝执行空回滚之后到来的一阶段Try请求;

可以在二阶段执行时插入一条事务控制记录,状态为已回滚,这样当一阶段执行时,先读取该记录,如果记录存在,就认为二阶段回滚操作已经执行,不再执行try方法;

事务消息

事务消息更倾向于达成分布式事务的最终一致性,适用于分布式事务的提交或回滚只取决于事务发起方的业务需求,如A给B打了款并且成功了,那么下游业务B一定需要加钱这种场景,或许下了单,用户积分一定得增加这种场景。RocketMQ4.3中已经开源了事务消息,具体设计思路分析及demo演示,可以参考文章《分布式事务-RocketMQ消息事务设计思路及Demo》

优缺点比较

事务方案 优点 缺点
2PC 实现简单 1、需要数据库(一般是XA支持) 2、锁粒度大,性能差
TCC 锁粒度小,性能好 需要侵入业务,实现较为复杂,复杂业务实现幂等有难度
消息事务 业务侵入小,无需编写业务回滚补偿逻辑 事务消息实现难度大,强依赖第三方中间件可靠性
查看评论