在系统是单体架构时,系统是和单个数据库进行交互,所以如果有多表操作的时候,可以使用数据库的事务实现数据的一致性,这种事务可以称之为本地事务。随着业务的发展,系统的压力越来越大,单体数据库的性能也达到了瓶颈,不可避免的进行数据库的拆分,还有系统模块的拆分,跨服务、跨数据库的事务场景就越来越多,这样解决分布式的事务的需求就出现了。
有需求就要解决啊!我们程序员就是用来解决问题,实现需求的。之前的文章已经说了,目前已经存在好多种解决分布式事务的方案了,今天我们来说说其中几种比较有代表性的方案。
两阶段提交协议(2PC)
两阶段提交协议(2PC,two phase commit protcol),是基于数据库资源层面的。把分布式事务分为两个阶段,一个是准备阶段,另外一个是提交阶段准备阶段和提交阶段都是由事务管理器(也称作协调者)发起,还有一个角色就是参与者。两阶段提交协议的流程如下:
准备阶段:协调者向参与者发起指令,参与者评估自己的资源,如果参与者评估指令能完成,则会写redo、undo日志,然后锁定资源、执行操作但是不提交。
提交阶段:如果每个参与者都明确的返回成功,也就是意味着资源锁定、执行操作成功,则协调者向各个参与者发起提交指令,参与者提交操作、释放锁定资源;如果有参与者在上述的两个步骤中有明确返回失败,也就是说资源锁定或者执行操作失败,则协调者向各个参与者发布中止指令,参与者执行undo日志,释放锁定的资源。
在最开始的准备阶段就锁定资源,这是一个重量级的操作,可以保证强一致性。但是实现起来却有很多的缺点:
- 阻塞,没有超时机制。如果在整个流程中,任何一个参与者或者协调者由于可能的网络延迟问题,导致协调者的指令不能发出或者参与者接受不到指令,整个流程也不能继续下去,且资源一直被锁定。
- 协调者有单点问题。协调者发出指令之后宕机,整个流程无法继续进行。
- 会有数据不一致的问题。协调者发出提交指令,部分参与者接受到了指令,执行了所有操作,但是有参与者宕机了,无法执行提交操作。
两阶段提交协议成功场景的示意图

三阶段提交协议(3PC)
针对两阶段提交协议(2PC)的缺点进行改进提出三阶段提交协议(3PC,three phase commit protcol),也是基于数据库资源层面的。增加了一个询问的阶段,增加协调者、参与者超时机制,一旦发生超时则默认提交事务,其他的步骤就和2PC相同。
询问阶段:协调者想参与者询问能否完成指令,参与者只需要回答是和否,无需做其他的操作。
准备阶段:协调者向参与者发起指令,参与者评估自己的资源,如果参与者评估指令能完成,则会写redo、undo日志,然后锁定资源、执行操作但是不提交。如果有参与者在询问阶段回答否,则协调者向参与者发送中止请求。
提交阶段:如果每个参与者都明确的返回成功,也就是意味着资源锁定、执行操作成功,则协调者向各个参与者发起提交指令,参与者提交操作、释放锁定资源;如果有参与者在上述的两个步骤中有明确返回失败,也就是说资源锁定或者执行操作失败,则协调者向各个参与者发布中止指令,参与者执行undo日志,释放锁定的资源。
三阶段提交协议增加了询问阶段,这样可以确保尽可能早的发现参与者无法进行准备操作,但是不能完全避免这种情况,增加了超时机制可以减少资源的锁定时间。但是仍然会有数据不一致的问题。假设在提交阶段,协调者发出中止命令,由于发生网络分区等问题,部分参与者没有接受到命令则按照超时默认提交事务的规则,导致部分参与者回滚了事务,部分参与者提交了事务,数据一致性被破坏。(其实你就是超时默认中止操作还是会发生数据不一致的情况,真是太难啦😄)
三阶段提交协议成功场景流程图

TCC(Try-Confirm-Cancel)
其实大家看了2PC和3PC的执行流程,就可以感觉到他们的实现,会出现资源阻塞、数据不一致的问题,性能效率也不高。在实际项目中也少有使用2PC和3PC实现分布式事务。后来又有大神提出TCC(Try-Confirm-Cancel)协议,协议是基于业务层面实现。这个协议将任务拆分成Try、Confirm、Cancel三个阶段,每个阶段都要保证各自操作幂等。
- Try(预处理阶段):参与者完成所有业务检查(一致性),预留业务资源(准隔离性),所有参与者预留都成功,try阶段才算成功。此阶段仅是一个初步操作,它和后续的Confirm 一起才能真正构成一个完整的业务逻辑。
这个预留就是说用户在下订单使用了50积分抵扣金额,我们给积分增加一种冻结的状态,直接把使用的50积分状态置为冻结状态,在订单未完成支付之前用户查看自己的总积分没有减少,但是可用来支付的积分少了50,这样就不会一直占用资源,更新完50积分的状态就是释放了资源。
Confirm(确认阶段):确认执行业务操作,不做任何业务检查,只使用Try阶段预留的业务资源。通常情况下,采用TCC则认为 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。
Cancel(取消阶段):取消Try阶段预留的业务资源。如果某个业务资源没有预留成功,则取消所有业务资源预留请求。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
TCC示意图

2PC(3PC)是追求的数据的强一致性,是一种强一致性事务,而TCC在Confirm、Cancel阶段允许重试,这就意味着数据在一段时间内一致性被破坏,TCC符合BASE理论则可称作一种柔性事务。
如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面实现,而TCC在应用层处理,通过业务逻辑来实现。这种分布式事务的实现方式的优势可以让应用自己定义数据操作的粒度,使得降低资源锁冲突、提高系统吞吐量成为可能。TCC的不足之处则在于对应用的代码侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作,增大了开发的成本。
目前业界已经有很多开源的TCC协议的分布式事务框架,例如Hmily、ByteTCC、TCC-transaction。使用这些框架就可以很大程度上节约时间,将更多的时间和注意力放到到具体业务中。
可靠消息最终一致性方案
可靠消息方案通过消息生产、消息存储、消息投递三个阶段的可靠性,实现最终数据一致性,这种方案又有两种实现方式,一种是基于本地消息表实现,另外一种是基于事务消息实现。其实这种本地消息表和消息队列方案不冲突,因为消息队列的消息能够100%投递不丢失也可以用本地消息表实现。
本地消息表方案
通过本地消息表实现分布式事务,我觉得是一种最简单、最简便的实现方式。它的核心思想就是讲分布式事务拆分成本地事务进行处理,用数据库的事务ACID特性保证数据一致性。还有再提一句这种实现思想是eBay里的大牛提出来的,前文说到的BASE理论也是同一家公司的人提出的,不得不赞叹eBay公司里大牛可真是多啊!
简版本地消息表方案
在这里我解释我所说的本地消息表方案中,是可以没有消息队列中间件的,这个可能和网上很多的说法不一样。因为考虑实际的情况,两个系统之间交互如果不存在高并发、大流量,以后也不会出现太多的业务耦合,引入消息中间件就会太过了,不仅提升了系统的复杂度,也增加了额外的中间件维护成本,降低了系统的可用性。所以解决这种情况最简单的方式就是使用http通信、异常用定时任务补偿即可。
这里我先举例说明我上面说的最简单的方式,之前我们的系统要从飞猪上进行会员引流,其中有这样一个需求,如果一个用户是我们系统的会员同时也是飞猪的会员,若这个用户在我们系统的等级发生了改变,则通知飞猪此用户的等级发生变动,然后用户在飞猪APP上查看会员信息的时候,飞猪重新调用一下我们系统的会员信息查询接口。你说对于这种需求用消息队列实现是不是有点大材小用了,最终的开发方案如下:
业务本地消息表设计
1 | CREATE TABLE `biz_local_message` ( |
- 保证biz_local_message表数据跟业务流程在一个事务中,一起成功写入数据库。
这里的业务模块biz_module就是会员,业务单号biz_no可以用uuid,消息内容就是要推送的消息json保存。开启一个异步线程进行消息推送,推送前将消息的状态handle_status从1待处理设置成2处理中、处理次数 handle_count 值加1、由backoff_second字段和当前时间计算出下次推送时间设置next_handle_time字段,SQL更新记录影响行数affectRow 返回1才继续处理,更新失败则直接return。
- 定时任务补偿,处理出现推送异常的消息。定时任务的执行频率可以根据自己的业务需要自行设定,我这里当时设定的是每5分钟执行一次。
总体上是先进行异常处理,然后再处理异步线程可能没有推送的消息。
第一步将 where handle_status=2 AND handle_count = max_handle_count AND next_handle_time <= now()
的记录状态handle_status更新为8处理失败 、next_handle_time字段清空,然后进入人工处理流程。
第二步将 where handle_status=2 AND handle_count < max_handle_count AND next_handle_time <= now()
则直接把这些记录的 handle_status 状态更新成 1待处理 、next_handle_time字段清空。
第三步将 where handle_status= 1
的记录分页处理。
处理前将消息的状态从1待处理 更新成 2处理中、处理次数 handle_count 值加1、由backoff_second字段和当前时间计算出下次推送时间设置next_handle_time字段、更新update_time字段,SQL更新记录影响行数affectRow 返回1才继续处理,更新失败则处理下一条记录。
前一步更新成功则继续处理;请求返回成功将记录的的状态由2处理中更新成4处理成功、清空 next_handle_time 字段,更新 update_time字段 。如果请求返回失败则更新将满足
handle_status='2' AND handle_count < max_handle_count
的记录更新 handle_status=1 、update_time字段,记录达到最大处理次数的将记录handle_status更新成 8处理失败,清空 next_handle_time 字段,更新update_time字段,然后进入人工处理流程。
在被调用的服务发生异常或者网络问题,短时间内的频繁重试所得到的结果也大致都是失败,这样的重试不仅没有效果,反而还会增服务的负担。所以在计算下一次处理时间next_handle_time除了加上backoff_second退避秒数之后也可以加上随机数,或者更高大上一点使用退避算法来计算。
简版本地消息表方案流程图

实际本地消息表方案
上面那种是简版的本地消息表方案,但是采用分布式架构的系统由于业务解耦、高并发大流量异步削峰等需要会引入消息队列,则消息就会直接发送到消息队列MQ中,不再通过http请求的方式进行通讯,所以方案的流程就会和上面的不一样,具体流程如下图:

由于消息队列(我这里以RocketMQ为例,若使用的是其他的消息队列,请按照需要自行修改)的引入,业务本地消息表的设计稍稍改动了一下:
1 | CREATE TABLE `biz_local_message` ( |
如果你的消息内容msg内容比较大使用的是text类型,为了提高数据库的效率,或许你可以将这个消息内容msg字段拿出来单独存一张表;
1 | CREATE TABLE `biz_local_message_content` ( |
biz_local_message
表中处理成功的消息可以直接统一删除,或者挪到一个新的表中备份。
这种和上面的方案相比就是把http通信的方式换成了把消息发送到消息队列中,然后下游系统从消息队列中消费消息,其他的流程差不多。
基于RocketMQ事务消息方案
数据库的本地事务无法解决业务逻辑和消息发送的一致性,因为消息发送是一个网络通信过程,发送消息可能出现发送失败或者超时情况。超时的情况也有可能消息已经发送成功了,也有可能发送失败,但消息发送方是无法确定的,所以这时消息发送方是提交事务还是回滚事务,都是会有可能出现数据不一致的地方。
要解决这个问题,可以采用上面的本地消息表方案,业务逻辑和消息记录在一个事务中一起提交,然后再发送消息。或者采用MQ事务消息(half消息)的办法,事务消息和普通消息的区别在于,事务消息发送到消息队列中后处于prepared状态,是对消费者不可见的,等到事务消息的状态更改为可消费状态后,下游系统的消费者才可以消费到消息。
事务消息方案的处理流程如下:
事务发起者先发送一个事务消息到MQ中。
MQ系统接收到事务消息后,将事务消息持久化,此时事务消息的状态是“已准备”,并给消息发送者返回一个ACK响应。
事务消息发送者接收到了步骤2中MQ的ACK响应,则执行本地事务成功则提交事务,失败则回滚本地事务,然后给MQ系统返回一个响应,通知MQ本地事务执行的情况。如果事务消息发送者没有接收到步骤2中ACK响应,则无须或者取消执行本地事务。
MQ系统接收到事务消息发送者的反馈消息后,根据反馈的消息更改事务消息的状态。若反馈的消息是本地事务执行成功,则将事务消息的状态从“已准备”修改为“可消费”,并将消息下发给相应的消费者;若反馈的消息是本地事务执行失败,则直接删除该条事务消息。
步骤4中的反馈消息,有可能在发送给MQ的过程中丢失。所以MQ系统有定时任务进行补偿,扫描系统中那些状态仍然为“已准备”的事务消息,并向事务消息发送者询问消息的实际状态,并根据反馈的情况更新事务消息的状态。因此,事务消息发送者要实现一个查询事务消息状态的接口,以供MQ系统使用。
下游服务接收到MQ系统推送的消息之后,进行消费执行本地事务,如果执行成功,则给MQ系统返回ACK消息。反之,不返回ACK消息。MQ系统会进行重试投递,下游服务要注意实现接口幂等。
事务消息方案流程图

以上面提到的下单场景为例,伪代码实现如下:
发送事务消息
1 | public Result buildOrder(OrderDto orderDto) { |
执行本地事务和RocketMQ检查接口实现
1 | /** |
其实,MQ事务消息和本地消息表两种方案其实是一样的,本质上就是MQ实现了那个日志记录的功能。还有这两种方案也是基于消息队列可靠性,对于如何提高消息队列的可靠性,恐怕还是可以写一篇长文。另外,基于事务消息的方案对MQ系统要求较高,目前并不是所有的MQ系统支持事务消息功能,貌似只有RocketMQ支持。如果你目前的系统使用的MQ系统不支持事务消息的功能,采用本地消息表方案实现分布式事务功能也算是一个不错的选择。
上述两种方式实现过程中可以发现,都是将分布式事务拆分成本地事务实现,流程中可能出现消息传递失败需要定时任务补偿,明显是符合BASE理论的,都不是强一致性的而都是柔性事务,实现数据的最终一致性。还有一点要提出来的就是,这些方案流程方向都是不可逆的,原则上上游系统事务成功提交,下游系统的事务则也一定要成功,业务不能回滚,如果要是出现消息消费失败,则只能进行不断的重试,直到成功为止。所以大家使用的时候要注意,如果要避免这种情况,可以加入前面提到预锁机制,例如,一个电商下单扣库存的业务场景,用户下单时不扣库存,等用户支付完成才扣库存,这种实现方案是有可能出现库存不足的问题,要解决这种情况,可以在用户下单的时候,直接使用RPC同步请求方式锁定库存进行预留,这样用户后面订单支付成功则将锁定的库存才真正的扣除,如果用户最终没有完成支付,则直接取消订单,是否锁定的库存即可。当然这种情况也有可能出现用户恶意下单,导致真正的用户无法购买的问题,还有库存锁定占用时间过长的问题等等,当然这些问题都是可以解决的,看业务的接受情况自行选择吧!
方案总结
世界上唯一不变的,就是变化本身。单体架构系统通过数据库事务的ACID特性很容易实现数据一致性,而且是强一致性。为了提高系统的高可用性、吞吐量,提出了分布式架构,结果导致出现了分布式事务问题。果然验证了那句话“软件行业是没有银弹的”!最终引出了CAP理论、BASE理论,各路大牛也给出了相应的解决方案。
在分布式架构的情况下,将之前的关系型数据库提供的事务称之为本地事务,解决分布式系统中存在事务方案进而称之为分布式事务(重新定义下概念)。2PC、3PC方案实现的事务能保证强一致性,我们称之为刚性事务;TCC和可靠消息方案实现的事务只能保证最终一致性,那么我们称之为柔性事务。
由于CAP理论太过于乐观导致对于构建分布式系统没有全面的指导作用,大牛们对CAP理论的缺点进行改进提出了BASE理论。相比之下BASE理论更符合实际的情况,更加的接地气。BASE理论的指导我们在发生消息丢失或者网络分区的问题情况下,分布式系统可以根据自身业务特点,采取是保大(Consistency)还是保小(Availability)😄😄,然后使得系统在一定时间内达到最终一致性。
刚性事务可以实现强一致性,但性能不高;柔性事务性能较高,但是只能实现弱一致性。所以大家在构建自己的分布式系统遇到分布式事务问题时,根据自己的需求场景和业务承受情况,自行选择吧!
[参考资料:《分布式服务架构 原理、设计与实践》 李艳鹏 杨彪 著 ]