分布式-事务

分布式-事务

转载声明

本文大量内容系转载自以下文章,有删改,并参考其他文档资料加入了一些内容:

0x01 事务简介

1.1 事务意义

  • 多个数据要同时操作,如何保证数据的完整性,以及一致性?
    答:事务,是常见的做法。事务提供一种机制将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。
    分布式-事务

  • 举个栗子:
    分布式-事务
    用户下了一个订单,需要修改余额表,订单表,流水表,于是会有类似的伪代码:

start transaction;
 CURD table t_account;  any Exception rollback;
 CURD table t_order;      any Exception rollback;
 CURD table t_flow;        any Exception rollback;
commit;
  • 如果对余额表,订单表,流水表的SQL操作全部成功,则全部提交
  • 如果任何一个出现问题,则全部回滚

事务,可保证数据的完整性以及一致性。

1.2 事务问题

互联网的业务特点,数据量较大,并发量较大,经常使用拆库的方式提升系统的性能。如果进行了拆库,余额、订单、流水可能分布在不同的数据库上,甚至不同的数据库实例上,此时就不能用数据库原生事务来保证数据的一致性了。

1.3 数据库事务的 ACID 属性

1.3.1 简介

事务是基于数据进行操作,需要保证事务的数据通常存储在数据库中,所以介绍到事务,就不得不介绍数据库事务的 ACID 特性。

ACID 指数据库事务正确执行的四个基本特性的缩写,包含以下四个:
分布式-事务

1.3.2 原子性(Atomicity)

整个事务中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。

如果事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。

例如:银行转账,从 A 账户转 100 元至 B 账户,分为两个步骤:

  1. 从 A 账户取 100 元。
  2. 存入 100 元至 B 账户。

这两步要么都完成,要么都不完成。因为如果只完成第一步,第二步失败,钱会莫名其妙少了 100 元。

1.3.3 一致性(Consistency)

指在事务开始之前和事务结束以后,数据库数据的一致性约束没有被破坏。

以转账案例为例,假设有五个账户,每个账户余额是100元,那么五个账户总额是500元,如果在这个5个账户之间同时发生多个转账,无论并发多少个,比如在A与B账户之间转账5元,在C与D账户之间转账10元,在B与E之间转账15元,五个账户总额也应该还是500元,这就是保护性和不变性。

1.3.4 隔离性(Isolation)

数据库允许多个并发事务同时对数据进行读写和修改的能力,如果一个事务要访问的数据正在被另外一个事务修改,只要另外一个事务未提交,它所访问的数据就不受未提交事务的影响。

隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

例如:现有有个交易是从 A 账户转 100 元至 B 账户,在这个交易事务还未完成的情况下,如果此时 B 查询自己的账户,是看不到新增加的 100 元的。

1.3.5 持久性(Durability)

事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

1.3.6 小结

简单而言,ACID 是从不同维度描述事务的特性:

  • 原子性:事务操作的整体性。
  • 一致性:事务操作下数据的正确性。
  • 隔离性:事务并发操作下数据的正确性。
  • 持久性:事务对数据修改的可靠性。

0x02 补偿事务(TCC)

2.1 简介

补偿事务,是一种在业务端实施业务逆向操作事务。TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:

  1. Try 阶段
    主要是对业务系统做检测及资源预留
  2. Confirm 阶段
    主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
  3. Cancel 阶段
    主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

2.2 例子

  • 修改余额,事务为:
int Do_AccountT(uid, money){
    start transaction;
         //余额改变money这么多
         CURD table t_account with money for uid;
         anyException rollback return NO;
    commit;
    return YES;
}

那么,针对修改余额的补偿事务可以是:

int Compensate_AccountT(uid, money){
         //做一个money的反向操作
         return Do_AccountT(uid, -1*money){
}

同理,订单操作,事务是:Do_OrderT,新增一个订单;
订单操作,补偿事务是:Compensate_OrderT,删除一个订单。

要保证余额与订单的一致性,伪代码:

// 执行第一个事务
int flag = Do_AccountT();
if(flag=YES){
    //第一个事务成功,则执行第二个事务
    flag= Do_OrderT();
    if(flag=YES){
        // 第二个事务成功,则成功
        return YES;
    }
    else{
        // 第二个事务失败,执行第一个事务的补偿事务
        Compensate_AccountT();
    }
}
  • TCC例子
    假入 Bob 要向 Smith 转账,思路大概是:
    我们有一个本地方法,里面依次调用
    1. 首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。
    2. 在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
    3. 如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。

缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。

2.3 补偿事务小结

2.3.1 优点

跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些。

2.3.1 缺点

  • 没有考虑补偿事务的失败。在2,3步中都有可能失败。
  • 补偿实现复杂
    TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理;
  • 不同的业务要写不同的补偿事务,不具备通用性;
  • 如果业务流程很复杂,if/else会嵌套非常多层,成指数型上升;

画外音:上面的例子还只考虑了余额+订单的一致性,就有22=4个分支,如果要考虑余额+订单+流水的一致性,则会有222=8个if/else分支,复杂性呈指数级增长。*

2.4 多个事务情况

2.4.1 多个事务带来的不一致

单库是用这样一个大事务保证一致性:

start transaction;
 CURD table t_account;  any Exception rollback;
 CURD table t_order;      any Exception rollback;
 CURD table t_flow;        any Exception rollback;
commit;

拆分成了多个库后,大事务会变成类似以下的三个小事务,发生在三个库甚至三个不同实例的数据库上:
分布式-事务

start transaction1;
         //第一个库事务执行
         CURD table t_account; any Exception rollback;// 第一个库事务提交
commit1;

一个事务,分成执行与提交两个阶段:

执行(CURD)的时间很长
提交(commit)的执行很快

于是三个事务的整个执行过程的时间轴如下:
分布式-事务

注意,可能出现数据不一致:
第一个事务成功提交之后,最后一个事务成功提交之前,如果出现问题(例如服务器重启,数据库异常等),都可能导致数据不一致。
分布式-事务

2.4.2 后置提交优化保证一致性

如果改变事务执行与提交的时序,变成事务先执行,最后一起提交,就是后置提交,如下:
分布式-事务

  • 后置提交优化后,在什么时候,会出现不一致?
    答:问题的答案与之前相同,第一个事务成功提交之后,最后一个事务成功提交之前,如果出现问题(例如服务器重启,数据库异常等),都可能导致数据不一致。
    分布式-事务

2.4.3 串行事务和后置提交差异

答:

  • 串行事务方案,总执行时间是303ms,最后202ms内出现异常都可能导致不一致;

  • 后置提交优化方案,总执行时间也是303ms,但最后2ms内出现异常才会导致不一致;

    虽然没有彻底解决数据的一致性问题,但不一致出现的概率大大降低了。

2.4.4 后置提交不足?

答:对事务吞吐量会有影响:

  • 无法彻底解决多个事务数据不一致性风险

  • 串行事务方案,第一个库事务提交,数据库连接就释放了;

    而后置提交优化方案,所有库的连接,要等到所有事务执行完才释放;这就意味着,数据库连接占用的时间增长了,系统整体的吞吐量降低了。

2.5 小结

分布式事务,两种常见的实践:

  • 补偿事务
  • 后置提交优化

trx1.exec(); trx1.commit();
trx2.exec(); trx2.commit();
trx3.exec(); trx3.commit();

优化为:

trx1.exec(); trx2.exec(); trx3.exec();
trx1.commit(); trx2.commit(); trx3.commit();

这个小小的改动(改动成本极低),不能彻底解决多库分布式事务数据一致性问题,但能大大降低数据不一致的概率,牺牲的是吞吐量。

对于一致性与吞吐量的折衷,还需要业务架构师谨慎权衡折衷。

0x03 分布式事务

3.1 基础概念

3.1.1 概述

随着互联网快速发展,微服务,SOA 等服务架构模式正在被大规模的使用,现在分布式系统一般由多个独立的子系统组成,多个子系统通过网络通信互相协作配合完成各个功能。具体来说,在分布式环境下,每个节点都可以知晓自己操作的成功或者失败,却无法知道其他节点操作的成功或失败。

当一个分布式事务跨多个节点时,保持事务的原子性与一致性,是非常困难的。我们需要一个分布式事务的解决方案保障业务全局的数据一致性。

有很多用例会跨多个子系统才能完成,比较典型的是电子商务网站的下单支付流程,至少会涉及交易系统和支付系统。而且这个过程中会涉及到事务的概念,即保证交易系统和支付系统的数据一致性,此处我们称这种跨系统的事务为分布式事务。
分布式-事务

3.1.2 交易系统分布式事务场景

分布式-事务
上图中包含了库存和订单两个独立的微服务,每个微服务维护了自己的数据库。

在交易系统的业务逻辑中,一个商品在下单之前需要先调用库存服务,进行扣除库存,再调用订单服务,创建订单记录。
分布式-事务
可以看到,如果多个数据库之间的数据更新没有保证事务,将会导致出现子系统数据不一致,业务出现问题。

3.1.3 分布式事务的难点

  • 事务的原子性
    事务操作跨不同节点时,当多个节点某一节点操作失败时,需要保证多节点操作的原子性。

  • 事务的一致性
    当发生网络传输故障或者节点故障,节点间数据复制通道中断,在进行事务操作时需要保证数据一致性,保证事务的任何操作都不会使得数据违反数据库定义的约束、触发器等规则。

  • 事务的隔离性
    事务隔离性的本质就是如何正确处理多个并发事务的读写冲突和写写冲突,因为在分布式事务控制中,可能会出现提交不同步的现象,这个时候就有可能出现“部分已经提交”的事务。

    此时并发应用访问数据如果没有加以控制,有可能出现“脏读”问题。

3.2 两阶段提交

3.2.1 简介

二阶段提交2PC(Two phase Commit)是一种,在分布式环境下,所有节点进行事务提交,保持一致性的算法。

它通过引入一个协调者(Coordinator)来统一掌控所有参与者(Participant)的操作结果,并指示它们是否要把操作结果进行真正的提交(commit)或者回滚(rollback)。

3.2.2 两阶段提交阶段

分布式-事务

  1. 投票阶段(voting phase):参与者通知各协调者(Prepare),等待各协调者反馈结果(Ready,具体来说此时参与者将告知协调者自己的决策:同意(Ready,事务参与者本地作业执行成功)或取消(Cancel,本地作业执行故障));
    画外音:可以理解为单机事务的trx.exec()。
  2. 提交阶段(commit phase):收到参与者的反馈后(Ready/Cancel),协调者再向参与者发出通知,根据反馈情况,当且仅当所有的参与者同意提交事务协调者才通知所有的参与者提交事务(Commit),否则协调者将通知所有的参与者回滚事务(Rollback)。
    画外音:可以理解为单机事务的trx.commit() 或者 trx.rollback()。

在第一阶段协调者的询盘之后,各个参与者会回复自己事务的执行情况,这时候存在三种可能:

  1. 所有的参与者回复能够正常执行事务
  2. 一个或多个参与者回复事务执行失败
  3. 协调者等待超时。

对于第一种情况,协调者将向所有的参与者发出提交事务的通知,具体步骤如下:

  1. 协调者向各个参与者发送commit通知,请求提交事务。
  2. 参与者收到事务提交通知之后,执行事务commit操作,然后释放占有的资源。
  3. 参与者向协调者返回事务commit结果信息

分布式-事务
对于第二、三种失败的情况,协调者均认为参与者无法正常成功执行事务,为了整个集群数据的一致性,所以要向各个参与者发送事务回滚通知,具体步骤如下:

  1. 协调者向各个参与者发送事务rollback通知,请求回滚事务。
  2. 参与者收到事务回滚通知之后,执行rollback操作,然后释放占有的资源。
  3. 参与者向协调者返回事务rollback结果信息。
    分布式-事务

3.2.3 例子

  • 场景
    甲乙丙丁四人要组织一个会议,需要确定会议时间,不妨设甲是协调者,乙丙丁是参与者。
  1. 投票阶段
    (1)甲发邮件给乙丙丁,通知明天十点开会,询问是否有时间;
    (2)乙回复有时间;
    (3)丙回复有时间;
    (4)丁迟迟不回复,此时对于这个事务,甲乙丙均处于阻塞状态,算法无法继续进行;

  2. 提交阶段
    (1)协调者甲将收集到的结果通知给乙丙丁;
    画外音:什么时候通知,以及反馈结果如何,在此例中取决与丁的时间与决定,
    假设丁回复有时间,则通知commit;
    假设丁回复没有时间,则通知rollback;

    (2)乙收到通知,并ack协调者;
    (3)丙收到通知,并ack协调者;
    (4)丁收到通知,并ack协调者;
    画外音:如果甲没有收到所有ack,则分布式事务迟迟不会结束,下一轮投票则迟迟不会开展。

3.2.4 两阶段提交的缺陷

  1. 同步阻塞
    2PC在执行过程中,所有节点都处于事务阻塞状态,所有节点所持有的资源(例如数据库数据,本地文件等)都处于锁定状态。

    典型情况为:

    1. 某一个参与者回复消息之前,所有参与者以及协调者都处于阻塞状态;
    2. 在协调者发出消息之前,所有参与者都处于阻塞状态;
  2. 协调者单点问题
    另外,如有协调者或者某个参与者出现了崩溃,为了避免整个算法处于一个完全阻塞状态,往往需要借助超时机制来将算法继续向前推进。

  3. 数据不一致
    在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。

    而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。

总的来说,2PC是一种比较保守并且低效的算法,分布式事务真的很难做。

3.2.5 XA

XA是由X/Open组织提出的分布式事务的规范,主要定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。

XA接口是双向的系统接口,在事务管理器(Transaction Manager)以及一个或多个资源管理器(Resource Manager)之间形成通信桥梁。

  • 事务管理器
    XA之所以需要引入事务管理器是因为,在分布式系统中,从理论上讲(参考Fischer等的论文),**两台机器理论上无法达到一致的状态,需要引入一个单点进行协调。**事务管理器控制着全局事务,管理事务生命周期,并协调资源。
  • 资源管理器
    资源管理器负责控制和管理实际资源(如数据库或JMS队列)。数据库就是一种资源管理器。资源管理还应该具有管理事务提交或回滚的能力。

下图说明了事务管理器™、资源管理器(RM),与应用程序(AP)之间的关系:
分布式-事务

由全局事务管理器管理和协调的事务,可以跨 越多个资源(如数据库或JMS队列)和进程。 全局事务管理器一般使用 XA 二阶段提交协议 与数据库进行交互。

  • 代码示例
    XA 分布式事务原理

  • 应用与XA
    MySQL从5.5版本开始支持,SQL Server 2005 开始支持,Oracle 7 开始支持。

3.2.6 JTA

作为java平台上事务规范JTA(Java Transaction API)也定义了对XA事务的支持,实际上,JTA是基于XA架构上建模的,在JTA 中,事务管理器抽象为javax.transaction.TransactionManager接口,并通过底层事务服务(即JTS)实现。

像很多其他的java规范一样,JTA仅仅定义了接口,具体的实现则是由供应商(如J2EE厂商)负责提供,目前JTA的实现主要由以下几种:

  1. J2EE容器所提供的JTA实现(JBoss)
  2. 独立的JTA实现:如JOTM,Atomikos.这些实现可以应用在那些不使用J2EE应用服务器的环境里用以提供分布事事务保证。如Tomcat,Jetty以及普通的java应用。

3.3 三阶段提交

3.3.1 简介

由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。

针对两阶段提交存在的问题,三阶段提交协议通过引入一个“预询问”阶段,以及超时策略来减少整个集群的阻塞时间,提升系统性能。三阶段提交的分别为:can_commitpre_commitdo_commit
分布式-事务
与两阶段提交不同的是,三阶段提交有两个改动点:

  1. 引入超时机制。同时在协调者和参与者中都引入超时机制,防止因为某个角色故障导致整个链路全局阻塞。
  2. 在第一阶段和第二阶段之间插入一个准备阶段,保证了在最后提交阶段之前各参与节点的状态是一致的。

也就是说,除了引入超时机制之外,3PC把2PC的投票阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。

3.3.2 CanCommit

3PC的CanCommit阶段其实和2PC的投票阶段很像。参与者根据自身情况回复一个预估值(YES/NO),相对于真正的执行事务,这个过程是轻量的,具体步骤如下:

  1. 事务询问
    协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
  2. 响应反馈
    参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No

3.3.3 PreCommit

本阶段协调者会根据第一阶段CanCommit的询问结果采取相应操作:

  1. 所有的参与者都返回确定信息
  2. 一个或多个参与者返回否定信息
  3. 协调者等待超时

针对第一种情况,协调者会向所有参与者发送事务执行请求,具体步骤如下:

  1. 发送PreCommit
    协调者向所有的事务参与者发送事务执行通知,进入Prepared阶段。
  2. 事务PreCommit
    参与者收到PreCommit请求后,执行事务,并将undo和redo信息记录到事务日志中,但不提交该事务
  3. 响应反馈
    参与者将事务执行情况返回给客户端(ACK响应),同时开始等待协调者的最终事务指令。

在上面的步骤中,如果参与者等待超时,则会中断事务。

针对第二、三种异常情况,协调者认为事务无法正常执行,于是向各个参与者发出abort通知,请求退出Prepared状态,具体步骤如下:

  1. 发送事务中断请求
    协调者向所有事务参与者发送abort通知
  2. 事务中断
    参与者收到abort通知后(或超时之后,仍未收到协调者的请求),中断当前事务
    分布式-事务

3.3.4 DoCommit

如果第二阶段事务未中断,那么本阶段协调者将会依据事务执行返回的结果来决定提交或回滚事务,分为三种情况:

  1. 所有的参与者都能正常执行事务
  2. 一个或多个参与者执行事务失败
  3. 协调者等待超时

针对第一种情况,协调者向各个参与者发起事务提交请求,具体步骤如下:

  1. 协调者向所有参与者发送事务commit通知
  2. 所有参与者在收到通知之后执行commit操作,并释放占有的资源
  3. 参与者向协调者反馈事务提交结果
    分布式-事务
    针对第二、三种异常情况,协调者认为事务无法正常执行,于是向各个参与者发送事务回滚请求,具体步骤如下:
  4. 协调者向所有参与者发送事务rollback通知
  5. 所有参与者在收到通知之后执行rollback操作,并释放占有的资源
  6. 参与者向协调者反馈事务提交结果
    分布式-事务
    注意:在DoCommit阶段如果因为协调者或网络问题,导致参与者迟迟不能收到来自协调者的commit或rollback请求,那么参与者将不会如两阶段提交中那样陷入阻塞,而是在等待超时后直接commit事务。

3.3.5 2PC与3PC的区别

  • 3PC解决了减少阻塞
    相对于2PC,3PC主要减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit,而不会一直持有事务资源并处于阻塞状态。

  • 没解决数据一致性问题
    但是这种机制也会导致数据一致性问题,因为在DoCommit阶段,由于网络原因,协调者发送的rollback响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到rollback命令并执行回滚的参与者之间存在数据不一致的情况。

    在分布式数据库中,如果期望达到数据的强一致性,那么服务基本没有可用性可言,这也是为什么许多分布式数据库提供了跨库事务,但也只是个摆设的原因,在实际应用中我们更多追求的是数据的弱一致性或最终一致性,为了强一致性而丢弃可用性是不可取的。

3.4 阿里Fescar

3.4.1 微服务与分布式事务

微服务倡导将复杂的单体应用拆分为若干个功能简单、松耦合的服务,这样可以降低开发难度、增强扩展性、便于敏捷开发。当前被越来越多的开发者推崇。

系统微服务化后,一个看似简单的功能,内部可能需要调用多个服务并操作多个数据库实现,服务调用的分布式事务问题变的非常突出。分布式事务已经成为微服务落地最大的阻碍,也是最具挑战性的一个技术难题。每一个微服务内部的数据一致性仍有本地事务来保证。而整个业务层面的全局数据一致性需要一个分布式事务的解决方案保障业务全局的数据一致性。
分布式-事务

3.4.2 Fescar简介

由2014年阿里中间件团队发布 TXC(Taobao Transaction Constructor)改造来的阿里分布式事务解决方案:GTS(Global Transaction Service),已正式推出开源版本,取名为“Fescar”,希望帮助业界解决微服务架构下的分布式事务问题,github连接

3.4.3 设计思想

3.4.3.1 设计初衷

高速增长的互联网时代,快速试错的能力对业务来说是至关重要的:

  • 接入成本低,对业务无侵入
    不应该因为技术架构上的微服务化和分布式事务支持的引入,给业务层面带来额外的研发负担。

    这里的“侵入”是指,因为分布式事务这个技术问题的制约,要求应用在业务层面进行设计和改造。这种设计和改造往往会给应用带来很高的研发和维护成本。把分布式事务问题在中间件这个层次解决掉,不要求应用在业务层面做额外工作。

  • 高性能
    引入分布式事务支持的业务应该基本保持在同一量级上的性能表现,不能因为事务机制显著拖慢业务。

    具体来说,引入分布式事务的保障,必然会有额外的开销,引起性能的下降。我们希望把分布式事务引入的性能损耗降到非常低的水平,让应用不因为分布式事务的引入导致业务的可用性受影响。

3.4.3.2 既有方案问题

既有的分布式事务解决方案按照对业务侵入性分为两类,即:对业务无侵入的和对业务有侵入的。

  • 业务无侵入的方案
    既有的主流分布式事务解决方案中,对业务无侵入的只有基于 XA 的方案,但应用 XA 方案存在 3 个方面的问题:

要求数据库提供对 XA 的支持。如果遇到不支持 XA(或支持得不好,比如 MySQL 5.7 以前的版本)的数据库,则不能使用。

受协议本身的约束,事务资源的锁定周期长。长周期的资源锁定从业务层面来看,往往是不必要的,而因为事务资源的管理器是数据库本身,应用层无法插手。这样形成的局面就是,基于 XA 的应用往往性能会比较差,而且很难优化。

已经落地的基于 XA 的分布式解决方案,都依托于重量级的应用服务器(Tuxedo/WebLogic/WebSphere 等),这是不适用于微服务架构的。
★侵入业务的方案

实际上,最初分布式事务只有 XA 这个唯一方案。XA 是完备的,但在实践过程中,由于种种原因(包含但不限于上面提到的 3 点)往往不得不放弃,转而从业务层面着手来解决分布式事务问题。比如:

基于可靠消息的最终一致性方案
TCC
Saga
都属于这一类。这些方案的具体机制在这里不做展开,网上这方面的论述文章非常多。总之,这些方案都要求在应用的业务层面把分布式事务技术约束考虑到设计中,通常每一个服务都需要设计实现正向和反向的幂等接口。这样的设计约束,往往会导致很高的研发和维护成本。

2.3 理想的方案应该是什么样子?

不可否认,侵入业务的分布式事务方案都经过大量实践验证,能有效解决问题,在各种行业的业务应用系统中起着重要作用。但回到原点来思考,这些方案的采用实际上都是迫于无奈。设想,如果基于 XA 的方案能够不那么重,并且能保证业务的性能需求,相信不会有人愿意把分布式事务问题拿到业务层面来解决。

一个理想的分布式事务解决方案应该:像使用本地事务一样简单,业务逻辑只关注业务层面的需求,不需要考虑事务机制上的约束。

3.5 本地消息表(异步确保)

详细实现思路可参考分布式事务之本地消息表

3.5.1 概述

该实现方式应该是业界使用最多,其核心思想是将分布式事务拆分成本地事务进行处理,来源于ebay。

我们可以从下面的流程图中看出其中的一些细节:
分布式-事务
基本思路就是:

  • 上游事务-消息生产方
    消息生产方(也就是发起方)需要在数据库额外建一个本地消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。

  • 下游事务-消息消费方
    查询自己的那份本地消息表,如果已经消费过的则不处理;否则需要处理这个消息,并完成自己的业务逻辑,最后写入该本地消息表,以避免重复消费问题。

    此时如果本地事务处理成功,表明已经处理成功了就更新本地消息表记录该条消息已经处理;如果处理失败,那么就会重试执行。

    如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。

  • 处理未完成消息
    生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。

该方案遵循BASE理论,采用的是最终一致性,笔者认为是这几种方案里面比较适合实际业务场景的,即不会出现像2PC那样复杂的实现(当调用链很长的时候,2PC的可用性是非常低的),也不会像TCC那样可能出现确认或者回滚不了的情况。

  • 优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。
  • 缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。而且,关系型数据库的吞吐量和性能方面存在瓶颈,频繁的读写消息会给数据库造成压力。所以,在真正的高并发场景下,该方案也会有瓶颈和限制的。

3.5.2 购买与kafka例子

  • producer:
@Transaction
public void buy(){
user.buy();
//新建一张msgtable本地消息表,消息在这里插入
insertInitMsgToDB();
}

public void sendMsg(){
//发送消息移到了新方法里
kafkaTemplete.sendMdg();
}

//主流程执行
buy();
sendMsg();
  • consumer:
@Kafkalistener
@Transaction
public void msgConsume(Record record){    
   if(isConsumed(record)){      
      //查询本地消息表,已经消费过的则不处理
      return} 
    //处理业务逻辑
    deal(record);
    // 更改本地消息表消息状态为成功
    changeRecord(record);

	// 还可以加逻辑,如果业务逻辑处理失败就给生产者发送消息,让他进行补偿事务等处理
}
  • MQ带来的消息重复或丢失的问题
    kafka中的配置enable.auto.commit 是 true,就有可能导致at least once,或者at most once的问题:
    • at most once
      当到达提交时间间隔,触发Kafka自动提交上次的偏移量时,就可能发生at most once的情况,在这段时间,如果消费者还没完成消息的处理进程就崩溃了, 消费者进程重新启动时,它开始接收上次提交的偏移量之后的消息,实际上消费者可能会丢失几条消息;
    • at least once
      而当消费者处理完消息并将消息提交到持久化存储系统,而消费者进程崩溃时,会发生at least once的情况。 在此期间,kafka没有向broker提交offset,因为自动提交时间间隔没有过去。 当消费者进程重新启动时,会收到从上次提交的偏移量开始的一些旧消息。
    • exactly once
      1. enable.auto.commit:false,消费数据、业务流程完成后再手动提交offset
      2. request.required.acks:-1,生产者必须等ISR全部确认后才返回
      3. 业务逻辑代码去重,比如增加消费表每次先去查下没有才插入或用redis。

3.5.3 在线支付系统的跨行转账例子

  1. 用户A事务
Begin transaction         
// 对用户id为A的账户扣款1000元
update user_account set amount = amount - 1000 where userId = 'A' 
// 通过本地事务将事务消息(包括本地事务id、支付账户、收款账户、金额、状态等)插入至消息表:  
insert into trans_message(xid,payAccount,recAccount,amount,status) 
values(uuid(),'A','B',1000,1);

end transactioncommit;
  1. 用户B事务
    通知对方用户id为B,增加1000元。通常通过MQ的方式发送异步消息,对方订阅并监听消息后自动触发转账的操作;

这里为了保证幂等性,防止触发重复的转账操作,需要在执行转账操作方新增一个trans_recv_log表用来做幂等:在第二阶段用户B收到消息后,通过判断trans_recv_log表来检测相关记录是否被执行,如果未被执行则会对B账户余额执行加1000元的操作,并会将该记录增加至trans_recv_log,事件结束后通过回调更新trans_message的状态值。

Begin transaction  
/**读取消息, B账户加1000
.....
*/
update trans_message set status = 0 where xid = ?
end transactioncommit;

3.6 MQ

使用消息中间件MQ,可能会由于消费者挂掉或网络波动,短时间无法消费消息,但是生产者只关心消息是否发出去,而不关心是否被消费,所以这就是上面CAP理论中的最终一致性,也是弱一致性。
分布式-事务
优点: 实现了最终一致性,不需要依赖本地数据库事务。

分布式-事务

3.7 支付宝回调

做过支付宝交易接口的同学都知道,我们一般会在支付宝的回调页面和接口里,解密参数,然后调用系统中更新交易状态相关的服务,将订单更新为付款成功。同时,只有当我们回调页面中输出了success字样或者标识业务处理成功相应状态码时,支付宝才会停止回调请求。否则,支付宝会每间隔一段时间后,再向客户方发起回调请求,直到输出成功标识为止。

其实这就是一个很典型的补偿例子,跟一些MQ重试补偿机制很类似。

一般成熟的系统中,对于级别较高的服务和接口,整体的可用性通常都会很高。如果有些业务由于瞬时的网络故障或调用超时等问题,那么这种重试机制其实是非常有效的。

当然,考虑个比较极端的场景,假如系统自身有bug或者程序逻辑有问题,那么重试1W次那也是无济于事的。那岂不是就发生了“明明已经付款,却显示未付款不发货”类似的悲剧?

其实为了交易系统更可靠,我们一般会在类似交易这种高级别的服务代码中,加入详细日志记录的,一旦系统内部引发类似致命异常,会有邮件通知。同时,后台会有定时任务扫描和分析此类日志,检查出这种特殊的情况,会尝试通过程序来补偿并邮件通知相关人员。

在某些特殊的情况下,还会有“人工补偿”的,这也是最后一道屏障。

0x04 柔性事务

4.1 柔性事务的概念

在电商等互联网场景下,传统的事务在数据库性能和处理能力上都暴露出了瓶颈。在分布式领域基于 CAP 理论以及 BASE 理论,有人就提出了柔性事务的概念。

基于 BASE 理论的设计思想,柔性事务下,在不影响系统整体可用性的情况下(Basically Available 基本可用),允许系统存在数据不一致的中间状态(Soft State 软状态),在经过数据同步的延时之后,最终数据能够达到一致。

并不是完全放弃了 ACID,而是通过放宽一致性要求,借助本地事务来实现最终分布式事务一致性的同时也保证系统的吞吐。

4.2 实现柔性事务的一些特性

下面介绍的是实现柔性事务的一些常见特性,这些特性在具体的方案中不一定都要满足,因为不同的方案要求不一样。

可见性(对外可查询) :在分布式事务执行过程中,如果某一个步骤执行出错,就需要明确的知道其他几个操作的处理情况,这就需要其他的服务都能够提供查询接口,保证可以通过查询来判断操作的处理情况。

为了保证操作的可查询,需要对于每一个服务的每一次调用都有一个全局唯一的标识,可以是业务单据号(如订单号)、也可以是系统分配的操作流水号(如支付记录流水号)。除此之外,操作的时间信息也要有完整的记录。

4.3 操作幂等性

幂等性,其实是一个数学概念。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。

幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。也就是说,同一个方法,使用同样的参数,调用多次产生的业务结果与调用一次产生的业务结果相同。

之所以需要操作幂等性,是因为为了保证数据的最终一致性,很多事务协议都会有很多重试的操作,如果一个方法不保证幂等,那么将无法被重试。

幂等操作的实现方式有多种,如在系统中缓存所有的请求与处理结果、检测到重复操作后,直接返回上一次的处理结果等。

0xFF 参考文档

XA 分布式事务原理

一段解决kafka消息处理异常的经典对话

面试问题(分布式事务除两阶段提交外的其他解决方案)

分布式系统事务一致性解决方案大对比,谁最好使?

分布式事务 ( DTS )及实现方法