分布式事务的产生的原因
事务的ACID特性
原子性(A)
所谓的原子性就是说,在整个事务中的所有操作,要么全部完成,要么全部不做,没有中间状态。对于事务在执行中发生错误,所有的操作都会被回滚,整个事务就像从没被执行过一样。
一致性(C)
事务的执行必须保证系统的一致性,不会出现中间结果。我个人理解是C和AID特性对事务的描述不是一个层面的,AID是基础特性。只要保证了AID,则C一定会得到满足。
隔离性(I)
所谓的隔离性就是说,事务与事务之间不会互相影响,一个事务的中间状态不会被其他事务感知。
持久性(D)
所谓的持久性,就是说一单事务完成了,那么事务对数据所做的变更就完全保存在了数据库中,即使发生停电,系统宕机也是如此。
我们都知道典型的关系型数据库都是支持单机事务的。单机数据库事务是针对单个数据库的。在分布式场景下,就产生了分布式事务。分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败(事务的原子性)。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
分布式事务产生的场景总结
数据库水平拆分
随着业务数据规模的快速发展,数据量越来越大,单库单表逐渐成为瓶颈。所以我们对数据库进行了水平拆分,将原单库单表拆分成数据库分片。这时候,如果一个操作既访问01库,又访问02库,而且要保证数据的一致性,那么就要用到分布式事务。
业务服务化拆分
随着业务的快速发展,系统的访问量和业务复杂程度都在快速增长,单系统架构逐渐成为业务发展瓶颈,解决业务系统的高耦合、可伸缩问题的需求越来越强烈。目前SOA、微服务架构已经席卷整个互联网行业。将单业务系统拆分成多个业务系统,降低了各系统之间的耦合度,使不同的业务系统专注于自身业务,更有利于业务的发展和系统容量的伸缩。业务系统按照服务拆分之后,一个完整的业务往往需要调用多个服务,多个服务间的数据一致性就是典型的分布式事务场景。
总结
数据表水平拆分后,存在两种跨库操作场景。1)操作同一张逻辑表A的sql实际需要分发到多张物理表(比如A_1,A_2)进行执行。2)同一个业务操作的多张表被分拆到多个库上。针对情况1,应该从业务层面杜绝(需要思考分片健选择是否合理等),如果杜绝不了,也需要在数据库序列化层面拆分为多个操作,也就是拆分为多条sql,然后再在业务层面去保证分布式一致性,而不是让底层数据库支持这种分表的一致性。
业务服务化拆分对应的就是库的拆分,和上面数据表水平拆分的情况2一样。都是库表的重新组装,这种是目前互联网行业普遍存在的分布式事务场景,下面就来说说常见的解决方案。
常见的分布式事务解决方案
分布式解决方案分强一致性解决方案和最终一致性解决方案。强一致性解决方案在一次请求中保证要么成功要么失败回滚到原始状态,是现实上可以分基于二阶段提交和基于回滚接口补偿实现;最终一致性解决方案非实时保证数据的一致性,只保证多个系统最终的状态是一致的,可以分带后向恢复和前向恢复(靠不断重试保证一定成功)的解决方案。
下面以支付场景下单扣款和扣积分为例讲解一下各种解决方案如何实现。
支付场景
如何保证扣款和加积分保持一致?
强一致性解决方案
强一致性解决方案--XA
XA是一个分布式事务协议,由Tuxedo提出。XA中大致分为两部分:事务管理器和本地资源管理器。其中本地资源管理器往往由数据库实现,比如Oracle、DB2这些商业数据库都实现了XA接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。XA实现分布式事务的原理如下:
XA事务步骤原理
两阶段提交执行步骤
准备操作(prepare)与ACID
• A: 准备后,仍可提交与回滚
• I: 准备后,事务结果仍然只在事务内可见
• D: 准备后,事务结果已经持久
局限
• 协议成本
• 准备阶段的持久成本
• 全局事务状态的持久成本
• 潜在故障点多带来的脆弱性
• 准备后,提交前的故障引发 一系列隔离与恢复难题
对比一下数据库层面实现差异
单机事务sql
mysql>START transaction 'xatest';
mysql>INSERT INTO mytable (i) VALUES(10);
mysql> ROLLBACK 'xatest';
mysql> COMMIT 'xatest';
XA事务sql
mysql> XA START 'xatest';
mysql> INSERT INTO mytable (i) VALUES(10);
mysql> XA END 'xatest';
mysql> XA PREPARE 'xatest';
mysql> XA ROLLBACK 'xatest';
mysql> XA COMMIT 'xatest';
总的来说,XA协议比较简单,而且一旦商业数据库实现了XA协议,使用分布式事务的成本也比较低。但是,XA也有致命的缺点,那就是性能不理想,之间简单测试了两方事务(就是上面这种两个DB操作的一致性)的性能是单库性能的1/5左右,多方事务的性能会随着参与方的数量成指数下降。XA目前在商业数据库支持的比较理想,在mysql数据库中支持的不太理想,mysql的XA实现,没有记录prepare阶段日志,主备切换回导致主库与备库数据不一致。许多nosql也没有支持XA,这让XA的应用场景变得非常狭隘。
强一致性解决方案--基于接口补偿
基于接口补偿
步骤
1)把扣款和加积分的服务调用放在一个本地方法中。
2)当用户请求登录接口时,先执行加积分操作,加分成功后再执行扣款操作
3)如果扣款成功,那当然最好了,积分也加成功了。如果扣款失败,则调用加积分对应的回滚接口(执行减积分的操作)。
局限
侵入性高,不适用于复杂场景
部分操作不能提供回滚接口
强一致性解决方案 -- TCC编程模式
所谓的TCC编程模式,也是两阶段提交的一个变种。TCC提供了一个编程框架,将整个业务逻辑分为三块:Try、Confirm和Cancel三个操作。TCC就是通过代码人为实现了两阶段提交,不同的业务场景所写的代码都不一样,复杂度也不一样,因此,这种模式并不能很好地被复用。
tcc
Try: 尝试执行业务
• 完成所有业务检查(一致性)
• 预留必须业务资源(准隔离性)
Confirm:确认执行业务
• 真正执行业务
• 不作任何业务检查
• 只使用Try阶段预留的业务资源
• Confirm操作满足幂等性
Cancel: 取消执行业务
• 释放Try阶段预留的业务资源
• Cancel操作满足幂等性
与2PC协议比较
• 位于业务服务层而非资源层
• 没有单独的准备(Prepare)阶段,Try操作兼备资源操作与准备能力
• Try操作可以灵活选择业务资源的锁定粒度
• 较高开发成本
TCC具体步骤
TCC步骤
TCC代码实现
伪代码实现
实现
• 一个完整的业务活动由一个主业务服务与若干从业务服务组成
• 主业务服务负责发起并完成整个业务活动
• 从业务服务提供TCC型业务操作
• 业务活动管理器控制业务活动的一致性,它登记业务活动中的操作,并在业务活动提交时确认所有的TCC型操作的confirm操作,在业务活动取消时调用所有TCC型操作的 cancel操作
成本
• 实现TCC操作的成本
• 业务活动结束时confirm或cancel操作的执行成本
• 业务活动日志成本
适用范围
• 强隔离性、严格一致性要求的业务活动
• 适用于执行时间较短的业务
最终一致性解决方案
最终一致性描述的是分布式系统中,当系统在数据一致的状态执行更新之后,也应该保持一致的状态。具体实现中可以表现为过程中异步软一致性,但结果要强一致性。消息一致性方案是通过消息中间件保证上、下游应用数据操作的一致性。基本思路:是将本地操作和发送消息放在一个事务中,保证本地操作和消息发送要么两者都成功或者都失败。下游应用向消息系统订阅该消息,收到消息后执行相应操作。本质上是:将分布式事务转换成两个本地事务,然后依靠下游业务的重试机制达到最终一致性。
Saga是一个长活事务可被分解成可以交错运行的子事务集合。其中每个子事务都是一个保持数据库一致性的真实事务。
Saga介绍
每个Saga由一系列sub-transaction Ti 组成
每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果
可以看到,和TCC相比,Saga没有“预留”动作,它的Ti就是直接提交到库。
Saga的执行顺序有两种:
(1) T1, T2, T3, ..., Tn
(2) T1, T2, ..., Tj, Cj,..., C2, C1,其中0 Saga定义了两种恢复策略: 1)backward recovery,向后恢复,补偿所有已完成的事务,如果任一子事务失败。即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。 2)forward recovery,向前恢复,重试失败的事务,假设每个子事务最终都会成功。适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。 显然,向前恢复没有必要提供补偿事务,如果你的业务中,子事务(最终)总会成功,或补偿事务难以定义或不可能,向前恢复更符合你的需求。 Saga看起来很有希望满足我们的需求。所有长活事务都可以这样做吗?这里有一些限制: Saga只允许两个层次的嵌套,顶级的Saga和简单子事务 在外层,全原子性不能得到满足。也就是说,sagas可能会看到其他sagas的部分结果 每个子事务应该是独立的原子行为 补偿也有需考虑的事项:补偿事务从语义角度撤消了事务Ti的行为,但未必能将数据库返回到执行Ti时的状态。(例如,如果事务触发导弹发射, 则可能无法撤消此操作) 协调式Saga VS编排式Saga 当系统命令启动saga时,协调逻辑必须选择并告知第一个saga参与者执行本地事务。一旦该事务完成,saga的排序协调选择并调用下一个saga参与者。这个过程一直持续到saga执行了所有步骤。如果任何本地事务失败,则saga必须以相反的顺序执行补偿事务。构建一个saga的协调逻辑有几种不同的方法: 1)协同式(Choreography):把Saga的决策和执行顺序逻辑分布在Saga的每一个参与方中,它们通过交换事件的方式来进行沟通。 2)编排式(Orchestration):把Saga的决策和执行顺序逻辑集中在一个Saga编排器类中。Saga编排器发出命令式消息给各个参与方,指示这些参与方服务完成具体的本地事务操作。 更多关于两种saga的描述可以参考附录中saga的引用文章。 saga落地--个人看法 不管是协调式saga还是编排式saga,都只允许两个层次的嵌套。saga倡导通过事件命令来实现不同参与方的通信。但是现实场景中,消息是属于异步通信,存在延迟的可能性,与异步消息对应的是实时RPC,很多对延迟敏感的业务场景,与参与方之间的通信都是采用RPC。同时,我们需要区分参与方的重要程度,有些参与方是跟场景密切相关,时效性要求很高,可以说是生死与共;而有些参与方跟场景的重要性不高,对时效没有要求。比如电商下单场景下,扣库存、支付、扣积分等行为是生死与共的,下单用户提醒IM消息、物流等相对就没那么重要,失败后异步重试都能接受。 我们可以借鉴编排式saga的思想,在分布式事务场景里面通过延迟回滚消息来实现参与方共进退的统一协调(而不是通过一个显示的流程定义来实现)。通过将实时性要求高&&重要的请求的改为RPC交互,其他用异步消息模式。下面以电商下单场景为例,看如何变种saga满足绝大部分场景下的分布式事务需求: 变种saga流程 1)交易系统创建订单(往DB插入一条记录),同时发送订单创建消息。通过RocketMq事务性消息保证一致性。 2)接着执行完成订单所需的同步核心RPC服务(非核心的系统通过监听MQ消息自行处理,处理结果不会影响交易状态)。执行成功更改订单状态,同时发送MQ消息。 3)交易系统接受自己发送的订单创建消息,通过定时调度系统创建延时回滚任务(或者使用RocketMq的重试功能,设置第二次发送时间为定时任务的延迟创建时间。在非消息堵塞的情况下,消息第一次到达延迟为1ms左右,这时可能RPC还未执行完,订单状态还未设置为完成,第二次消费时间可以指定)。延迟任务先通过查询订单状态判断订单是否完成,完成则不创建回滚任务,否则创建。 PS:多个RPC可以创建一个回滚任务,通过一个消费组接受一次消息就可以;也可以通过创建多个消费组,一个消息消费多次,每次消费创建一个RPC的回滚任务。 回滚任务失败,通过MQ的重发来重试。 以上是交易系统和其他系统之间保持最终一致性的解决方案。 本地事件表 VS 事务消息 本地时间表和事务消息本质上都是保证MQ发送和本地DB操作原子性(要么一起成功、要么一起失败)。目前MQ中支持事务消息的只有Rocketmq。 Rocketmq事务消息 流程图 上图是RocketMQ提供的保证MQ消息、DB事务一致性的方案。 MQ消息、DB操作一致性方案: 1)发送消息到MQ服务器,此时消息状态为SEND_OK。此消息为consumer不可见。 2)执行DB操作;DB执行成功Commit DB操作,DB执行失败Rollback DB操作。 3)如果DB执行成功,回复MQ服务器,将状态为COMMIT_MESSAGE;如果DB执行失败,回复MQ服务器,将状态改为ROLLBACK_MESSAGE。注意此过程有可能失败。 4)MQ内部提供一个名为“事务状态服务”的服务,此服务会检查事务消息的状态,如果发现消息未COMMIT,则通过Producer启动时注册的TransactionCheckListener来回调业务系统,业务系统在checkLocalTransactionState方法中检查DB事务状态,如果成功,则回复COMMIT_MESSAGE,否则回复ROLLBACK_MESSAGE。 说明: 上面以DB为例,其实此处可以是任何业务或者数据源。 以上SEND_OK、COMMIT_MESSAGE、ROLLBACK_MESSAGE均是client jar提供的状态,在MQ服务器内部是一个数字。 TransactionCheckListener 是在消息的commit或者rollback消息丢失的情况下才会回调(上图中灰色部分)。这种消息丢失只存在于断网或者rocketmq集群挂了的情况下。 本地事件表如何与DDD相得益彰 通常的业务处理过程都会更新数据库然后发布领域事件,这里一个比较重要的点是:我们需要保证数据库更新和事件发布之间的原子性,也即要么二者都成功,要么都失败。如果我们的mq中间件不支持事务消息,一种较佳的实现方式是通过事件表来保证消息和本地事务的一致性。流程大致如下: 事件表步骤 在更新业务表的同时,将领域事件一并保存到数据库的事件表中,此时业务表和事件表在同一个本地事务中,即保证了原子性,又保证了效率。 在后台开启一个任务(或者基于监听事件表的binlog),将事件表中的事件发布到消息队列中,发送成功之后删除掉事件。 发布领域事件的整个流程如下: 接受用户请求; 处理用户请求; 写入业务表; 写入事件表,事件表和业务表的更新在同一个本地数据库事务中; 事务完成后,即时触发事件的发送(可以定时扫描事件表,还可以借助诸如MySQL的binlog之类的机制); 后台任务读取事件表; 后台任务发送事件到消息队列; 发送成功后删除事件。 在事件表场景下,一种常见的做法是将领域事件保存到聚合根中,然后在Repository保存聚合根的时候,将事件保存到事件表中。 总结 分布式解决方案 简介 优缺点 XA两阶段提交 资源管理器(数据库等存储管理结点)实现Xa规范 协议成本带来交互成本高、持久成本高。虽能保障强一致性,单并发能力弱 提供回滚接口 业务方依次执行,出现异常调用回滚接口 业务侵入性高。一致性实时性高 TCC模式 原来一个接口需要改成三个接口,TCC框架通过拦截器捕获Try操作异常,决定调用Confirm对应接口还是Cancal对应接口 开发成本高,一致性实时性强 saga 通过消息将长活事务分解成可以交错运行的子事务集合,其中每个子事务都是一个保持数据库一致性的真实事务。 只能满足最终一致性,需要事务消息中间件或者使用事件表;优点是业务方开发成本低 分布式事务,本质上是对多个数据库的事务进行统一控制,按照控制力度可以分为:不控制、部分控制和完全控制。不控制就是不引入分布式事务,部分控制就是各种变种的两阶段提交。部分控制的好处是并发量和性能很好,缺点是数据一致性减弱了,完全控制则是牺牲了性能,保障了一致性,具体用哪种方式,最终还是取决于业务场景。作为技术人员,一定不能忘了技术是为业务服务的,不要为了技术而技术,针对不同业务进行技术选型也是一种很重要的能力。 参考