分布式事务框架

分布式事务框架

XA、2PC、3PC、TCC、MQ、Seata

XA:

XA是X/Open XA 规范的缩写,指由X/Open 组织提出的分布式事务处理的规范,或者说是协议,定义了事务协调者、事务参与者,和事务发起者(应用程序),但并不是一个真正的解决方案,2PC和3PC才是他的具体实现方式。

2PC:

two-phase commit,也叫做二阶段提交,分为准备阶段和执行阶段两个阶段,是一个非常经典的强一致。

它由两类节点组成,一个协调者节点(coordinator)和N个参与者节点(partcipant)。

1)准备阶段

协调者向所有的参与者发送prepare请求,询问是否可以执行事务,等待各个参与者的响应。这个阶段可以认为参与者只是执行了事务的SQL语句,但是还没有提交。如果参与者执行成功了就返回YES,否则返回NO。

2)执行阶段

执行阶段就是真正的事务提交的阶段,但是要考虑到失败的情况。

如果所有的参与者都返回YES,那么就协调者会发送commit命令给所有参与者,参与者收到commit命令之后提交事务。

但只要有一个参与者在准备阶段返回的是NO的话,协调者就会发送rollback命令,然后所有参与者执行回滚的操作。

2PC潜在的问题

1)性能问题

从流程上我们可以看得出,其最大缺点就在于它的执行过程中间,节点都处于阻塞状态。各个操作数据库的节点此时都占用着数据库资源,只有当所有节点准备完毕,事务协调者才会通知进行全局提交,参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大。

2)协调者单点故障问题。

事务协调者是整个XA模型的核心,一旦事务协调者节点挂掉,会导致参与者收不到提交或回滚的通知,从而导致参与者节点始终处于事务无法完成的中间状态。

3)数据不一致问题。

在第二个阶段,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就会导致节点间数据的不一致问题。

3PC

3PC是基于2PC存在的问题,而提出的新方案,它把整个流程分成了CanCommit、PreCommit、DoCommit三个步骤,相比2PC,增加的就是CanCommit阶段,并引入了超时机制,一旦事务参与者迟迟没有收到协调者的Commit命令,就会自动进行本地commit,这样相对有效地解决了协调者单点故障的问题。

1)CanCommit阶段

在该阶段中,协调者会向所有参与者发送请求,该阶段存在意义在于先断定是否所有参与者都能够正常提供服务。

2)PreCommit阶段

这个阶段就等同于2PC的准备阶段了,发送precommit命令,然后去执行SQL事务(执行但不提交),执行成功就返回YES,反之返回NO。这个阶段与2PC的区别在于参与者有了超时机制,如果参与者超时未收到doCommit命令的话,将会默认去提交事务。

3)DoCommit阶段

这个阶段就等同于2PC的执行阶段了,如果上一个阶段都是收到YES的话,那么就发送doCommit命令去提交事务,反之则会发送abort命令去中断事务的执行。

相对于2PC,3PC对协调者和参与者都设置了超时时间,而2PC只有协调者才拥有超时机制。这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。

3PC相对缓解了2PC中的前两个问题,但3PC仍然没有完全解决数据不一致的问题。

TCC

TCC(Try-Confirm-Cancel)的模式叫做Try、Confirm、Cancel,实际上也是2PC的一个变种,又称补偿事务。

其核心思想是:“针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)”。

它分为三个操作:

  • Try阶段:主要是对业务系统做检测及资源预留
  • Confirm阶段:确认执行业务操作
  • Cancel阶段:取消执行业务操作

TCC事务的处理流程与2PC两阶段提交类似,不过2PC通常都是在跨库的DB层面,而TCC本质上就是一个应用层面的2PC,需要通过业务逻辑来实现。

简单说就是2PC和3PC实际上大多数操作都是通过MySQL来实现的,但是TCC更大程度上是通过调用各方接口实现的。例如商品服务创建商品,通知库存服务和活动服务添加对应记录,库存服务和活动服务除了要有一个添加商品记录的方法,还要有一个删除商品记录的方法,以供商品服务来进行回滚。

优点:

可以让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。

缺点:

1.对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。

2.实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口还必须实现幂等。

总结起来就是开发量很大,没有人用。毕竟程序员的本身素质参差不齐,多个团队协作你也很难去约束别人按照你的规则来实现。

MQ

基于消息队列来实现最终一致性的方案。

MQ实现的分布式事务,其实和事务关系并不大,它所能保证的是最终该执行的都执行完毕。但是对于回滚,MQ的这种方案是不支持的。

1)使用 confirm 机制确保消息100%投递成功

1
2
3
4
5
6
7
8
9
10
// 开启confirm模式,confirm模式下,投递消息后,RabbitMQ会异步返回是否投递成功,confirm模式不可以和事务模式同时存在
$channel->confirm_select();

// 推送消息到RabbitMQ成功的异步回调,如果消息推送成功,想做什么业务处理写在这里
$channel->set_ack_handler(function (AMQPMessage $message) {
});

// 推送消息到RabbitMQ失败的异步回调,如果消息推送失败,想做什么业务处理写在这里
$channel->set_nack_handler(function (AMQPMessage $message) {
});

这里为了避免之后消费者消费消息时可能产生的重复消费问题,我们最好在消息中添加一个唯一ID(生成方式多种多样,最简单的比如时间戳+随机数+机器码),这样之后消费者消费消息时先去缓存中查有没有消费过这个消息,如果有消费过,则不再处理并且直接ack让rabbitmq删除这条消息。如果缓存中没有这个ID,则说明没有消费过这条消息,那就先消费执行业务逻辑,执行成功后将这个ID写入缓存,然后ack确认让rabbitmq删除掉这条消息。

这里需要注意的是,建议消费者将唯一ID存到缓存中时,设置个有效期TTL,这样可以避免内存爆炸。一般设置为1-2天足以了,因为即使有失败的消息,我们的业务人员也会在1-2天内手动处理好。

2)使用ack、nack机制确保消息100%消费成功

在消费消息时,将no_ack参数设置为false,表明要我们确认,rabbitmq才可以删除消息,否则不可以删除消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 消费监听,在参数里将第四个参数no_ack设置为false,表示需要我这里确认,你rabbitmq才可以把消息删掉
$channel->basic_consume($queueName, '', false, false, false, false, function ($message) {

// $message->body是推送过来的消息,业务代码写在这里

// 标识取出消息后,要执行的业务是否已经执行成功
$isSuccess = true;

// 如果业务执行成功,则调用ack方法,告诉rabbitmq可以把这条消息删除了
if (true == $isSuccess) {
$message->ack();
} else {
// 如果如果业务执行失败,则调用nack方法,告诉rabbitmq不可以删除这条消息,我执行失败了
$message->nack(true);
}
});

这里,对于失败的消息,可以将消息及捕捉到的错误信息先记录到日志表,待错误排查后再重新消费

Seata

阿里开源的分布式事务框架,官方文档介绍的非常全面,Seata官方网址 https://seata.io/zh-cn/

总结

分布式事务一直是微服务的痛点,实际工作选择MQ方式/Seata分布式事务框架或许会更好,原因在于开发成本低、学习成本低,可靠不容易出错。