一、概述
1.1 什么是分布式事务
事务我们都很熟悉,事务提供一种机制将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成这组操作的各个单元,要么全部成功,要么全部失败。
事务有四大特性:
- Atomic(原子性)事务是一个不可分割的工作单元,事务中的操作要么都发生,要么都不发生;
- Consistent(一致性)事务完成时,必须使所有数据都保持一致状态;
- Isolation(隔离性)并发事务所做的修改必须和其他事务所做的修改是隔离的;
- Duration(持久性)事务完成之后,对数据库中数据的改变是永久性的;
为了解决传统单体服务架构带来的各种问题,分布式系统会把一个应用系统拆分成可独立部署的多个服务。如果把单体架构服务器比做篮子,那代码就是鸡蛋,不要让所有鸡蛋别装在一个篮子里。在分布式事务中,事务的参与者、支持事务的服务器、事务管理器分别位于不同的分布式系统的不同节点之上。我们都知道一次大的操作由不同的小操作组成,而在分布式系统中这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
二、理论基础
2.1 CAP理论
CAP定理,又被叫作布鲁尔定理。对于设计分布式系统来说(不仅仅是分布式事务)的架构师来说,CAP就是你的入门理论。
- C (一致性):指写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取的到数据都是最新的状态。如果在某个节点更新了数据,在其他节点如果都能读取到这个最新的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。
- A (可用性):指任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。。可用性的两个关键一个是合理的时间,一个是合理的响应。合理的时间指的是请求不能无限被阻塞,应该在合理的时间给出返回。合理的响应指的是系统应该明确返回结果并且结果是正确的。
- P (分区容错性):分布式系统的各个节点一般部署在不同的子网上,当有台机器的网络出现了问题,此时整体仍然能够对外提供服务,这叫分区容忍性。分区容忍性是分布式系统具备的基本能力。
CAP中三者不能共有,只能选择其中的两项:
- AP:放弃一致性,追求分区容忍性和可用性。大部分分布式系统设计时的选择。
- CP:放弃可用性,追求一致性和分区容错性。如Zookeeper。
- CA:放弃分区容错性,不考虑网络因素或结点挂掉的问题。关系型数据库就满足了CA,但不是一个标准的分布式系统。
顺便一提,CAP理论中是忽略网络延迟的,也就是当事务提交时,从节点A复制到节点B,但是在现实中这个是明显不可能的,所以总会有一定的时间是不一致。同时CAP中选择两个,比如你选择了CP,并不是叫你放弃A。因为P出现的概率实在是太小了,大部分的时间你仍然需要保证CA。就算分区出现了你也要为后来的A做准备,比如通过一些日志的手段,是其他机器回复至可用。
2.2 BASE理论
BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写。是对CAP中AP的一个扩展
- 基本可用:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如电商网站交易付款出现问题了,商品依然可以正常浏览。
- 软状态:允许系统中存在中间状态,这个状态不影响系统可用性,这个状态不影响系统可用性,如订单的"支付中"、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。
- 最终一致:最终一致是指经过一段时间后,所有节点数据都将会达到一致。如订单的"支付中"状态,最终会变 为“支付成功”或者"支付失败"。
BASE和 ACID 是相反的,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。
三、常见解决方案
3.1 2PC
2PC(Two-phase commit protocol),二阶段提交,它将整个事务流程分为两个阶段:准备阶段(prepare phase)、提交阶段(commit phase)。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者的角色来协调管理各参与者的提交和回滚,二阶段分别指的是准备和提交两个阶段。
- 准备阶段:协调者会给各参与者发送准备命令,但是不会提交事务,此时除提交以外所有都准备完了,就差临门一脚。
- 提交阶段:
a、假如在第一阶段所有参与者都返回准备成功,那么协调者则向所有参与者发送提交事务命令,然后等待所有事务都提交成功之后,返回事务执行成功。
b、假如在第一阶段有一个参与者返回失败,那么协调者就会向所有参与者发送回滚事务的请求,即分布式事务执行失败。
假如第二阶段提交失败的话,可以分为两种情况:
- 第一种是第二阶段执行的是回滚事务操作,那么答案是不断重试,直到所有参与者都回滚了,不然那些在第一阶段准备成功的参与者会一直阻塞着。
- 第二种是第二阶段执行的是提交事务操作,那么答案也是不断重试,因为有可能一些参与者的事务已经提交成功了,这个时候只有一条路,就是头铁往前冲,不断的重试,直到提交成功,到最后真的不行只能人工介入处理。
优点:原理简单,实现很方便。
缺点:
-
同步阻塞:在阶段一里执行prepare操作会占用资源,一直到整个分布式事务完成,才会释放资源,这个过程中,如果有其他人要访问这个资源,就会被阻塞住。
-
单点故障:协调者是个单点,如果协调者在commit出现故障,那么其它参与者一直处于锁定状态。
-
事务状态丢失:即使把协调者做成一个双机热备的,一个协调者挂了自动选举其他的协调者出来,但如果协调者挂掉的同时,接收到commit消息的某个库也挂了,此时即使重新选举了其它的协调者,也不知道这个分布式事务当前的状态。
-
数据不一致问题:在commit阶段,当协调者向所有的参与者发送commit请求后,发生了网络异常导致协调者在还未发完commit请求之前崩溃,可能会导致只有部分的参与者接收到commit请求,剩下没收到commit请求的参与者将无法提交事务,也就可能导致数据不一致的问题。
总结:2PC 是一种尽量保证强一致性的分布式事务,因此它是同步阻塞的,而同步阻塞就导致长久的资源锁定问题,总体而言效率低,并且存在单点故障问题,在极端条件下存在数据不一致的风险。
3.2 3PC
3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit、PreCommit 和 DoCommit。
(1)CanCommit阶段:事务管理器向各个数据库发送CanCommit消息,然后各个库返回结果。这一阶段主要是确定分布式事务的参与者是否具备了完成commit的条件,并不会执行事务操作。
(2)PreCommit阶段:事务管理器根据数据库的反馈情况来决定是否继续执行事务的PreCommit操作。如果各个数据库都返回成功,则进入PreCommit阶段,事务管理器发送PreCommit消息给各个数据库(相当于2PC里的阶段一),执行各个SQL语句,但不提交。如果有某个库对CanCommit消息返回了失败,则TM发送abort消息给各个库,结束这个分布式事务。
(3)DoCommit阶段:如果各个库对PreCommit阶段都返回了成功,那么发送DoCommit消息给各个库提交事务,各个库如果都返回成功给事务管理器,那么分布式事务成功。如果有个库对PreCommit返回的是失败,或者超时一直没返回,那么事务管理器认为分布式事务失败,直接发abort消息给各个库进行回滚,各个库回滚成功之后通知事务管理器,分布式事务回滚。
看起来是把 2PC 的提交阶段变成了预提交阶段和提交阶段,但是 3PC 的CanCommit阶段协调者只是询问参与者的自身状况,比如你现在还好吗?负载重不重?而预提交阶段就是和 2PC 的准备阶段一样,除了事务的提交该做的都做了。提交阶段和 2PC 的一样,让我们来看一下图。
不管哪一个阶段有参与者返回失败都会宣布事务失败,这和 2PC 是一样的(当然到最后的提交阶段和 2PC 一样只要是提交请求就只能不断重试)。
与2PC相比,主要有两个改进点:
(1)引入了CanCommit阶段。
(2)在DoCommit阶段,各个库自己也有超时机制。如果一个库收到了PreCommit自己还返回成功了,过了一段时间还没收到事务管理器发送的DoCommit消息或者是abort消息,直接判定为事务管理器可能出故障了,则会自己执行DoCommit操作提交事务。在3PC里面不会因为故障导致某个库一直锁住某个资源,导致长时间的资源阻塞。
3PC 的阶段变更有什么影响:
首先准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。
预提交阶段的引入起到了一个统一状态的作用,它像一道栅栏,表明在预提交阶段前所有参与者其实还未都回应,在预处理阶段表明所有参与者都已经回应了。假如你是一位参与者,你知道自己进入了预提交状态那你就可以推断出来其他参与者也都进入了预提交状态。
但是多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次。
参与者超时能带来什么样的影响:
2PC 是同步阻塞的,事务管理器挂在了提交请求还未发出去的时候是最伤的,所有参与者都已经锁定资源并且阻塞等待着。
那么引入了超时机制,参与者就不会傻等了,如果是等待提交命令超时,那么参与者就会提交事务了,因为都到了这一阶段了大概率是提交的,如果是等待预提交命令超时,那该干啥就干啥了,反正本来啥也没干。
然而超时机制也会带来数据不一致的问题,比如在等待提交命令时候超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了。
3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制,并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。
3.3 TCC
TCC 指的是Try - Confirm - Cancel,2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,一共分为三个阶段:
- try阶段:这个阶段是对各个服务的资源做检测以及对资源进行锁定或者预留。
- confirm阶段:这个阶段是在各个服务中执行实际的操作。
- cancel阶段:如果有任何一个服务的业务方法执行出错,那么这里就需要进行补偿,就是执行已经执行成功业务逻辑的回滚操作。
其实从思想上看和 2PC 差不多,都是先试探性的执行,如果都可以那就真正的执行,如果不行就回滚。比如说一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。
TCC模型还有个事务管理者的角色,用来记录TCC全局事务状态并提交或者回滚事务。
可以看到流程还是很简单的,难点在于业务上的定义,对于每一个操作你都需要定义三个动作分别对应Try - Confirm - Cancel。
适用场景:
对一致性要求很高的系统的核心链路,如支付、交易相关的场景。严格保证分布式事务要么全部成功,要么全部自动回滚,严格保证链路的正确性。而且,最好各个业务执行的时间都比较短。
相对于 2PC、3PC ,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务。
3.4 本地消息表
此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。
假设有一个在线商城系统,包含订单服务和库存服务,它们运行在不同的服务器上,因此需要使用分布式事务来保证订单和库存的一致性。当一个客户下单时,订单服务会向库存服务发起扣减库存的请求。如果请求成功,订单服务再创建订单并提交事务;如果请求失败,则订单服务回滚事务。
然而,在分布式事务中,网络故障、服务故障或其他因素可能导致事务提交或回滚失败,从而导致订单和库存不一致。因此,我们需要使用本地消息表来解决这个问题。
本地消息表是一个本地数据库表,用于保存需要在分布式事务中执行的操作。当订单服务接收到客户的下单请求时,它会将扣减库存的请求和创建订单的操作插入到本地消息表中,然后立即提交事务。此时,订单服务并不会向库存服务发起扣减库存的请求,而是将扣减库存的请求作为消息发送到一个消息队列中。
库存服务从消息队列中读取消息,并执行扣减库存的操作。如果扣减库存成功,则将消息标记为已消费,并将操作的结果保存到本地数据库中。如果扣减库存失败,则将消息标记为未消费,并在一定时间后重新发送消息。这样可以保证库存服务在故障恢复后能够重新处理消息。
通过本地消息表,订单服务和库存服务可以保证操作的原子性,从而避免了分布式事务中的问题。如果订单服务在创建订单时发生故障,它可以从本地消息表中读取操作,重新执行扣减库存的请求,从而保证订单和库存的一致性。同样的,如果库存服务在扣减库存时发生故障,它可以从本地数据库中读取操作,重新执行扣减库存的请求,从而保证订单和库存的一致性。
3.5 消息队列
为了确保消息的可靠性,通常情况下,在完成一阶段的 DB 事务之后再发送消息,但这种方式存在一定的风险,因为业务程序很难保证 DB 事务提交后消息一定能成功发送。即使将发送失败的消息放到内存队列中稍后重试,也不能完全保证消息发送的成功率达到100%。
为了解决这个问题,RocketMQ 提供了半消息(Half Msg)机制,即先发送一个半消息,类似于 Prepare 操作,这个半消息不会立即被投递给消费者,而是等待本地事务执行的结果。如果本地事务执行成功,则将半消息状态改为“Committing”,表示消息已经发送并且事务已经提交。如果本地事务执行失败,则将半消息状态改为“Rollback”,表示消息已经发送但是事务需要回滚。
RocketMQ 接收到半消息后,会向消息生产者发送一个“预提交”(Pre-Commit)请求。生产者需要根据请求返回“提交”或“回滚”操作。如果生产者返回“提交”操作,则 RocketMQ 将消息状态改为“已提交”(Committed)并将消息发送给消费者。如果生产者返回“回滚”操作,则 RocketMQ 将消息状态改为“已回滚”(Rollbacked),消息不会被投递给消费者。
这种方式可以确保消息发送的可靠性和一致性,并且避免了传统方式的风险。但需要注意,该机制存在单点故障和性能瓶颈等问题,因此需要综合考虑使用的利弊。
对于 RocketMQ 消费者而言,事务消息和非事务消息是没有区别的,具体流程如下图:
如果不顺利,以上任何一步发生了异常会如何?我们挨个看一下:
- 如果在第1步出现异常导致半消息发送失败,则本地数据库事务不会执行,整个操作会失败。在此情况下,数据库和消息的状态是一致的,即都没有提交。
- 如果在第2步发生异常或返回超时,生产者认为操作已失败,因此不会执行数据库操作,进而无法继续进行后续操作。而另一方面,Broker已经成功存储了半消息,但却迟迟等不到后续的提交操作,等待时间超时后,Broker会向生产者发送询问,询问该半消息是应提交还是回滚。此时,生产者可以通过访问数据库来确认本地数据库事务的完成状态,并回答Broker的询问,从而使Broker得知如何处理该半消息。
- 如果在第3步中数据库操作失败,生产者可以在第4步告知Broker回滚半消息,或者报告状态为“未知”,让Broker稍后通过回查决定是提交还是回滚该半消息。
- 如果在第4步提交或回滚半消息失败,Broker会在一段时间后发起回查,与第2步异常情况类似。
- 如果在第5、6、7步回查失败,即回查发生异常或者回查仍然返回“未知”,或回查失败,RocketMQ会在稍后重新调度该消息,最多回查15次。
3.6 最大努力通知
最大努力通知方案的核心在于最大努力通知服务,这个服务的核心在于根据上游服务定义的重试规则对调用事变的消息,重试几次,最大努力尝试调用成功。
它跟可靠消息服务的区别在于:
- 可靠消息服务会保证,如果下游服务执行不成功,会一直不停的重试,直到下游服务执行成功为止,最终达到数据一致性,但是中间可能有很长的一段时间数据是不一致的。
- 最大努力通知服务,如果一次请求没成功,那么就将消息存到数据库里去,同时记录下它的重试规则,以及上一次重试的时间,是第几次重试,然后开启一个后台线程进行扫描,每次扫出来就根据规则去重新调用下游服务。