浅析分布式事务:XA、TCC、MQ事务、最大努力通知
事务的定义
事务提供一种机制将一个活动涉及的所有操作纳入到一个不可分割的执行单元,也就是说事务提供一种 “要做就全部执行成功,要不做就一个也不做“ 的机制
事务的特性
数据库的事务具有四大特性:原子性 Actomicity
、一致性 Consistency
、隔离性 Isolation
、持久性 Durability
InnoDB 事务
InnoDB 是 MySQL 的一个存储引擎,它的事务是由本地事务资源管理器进行管理的:
事务的 ACID 通过 InnoDB 日志和锁来保证,事物的隔离性是通过数据库锁机制实现的。
-
原子性和一致性通过 Undo Log 来实现
在操作任何数据之前,首先将数据备份到一个地方(这个存储备份的地方称为 Undo Log),然后进行数据的修改,如果出现用户错误或者用户执行 ROLLBACK 语句,系统可以利用 Undo Log 中的备份将数据恢复到事务开始之前的状态
-
持久性和通过 Redo Log 来实现
Redo Log 记录的是新数据的备份,在事务提交前,只要将 Redo Log 持久化即可,不需要将数据持久化。当系统崩溃时,虽然数据没有持久化,但是 Redo Log 已经持久化,系统可以根据 Redo Log 的内容,将所有数据恢复到最新的状态
分布式事务
分布式事务就是指事务的参与者,支持事务的服务器,资源服务器已将事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败,本质上说,分布式事务就是为了保证不同数据库的数据一致性。换句话说,分布式事务 = n 个本地事务。通过事务管理器实现 n 个本地事务要么全部成功要么全部失败。
分布式事务产生
这里举一个分布式事务的典型例子 —— 用户下单过程。当整个系统采用微服务架构后,一个电商系统往往被拆分成多个子系统:商品系统、订单系统、支付系统、积分系统等。
这样,整个下单流程如下:
- 用户浏览商品,选择某个商品,点击下单
- 订单系统会生成一条订单
- 订单创建成功后,支付系统提供支付功能
- 支付完成后,积分系统为用户增加积分
在上述的步骤中,2、3 和 4 是需要在一个事务中完成的。对于单体应用来说,实现事务很简单,只要将这三个步骤放在同一个方法中,再用 Spring @Transaction
注解标识改方法就可以。但是在分布式架构中,这三个步骤要涉及三个系统和三个数据库,因此必须要通过分布式事务,实现三个数据库的本地事务同时成功或同时失败。
分布式事务基础
CAP 定理
一个分布式系统不可能同时满足一致性、可用性和分区容错性
- C(一致性):对于数据分布在不同节点上的数据来说,一致性是指数据在多个副本之间都能保持一致的特性。如果某个节点更新了数据,那么在其他节点如果都能读取到这个最新的数据,那么就称为强一致性,如果某个节点没有被读取到,那么就是分布式不一致
- A(可用性):非故障节点在合理的时间内返回合理的响应。也就是说集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求(数据高可用)
- P(分区容错性):当遇到网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务
高可用和数据一致性是很多系统的设计目标,但是分区又是不可避免的事情。
- CA without P:如果不要求 P(不允许分区)则 C 和 A 是可以保证的。但是分区是始终会存在的,因此 CA 系统更多的是允许分区后各子系统仍然保持 CA
- CP without A:如果不要求 A,相当于每个请求都需要在 Server 之间强一致,而 P 会导致同步时间无限延长,因此 CP 也是可以保证的。MySQL 主从半同步复制、Zookeeper
- AP without C:要高可用并允许分区,则需要放弃一致性。一旦分区发生,节点之间就会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致,很多 NoSQL 都属于此类。MySQL 主从同步异步复制、Redis 主从同步
Base 理论
Base 理论是 Basically Available(基本可用),Soft state(软状态)和 Eventually consisten(最终一致性)三个短语的缩写,是对 CAP 中 AP 的一个扩展
- BA 基本可用:分布式系统出现故障时,允许损失部分可用功能,保证核心功能可用
- S 软状态:允许系统中存在中间状态,这个状态不影响系统可用性,这里指的是 CAP 中的不一致
- E 最终状态:最终一致是指经过一段时间后,所有节点数据都将会达到一致
Base 解决了 CAP 中理论没有网络延迟,在 Base 中用软引用和最终一致保证了延迟后的一致性。BASE 和 ACID 是相反的,它完全不同于 ACID 的强一致模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。
酸碱平衡
ACID 能保证事务的强一致性,即数据是实时一致的。这在本地事务中是没问题的,在分布式事务中,强一致性会极大影响分布式系统的性能,因此分布式系统中遵循 BASE 理论即可。但分布式系统的不同业务场景对一致性的要求也不同,比如交易场景,就要求强一致,因此遵循 ACID 理论,而在注册成功发送短信验证码等场景下,并不需要实时一致,因此遵循 BASE 理论即可,所以需要根据具体的业务场景,在 ACID 和 BASE 之间寻求平衡。
分布式事务协议
两阶段提交协议:2PC
分布式系统的一个难点是如何保证架构下多个节点在进行事务性操作的时候保持一致,为实现这个目的,两阶段提交算法的成立基于以下假设:
- 该分布式系统中,存在一个节点作为协调者,其他节点作为参与者,且节点之间可以进行网络通信
- 所有节点都采用预写式日志,且日志被写入之后即被保持在可靠的设备上,即使节点损坏也不会导致日志数据的消失
- 所有节点不会永久性损坏,即使损坏后仍然可以恢复
第一阶段:提交事务请求
- 事务询问:协调者向所有的参与者发送事务内容,询问是否可以执行事务提交操作,并开始等待各参与者的响应
- 执行事务:各参与者节点执行事务操作,并将 Undo 和 Redo 信息记入事务日志中
- 各参与者向协调者反馈事务询问的响应:如果参与者成执行了事务操作,那么就反馈给协调者 YES,表示事务可以执行;反正为 NO,事务不可以执行
第二阶段:执行事务提交
协调者从所有的参与者获得的反馈都是 YES
- 发送提交请求:协调者向所有参与者节点发出 Commit 请求
- 事务提交:参与者在接收到 Commit 请求后,会正式执行事务提交操作,并在完成提交之后释放在整个事务执行期间占用的事务资源
- 反馈事务提交结果:参与者在完成事务提交后,向协调者发送 Ack 消息
- 完成事务:协调者接收到所有参与者反馈的 Ack 消息后,完成事务
第二阶段:中断事务
假设任何一个参与者向协调者反馈了 NO 请求,或者等待超时后吗,协调者尚无法接收所有参与者的反馈响应,就会中断事务
- 发送回滚请求:协调者向所有参与者节点发送 Rollback 请求
- 事务回滚:参与者收到 Rollback 请求后,会利用其在阶段一中记录的 Undo 信息来执行事务回滚操作,并在完成回滚之后释放在这个事务执行期间占用的资源
- 反馈事务回滚结果:参与者在完成事务回滚之后,向协调者发送 Ack 消息
- 中断事务:协调者接收到所有参与者反馈的 Ack 消息后,完成事务中断
优缺点
优点:原理简单,实现方便
缺点:
- 同步阻塞:在事务提交过程中,所以参与该事务操作的逻辑都处于阻塞状态,无法进行其他任何操作
- 单点问题:如果协调者在事务提交阶段挂掉,那么其他参与者将一直处于锁定事务资源的状态,而无法继续完成事务操作
- 数据不一致:如果协调者在发送 Commit 请求时,出现局部网络异常,那么就会导致只有部分参与者收到了 Commit 请求并进行事务的提交,而其他没有收到 Commit 请求的参与者则无法进行事务提交,于是会出现数据不一致现象
- 太过保守:没有设计较为完善的容错机制,任意一个节点的失败都会导致整个事务的失败
三阶段提交协议:3PC
与两阶段提交不同的是,三阶段提交有两个改动点
- 在协调者和参与者中均引入超时机制
- 在第一阶段和第二阶段中插入一个准备阶段
第一阶段 CanCommit
- 事务询问:协调者向所有参与者发送一个包含事务内容的 canCommit 请求,询问是否可以执行事务提交操作,并开始等待各参与者的响应
- 各参与者向协调者反馈事务询问的响应:参与者在接收到来自协调者 canCommit 请求后,正常情况下,如果其自身认为可以顺利执行事务,就会反馈 Yes 响应,并进入预备状态,否则反馈 NO 响应
第二阶段 PreCommit
执行事务预提交
- 发送预提交请求:协调者向所有参与者节点发出 preCommit 的请求,并进入 Prepared 阶段
- 事务预提交请求:参与者接收到 preCommit 请求后,会执行事务操作,并将 Undo 和 Redo 信息记录到事务日志中
- 各参与者向协调者反馈事务执行的响应:如果参与者成功执行了事务操作,那么就会反馈给协调者 Ack 响应,同时等待最终的指令
中断事务
假设任何一个参与者向协调者反馈了 No 响应,或者在等待超时之后,协调者尚无法接收到所有参与者的反馈响应,就会中断事务
- 发送中断请求:协调者向所有参与者节点发送 abort 请求
- 中断事务:参与者中断事务
第三阶段 doCommit
执行提交
- 发送提交请求:协调者收到所有参与者的 Ack 的响应后,会向所有参与者发送 doCommit 请求
- 事务提交:参与者接收到 doCommit 请求后,会正式执行事务提交操作,并在完成提交之后释放在整个事务执行期间占用的事务资源
- 反馈事务提交结果:参与者在完成事务提交后,向协调者发送 Ack 消息
- 完成事务:协调者接收到所有参与者反馈的 Ack 消息后,完成事务
中断事务
任何一个参与者向协调者发送 NO 反馈,或者协调者无法接收到所有参与者的反馈响应
- 发送中断请求:协调者向所有参与者节点发送 abort 请求
- 事务回滚:参与者接收到 abort 请求后,会利用其记录的 Undo 信息来执行事务回滚操作,并在完成回滚之后释放占用的资源
- 反馈事务回滚结果:参与者在完成事务回滚之后,向协调者发送 Ack 消息
- 中断事务
优缺点
优点:降低了参与者的阻塞范围,并且能够在出现单点故障后继续达成一致
缺点:参与者接收到 preCommit 消息后,如果网络出现分区,此时协调者所在的节点和参与者无法进行正常的网络通信,这种情况下,该参与者依然会进行事务的提交,这必然出现数据的不一致性
分布式事务解决方案
XA 方案
XA 是 DTP 模型,它定义了 TM(事务管理器)和 RM(本地资源管理器)之间进行通信的接口
在 XA 规范中,数据库充当本地资源管理器角色,应用充当事务管理器角色,即生成全局 txId,调用 XAResource 接口,把多个本地事务协调为全局统一的分布式事务。
目前 XA 有两种实现:基于 1PC 的弱 XA 和基于 2PC 的强 XA
优缺点
优点
尽量保证了数据的强一致,实现成本低,在各大主流数据库都有实现
缺点
- 单点问题:事务管理器在整个流程中扮演的角色很关键,如果宕机,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,则资源管理器就会一直阻塞,导致数据库无法使用
- 同步阻塞:在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源
- 数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit 的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了 commit 操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性
两阶段、补偿型:TCC
TCC 是补偿型分布式事务,它把锁粒度完全交给业务处理,它需要每个子事务业务都实现 Try-Confirm/Cancel 接口
TCC 模式本质也是 2PC,只是 TCC 在应用层控制
TCC 的三个阶段
- Try
- 尝试执行业务
- 完成所有业务检查(一致性)
- 预留必须业务资源(准隔离性)
- Confirm
- 确认执行业务
- 真正执行业务,不作任何业务检查
- 只使用 Try 阶段预留的业务资源
- Confirm 操作满足幂等性
- Cancel
- 取消执行业务
- 释放 Try 阶段预留的业务资源
- Cancel 操作满足幂等性
以一个转账的例子来说明 TCC 分布式事务的具体过程: 假设用户 A 用他的账户余额给用户 B 发一个 100 元的红包,并且余额系统和红包系统是两个独立的系统
- Try
- 创建一条流水,并将流水的状态设置为交易中
- 将用户 A 的账户中扣除 100 元(预留业务资源)
- Try 成功后,进入 Confirm 阶段
- Try 阶段发生任何异常均进入 Cncel 阶段
- Confirm
- 向用户 B 的红包账户中增加 100 元
- 将流水状态设置为交易已完成
- Comfirm 过程发生任何异常,均进入 Cancel 阶段
- Confirm 过程执行成功,则该事务结束
- Cancel
- 将用户 A 的账户增加 100 元
- 将流水的状态设置为交易失败
优缺点
优点:
TCC 实现的是强一致性
缺点:
-
TCC 模型对业务的侵入性比较强,改造难度大
-
TCC 全局事务必须基于 RM 本地事务来实现
假设服务 B 没有基于 RM 本地事务,一旦事务 B 的 Try 操作失败, TCC 决定回滚全局事务,B 的 Cancel 操作需要判断哪些操作已经写到 DB、哪些操作还没有写到 DB:假设 B 的 Try 业务有5个写库操作,B 的 Cancel 业务则需要逐个判断这5个操作是否生效,并将生效的操作执行反向操作;但是由于 B 的 Cancel 业务也有 5 个反向的写库操作,因此一旦 B 的 Cancel 操作也中途出错,则后续 B 的 Cancel 执行任务更加麻烦。
基于 RM 本地事务的 TCC 事务,这种情况就很容易处理:B 的 Try 操作中途失败,TCC 事务框架将其参与 RM 本地事务直接 RollBack 即可。后续 TCC 事务框架决定回滚全局事务时,在知道 B 的 Try 操作涉及的 RM 本地事务已经 RollBack 的情况下,无需执行 B Cancel 操作
-
TCC 事务框架应该提供 Comfirm 和 Cancel 服务的幂等性保证
在 TCC 事务模型中,Confrim 和 Cancel 业务可能会被重复调用,比如:全局事务在提交/回滚时会调用各 TCC 服务的 Confirm/Cancel 业务逻辑。执行这些 Confirm/Cancel 业务时,可能会出现如网络中断的故障而使得全局事务不能完成。因此,故障恢复机制后续仍然会重新提交/回滚 这些未完成的事务,这样就会再次调用参与该全局事务的各 TCC 服务的 Confirm/Cancel 业务逻辑
可靠消息最终一致性方案
MQ事务是对本地消息表的一个封装。首先介绍下本地消息表,其核心是将需要分布式处理的任务通过消息日志的方式来异步执行,消息日志可以存储到本地文本、数据库或消息队列中,再通过业务规则或者人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。
这种方式实现分布式事务需要通过消息中间件来实现。假设有 A 和 B 两个系统,分别可以处理任务 A 和任务 B,此时系统 A 中存在一业务流程,需要将任务 A 和任务 B 在同一个事务中处理,其基于消息中间件实现的这种分布式事务如下:
首先看下事务的正常处理流程:
- 在系统 A 处理任务 A 之前,首先向消息中间件发送一条消息
- 消息中间件收到该消息后持久化,并不处理。此时下游系统 B 仍然不知道该消息的存在
- 消息中间件持久化成功后,便向系统 A 返回一个确认应达
- 系统 A 在收到确认应达后,则开始处理事务 A
- 任务 A 处理完后,向消息中间件发送 Commit 请求,该请求发送完成后,对系统 A 而言,该事务的处理过程就结束了,此时它可以处理别的任务
- 消息中间件收到 Commit 指令后,便向系统 B 投递该消息,从而触发任务 B 的执行
- 当任务 B 执行完成后,系统 B 向消息中间件返回一个确认应答,告诉中间件该消息已经成功消费,此时分布式事务完成
系统 A 完成任务 A 后,到任务 B 执行完成之间,会存在一定的时间差,在这个时间差内,整个系统处于数据不一致的状态,但这短暂的不一致是可以接受的,因为经过短暂的时间之后,系统又可以保持数据一致性,满足 BASE 理论
事务回滚
在上面的流程中,第 4 步如果事务 A 处理失败,就会进入回滚流程:
- 若系统 A 在处理任务 A 失败时,那么就会向消息中间件发送 Rollback 请求,和发送 Commit 请求一样,系统 A 发完之后便可以认为回滚已经完成,它便可以去做其他的事情
- 消息中间件收到回滚请求后,直接将该消息丢弃,而不投递给系统 B,从而不触发系统 B 的任务 B
此时系统又处于一致性状态,因为任务 A 和任务 B 都没有执行
超时轮询
在实际系统中,Commit 和 Rollback 指令都可能在传输途中丢失,这时候就需要超时轮询机制来保证数据一致性
系统 A 除了实现正常的业务流程外,还需要提供一个事务询问的接口,供消息中间件使用。当消息中间件收到一条事务型消息后变开始计时,如果到了超时时间也没有收到系统 A 发来的 Commit 或 Rollback 指令的话,就会主动调用系统 A 提供的事务询问接口询问该系统目前的状态,该接口会返回三种结果:
- 提交:将消息投递给系统 B
- 回滚:直接将消息丢弃
- 处理中:继续等待
消息中间件的轮询机制能够有效防止上游系统因在传输过程中丢失 Commit/Rollback 指令而导致的系统不一致情况,而且能降低上游系统的阻塞时间,上游系统只要发出 Commit/Rollback 指令后便可以处理其他任务,无需等待应答。而 Commit/Rollback 指令丢失的情况通过超时询问机制来弥补,这样大大降低了上游系统的阻塞时间,提升系统并发度
消息投递的可靠性保证
上游系统执行完任务并向消息中间件提交了 Commit 指令后,便可以处理其他任务了,此时它可以认为事务已经完成,接下来消息中间件一定会保证消息被下游系统成功消费掉,实现了消息投递的可靠性保证。
消息中间件向下游系统投递完消息后便进入阻塞等待状态,下游系统便立即进行任务处理,任务处理完成后便向消息中间件返回应答,消息中间件收到确认应答后便任务该事务处理完毕。如果消息在投递过程中丢失,或消息的确认应答在返回途中丢失,那么消息中间件在等待应该超时之后就会重新投递,直到下游消费者返回消费成功响应为止。
消息投递失败后,为什么不回滚消息,而是不断尝试重新投递
我们知道,当系统A向消息中间件发送 Commit 指令后,它便去做别的事情了。如果此时消息投递失败,需要回滚的话,就需要让系统A事先提供回滚接口,这无疑增加了额外的开发成本,业务系统的复杂度也将提高。
由于不断尝试,B 系统一定要保证自己的幂等性:
- 在 Redis 中记录一个标识,表明自己已经处理过这个消息了
- 在 Zookeeper 中创建一个 node,也就是代表某个消息自己已经处理过了,如果重复消息过来,创建 zookeeper node 就会报错
上游系统 A 向消息中间件提交消息时采用的是异步方式
上游系统和消息中间件之间采用异步通信是为了提高系统并发度。业务系统直接和用户打交道,这种异步通信方式能够极大程度降低用户等待时间,此外异步通信相对于同步通信,没有长时间的阻塞等待,因此系统并发性也大大提高。但是异步通信可能会引起 Commit/Rollback 指令丢失的问题,这就由消息中间件的超时询问机制来弥补
消息中间件和下游系统之间采用同步通信
异步通信提升性能,但随之增加系统复杂度;而同步系统虽然降低系统并发度,但是实现成本较低。因此,在对并发度要求不是很高的情况下,或者服务器资源较为充裕的情况下,我们可以选择同步来降低系统的复杂度。 我们知道,消息中间件是一个独立于业务系统的第三方中间件,它不和任何业务系统产生直接的耦合,它也不和用户产生直接的关联,它一般部署在独立的服务器集群上,具有良好的可扩展性,所以不必太过于担心它的性能,如果处理速度无法满足我们的要求,可以增加机器来解决。而且,即使消息中间件处理速度有一定的延迟那也是可以接受的,因为前面所介绍的BASE理论就告诉我们了,我们追求的是最终一致性,而非实时一致性,因此消息中间件产生的时延导致事务短暂的不一致是可以接受的。
最大努力通知
最大努力送达型
最大努力送达性是针对弱 XA 的一种补偿策略,它采用事务表记录所有的事务操作 SQL
- 子事务提交成功,将会删除日志
- 如果执行失败,则会按照配置的重试次数,尝试再次提交,即最大努力的进行提交,尽量保证数据一致性,这里可以根据业务采用同步重试和异步重试
优点:无锁资源,性能损耗小
缺点:多次尝试提交失败后,事务无法回滚,它仅适用于事务最终一定能够成功的业务场景
定期校对型
可靠消息最终一致性方案就是定期校对型的一个实现
- 上游系统在完成任务后,向消息中间件同步地发送一条消息,确保消息中间件持久化这条消息,然后上游系统继续执行其他任务
- 消息中间件收到消息后负责将该消息同步投递给相应的下游系统,并触发下游系统的任务执行
- 当下游系统处理成功后,向消息中间件反馈确认应答,消息中间件便可以将该条消息删除,从而该事务完成
消息中间件向下游系统投递消息失败,可以利用消息中间件的重试机制,在消息中间件中设置消息的重试次数和重试时间间隔,对于网络不稳定导致的消息投递失败的情况,往往重试几次后消息便可以成功投递,如果超过了重试的上限仍然投递失败,那么消息中间件不再投递该消息,而是记录在失败消息表中,消息中间件需要提供失败消息的查询接口,下游系统会定期查询失败消息,并将其消费,这就是所谓的“定期校对”。
上游系统向消息中间件发送消息失败,可以在上游系统建立消息重发机制,在上游系统建立一张本地消息表,并将任务处理过程和向本地消息表中插入消息这两个步骤放在同一个本地事务中完成。如果向本地插入消息失败,就会触发回滚,之前的任务处理结果就会被取消。如果这两步都执行成功,那么该本地事务也都完成了,接下来有一个专门的消息发送者不断地发送本地消息表中的消息,如果发送失败它会返回重试