1. 相关概念
1.1 分布式事务定义
分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
1.2 XA协议
X/Open DTP(X/Open Distributed Transaction Processing Reference Model)全局事务管理器与资源管理器的接口。XA是由X/Open组织提出的分布式事务规范。该规范主要定义了全局事务管理器和局部资源管理器之间的接口。主流的数据库产品都实现了XA接口。XA接口是一个双向的系统接口,在事务管理器以及多个资源管理器之间作为通信桥梁。之所以需要XA是因为在分布式系统中从理论上讲两台机器是无法达到一致性状态的,因此引入一个单点进行协调。由全局事务管理器管理和协调的事务可以跨越多个资源和进程。全局事务管理器一般使用XA二阶段协议与数据库进行交互。
1.3 CAP定理
C(Consistency)一致性 每一次读取都会让你得到最新的写入结果
A (Availability)可用性 每个节点(如果没有失败),总能执行查询(读取和写入)操作
P (Partition Tolerance)分区容忍性 即使节点之间的连接关闭,其他两个属性也会得到保证
CAP理论认为,任何联网的共享数据系统智能实现三个属性中的两个,但是可以通过明确处理分区,优化一致性和可用性,从而实现三者之间的某种权衡
1.4 Base理论
BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写,是对 CAP 中 AP 的一个扩展。
- 基本可用:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。
- 软状态:允许系统中存在中间状态,这个状态不影响系统可用性,这里指的是 CAP 中的不一致。
- 最终一致:最终一致是指经过一段时间后,所有节点数据都将会达到一致。
BASE 解决了 CAP 中理论没有网络延迟,在 BASE 中用软状态和最终一致,保证了延迟后的一致性。
BASE 和 ACID 是相反的,它完全不同于 ACID 的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。
2. 分布式事务解决理论
2.1 2PC(Two-phaseCommit)
2.1.1 二阶段提交
二阶段提交的算法思路可以概括为: 参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
二阶段是指: 第一阶段 - 请求阶段(表决阶段) 第二阶段 - 提交阶段(执行阶段)
1. 请求阶段(表决)
事务协调者通知每个参与者准备提交或取消事务,然后进入表决过程,参与者要么在本地执行事务,写本地的redo和undo日志,但不提交,到达一种"万事俱备,只欠东风"的状态。请求阶段,参与者将告知协调者自己的决策: 同意(事务参与者本地作业执行成功)或取消(本地作业执行故障)
2. 提交阶段(执行)
在该阶段,写调整将基于第一个阶段的投票结果进行决策: 提交或取消
当且仅当所有的参与者同意提交事务,协调者才通知所有的参与者提交事务,否则协调者将通知所有的参与者取消事务
参与者在接收到协调者发来的消息后将执行响应的操作
2.1.2 两阶段提交的缺点
- 同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
- 单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
- 数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。
2.1.3 两阶段提交无法解决的问题
当协调者出错,同时参与者也出错时,两阶段无法保证事务执行的完整性。
考虑协调者在发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。
那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
2.2 3PC(Three-phaseCommit)
2.2.1 三阶段提交
三阶段提交协议在协调者和参与者中都引入超时机制,并且把两阶段提交协议的第一个阶段分成了两步,询问,然后锁定资源,最后真正提交。
2.2.2 三阶段的执行
1. canCommit阶段
3PC的canCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回yes响应,否则返回no响应。
2. preCommit阶段
协调者根据参与者canCommit阶段的响应来决定是否可以继续事务的preCommit操作。根据响应情况,有下面两种可能:
1. 协调者从所有参与者得到的反馈都是yes
那么进行事务的预执行,协调者向所有参与者发送preCommit请求,并进入prepared阶段。参与泽和接收到preCommit请求后会执行事务操作,并将undo和redo信息记录到事务日志中。如果一个参与者成功地执行了事务操作,则返回ACK响应,同时开始等待最终指令
2. 协调者从所有参与者得到的反馈有一个是No或是等待超时之后协调者都没收到响应:
那么就要中断事务,协调者向所有的参与者发送abort请求。参与者在收到来自协调者的abort请求,或超时后仍未收到协调者请求,执行事务中断。
3. doCommit阶段
协调者根据参与者preCommit阶段的响应来决定是否可以继续事务的doCommit操作。根据响应情况,有下面两种可能:
1. 协调者从参与者得到了ACK的反馈:
协调者接收到参与者发送的ACK响应,那么它将从预提交状态进入到提交状态,并向所有参与者发送doCommit请求。参与者接收到doCommit请求后,执行正式的事务提交,并在完成事务提交之后释放所有事务资源,并向协调者发送haveCommitted的ACK响应。那么协调者收到这个ACK响应之后,完成任务。
2. 协调者从参与者没有得到ACK的反馈, 也可能是接收者发送的不是ACK响应,也可能是响应超时:
执行事务中断。
2.2.3 三阶段提交的缺点
如果进入PreCommit后,Coordinator发出的是abort请求,假设只有一个Cohort收到并进行了abort操作,而其他对于系统状态未知的Cohort会根据3PC选择继续Commit,此时系统状态发生不一致性。
2.3 SAGA协议实现分布式事务
Saga 是 30 年前一篇数据库伦理提到的一个概念。其核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
Saga 的组成:每个 Saga 由一系列 sub-transaction Ti 组成,每个 Ti 都有对应的补偿动作 Ci,补偿动作用于撤销 Ti 造成的结果。这里的每个 T,都是一个本地事务。可以看到,和 TCC 相比,Saga 没有“预留 try”动作,它的 Ti 就是直接提交到库。
Saga 的执行顺序有两种:
- T1,T2,T3,...,Tn。
- T1,T2,...,Tj,Cj,...,C2,C1,其中 0 < j < n 。
Saga 定义了两种恢复策略:
- 向后恢复,即上面提到的第二种执行顺序,其中 j 是发生错误的 sub-transaction,这种做法的效果是撤销掉之前所有成功的 sub-transation,使得整个 Saga 的执行结果撤销。
- 向前恢复,适用于必须要成功的场景,执行顺序是类似于这样的:T1,T2,...,Tj(失败),Tj(重试),...,Tn,其中 j 是发生错误的 sub-transaction。该情况下不需要 Ci。
这里要注意的是,在 Saga 模式中不能保证隔离性,因为没有锁住资源,其他事务依然可以覆盖或者影响当前事务。
还是拿 100 元买一瓶水的例子来说,这里定义:
- T1 = 扣 100 元,T2 = 给用户加一瓶水,T3 = 减库存一瓶水。
- C1 = 加100元,C2 = 给用户减一瓶水,C3 = 给库存加一瓶水。
我们一次进行 T1,T2,T3 如果发生问题,就执行发生问题的 C 操作的反向。
上面说到的隔离性的问题会出现在,如果执行到 T3 这个时候需要执行回滚,但是这个用户已经把水喝了(另外一个事务),回滚的时候就会发现,无法给用户减一瓶水了。
这就是事务之间没有隔离性的问题。可以看见 Saga 模式没有隔离性的影响还是较大,可以参照华为的解决方案:从业务层面入手加入一 Session 以及锁的机制来保证能够串行化操作资源。
也可以在业务层面通过预先冻结资金的方式隔离这部分资源, ***在业务操作的过程中可以通过及时读取当前状态的方式获取到***的更新。(具体实例:可以参考华为的 Service Comb)
2.4 TCC
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,就像我前面说的分布式事务不仅仅包括数据库的操作,还包括发送短信等,这时候 TCC 就派上用场了!
TCC 指的是Try - Confirm - Cancel
。
- Try 指的是预留,即资源的预留和锁定,注意是预留。
- Confirm 指的是确认操作,这一步其实就是真正的执行了。
- Cancel 指的是撤销操作,可以理解为把预留阶段的动作撤销了。
其实从思想上看和 2PC 差不多,都是先试探性的执行,如果都可以那就真正的执行,如果不行就回滚。
比如说一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。
我们来看下流程,TCC模型还有个事务管理者的角色,用来记录TCC全局事务状态并提交或者回滚事务。
可以看到流程还是很简单的,难点在于业务上的定义,对于每一个操作你都需要定义三个动作分别对应Try - Confirm - Cancel
。
因此 TCC 对业务的侵入较大和业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。
还有一点要注意,撤销和确认操作的执行可能需要重试,因此还需要保证操作的幂等。
相对于 2PC、3PC ,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务。
3. 分布式事务解决方案
3.1 本地消息表方案
本地消息表这个方案最初是 eBay 提出的,eBay 的完整方案 https://queue.acm.org/detail.cfm?id=1394128。
此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。
对于本地消息队列来说核心是把大事务转变为小事务。还是举上面用 100 元去买一瓶水的例子。
- 当你扣钱的时候,你需要在你扣钱的服务器上新增加一个本地消息表,你需要把你扣钱和减去水的库存写入到本地消息表,放入同一个事务(依靠数据库本地事务保证一致性)。
- 这个时候有个定时任务去轮询这个本地事务表,把没有发送的消息,扔给商品库存服务器,叫它减去水的库存,到达商品服务器之后,这时得先写入这个服务器的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。
- 商品服务器通过定时任务扫描消息表或者直接通知扣钱服务器,扣钱服务器在本地消息表进行状态更新。
- 针对一些异常情况,定时扫描未成功处理的消息,进行重新发送,在商品服务器接到消息之后,首先判断是否是重复的。
- 如果已经接收,再判断是否执行,如果执行在马上又进行通知事务;如果未执行,需要重新执行由业务保证幂等,也就是不会多扣一瓶水。
本地消息队列是 BASE 理论,是最终一致模型,适用于对一致性要求不高的情况。实现这个模型时需要注意重试的幂等。
3.1.1 示例
假如有这样一个背景:当扣款成功以后,需要发送扣款短信通知给用户
实现上采用扣款之后发送mq出去,接下来短信服务监听到该消息之后,给用户发送短信。
@Transactional
扣款方法(String userId,BigDecimal money){
扣掉用户的钱(userId,money);
往公司账户上增加钱(money);
发送mq(userId,money);
}
按照这种实现可能会出现以下情况:
- 扣款成功,短信发送成功(最理想的情况,不需要处理)
- 扣款成功,短信发送失败
- 扣款失败,短信发送成功
- 扣款失败,短信发送失败(不需要处理)
怎么做可以保保证2,3两种情况不会发生呢??
首先,需要把 2 3 中的“发送短信失败”分解为2个情况
- 发送 mq 失败
- 消费 mq 发送短信接口失败
对于发送短信接口失败,题主关注的多数情况应该来自于短信接口的不可用或到短信接口的网络问题,而众所周知短信和推送自身就是一个“不要求实时”的操作,如果发短信失败,mq 消费者自己做重试就好,所有 mq 都有 ack 或类似 ack 的机制,如果没发成功不做 ack 或 requeue 一个 delay 的重试消息,然后等待重试直到调用成功即可,用户只要求能收到短信延迟多数情况可以接受。
有消费重试发送后,我们只需要保证发送mq和修改数据库一起成功或失败的问题,这里有种办法可以考虑;
- 在数据库中,除了扣款相关表外加一个发送消息表。
- 扣款时先在一个数据库事务中,进行扣款相关记录并插入一条消息到发送表,提交事务。
- 如果发送mq成功则删除发送表的消息,如果 mqbroker 出现故障发送不了则不删除,这样会看到扣款成功发mq失败,但发送表中有失败的消息,之后通过一个定时任务定期重发失败表中的失败消息即可。
- 消息的处理方一定要做幂等处理。如果删除发送表失败会导致消息重发。
- 这个方案不对 mq 有特殊要求,缺点是要多写一份消息,如果非常关心这个可以通过用 binlog 订阅来代替那个发送表。
3.2 基于RocketMQ事务方案
在 RocketMQ 中实现了分布式事务,实际上是对本地消息表的一个封装,将本地消息表移动到了 MQ 内部。
基本流程如下:
- 第一阶段 Prepared 消息,会拿到消息的地址。
- 第二阶段执行本地事务。
- 第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。消息接受者就能使用这个消息。
- 如果确认消息失败,在 RocketMQ Broker 中提供了定时扫描没有更新状态的消息。
- 如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在 RocketMQ 中是以 Listener 的形式给发送者,用来处理。
- 如果消费超时,则需要一直重试,消息接收端需要保证幂等。如果消息消费失败,这时就需要人工进行处理,因为这个概率较低,如果为了这种小概率时间而设计这个复杂的流程反而得不偿失。
3.2.1 流程和架构
参考石杉的架构图
方案的大致流程说明:
- A系统先发送一个prepared消息到mq,如果这个prepared消息发送失败那么就直接取消操作别执行了,后续操作都不再执行。
- 如果这个消息发送成功过了,那么接着执行A系统的本地事务,如果执行失败就告诉mq回滚消息,后续操作都不再执行。
- 如果A系统本地事务执行成功,就告诉mq发送确认消息。
- 那如果A系统迟迟不发送确认消息呢?此时mq会自动定时轮询所有prepared消息,然后调用A系统事先提供的接口,通过这个接口反查A系统的上次本地事务是否执行成功。如果成功,就发送确认消息给mq;失败则告诉mq回滚消息(后续操作都不再执行)。
- 此时B系统会接收到确认消息,然后执行本地的事务,如果本地事务执行成功则事务正常完成。
- 如果系统B的本地事务执行失败了咋办?基于mq重试咯,mq会自动不断重试直到成功,如果实在是不行,可以发送报警由人工来手工回滚和补偿。
这种方案的要点就是可以基于mq来进行不断重试,最终一定会执行成功的。因为一般执行失败的原因是网络抖动或者数据库瞬间负载太高,都是暂时性问题。通过这种方案,99.9%的情况都是可以保证数据最终一致性的,剩下的0.1%出问题的时候,就人工修复数据呗。
3.2.2 适用场景
这个方案的使用还是比较广,目前国内互联网公司大都是基于这种思路玩儿的。