目录
什么是分布式事务
单体应用
下图是一个单体应用的 3 个 模块,在同一个数据源上更新数据来完成一项业务,整个过程的数据一致性可以由数据库的本地事务来保证,如下图:
分布式应用
随着业务需求和架构的变化,单体应用进行了服务化拆分:原来的 3 个 模块被拆分为 3 个独立的服务,每个服务使用独立的数据源(Pattern: Database per service)。整个业务过程将由 3 个服务的调用来完成,如下图:
每个服务自身的数据一致性仍由本地事务来保证,但是整个业务层面的全局数据一致性要如何保障呢?比如订单服务和账户服务,都有各自的数据库,必须保证操作的一致性,不能出现下单成功但是没记账的情况。这就是分布式系统所面临的典型分布式事务需求:
分布式系统需要一个解决方案来保障对所有节点操作的数据一致性,这些操作组成一个分布式事务,要么全部执行,要么全部不执行。
2PC解决方案
二阶段提交协议,包含两类节点:
- 一个中心化协调者节点(coordinator),一般也叫做事务协调者
- 多个参与者节点(participant、cohort),一般也叫做事务参与者
2PC
协议事务提交过程分为两个阶段:投票阶段和提交阶段:
投票阶段
- 事务协调者询问所有事务参与者进行投票:是否可以提交事务,然后等待所有参与者的投票结果;
- 参与者如果投票表示可以提交事务,那么就必须预留本地资源(执行本地事务->写
redo,undo
日志,锁定资源,执行操作,但是不提交),然后响应YES
,后续也不再允许放弃事务;如果不能,就返回NO
响应; - 如果协调者接受某个参与者的响应超时,它会认为该参与者投票为
NO
,即预留资源失败。
提交阶段
在该阶段,事务协调者将基于投票阶段的投票结果进行决策:提交或取消各参与者的本地事务
- 仅当所有参与者都返回
YES
响应时,协调者才向所有参与者发出提交请求,此时所有参与者必须保证提交事务成功; - 如果投票阶段中任意一个参与者返回
No
响应,则协调者向所有参与者发出回滚请求,所有参与者进行回滚操作。
两阶段提交协议成功场景示意图:
优缺点
优点:
- 强一致性,因为一阶段预留了资源,所有只要节点或者网络最终恢复正常,协议就能保证二阶段执行成功;
- 业界标准支持,二阶段协议在业界有标准规范——XA 规范,许多数据库和框架都有针对XA规范的分布式事务实现。
缺点:
- 在提交请求阶段,需要预留资源,在资源预留期间,其他人不能操作(比如,XA 在第一阶段会将相关资源锁定) ,会造成分布式系统吞吐量大幅下降;
- 容错能力较差,比如在节点宕机或者超时的情况下,无法确定流程的状态,只能不断重试,同时这也会导致事务在访问共享资源时发生冲突和死锁的概率增高,随着数据库节点的增多,这种趋势会越来越严重,从而成为系统在数据库层面上水平伸缩的"枷锁";
3PC解决方案
在二阶段协议中,事务参与者在投票阶段,如果同意提交事务,则会锁定资源,此时任何其他访问该资源的请求将处于阻塞状态。
正因为这个原因,三阶段协议(Three-phase commit protocol, 3PC)对二阶段协议进行了改进:
- 一方面引入超时机制,解决资源阻塞问题;
- 另一方面新增一个询问阶段(CanCommit),提前确认下各个参与者的状态是否正常。
三阶段提交协议的成功场景:
其实就是在2pc
的投票阶段前面,再加一个询问阶段
询问阶段(CanCommit)
- 询问阶段,事务协调者向事务参与者发送
CanCommit
请求,参与者如果可以提交就返回Yes
响应,否则返回No
响应。 - 询问阶段可以确保尽早的发现无法执行操作的参与者节点,这样的话参与者就不会取锁定资源。
- 对于事务协调者,如果询问阶段有任一参与者返回NO或超时,则协调者向所有参与者发送abort指令。
- 对于返回NO的参与者,如果在指定时间内无法收到协调者的abort指令,则自动中止事务。
准备阶段(PreCommit,2pc中是叫投票阶段)
事务协调者根据事务参与者在询问阶段的响应,判断是执行事务还是中断事务:
- 如果询问阶段所有参与者都返回
YES
,则协调者向参与者们发送预执行指令(preCommit
),参与者接受到preCommit
指令后,写redo
和undo
日志,执行事务操作,占用资源,但是不会提交事务; - 参与者响应事务操作结果,并等待最终指令:提交(
doCommit
)或中止(abort
)。
提交阶段(DoCommit)
- 如果每个参与者在准备阶段都返回
ACK
确认(即事务执行成功),则协调者向参与者发起提交指令(doCommit
),参与者收到指令后提交事务,并释放锁定的资源,最后响应ACK
;
当参与者响应ACK后,即使在指定时间内没收到doCommit指令,也会进行事务的最终提交;
一旦进入提交阶段,即使因为网络原因导致参与者无法收到协调者的doCommit或Abort请求,超时时间一过,参与者也会自动完成事务的提交。
- 如果任意一个参与者在准备阶段返回
NO
(即执行事务操作失败),或者协调者在指定时间没收到全部的ACK
响应,就会发起中止(abort
)指令,参与者取消已经变更的事务,执行undo
日志,释放锁定的资源。
优缺点
优点:
- 增加了一个询问阶段,询问阶段可以确保尽早的发现无法执行操作的参与者节点,提升效率;
- 在准备阶段成功以后,协调者和参与者执行的任务中都增加了超时,一旦超时,参与者都会继续提交事务,默认为成功,降低了阻塞范围。
缺点:
- 如果准备阶段执行事务后,某些参与者反馈执行事务失败,但是由于出现网络分区,导致这些参与者无法收到协调者的中止请求,那么由于超时机制,这些参与者仍会提交事务,导致出现不一致;
- 性能瓶颈,不适合高并发场景。
所以无论是 2PC
还是 3PC
,当出现网络分区且不能及时恢复时, 都不能保证分布式系统中的数据 100% 一致。
TCC解决方案
两阶段提交(2PC
)和三阶段提交(3PC
)并不适用于并发量大的业务场景。TCC
事务机制相比于2PC、3PC
,不会锁定整个资源,而是通过引入补偿机制,将资源转换为业务逻辑形式,锁的粒度变小。
TCC
的核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作,分为三个阶段:
Try
:这个阶段对各个服务的资源做检测以及对资源进行锁定或者预留;Confirm
:执行真正的业务操作,不作任何业务检查,只使用Try
阶段预留的业务资源,Confirm
操作要求具备幂等设计,Confirm
失败后需要进行重试;Cancel
:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,即执行回滚操作,释放Try
阶段预留的业务资源,Cancel
操作要求具备幂等设计,Cancel
失败后需要进行重试。
TCC的执行
TCC
将一次事务操作分为三个阶段:Try、Confirm、Cancel,
我们通过一个订单/库存的示例来理解。假设我们的分布式系统一共包含4个服务:订单服务、库存服务、积分服务、仓储服务,每个服务有自己的数据库,如下图:
Try
Try
阶段一般用于锁定某个资源,设置一个预备状态或冻结部分数据。
对于示例中的每一个服务,Try
阶段所做的工作如下:
订单服务:先置一个中间状态“UPDATING
”,而不是直接设置“支付成功”状态;
库存服务:先用一个冻结库存字段保存冻结库存数,而不是直接扣掉库存(可销售库存是另一个字段);
积分服务:预增加会员积分;
仓储服务:创建销售出库单,但状态是UNKONWN
。
Confirm
根据Try
阶段的执行情况,Confirm
分为两种情况:
- 理想情况下,所有
Try
全部执行成功,则执行各个服务的Confirm
逻辑; - 部分服务
Try
执行失败,则执行第三阶段——Cancel
。
Confirm
阶段一般需要各个服务自己实现Confirm
逻辑:
订单服务:confirm
逻辑可以是将订单的中间状态变更为PAYED
-支付成功;
库存服务:将冻结库存数清零,同时扣减掉真正的库存;
积分服务:将预增加积分清零,同时增加真实会员积分;
仓储服务:修改销售出库单的状态为已创建-CREATED
。
Confirm
阶段的各个服务本身可能出现问题,这时候一般就需要TCC
框架了(比如ByteTCC,tcc-transaction,himly),TCC事务框架一般会记录一些分布式事务的活动日志,保存事务运行的各个阶段和状态,从而保证整个分布式事务的最终一致性。
Cancel
如果Try
阶段执行异常,就会执行Cancel
阶段。比如:对于订单服务,可以实现的一种Cancel
逻辑就是:将订单的状态设置为“CANCELED
”;对于库存服务,Cancel
逻辑就是:将冻结库存扣减掉,加回到可销售库存里去。
总结
从正常的流程上讲,TCC
仍然是一个两阶段提交协议。但是,在执行出现问题的时候,有一定的自我修复能力,如果任何一个事务参与者出现了问题,协调者可以通过执行逆操作来取消之前的操作,达到最终的一致状态(比如冲正交易、查询交易)。
从TCC
的执行流程也可以看出,服务提供方需要提供额外的补偿逻辑,那么原来一个服务接口,引入TCC
后可能要改造成3种逻辑:
Try
:先是服务调用链路依次执行Try逻辑;Confirm
:如果都正常的话,TCC
分布式事务框架推进执行Confirm
逻辑,完成整个事务;Cancel
:如果某个服务的Try
逻辑有问题,TCC
分布式事务框架感知到之后就会推进执行各个服务的Cancel
逻辑,撤销之前执行的各种操作。
目前相对比较成熟的是阿里开源的分布式事务框架seata
。
优点:
跟2PC
比起来,实现以及流程相对简单了一些,但数据的一致性比2PC
也要差一些,当然性能也可以得到提升。
缺点:
TCC
模型对业务的侵入性太强,事务回滚实际上就是自己写业务代码来进行回滚和补偿,改造的难度大。一般来说支付、交易等核心业务场景,可能会用TCC
来严格保证分布式事务的一致性,要么全部成功,要么全部自动回滚。这些业务场景都是整个公司的核心业务有,比如银行核心主机的账务系统,不容半点差池。
但是,在一般的业务场景下,尽量别没事就用TCC
作为分布式事务的解决方案,因为自己手写回滚/补偿逻辑,会造成业务代码臃肿且很难维护。
可靠消息最终一致性方案
所谓可靠消息最终一致性方案,其实就是在分布式系统当中,把一个业务操作转换成一个消息,然后利用消息来实现事务的最终一致性。
可靠消息最终一致性方案一般有两种实现方式,原理其实是一样的:
- 基于本地消息表
- 基于支持分布式事务的消息中间件,如
RocketMQ
等
本地消息表
核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于eBay。
基于本地消息服务的分布式事务分为三大部分:
- 可靠消息服务:存储消息,因为通常通过数据库存储,所以也叫本地消息表
- 生产者(上游服务):生产者是接口的调用方,生产消息
- 消费者(下游服务):消费者是接口的服务方,消费消息
可靠消息服务
可靠消息服务就是一个单独的服务,有自己的数据库,其主要作用就是存储消息(包含接口调用信息,全局唯一的消息编号),消息通常包含以下状态:
- 待确认:上游服务发送待确认消息
- 已发送:上游服务发送确认消息
- 已取消(终态):上游服务发送取消消息
- 已完成(终态):下游服务确认接口执行完成
生产者
服务调用方(消息生产者)需要调用下游接口时,不直接通过RPC之类的方式调用,而是先生成一条消息,其主要步骤如下:
- 生产者调用接口前,先发送一条待确认消息(一般称为half-msg,包含接口调用信息)给可靠消息服务,可靠消息服务会将这条记录存储到自己的数据库(或本地磁盘),状态为【待确认】;
- 生产者执行本地事务,本地事务执行成功并提交后,向可靠消息服务发送一条确认消息;如果本地执行失败,则向消息服务发送一条取消消息;
- 可靠消息服务如果收到消息后,修改本地数据库中的那条消息记录的状态改为【已发送】或【已取消】。如果是确认消息,则将消息投递到MQ消息队列;(修改消息状态和投递MQ必须在一个事务里,保证要么都成功要么都失败)。
为了防止出现:生产者的本地事务执行成功,但是发送确认/取消消息超时的情况。可靠消息服务里一般会提供一个后台定时任务,不停的检查消息表中那些【待确认】的消息,然后回调生产者(上游服务)的一个接口,由生产者确认到底是取消这条消息,还是确认并发送这条消息。
通过上面这套机制,可以保证生产者对消息的100%可靠投递。
消费者
服务提供方(消息消费者),从MQ
消费消息,然后执行本地事务。执行成功后,反过来通知可靠消息服务,说自己处理成功了,然后可靠消息服务就会把本地消息表中的消息状态置为最终状态【已完成】 。
这里要注意两种情况:
- 消费者消费消息失败,或者消费成功但执行本地事务失败。
针对这种情况,可靠消息服务可以提供一个后台定时任务,不停的检查消息表中那些【已发送】但始终没有变成【已完成】的消息,然后再次投递到MQ,让下游服务来再次处理。也可以引入zookeeper,由消费者通知zookeeper,生产者监听到zookeeper上节点变化后,进行消息的重新投递。 - 如果消息重复投递,消息者的接口逻辑需要实现幂等性,保证多次处理一个消息不会插入重复数据或造成业务数据混乱。
针对这种情况,消费者可以准备一张消息表,用于判重。消费者消费消息后,需要去本地消息表查看这条消息有没处理成功,如果处理成功直接返回成功。
总结
这个方案的优点是简单,但最大的问题在于可靠消息服务是严重依赖于数据库的,即通过数据库的消息表来管理事务,不太适合并发量很高的场景。
分布式消息中间件
许多开源的消息中间件都支持分布式事务,比如RocketMQ
、Kafka
。其思想几乎是和本地消息表/服务实一样的,只不过是将可靠消息服务和MQ功能封装在一起,屏蔽了底层细节,从而更方便用户的使用。这种方案有时也叫做可靠消息最终一致性方案。
以RocketMQ
为例,消息的发送分成2个阶段:Prepare阶段和确认阶段
。
prepare阶段
- 生产者发送一个不完整的事务消息——
HalfMsg
到消息中间件,消息中间件会为这个HalfMsg
生成一个全局唯一标识,生产者可以持有标识,以便下一阶段找到这个HalfMsg
; - 生产者执行本地事务。
注意:消费者无法立刻消费HalfMsg
,生产者可以对HalfMsg
进行Commit
或者Rollback
来终结事务。只有当Commit
了HalfMsg
后,消费者才能消费到这条消息。
确认阶段
- 如果生产者执行本地事务成功,就向消息中间件发送一个
Commit
消息(包含之前HalfMsg
的唯一标识),中间件修改HalfMsg
的状态为【已提交】,然后通知消费者执行事务; - 如果生产者执行本地事务失败,就向消息中间件发送一个
Rollback
消息(包含之前HalfMsg
的唯一标识),中间件修改HalfMsg
的状态为【已取消】。
消息中间件会定期去向生产者询问,是否可以Commit
或者Rollback
那些由于错误没有被终结的HalfMsg
,以此来结束它们的生命周期,以达成事务最终的一致。之所以需要这个询问机制,是因为生产者可能提交完本地事务,还没来得及对HalfMsg
进行Commit
或者Rollback
,就挂掉了,这样就会处于一种不一致状态。
ACK机制
消费者消费完消息后,可能因为自身异常,导致业务执行失败,此时就必须要能够重复消费消息。RocketMQ
提供了ACK
机制,即RocketMQ
只有收到服务消费者的ack message
后才认为消费成功。
所以,服务消费者可以在自身业务员逻辑执行成功后,向RocketMQ
发送ack message
,保证消费逻辑执行成功。