MySQL-高可用篇-⑥分布式事务
MySQL-高可用篇-⑥分布式事务
学习核心
- 分布式事务概念核心
- 分布式事务解决方案
- MySQL如何实现分布式事务?
- 哪个方案比较容易落地?
学习资料
分布式事务概念核心
什么是分布式事务?(All or nothing)
对于网上购物的每一笔订单来说,电商平台一般都会有两个核心步骤:一是订单业务采取下订单操作,二是库存业务采取减库存操作。
通常,这两个业务会运行在不同的机器上,甚至是运行在不同区域的机器上。针对同一笔订单,当且仅当订单操作和减库存操作一致时,才能保证交易的正确性。也就是说一笔订单,只有这两个操作都完成,才能算做处理成功,否则处理失败,充分体现了“All or nothing”的思想。在分布式领域中,这个问题就是分布式事务问题。
传统的事务一般指的是本地事务(单机事务),具备ACID 4个基本特性。而分布式事务(分布式系统中运行的事务)是由多个本地事务组合而成的,在分布式场景下,对事务的处理操作可能来自不同的机器,甚至是来自不同的操作系统。分布式事务由多个事务组成,因此基本满足ACID,其中的C是强一致性,也就是所有操作均执行成功,才提交最终结果,以保证数据一致性或完整性。但随着分布式系统规模不断扩大,复杂度急剧上升,达成强一致性所需时间周期较长,限定了复杂业务的处理。为了适应复杂业务,出现了BASE理论,该理论的一个关键点就是采用最终一致性代替强一致性
分布式事务场景
微服务和DDD领域驱动设计是在业界非常流行。DDD是一种拆分微服务的方法,它从业务流程的视角从上往下拆分领域,通过聚合根关联多个领域,将多个流程聚合在一起,形成独立的服务。相比由数据表结构设计出的微服务,DDD这种方式更加合理,但也加大了分布式事务的实现难度。
在传统的分布式事务实现方式中,一般会将一个完整的事务放在一个独立的项目中统一维护,并在一个数据库中统一处理所有的操作。这样在出现问题时,直接一起回滚,即可保证数据的互斥和统一性。
不过,**这种方式的服务复用性和隔离性较差,很多核心业务为了事务的一致性只能聚合在一起。**为了保证一致性,事务在执行期间会互斥锁定大量的数据,导致服务整体性能存在瓶颈。**而非核心业务要想在隔离要求高的系统架构中,实现跨微服务的事务,难度更大,**因为核心业务基本不会配合非核心业务做改造,再加上核心业务经常随业务需求改动(聚合的业务过多),结果就是非核心业务没法做事务,核心业务也无法做个性化改造。
也正因为如此,多个系统要想在互动的同时保持事务一致性,是一个令人头疼的问题,业内很多非核心业务无法和核心模块一起开启事务,经常出现操作出错,需要人工补偿修复的情况。尤其在微服务架构或用DDD方式实现的系统中,服务被拆分得更细,并且都是独立部署,拥有独立的数据库,这就导致要想保持事务一致性实现就更难了,因此跨越多个服务实现分布式事务已成为刚需。
如何实现分布式事务?
实际上并没有一种分布式事务的服务或者组件,能够很简单地解决分布式系统下的数据一致性问题。在使用分布式事务时,更多的情况是,用分布式事务的理论来指导设计和开发,自行来解决数据一致性问题。
分布式事务主要是解决在分布式环境下组合事务的一致性问题。实现分布式事务有以下3种基本方法:
基于XA协议的二阶段提交协议(2PC)方法;
基于XA协议的三阶段提交协议(3PC)方法;
基于TCC协议的分段提交方法;
- 场景问题解决方案
- SAGA事务基于数据补偿代替回滚的解决思路
- AT事务
- 场景问题解决方案
基于消息的最终一致性方法;(本地消息表和事务消息)
其中,基于XA协议的二阶段提交协议方法和三阶段提交协议方法,采用了强一致性,遵从ACID。基于消息的最终一致性方法,采用了最终一致性,遵从BASE理论。
分布式事务解决方案
1.基于XA协议的二阶段提交协议(2PC)方法
XA协议
XA协议是一个很流行的分布式事务协议,可以很好地支撑分布式事务的实现,比如常见的2PC、3PC等。这个协议适合在多个数据库中,协调分布式事务,目前Oracle、DB2、MySQL 5.7.7以上版本都支持它(虽然有很多bug)。理解XA协议对深入了解分布式事务的本质很有帮助。
支持XA协议的数据库可以在客户端断开的情况下,将执行好的业务结果暂存起来,直到另外一个进程确认才会最终提交或回滚事务,这样就能轻松实现多个数据库的事务一致性。
在XA协议里有三个主要的角色:
- 应用(AP):应用是具体的业务逻辑代码实现,业务逻辑通过请求事务协调器开启全局事务,在事务协调器注册多个子事务后,业务代码会依次给所有参与事务的子业务下发请求。待所有子业务提交成功后,业务代码根据返回情况告诉事务协调器各个子事务的执行情况,由事务协调器决策子事务是提交还是回滚(有些实现是事务协调器发请求给子服务)。
- 事务协调器(TM):用于创建主事务,同时协调各个子事务。事务协调器会根据各个子事务的执行情况,决策这些子事务最终是提交执行结果,还是回滚执行结果。此外,事务协调器很多时候还会自动帮我们提交事务;
- 资源管理器(RM):是一种支持事务或XA协议的数据资源,比如MySQL、Redis等。
另外,XA还对分布式事务规定了两个阶段:Prepare阶段和Commit阶段。
在Prepare阶段,事务协调器会通过xid(事务唯一标识,由业务或事务协调器生成)协调多个资源管理器执行子事务,所有子事务执行成功后会向事务协调器汇报。这时的子事务执行成功是指事务内SQL执行成功,并没有执行事务的最终commit(提交),所有子事务是提交还是回滚,需要等事务协调器做最终决策。
接着分布式事务进入Commit阶段:当事务协调器收到所有资源管理器成功执行子事务的消息后,会记录事务执行成功,并对子事务做真正提交。如果Prepare阶段有子事务失败,或者事务协调器在一段时间内没有收到所有子事务执行成功的消息,就会通知所有资源管理器对子事务执行回滚的操作。
子事务状态
每个子事务都有多个状态,每个状态的流转情况如下所示:
- **ACTIVE:**子事务SQL正在执行中;
- **IDLE:**子事务执行完毕等待切换Prepared状态,如果本次操作不参与回滚,就可以直接提交完成;
- **PREPARED:**子事务执行完毕,等待其他服务实例的子事务全部Ready;
- **COMMITED/FAILED:**所有子事务执行成功/失败后,一起提交或回滚;
(1)MySQL如何实现XA规范?
MySQL 中 XA 事务有两种情况,内部 XA 和外部 XA,其区别是事务发生在 MySQL 服务器单机上,还是发生在多个外部节点间上。
内部 XA:在 MySQL 的 InnoDB 存储引擎中,开启 binlog 的情况下,MySQL 会同时维护 binlog 日志与 InnoDB 的 redo log,为了保证这两个日志的一致性,MySQL 使用了 XA 事务,由于是在 MySQL 单机上工作,所以被称为内部 XA。内部 XA 事务由 binlog 作为协调者,在事务提交时,则需要将提交信息写入二进制日志,也就是说,binlog 的参与者是 MySQL 本身。
外部 XA:外部 XA 就是典型的分布式事务,MySQL 支持 XA START/END/PREPARE/Commit 这些 SQL 语句,通过使用这些命令,可以完成分布式事务。MySQL 外部 XA 主要应用在数据库代理层,实现对 MySQL 数据库的分布式事务支持,例如开源的数据库中间层,比如淘宝的 TDDL、阿里巴巴 B2B 的 Cobar 等。外部 XA 一般是针对跨多 MySQL 实例的分布式事务,需要应用层作为协调者,比如在写业务代码,在代码中决定提交还是回滚,并且在崩溃时进行恢复。
实现思路
XA是一个分布式事务协议,规定了事务管理器TM和资源管理器RM接口。因此,XA协议包括事务管理器和本地资源管理器两个部分。
XA实现分布式事务的原理,类似于集中式算法:事务管理器相当于协调者,负责各个本地资源的提交和回滚;而资源管理器就是分布式事务的参与者,通常由数据库实现,比如Oracle、DB2等商业数据库都实现了XA接口。
基于 XA协议的二阶段提交方法中,二阶段提交协议(Two-phase Commit Protocol,2PC)用于保证分布式系统中事务提交时的数据一致性,是XA在全局事务中用于协调多个资源的机制。
两阶段提交协议如何保证分布在不同节点上的分布式事务的一致性?
为了保证它们的一致性,需要引入一个协调者来管理所有的节点,并确保这些节点正确提交操作结果,若提交失败则放弃事务。下述分析两阶段提交的执行过程:两阶段提交协议的执行过程,分为投票(Voting)和提交(Commit)两个阶段
- 第一阶段-投票阶段:在这一阶段,协调者(Coordinator,即事务管理器)会向事务的参与者(Cohort,即本地资源管理器)发起执行操作的CanCommit请求,并等待参与者的响应。参与者接收到请求后,会执行请求中的事务操作,将操作信息记录到事务日志中但不提交(即不会修改数据库中的数据),待参与者执行成功,则向协调者发送“Yes”消息,表示同意操作;若不成功,则发送“No”消息,表示终止操作。
- 第二阶段-提交阶段(执行阶段):在提交阶段,协调者会根据所有参与者返回的信息向参与者发送DoCommit(提交)或DoAbort(取消)指令。具体规则如下:
- 若协调者从参与者那里收到的都是“Yes”消息,则向参与者发送“DoCommit”消息。参与者收到“DoCommit”消息后,完成剩余的操作(比如修改数据库中的数据)并释放资源(整个事务过程中占用的资源),然后向协调者返回“HaveCommitted”消息;
- 若协调者从参与者收到的消息中包含“No”消息,则向所有参与者发送“DoAbort”消息。此时投票阶段发送“Yes”消息的参与者,则会根据之前执行操作时的事务日志对操作进行回滚,就好像没有执行过请求操作一样,然后所有参与者会向协调者发送“HaveCommitted”消息;
- 协调者接收到来自所有参与者的“HaveCommitted”消息后,就意味着整个事务结束了。
以订单系统和促销系统(事务:下订单使用优惠券)这一案例为参考,其时序图分析如下:
- CanCommit阶段:协调者分别向两个子系统发送指令,子系统接收指令后分别执行SQL事务但不提交,并向协调者发送执行结果(执行成功/执行失败)
- DoCommit/DoAbort阶段:协调者接收到所有的消息后,根据执行结果进行响应
- 如果所有的系统都执行成功,则发送DoCommit指令,子系统接收到指令后执行真正的commit操作
- 如果存在子系统执行失败的消息,则发送DoAbort指令,则相关子系统需根据相应的事务日志进行数据回滚操作
二阶段提交具体执行流程
结合流程图分析,2PC的概念理解和实现相对简单,但是其也有相应的缺点:
在Prepare阶段,很多操作的数据需要做行锁定以确保数据一致性,且存在同步阻塞问题(应用和每个子事务的过程需要阻塞,需要等整个事务全部完成才能释放资源,就会导致资源锁定时间较长,无法支持高并发场景,常有大量事务排队)
在Commit阶段,如果事务协调器的提交操作被打断,XA事务就会被遗留在MySQL中,且2PC整体设计没有超时机制,如果长时间不提交遗留在MySQL中的XA子事务,就会导致数据库长期被锁表。在很多开源的实现中,2PC的事务协调器会自动回滚或强制提交长时间没有提交的事务,但是如果进程重启或宕机,这个操作就会丢失,则需要人工介入修复
案例分析
以用户A要在网上下单购买100件T恤为例,结合下单操作和减库存操作这两个操作进行理解。
第一阶段(投票阶段):订单系统中将与用户A有关的订单数据库锁住,准备好增加一条关于用户A购买100件T恤的信息,并将同意消息“Yes”回复给协调者。而库存系统由于T恤库存不足,出货失败,因此向协调者回复了一个终止消息“No”。
第二阶段:由于库存系统操作不成功,因此,协调者就会向订单系统和库存系统发送“DoAbort”消息。订单系统接收到“DoAbort”消息后,将系统内的数据退回到没有用户A购买100件T恤的版本,并释放锁住的数据库资源。订单系统和库存系统完成操作后,向协调者发送“HaveCommitted”消息,表示完成了事务的撤销操作。
至此,用户A购买100件T恤这一事务已经结束,用户A购买失败。
存在问题
结合上述分析,二阶段提交的算法思路概括为:协调者向参与者下发请求事务操作,参与者接收到请求后进行相关操作并将操作结果通知协调者,协调者根据所有参与者的反馈结果决定各参与者是要提交操作还是撤销操作。
虽然基于XA的二阶段提交算法尽量保证了数据的强一致性,而且实现成本低,但依然有些不足。主要有以下三个问题:
- 同步阻塞问题:二阶段提交算法在执行过程中,所有参与节点都是事务阻塞型的。即当本地资源管理器占有临界资源时,其他资源管理器如果要访问同一临界资源,会处于阻塞状态。因此,基于XA的二阶段提交协议不支持高并发场景。
- **单点故障问题:**该算法类似于集中式算法,一旦事务管理器发生故障,整个系统都处于停滞状态。尤其是在提交阶段,一旦事务管理器发生故障,资源管理器会由于等待管理器的消息,而一直锁定事务资源,导致整个系统被阻塞。
- **数据不一致问题:**在提交阶段,当协调者向所有参与者发送“DoCommit”请求时,如果发生了局部网络异常,或者在发送提交请求的过程中协调者发生了故障,就会导致只有一部分参与者接收到了提交请求并执行提交操作,但其他未接到提交请求的那部分参与者则无法执行事务提交。于是整个分布式系统便出现了数据不一致的问题。
2.基于XA协议的三阶段提交协议(3PC)方法
实现思路
三阶段提交协议(Three-phase Commit Protocol,3PC),是对二阶段提交(2PC)的改进。为了更好地处理两阶段提交的同步阻塞和数据不一致问题(仅是优化不是完全消灭问题),三阶段提交引入了超时机制和准备阶段。
- 与2PC只是在协调者引入超时机制不同,3PC同时在协调者和参与者中引入了超时机制。如果协调者或参与者在规定的时间内没有接收到来自其他节点的响应,就会根据当前的状态选择提交或者终止整个事务,从而减少了整个集群的阻塞时间,在一定程度上减少或减弱了2PC中出现的同步阻塞问题。
- 在第一阶段和第二阶段中间引入了一个准备阶段,或者说把2PC的投票阶段一分为二,也就是在提交阶段之前,加入了一个预提交阶段。在预提交阶段尽可能排除一些不一致的情况,保证在最后提交之前各参与节点的状态是一致的。
三阶段提交协议就有CanCommit、PreCommit、DoCommit三个阶段:
CanCommit阶段
协调者向参与者发送请求操作(CanCommit请求),询问参与者是否可以执行事务提交操作,然后等待参与者的响应;参与者收到CanCommit请求之后,回复Yes,表示可以顺利执行事务;否则回复No。
3PC的CanCommit阶段与2PC的Voting阶段相比:
- 类似之处在于:协调者均需要向参与者发送请求操作(CanCommit请求),询问参与者是否可以执行事务提交操作,然后等待参与者的响应。参与者收到CanCommit请求之后,回复Yes,表示可以顺利执行事务;否则回复No。
- 不同之处在于,在2PC中,在投票阶段,若参与者可以执行事务,会将操作信息记录到事务日志中但不提交,并返回结果给协调者。但在3PC中,在CanCommit阶段,参与者仅会判断是否可以顺利执行事务,并返回结果。而操作信息记录到事务日志但不提交的操作由第二阶段预提交阶段执行。
当协调者接收到所有参与者回复的消息后,进入预提交阶段(PreCommit阶段)
PreCommit阶段
协调者根据参与者的回复情况,来决定是否可以进行PreCommit操作(预提交阶段)
- 如果所有参与者回复的都是“Yes”,那么协调者就会执行事务的预执行:
- 协调者向参与者发送PreCommit请求,进入预提交阶段;
- 参与者接收到PreCommit请求后执行事务操作,并将Undo和Redo信息记录到事务日志中;
- 如果参与者成功执行了事务操作,则返回ACK响应,同时开始等待最终指令;
- 假如任何一个参与者向协调者发送了“No”消息,或者等待超时之后,协调者都没有收到参与者的响应,就执行中断事务的操作:
- 协调者向所有参与者发送“Abort”消息;
- 参与者收到“Abort”消息之后,或超时后仍未收到协调者的消息,执行事务的中断操作;
预提交阶段保证了在最后提交阶段(DoCmmit阶段)之前所有参与者的状态是一致的
DoCommit阶段
DoCmmit阶段进行真正的事务提交,根据PreCommit阶段协调者发送的消息,进入执行提交阶段或事务中断阶段。
- 执行提交阶段:
- 若协调者接收到所有参与者发送的Ack响应,则向所有参与者发送DoCommit消息,开始执行阶段
- 参与者接收到DoCommit消息之后,正式提交事务。完成事务提交之后,释放所有锁住的资源,并向协调者发送Ack响应
- 协调者接收到所有参与者的Ack响应之后,完成事务
- 事务中断阶段:
- 协调者向所有参与者发送Abort请求
- 参与者接收到Abort消息之后,利用其在PreCommit阶段记录的Undo信息执行事务的回滚操作,释放所有锁住的资源,并向协调者发送Ack消息
- 协调者接收到参与者反馈的Ack消息之后,执行事务的中断,并结束事务
每个阶段不同节点之间的事务请求成功和失败的流程简化说明如下所示
存在问题
3PC的引入是基于对2PC的优化,其还是会存在同步执行性能问题和数据不一致问题
引入超时机制优化同步阻塞问题,但同步性能问题依然存在:3PC协议在协调者和参与者均引入了超时机制。即当参与者在预提交阶段向协调者发送 Ack消息后,如果长时间没有得到协调者的响应,在默认情况下,参与者会自动将超时的事务进行提交,从而减少整个集群的阻塞时间,在一定程度上减少或减弱了2PC中出现的同步阻塞问题。
第三阶段提交存在因网络延迟导致ACK消息接受异常引发的存在数据不一致问题:但三阶段提交仍然存在数据不一致的情况,比如在PreCommit阶段,部分参与者已经接受到ACK消息进入执行阶段,但部分参与者与协调者网络不通,导致接收不到ACK消息,此时接收到ACK消息的参与者会执行任务,未接收到ACK消息且网络不通的参与者无法执行任务,最终导致数据不一致
3PC步骤过多,过程较2PC复杂,且因其确认步骤过多,很多业务的互斥排队时间会很长,因此3PC的事务失败率会比2PC的高很多,整体执行更加缓慢,实际分布式生产环境中比较少用。
场景应用
MySQL 的主从复制
在 MySQL 中,二进制日志是 server 层,主要用来做主从复制和即时点恢复时使用的;而事务日志(Redo Log)是 InnoDB 存储引擎层,用来保证事务安全的。
在数据库运行中,需要保证 Binlog 和 Redo Log 的一致性,如果顺序不一致, 则意味着 Master-Slave 可能不一致。
在开启 Binlog 后,如何保证 Binlog 和 InnoDB redo 日志的一致性呢?MySQL 使用的就是二阶段提交,内部会自动将普通事务当做一个 XA 事务(内部分布式事务)来处理:
- Commit 会被自动的分成 Prepare 和 Commit 两个阶段;
- Binlog 会被当做事务协调者(Transaction Coordinator),Binlog Event 会被当做协调者日志;
3.基于TCC协议的分段提交
实现思路
TCC是Try-Confirm-Cancel的缩写,从流程上来看,它比2PC多了一个阶段,也就是将Prepare阶段又拆分成了两个阶段:Try阶段和Confirm阶段。TCC可以不使用XA,只使用普通事务就能实现分布式事务。
在 Try阶段,业务代码会预留业务所需的全部资源,比如冻结用户账户100元、提前扣除一个商品库存、提前创建一个没有开始交易的订单等,这样可以减少各个子事务锁定的数据量。业务拿到这些资源后,后续两个阶段操作就可以无锁进行了。
在 Confirm阶段,业务确认所需的资源都拿到后,子事务会并行执行这些业务。执行时可以不做任何锁互斥和检查,直接执行Try阶段准备的所有资源。
在 Cancel阶段:如果子事务在Try阶段或Confirm阶段多次执行重试后仍旧失败,TM就会执行Cancel阶段的代码,并释放Try预留的资源,同时回滚Confirm期间的内容。
此处需注意,协议要求所有操作都是幂等的(尤其是Confirm、Cancel阶段)以支持失败时重试,如果重试失败则需要人工介入进行恢复和处理。因为在一些特殊情况下,比如资源锁争抢超时、网络不稳定等,操作要尝试执行多次才会成功。所谓幂等操作:指的是其任意多次执行所产生的影响均与一次执行的影响相同
TCC执行流程分析
TCC事务的优缺点
优点:
- 并发能力高,且无长期资源锁定;
- 代码入侵实现分布式事务回滚,开发量较大,需要代码提供每个阶段的具体操作;
- 数据一致性相对来说较好;
- 适用于订单类业务,以及对中间状态有约束的业务;
缺点:
- 只适合短事务,不适合多阶段的事务;
- 不适合多层嵌套的服务;
- 相关事务逻辑要求幂等;
- 存在执行过程被打断时,容易丢失数据的情况;
案例分析
案例场景:支付业务
以一个电商中的支付业务来演示,用户在支付以后,需要进行更新订单状态、扣减账户余额、增加账户积分和扣减商品操作。
在实际业务中为了防止超卖,有下单减库存和付款减库存的区别,支付除了账户余额,还有各种第三方支付等,此处为了描述方便,统一使用扣款减库存,扣款来源是用户账户余额
如果不使用事务,上面的几个步骤都可能出现失败,最终会造成大量的数据不一致,比如订单状态更新失败,扣款却成功了;或者扣款失败,库存却扣减了等情况,这个在业务上是不能接受的,会出现大量的客诉。
如果直接应用事务,不使用分布式事务,比如在代码中添加 Spring 的声明式事务 @Transactional 注解,这样做实际上是在事务中嵌套了远程服务调用,一旦服务调用出现超时,事务无法提交,就会导致数据库连接被占用,出现大量的阻塞和失败,会导致服务宕机。另一方面,如果没有定义额外的回滚操作,比如遇到异常,非 DB 的服务调用失败时,则无法正确执行回滚。
【1】业务系统改造(TCC)
应用 TCC 事务,需要对业务代码改造,抽象 Try、Confirm 和 Cancel 阶段。
- Try 操作
Try 操作一般都是锁定某个资源,设置一个预备的状态,冻结部分数据。例如此处对每个服务中要用到的资源通过设定一个状态进行锁定
订单服务添加一个预备状态,修改为 UPDATING(更新中),冻结当前订单的操作,而不是直接修改为支付成功;
库存服务设置冻结库存,可以扩展字段,也可以额外添加新的库存冻结表;
积分服务和库存一样,添加一个预增加积分,比如本次订单积分是 100,添加一个额外的存储表示等待增加的积分;
账户余额服务等也是类似的操作;
- Confirm 操作
Confirm 操作就是把前边的 Try 操作锁定的资源提交,类比数据库事务中的 Commit 操作。
在支付的场景中,包括订单状态从准备中更新为支付成功;库存数据扣减冻结库存,积分数据增加预增加积分。
- Cancel 操作
Cancel 操作执行的是业务上的回滚处理,类比数据库事务中的 Rollback 操作。
首先订单服务,撤销预备状态,还原为待支付状态或者已取消状态,库存服务删除冻结库存,添加到可销售库存中,积分服务也是一样,将预增加积分扣减掉。
【2】执行业务操作
业务请求过来,开始执行 Try 操作,如果 TCC 分布式事务框架感知到各个服务的 Try 阶段都成功了以后,就会执行各个服务的 Confirm 逻辑。如果 Try 阶段有操作不能正确执行,比如订单失效、库存不足等,就会执行 Cancel 的逻辑,取消事务提交
存在问题
由于TCC的业务侵入性比较高,需要开发编码配合,在一定程度上增加了不少工作量,存在一些使用上的弊端,需要投入更高的开发成本和更换事务实现方案的替换成本。通常并不会完全靠裸编码来实现TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量
假设将上述TCC案例场景调整一下限制条件,现系统需要扩展支付方式,支付形式不再单纯由系统进行管理,而是将用户、商家的账户余额由银行进行托管的话,其操作权限和数据结构就不可能再随心所欲地自行定义了,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以,在TCC的执行过程中,第一步Try阶段往往就已经无法施行了。因此此处只能考虑另一种柔性事务方案:SAGA事务
(1)SAGA事务基于数据补偿代替回滚的解决思路
SAGA事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA的意思是“长篇故事、长篇记叙、一长串事件”,它起源于1987年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在ACM发表的一篇论文《SAGAS》(这就是论文的全名)。文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出SAGA的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。
SAGA由两部分操作组成
一部分是把大事务拆分成若干个小事务,将整个分布式事务T分解为n个子事务,我们命名为T1,T2,…,Ti,…,Tn。每个子事务都应该、或者能被看作是原子行为。如果分布式事务T能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务Ti等价。
另一部分是为每一个子事务设计对应的补偿动作,我们命名为C1,C2,…,Ci,…,Cn。Ti与Ci必须满足以下条件:
- Ti与Ci都具备幂等性;
- Ti与Ci满足交换律(Commutative),即不管是先执行Ti还是先执行Ci,效果都是一样的;
- Ci必须能成功提交,即不考虑Ci本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。
如果T1到Tn均成功提交,那么事务就可以顺利完成。否则,就要采取以下两种恢复策略之一:
- **正向恢复(Forward Recovery):**如果Ti事务提交失败,则一直对Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
- **反向恢复(Backward Recovery):**如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。
与TCC相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。
以在前面提到的账户余额直接在银行维护的场景,从银行划转货款到业务系统中,这步是经由用户支付操作(扫码或U盾)来促使银行提供服务;如果后续业务操作失败,尽管无法要求银行撤销掉之前的用户转账操作,但是作为补偿措施,可以让业务系统将货款转回到用户账上,却是完全可行的。
SAGA必须保证所有子事务都能够提交或者补偿,但SAGA系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。
另外还要注意,尽管补偿操作通常比冻结/撤销更容易实现,但要保证正向、反向恢复过程能严谨地进行,也需要花费不少的工夫。比如,可能需要通过服务编排、可靠事件队列等方式来完成。所以,SAGA事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。例如Seata就同样支持SAGA事务模式。
SAGA基于数据补偿来代替回滚的思路,也可以应用在其他事务方案上。举个例子,阿里的GTS(Global Transaction Service,Seata由GTS开源而来)所提出的“AT事务模式”就是这样的一种应用
(2)AT事务
从整体上看,AT事务是参照了XA两段提交协议来实现的,但针对XA 2PC的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出Commit命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT事务也设计了针对性的解决方案
它大致的做法是在业务数据提交时,自动拦截所有SQL,分别保存SQL对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。
如果分布式事务成功提交了,那么我们只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向SQL”。
所以,基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT事务这种异步提交的模式,相比2PC极大地提升了系统的吞吐量水平。**而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。**因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。
比如,当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向SQL来实现补偿,只能由人工介入处理了。一般来说,对于脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。
所以,GTS增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。
另外,在读隔离方面,AT事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,要付出的代价就非常大了,一般并不会这样做。
至此,分布式事务中并没有能一揽子包治百病的解决办法,只有因地制宜地选用合适的事务处理方案,才是唯一有效的做法。
4.基于消息补偿的最终一致方案
实现思路
2PC和3PC核心思想均是以集中式的方式实现分布式事务,这两种方法都存在两个共同的缺点:1.同步执行性能差(执行缓慢、并发低);2.存在数据不一致问题。为了解决这两个问题,引入分布式消息来确保事务最终一致性的方案。
在eBay的分布式系统架构中,架构师解决一致性问题的核心思想就是:将需要分布式处理的事务通过消息或者日志的方式异步执行,消息或日志可以存到本地文件、数据库或消息队列中,再通过业务规则进行失败重试。这个案例,就是使用基于分布式消息的最终一致性方案解决了分布式事务的问题。基于分布式消息的最终一致性方案的事务处理,引入了一个消息中间件(在本案例中,例如采用Message Queue,MQ,消息队列),用于在多个应用之间进行消息传递。实际使用中,阿里就是采用RocketMQ 机制来支持消息事务。
以网上购物为例。假设用户A在某电商平台下了一个订单,需要支付50元,发现自己的账户余额共150元,就使用余额支付,支付成功之后,订单状态修改为支付成功,然后通知仓库发货。在该事务中,涉及到了订单系统、支付系统、仓库系统,这三个系统是相互独立的应用,通过远程服务进行调用
像是下订单和是使用优惠券的场景中,它要求数据强一致,在同一个操作时限中要么都成功要么都不成功。而在这个分布式事务场景中,创建订单、支付、仓库系统这三个数据操作对数据的一致性要求并没有那么高,例如订单创建成功后一些支付和物流信息可以晚点再更新,只要确保经过一段时间的延迟之后,最终订单数据和其他支付、仓库数据保持一致即可。
基于分布式消息的最终一致性方案,用户A通过终端手机首先在订单系统上操作,通过消息队列完成整个购物流程,整个购物的流程如下所示
订单系统把订单消息发给消息中间件,消息状态标记为“待确认”
消息中间件收到消息后,进行消息持久化操作,即在消息存储系统中新增一条状态为“待发送”的消息
消息中间件返回消息持久化结果(成功/失败),订单系统根据返回结果判断如何进行业务操作
- 失败,放弃订单,结束(必要时向上层返回失败结果);
- 成功,则创建订单
订单操作完成后,把操作结果(成功/失败)发送给消息中间件
消息中间件收到业务操作结果后,根据结果进行处理:
- 失败,删除消息存储中的消息,结束;
- 成功,则更新消息存储中的消息状态为“待发送(可发送)”,并执行消息投递
如果消息状态为“可发送”,则MQ会将消息发送给支付系统,表示已经创建好订单,需要对订单进行支付。支付系统也按照上述方式进行订单支付操作
订单系统支付完成后,会将支付消息返回给消息中间件,中间件将消息传送给订单系统。若支付失败,则订单操作失败,订单系统回滚到上一个状态,MQ中相关消息将被删除;若支付成功,则订单系统再调用库存系统,进行出货操作,操作流程与支付系统类似
在上述过程中,可能会产生如下异常情况,其对应的解决方案为:
- 订单消息未成功存储到MQ中,则订单系统不执行任何操作,数据保持一致;
- MQ成功将消息发送给支付系统(或仓库系统),但是支付系统(或仓库系统)操作成功的ACK消息回传失败(由于通信方面的原因),导致订单系统与支付系统(或仓库系统)数据不一致,此时MQ会确认各系统的操作结果,删除相关消息,支付系统(或仓库系统)操作回滚,使得各系统数据保持一致;
- MQ成功将消息发送给支付系统(或仓库系统),但是支付系统(或仓库系统)操作成功的ACK消息回传成功,订单系统操作后的最终结果(成功或失败)未能成功发送给MQ,此时各系统数据可能不一致,MQ也需确认各系统的操作结果,若数据一致,则更新消息;若不一致,则回滚操作、删除消息。
基于分布式消息的最终一致性方案采用消息传递机制,并使用异步通信的方式,避免了通信阻塞,从而增加系统的吞吐量。同时,这种方案还可以屏蔽不同系统的协议规范,使其可以直接交互。
在不需要请求立即返回结果的场景下, 这些特性就带来了明显的通信优势,并且通过引入消息中间件,实现了消息生成方(如上述的订单系统)本地事务和消息发送的原子性,采用最终一致性的方式,只需保证数据最终一致即可,一定程度上解决了二阶段和三阶段方法要保证强一致性而在某些情况导致的数据不一致问题。
可以看出,分布式事务中,当且仅当所有的事务均成功时整个流程才成功。所以,分布式事务的一致性是实现分布式事务的关键问题,目前来看还没有一种很简单、完美的方案可以应对所有场景。
案例分析
以下单减库存为例分析基于消息补偿的最终一致方案的实现思路:
(1)系统收到下单请求,将订单业务数据存入到订单库中,并且同时存储该订单对应的消息数据,比如购买商品的 ID 和数量,消息数据与订单库为同一库,更新订单和存储消息为一个本地事务,要么都成功,要么都失败。
(2)库存服务通过消息中间件收到库存更新消息,调用库存服务进行业务操作,同时返回业务处理结果。
(3)消息生产方,也就是订单服务收到处理结果后,将本地消息表的数据删除或者设置为已完成。
(4)设置异步任务,定时去扫描本地消息表,发现有未完成的任务则重试,保证最终一致性。
分布式事务实现对比
无论是哪种分布式事务方法,其实都是把一个分布式事务,拆分成多个本地事务。**本地事务可以用数据库事务来解决,分布式事务则专注于解决如何让这些本地事务保持一致的问题。**在遇到分布式一致性问题的时候,也要基于这个思想来考虑问题,再结合实际的情况选择分布式事务的方法
2PC:
2PC方式实现的事务概念简单易懂且容易实现,使用的是强一致的设计,可以保证原子性和隔离性。但存在同步性能阻塞问题和数据不一致问题,适用于对数据一致性要求较高、但并发量不大的场景
- 2PC可以实现多个子事务统一提交回滚,但因为要保证数据的一致性,所以并发性能不好
- 2PC没有超时的机制,经常会将很多XA子事务遗漏在数据库中,不仅导致资源长期被锁定阻塞分布式事务进程,还会导致数据不一致问题
3PC:
3PC的实现方式是基于2PC的优化版本,但仍然存在同步性能阻塞问题和数据不一致问题
- 3PC的check步骤较多,虽引入了超时机制,但由于交互过多会经常出现超时的情况,导致事务的性能很差
- 存在数据不一致的场景,在PreCommit阶段,部分参与者已经接受到ACK消息进入执行阶段,但部分参与者与协调者网络不通,导致接收不到ACK消息,此时接收到ACK消息的参与者会执行任务,未接收到ACK消息且网络不通的参与者无法执行任务。如果经过多次执行失败超时后会尝试回滚,如果回滚也超时就会出现丢数据的情况,最终导致数据不一致
TCC:
TCC可以提前预定事务执行需锁定的资源,较少业务锁定的粒度。它可以使用普通事务即可完成分布式事务协调,因此相对地TCC的性能很好。但是,提交最终事务和回滚逻辑都需要支持幂等,为此需要人工要投入的精力也更多。它适用于需要强隔离性的分布式事务中
对比2PC实现方式,参考如下图所示。其核心区别在于:
- 2PC/XA 是数据库或者存储资源层面的事务,实现的是强一致性,在两阶段提交的整个过程中,一直会持有数据库的锁
- TCC 关注业务层的正确提交和回滚,在 Try 阶段不涉及加锁,是业务层的分布式事务,关注最终一致性,不会一直持有各个业务资源的锁
TCC 的核心思想是针对每个业务操作,都要添加一个与其对应的确认和补偿操作,同时把相关的处理,从数据库转移到业务中,以此实现跨数据库的事务
三种实现方式对比
分布式事务扩展
刚性事务和柔性事务
- 刚性事务,遵循ACID原则,具有强一致性。比如,数据库事务。
- 柔性事务,其实就是根据不同的业务场景使用不同的方法实现最终一致性,也就是说我们可以根据业务的特性做部分取舍,容忍一定时间内的数据不一致。
总结来讲,与刚性事务不同,柔性事务允许一定时间内,数据不一致,但要求最终一致。而柔性事务的最终一致性,遵循的是BASE理论。
eBay 公司的工程师 Dan Pritchett曾提出了一种分布式存储系统的设计模式——BASE理论。 BASE理论包括基本可用(Basically Available)、柔性状态(Soft State)和最终一致性(Eventual Consistency)。
- 基本可用:分布式系统出现故障的时候,允许损失一部分功能的可用性,保证核心功能可用。比如,某些电商618大促的时候,会对一些非核心链路的功能进行降级处理。
- 柔性状态:在柔性事务中,允许系统存在中间状态,且这个中间状态不会影响系统整体可用性。比如,数据库读写分离,写库同步到读库(主库同步到从库)会有一个延时,其实就是一种柔性状态。
- 最终一致性:事务在操作过程中可能会由于同步延迟等问题导致不一致,但最终状态下,所有数据都是一致的。
BASE理论为了支持大型分布式系统,通过牺牲强一致性,保证最终一致性,来获得高可用性,是对ACID原则的弱化。ACID 与 BASE 是对一致性和可用性的权衡所产生的不同结果,但二者都保证了数据的持久性。ACID 选择了强一致性而放弃了系统的可用性。与ACID原则不同的是,BASE理论保证了系统的可用性,允许数据在一段时间内可以不一致,最终达到一致状态即可,也即牺牲了部分的数据一致性,选择了最终一致性。
具体到今天的三种分布式事务实现方式,二阶段提交、三阶段提交方法,遵循的是ACID原则,而消息最终一致性方案遵循的就是BASE理论。
海量并发场景中分布式事务一致性问题
在互联网分布式场景中,原本一个系统被拆分成多个子系统,要想完成一次写入操作,需要同时协调多个系统,这就带来了分布式事务的问题(分布式事务是指:一次大的操作由多个小操作组成,这些小的操作分布在不同的服务器上,分布式事务需要保证这些小操作要么全部成功,要么全部失败)。那怎么设计才能实现系统之间的事务一致性呢? =》先从解决分布式事务问题出发剖析解决方案,再结合高并发业务场景拆解实际应用,而不局限于纸上谈兵
以京东旅行系统为例,早期的交易系统是通过 .NET 实现的,所有的交易下单逻辑都写在一个独立的系统中。随着技术改造,用 Java 重写了核心系统,原本的系统也被拆分成多个子系统,如商品系统、促销系统、订单系统等。当用户下单时,订单系统生成订单,商品系统扣减库存,促销系统扣减优惠券,只有当三个系统的事务都提交之后,才认为此次下单成功,否则失败。
可以先介绍目前主流实现分布式系统事务一致性的方案(也就是基于 MQ 的可靠消息投递的机制)然后回答出可实现方案和关键知识点。为进一步交流,可以扩展提出 2PC 或 TCC 。因为 2PC 或 TCC 在工业界落地代价很大,不适合互联网场景,所以只有少部分的强一致性业务场景(如金融支付领域)会基于这两种方案实现,可以进一步围绕它们的解决思路和方案弊端去引申问题
- 基于 MQ 的可靠消息投递的考核点是可落地性,要抓住“双向确认”的核心原则,只要能实现生产端和消费端的双向确认,这个方案就是可落地了,又因为基于 MQ 来实现,所以天生具有业务解耦合流量削峰的优势。
- 基于 2PC 的实现方案很少有实际的场景,但还是要掌握它的实现原理和存在的问题(即这个方案你可以不用,但你必须会)