分布式事务-2PC与TCC

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

随着微服务的发展,需要实现分布式事务的场景越来越多。分布式事务在实现上分为基于补偿的方案和基于消息通知方案两种类型。

基于补偿的方案有2PC、TCC模式、Saga模式、Seata AT模式,它们都可以看成是遵守XA协议或是XA协议的变种。本次只聊2PC和TCC,今后有时间再聊其它模式。

分布式事务定义

分布式事务用于在分布式系统中保证不同节点之间的数据一致性。

分布式事务是相对于单机事务/本地事务而言的,在分布式场景下,一个系统由多个子系统构成,每个子系统有独立的数据源。在微服务系统架构中,我们把每个子系统看成是一个微服务,每个微服务都可以维护自己的数据存储,各自保持独立,最后通过他们之间的互相调用组合出更复杂的业务逻辑。

举一个简单例子,在电商系统里,会有库存服务、订单服务,还有业务层的购物服务。生成订单的时候,需要调用库存服务扣减库存和调用订单服务插入订单记录。我们需要同时保证库存服务和订单服务的事务性。这就是分布式事务要解决的问题。

图片

XA协议


在讲分布式事务之前,必然需要先了解XA协议。XA是一个协议,是X/Open组织制定的关于分布式事务的一组标准接口,实现这些接口,便意味支持XA协议。

XA协议有两个重要贡献,一是定义了两个角色,二是定义了相关接口(只有定义无实现)。

角色

XA协议定义了分布式事务参与方的两个角色:

扫描二维码关注公众号,回复: 13165834 查看本文章
  • 事务协调者(TM=Transaction Manager,对应例子中的购物服务)

  • 资源管理器/事务参与者(RM=Resource Manager,对应例子中的库存服务和订单服务)

接口

XA 规范主要定义了事务管理器(Transaction Manager)和局部资源管理器(Local Resource Manager)之间的接口。

以下的函数使事务管理器可以对资源管理器进行的操作:

1)xa_open,xa_close:建立和关闭与资源管理器的连接。

2)xa_start,xa_end:开始和结束一个本地事务。

3)xa_prepare,xa_commit,xa_rollback:预提交、提交和回滚一个本地事务。

4)xa_recover:回滚一个已进行预提交的事务。

5)ax_开头的函数使资源管理器可动态地在事务管理器中进行注册,并可以对XID(TRANSACTION IDS)进行操作。

6)ax_reg,ax_unreg;允许资源管理器在一个TMS(TRANSACTION MANAGER SERVER)中动态注册或撤消注册。

两阶段提交(2PC)

二阶段提交(2PC)是XA分布式事务协议的一种实现。其实在XA协议定义的函数中,通过xa_prepare,xa_commit已经能发现XA完整提交分准备和提交两个阶段。

2PC多用于数据库层面,在业务层面使用2PC需要处理很多问题,用的相对少一些。本次主要聊数据库层面上的2PC。下面我们看一下两阶段提交的流程:

流程

准备阶段

准备阶段有如下三个步骤:

  • 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待所有参与者答复。

  • 各参与者执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。

  • 如参与者执行成功,给协调者反馈 yes,即可以提交;如执行失败,给协调者反馈 no,即不可提交。

提交阶段

协调者基于各个事务参与者的准备状态,来决策是事务提交Commit()或事务回滚Rollback()。

如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(rollback)消息;否则,发送提交(commit)消息。

参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)

样例

接下来分两种情况分别讨论提交阶段的过程。

提交事务

情况 1,当所有参与者均反馈 yes,提交事务,如下图所示:

  • 协调者向所有参与者发出正式提交事务的请求(即 commit 请求)。

  • 参与者执行 commit 请求,并释放整个事务期间占用的资源。

  • 各参与者向协调者反馈 ack(应答)完成的消息。

  • 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。

图片

中断事务

情况 2,当准备阶段中任何一个参与者反馈 no,中断事务,如下图所示:

  • 协调者向所有参与者发出回滚请求(即 rollback 请求)。

  • 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。

  • 各参与者向协调者反馈 ack 完成的消息。

  • 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。

图片

伪代码

数据库

XA 事务,通过 Start 启动一个 XA 事务,并且被置为 Active 状态,处在 active 状态的事务可以执行 SQL 语句,通过 END 方法将 XA 事务置为 IDLE 状态。处于 IDLE 状态可以执行 PREPARE 操作或者 COMMIT…ONE PHASE 操作,也就是二阶段提交中的第一阶段,PREPARED 状态的 XA事务的时候就可以 Commit 或者 RollBack,也就是二阶段提交的第二阶段。

「场景:」 模拟现金 + 红包组合支付,假设我们购买了 100 块钱的东西,90块使用现金支付,10 块红包支付,现金和红包处在不同的库。

「假设:」 现在有两个库:xa_account(账户库,现金库)、xa_red_account(红包库)。两个库下面都有一张 account 表,account 表中的字段也比较简单,就 id、user_id、balance_amount 三个字段。

public class XaDemo {
    public static void main(String[] args) throws Exception{
        
        // 是否开启日志
        boolean logXaCommands = true;

        // 获取账户库的 rm(ap做的事情)
        Connection accountConn = DriverManager.getConnection("jdbc:mysql://106.12.12.xxxx:3306/xa_account?useUnicode=true&characterEncoding=utf8","root","xxxxx");
        XAConnection accConn = new MysqlXAConnection((JdbcConnection) accountConn, logXaCommands);
        XAResource accountRm = accConn.getXAResource();
        // 获取红包库的RM
        Connection redConn = DriverManager.getConnection("jdbc:mysql://106.12.12.xxxx:3306/xa_red_account?useUnicode=true&characterEncoding=utf8","root","xxxxxx");
        XAConnection Conn2 = new MysqlXAConnection((JdbcConnection) redConn, logXaCommands);
        XAResource redRm = Conn2.getXAResource();
  // XA 事务开始了
        // 全局事务
        byte[] globalId = UUID.randomUUID().toString().getBytes();
        // 就一个标识
        int formatId = 1;
  
        // 账户的分支事务
        byte[] accBqual = UUID.randomUUID().toString().getBytes();;
        Xid xid = new MysqlXid(globalId, accBqual, formatId);

        // 红包分支事务
        byte[] redBqual = UUID.randomUUID().toString().getBytes();;
        Xid xid1 = new MysqlXid(globalId, redBqual, formatId);
        try {
            // 账号事务开始 此时状态:ACTIVE
            accountRm.start(xid, XAResource.TMNOFLAGS);
            // 模拟业务
            String sql = "update account set balance_amount=balance_amount-90 where user_id=1";
            PreparedStatement ps1 = accountConn.prepareStatement(sql);
            ps1.execute();
            accountRm.end(xid, XAResource.TMSUCCESS);
    // 账号 XA 事务 此时状态:IDLE
            // 红包分支事务开始
            redRm.start(xid1, XAResource.TMNOFLAGS);
            // 模拟业务
            String sql1 = "update account set balance_amount=balance_amount-10 where user_id=1";
            PreparedStatement ps2 = redConn.prepareStatement(sql1);
            ps2.execute();
            redRm.end(xid1, XAResource.TMSUCCESS);


            // 第一阶段:准备提交 
            int rm1_prepare = accountRm.prepare(xid);
            int rm2_prepare = redRm.prepare(xid1);
   
   //  XA 事务 此时状态:PREPARED  
            // 第二阶段:TM 根据第一阶段的情况决定是提交还是回滚
            boolean onePhase = false; //TM判断有2个事务分支,所以不能优化为一阶段提交
            if (rm1_prepare == XAResource.XA_OK && rm2_prepare == XAResource.XA_OK) {
                accountRm.commit(xid, onePhase);
                redRm.commit(xid1, onePhase);
            } else {
                accountRm.rollback(xid);
                redRm.rollback(xid1);
            }

        } catch (Exception e) {
            // 出现异常,回滚
            accountRm.rollback(xid);
            redRm.rollback(xid1);
            e.printStackTrace();
        }
    }
}

复制代码

业务

一般分为协调器和若干事务执行者两种角色:

  1. 首先协调器先将Prepare()消息写到本机日志,然后向所有事务执行者发Prepare()消息 ;

  2. 事务执行者收到Prepare()消息后,根据本机执行情况,如果成功返回Yes,不成功返回No,返回前把要返回的消息写到日志里。

  3. 协调器收集完所有事务执行者的返回消息后(或经过一个超时周期后) ,如果都返回的是Yes,则事务成功,发送给所有执行者Commit(),否则认为事务失败发送Rollback()。

  4. 协调器发送前还是应把消息写到日志里。

  5. 执行者接收到协调者的Commit()或Rollback()后先把消息写到日志里,然后再根据消息提交或回滚。

注:协调者或事务执行者把发送或接收到的消息先写到日志里,主要是为了故障后恢复用。如某一事务执行者从故障中恢复后,先检查本机的日志,如果已收到Commit(),则提交,如果已收到Rollback()则回滚。如果是Yes,则再向协调者询问一下,确定下一步怎么做。如果日志里什么都没有,则很可能在Prepare阶段事务执行者就崩溃了,因此需要回滚。

二阶段提交的缺陷在于如果事务协调者崩溃,所有执行者可能都需要等待协调者,从而产生阻塞。

按照这种实现思路,唯一理论上两阶段提交出现问题的情况是当协调者发出提交指令后宕机并出现磁盘故障等永久性错误,导致事务不可追踪和恢复。

异常情况

上面的流程都是理想状态,但网络往往没有这么理想,会产生很多中间状态,让我们看几种异常情况:

  1. 在准备阶段,事务协调者故障,故障时间为发送Prepare后
  • 无解:发送后故障,参与者会一直处于锁定状态
  1. 提交阶段,参与者超时未返回或网络问题导致部分参与者未收到信息
  • 无解:不知道参与者是什么状态
  1. 提交阶段,事务协调者故障,故障时间分发送Confirm前、发送过程中、发送后
  • 无解:发送前故障,参与者会一直处于锁定状态

  • 无解:发送中故障,不知道之前的决策结果

  • 无解:发送后故障,不知道完全结束没有

纯2PC方案,对于很多异常情况,无法处理。要解决这些问题,需要增加新的特性,就不算2PC了。

2PC总结

将提交分成两阶段进行的目的很明确,就是尽可能晚地提交事务,让事务在提交前尽可能地完成所有能完成的工作,这样,最后的提交阶段将是一个耗时极短的微小操作,这种操作在一个分布式系统中失败的概率是非常小的,也就是所谓的“网络通讯危险期”非常的短暂,这是两阶段提交确保分布式事务原子性的关键所在。

2PC 方案实现起来简单,但太过单薄,所以实际项目中使用比较少,总结一下原因:

  • 性能:在阶段一需要所有的参与者都返回状态后才能进入第二阶段,并且要把相关的全局资源锁定住,这种同步阻塞的操作,会影响整体事务的并发度。

  • 协议:2PC要求RM必须实现XA协议,准确讲XA是一个规范,它只是定义了一系列的接口,只是目前大多数实现XA的都是数据库或者MQ,在微服务架构中,RM可能是任意的类型,可以是一个微服务,也可以是一个KV

  • 可靠性:如果协调者存在单点故障问题,如果协调者出现故障,参与者将一直处于锁定状态。

  • 数据不一致:在二阶段提交的阶段中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作,但是其他未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。

TCC

TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。

TCC本质上是一个业务层面上的2PC,他要求业务在使用TCC模式时必须实现三个接口Try()、Confirm()和Cancel()。在讲述2PC的时候,我们说过2PC无法解决宕机问题,那TCC如何解决2PC无法应对宕机问题的缺陷的呢?答案是不断重试。

TCC 是服务化的二阶段编程模型,其 Try、Confirm、Cancel 3 个方法均由业务编码实现:

  • Try 操作作为一阶段,负责资源的检查和预留。

  • Confirm 操作作为二阶段提交操作,执行真正的业务。

  • Cancel 是预留资源的取消。

流程

我们以上面的电商下单为例进行分析。

Try阶段

Try 仅是一个初步操作,它和后续的Confirm一起才能真正构成一个完整的业务逻辑,这个阶段主要完成:

  • 完成所有业务检查( 一致性 ) 。

  • 预留必须业务资源( 准隔离性 ) 。

  • Try 尝试执行业务。

假设商品库存为 100,购买数量为 2,这里检查和更新库存的同时,冻结用户购买数量的库存,同时创建订单,订单状态为待确认。

图片

Confirm/Cancel阶段

根据 Try 阶段服务是否全部正常执行,继续执行确认操作(Confirm)或取消操作(Cancel)。

Confirm 和 Cancel 操作满足幂等性,如果 Confirm 或 Cancel 操作执行失败,将会不断重试直到执行完成。

Confirm:当 Try 阶段服务全部正常执行, 执行确认业务逻辑操作

图片

这里使用的资源一定是 Try 阶段预留的业务资源。在 TCC 事务机制中认为,如果在 Try 阶段能正常的预留资源,那 Confirm 一定能完整正确的提交。

Confirm 阶段也可以看成是对 Try 阶段的一个补充,Try+Confirm 一起组成了一个完整的业务逻辑。

Cancel:当 Try 阶段存在服务执行失败, 进入 Cancel 阶段

图片

Cancel 取消执行,释放 Try 阶段预留的业务资源,上面的例子中,Cancel 操作会把冻结的库存释放,并更新订单状态为取消。

设计要点

空回滚

如果协调者的Try()请求因为网络超时失败,那么协调者在阶段二时会发送Cancel()请求,而这时这个事务参与者实际上之前并没有执行Try()操作而直接收到了Cancel()请求。

针对这个问题,TCC模式要求在这种情况下Cancel()能直接返回成功,也就是要允许「空回滚」。

防悬挂

接着上面的问题1,Try()请求超时,事务参与者收到Cancel()请求而执行了空回滚,但就在这之后网络恢复正常,事务参与者又收到了这个Try()请求,所以Try()和Cancel()发生了悬挂,也就是先执行了Cancel()后又执行了Try()

针对这个问题,TCC模式要求在这种情况下,事务参与者要记录下Cancel()的事务ID,当发现Try()的事务ID已经被回滚,则直接忽略掉该请求。

幂等性

Confirm()和Cancel()的实现必须是幂等的。当这两个操作执行失败时协调者都会发起重试。

伪代码

  1. 初始化:向事务管理器注册新事务,生成全局事务唯一ID

  2. try阶段执行:try相关的代码执行,期间注册相应的调用记录,发送try执行结果到事务管理器,执行成功由事务管理器执行confirm或者cancel步骤

  3. confirm阶段:事务管理器收到try执行成功信息,根据事务ID,进入事务confirm阶段执行,confirm失败进入cancel,成功则结束

  4. cancel阶段:事务管理器收到try执行失败或者confirm执行失败,根据事务ID,进入cancel阶段执行后结束,如果失败了,打印日志或者告警,让人工参与处理,也可记录失败,系统不断对cancel进行重试

TCC总结

TCC和2PC看起来很像,TCC和2PC最大的区别是,2PC是偏数据库层面的,而TCC是纯业务层面。

TCC 事务机制相对于传统事务机制(X/Open XA),TCC 事务机制相比于上面介绍的 XA 事务机制,有以下优点:

  • 性能提升:具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。

  • 数据最终一致性:基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。

  • 可靠性:解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。

  • 支持度:该模式对有无本地事务控制都可以支持使用面广。

缺点:TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。

总结

在看分布式事务的时候,看了一下公司下单代码,发现根本没有使用2PC或者TCC,方案也比较莽:执行正向操作,如果有失败,则调用逆向操作,逆向失败重试几次,还失败就记录,由另一个系统进行重试。

之所以这么设计,主要是这套系统设计的比较早,当时还没这么复杂场景。一旦系统复杂了,对核心功能的修改难度又较大。虽然系统目前能正常运作,但最终还是需要优化。

如果重新设计的话,我倒是挺喜欢TCC,因为各个系统按规范设计,都实现上述的设计要点,加入中间状态和重试系统,后面修改、扩充都要容易很多。后面看看有没有时间,实现一版TCC方案,否则容易一看就懂一写就废。不过得先把Saga模式、Seata AT模式和基于消息通知的方案聊完。

资料

  1. 分布式事务

  2. mp.weixin.qq.com/s/0eKX26pAb…

  3. github.com/TIGERB

  4. 关于分布式事务,XA协议的学习笔记(整理转载)

  5. xa

  6. 分布式事务之两阶段提交

  7. 对分布式事务及两阶段提交、三阶段提交的理解

  8. 如何理解两阶段提交?

  9. 两阶段提交的工程实践

  10. 分布式事务和两阶段提交及三阶段提交

  11. TCC和两阶段分布式事务处理的区别

  12. 关于分布式事务:两阶段提交,TCC和tx-lcn框架

  13. 分布式事务解决方案-seata实现2pc分布式事务控制

  14. 攀博课堂

  15. 分布式强一致性有哪些实现方案,2PC是不是强一致?

  16. MySQL两阶段提交具体实现

  17. 基于两阶段提交的分布式事务实现(UP-2PC)

  18. Java分布式事务 两阶段提交的编码实现-TCC

  19. Go分布式事务调研

  20. RocketMQ事务分享

  21. TCC Demo 代码实现

最后

大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)

我的个人博客为:shidawuhen.github.io/

往期文章回顾:

  1. 设计模式

  2. 招聘

  3. 思考

  4. 存储

  5. 算法系列

  6. 读书笔记

  7. 小工具

  8. 架构

  9. 网络

  10. Go语言

猜你喜欢

转载自juejin.im/post/7017333689109446670