下图是一个单体应用的 3 个 模块,在同一个数据源上更新数据来完成一项业务,整个过程的数据一致性可以由数据库的本地事务来保证,如下图:
随着业务需求和架构的变化,单体应用进行了服务化拆分:原来的 3 个 模块被拆分为 3 个独立的服务,每个服务使用独立的数据源(Pattern: Database per service)。整个业务过程将由 3 个服务的调用来完成,如下图:
每个服务自身的数据一致性仍由本地事务来保证,但是整个业务层面的全局数据一致性要如何保障呢?比如订单服务和账户服务,都有各自的数据库,必须保证操作的一致性,不能出现下单成功但是没记账的情况。这就是分布式系统所面临的典型分布式事务需求:
分布式系统需要一个解决方案来保障对所有节点操作的数据一致性,这些操作组成一个分布式事务,要么全部执行,要么全部不执行。
二阶段提交协议,包含两类节点:
2PC
协议事务提交过程分为两个阶段:投票阶段和提交阶段:
redo,undo
日志,锁定资源,执行操作,但是不提交),然后响应YES
,后续也不再允许放弃事务;如果不能,就返回NO
响应;NO
,即预留资源失败。在该阶段,事务协调者将基于投票阶段的投票结果进行决策:提交或取消各参与者的本地事务
YES
响应时,协调者才向所有参与者发出提交请求,此时所有参与者必须保证提交事务成功;No
响应,则协调者向所有参与者发出回滚请求,所有参与者进行回滚操作。优点:
缺点:
在二阶段协议中,事务参与者在投票阶段,如果同意提交事务,则会锁定资源,此时任何其他访问该资源的请求将处于阻塞状态。
正因为这个原因,三阶段协议(Three-phase commit protocol, 3PC)对二阶段协议进行了改进:
CanCommit
请求,参与者如果可以提交就返回 Yes
响应,否则返回 No
响应。事务协调者根据事务参与者在询问阶段的响应,判断是执行事务还是中断事务:
YES
,则协调者向参与者们发送预执行指令(preCommit
),参与者接受到preCommit
指令后,写redo
和undo
日志,执行事务操作,占用资源,但是不会提交事务;doCommit
)或中止(abort
)。ACK
确认(即事务执行成功),则协调者向参与者发起提交指令(doCommit
),参与者收到指令后提交事务,并释放锁定的资源,最后响应ACK
;当参与者响应ACK后,即使在指定时间内没收到doCommit指令,也会进行事务的最终提交;
一旦进入提交阶段,即使因为网络原因导致参与者无法收到协调者的doCommit或Abort请求,超时时间一过,参与者也会自动完成事务的提交。
NO
(即执行事务操作失败),或者协调者在指定时间没收到全部的ACK
响应,就会发起中止(abort
)指令,参与者取消已经变更的事务,执行undo
日志,释放锁定的资源。优点:
缺点:
所以无论是 2PC
还是 3PC
,当出现网络分区且不能及时恢复时, 都不能保证分布式系统中的数据 100% 一致。
两阶段提交(2PC
)和三阶段提交(3PC
)并不适用于并发量大的业务场景。TCC
事务机制相比于2PC、3PC
,不会锁定整个资源,而是通过引入补偿机制,将资源转换为业务逻辑形式,锁的粒度变小。
TCC
的核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作,分为三个阶段:
Try
:这个阶段对各个服务的资源做检测以及对资源进行锁定或者预留;Confirm
:执行真正的业务操作,不作任何业务检查,只使用Try
阶段预留的业务资源,Confirm
操作要求具备幂等设计,Confirm
失败后需要进行重试;Cancel
:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,即执行回滚操作,释放Try
阶段预留的业务资源,Cancel
操作要求具备幂等设计,Cancel
失败后需要进行重试。TCC
将一次事务操作分为三个阶段:Try、Confirm、Cancel,
我们通过一个订单/库存的示例来理解。假设我们的分布式系统一共包含4个服务:订单服务、库存服务、积分服务、仓储服务,每个服务有自己的数据库,如下图:
Try
阶段一般用于锁定某个资源,设置一个预备状态或冻结部分数据。
对于示例中的每一个服务,Try
阶段所做的工作如下:
订单服务:先置一个中间状态“UPDATING
”,而不是直接设置“支付成功”状态;
库存服务:先用一个冻结库存字段保存冻结库存数,而不是直接扣掉库存(可销售库存是另一个字段);
积分服务:预增加会员积分;
仓储服务:创建销售出库单,但状态是UNKONWN
。
根据Try
阶段的执行情况,Confirm
分为两种情况:
Try
全部执行成功,则执行各个服务的Confirm
逻辑;Try
执行失败,则执行第三阶段——Cancel
。Confirm
阶段一般需要各个服务自己实现Confirm
逻辑:
订单服务:confirm
逻辑可以是将订单的中间状态变更为PAYED
-支付成功;
库存服务:将冻结库存数清零,同时扣减掉真正的库存;
积分服务:将预增加积分清零,同时增加真实会员积分;
仓储服务:修改销售出库单的状态为已创建-CREATED
。
Confirm
阶段的各个服务本身可能出现问题,这时候一般就需要TCC
框架了(比如ByteTCC,tcc-transaction,himly),TCC事务框架一般会记录一些分布式事务的活动日志,保存事务运行的各个阶段和状态,从而保证整个分布式事务的最终一致性。
如果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之类的方式调用,而是先生成一条消息,其主要步骤如下:
为了防止出现:生产者的本地事务执行成功,但是发送确认/取消消息超时的情况。可靠消息服务里一般会提供一个后台定时任务,不停的检查消息表中那些【待确认】的消息,然后回调生产者(上游服务)的一个接口,由生产者确认到底是取消这条消息,还是确认并发送这条消息。
通过上面这套机制,可以保证生产者对消息的100%可靠投递。
服务提供方(消息消费者),从MQ
消费消息,然后执行本地事务。执行成功后,反过来通知可靠消息服务,说自己处理成功了,然后可靠消息服务就会把本地消息表中的消息状态置为最终状态【已完成】 。
这里要注意两种情况:
这个方案的优点是简单,但最大的问题在于可靠消息服务是严重依赖于数据库的,即通过数据库的消息表来管理事务,不太适合并发量很高的场景。
许多开源的消息中间件都支持分布式事务,比如RocketMQ
、Kafka
。其思想几乎是和本地消息表/服务实一样的,只不过是将可靠消息服务和MQ功能封装在一起,屏蔽了底层细节,从而更方便用户的使用。这种方案有时也叫做可靠消息最终一致性方案。
以RocketMQ
为例,消息的发送分成2个阶段:Prepare阶段和确认阶段
。
HalfMsg
到消息中间件,消息中间件会为这个HalfMsg
生成一个全局唯一标识,生产者可以持有标识,以便下一阶段找到这个HalfMsg
;注意:消费者无法立刻消费HalfMsg
,生产者可以对HalfMsg
进行Commit
或者Rollback
来终结事务。只有当Commit
了HalfMsg
后,消费者才能消费到这条消息。
Commit
消息(包含之前HalfMsg
的唯一标识),中间件修改HalfMsg
的状态为【已提交】,然后通知消费者执行事务;Rollback
消息(包含之前HalfMsg
的唯一标识),中间件修改HalfMsg
的状态为【已取消】。消息中间件会定期去向生产者询问,是否可以Commit
或者Rollback
那些由于错误没有被终结的HalfMsg
,以此来结束它们的生命周期,以达成事务最终的一致。之所以需要这个询问机制,是因为生产者可能提交完本地事务,还没来得及对HalfMsg
进行Commit
或者Rollback
,就挂掉了,这样就会处于一种不一致状态。
消费者消费完消息后,可能因为自身异常,导致业务执行失败,此时就必须要能够重复消费消息。RocketMQ
提供了ACK
机制,即RocketMQ
只有收到服务消费者的ack message
后才认为消费成功。
所以,服务消费者可以在自身业务员逻辑执行成功后,向RocketMQ
发送ack message
,保证消费逻辑执行成功。