分布式事务及其常见解决方案
梳理和总结有关分布式事务及其常见的解决方案,将从事务的基本概念延伸到分布式领域在解决事务问题的基本思想,最后具体阐述一些常见的解决方案。
什么是事务?
事务是由客户定义的针对服务器对象的一组操作,它们组成一个不可分割的单元,由服务器执行。
《分布式系统概念与设计》
事务定义了一个服务器操作序列,由服务器保证这些操作序列在多个客户并发访问和服务器出现故障的情况下的原子性。其目标是在多个事务访问对象以及服务器面临故障的情况下,保证所有由服务器管理的地对象始终保持一个一致的状态。服务器必须保证:
- 整个事务被执行并将结果记录到持久存储中
- 或者,在出现故障时,能完全消除这些操作的所有影响
事务的ACID特性
从前面的事务定义中我们了解到事务具有原子性,这样的事务我们也称之为原子事务。这里的的原子性包含两方面的含义:
- 全有和全无:事务的所有操作要么全部成功完成并记录到持久存储中,要么因为故障或有意终止而不产生任何效果。这种全有和全无本身又包含两层含义:
- 故障原子性:即使服务器奔溃,事务的效果也是原子的;
- 持久性:一旦事务成功完成,它的所有效果将被保存到持久存储中。这里的持久存储指的是磁盘等永久介质中的文件,文件中存放的数据不受服务器奔溃影响。
- 隔离性:每个事务的执行不受其它事务的影响。换言之,事务在执行过程中的中间效果对其它事务是不可见的。
可以从上面事务的定义中归纳总结出事务的ACID特性:
- 原子性:Automatic, 事务的操作序列要么全部执行,要么都不执行
- 一致性:Consistency, 从一个一致性的状态迁移到另一个一致性的状态
- 隔离性:Isolation, 并发事务之间的影响
- 持久性:Duration, 事务一旦成功完成,它的所有影响将保存到持久存储中
从上面我们可以了解到ACID特性之间其实是相辅相成彼此依赖的。事务的原子性和持久性要求我们操作的对象是可恢复的;隔离性则要求我们对操作的时序进行限制从而进行有效的同步来保证隔离性;而事务的一致性则通常是业务的开发人员来保证。
关于事务的ACID特性这里只做简单的介绍,不做过多的讨论。
什么是分布式事务?
简单来说访问由多个服务器(两个及以上)管理的对象的事务称为分布式事务,即涉及到多台服务器的事务。相对于单机事务,分布式事务也可以称为多机事务。
由于业务发展的需要,其规模也越来越大,逐渐的从单机系统转变为分布式系统。我们会对我们的业务进行横向和纵向的划分,垂直划分为WEB服务器、某某服务、数据库存储服务、缓存存储服务等,又通过水平部署多台同样功能的服务器来提升我们的服务容量。
由于分布式系统的需要和出现,以前只需要一台DB便能完成的事务不得不转变为多机事务,分布式事务因此应运而生。时下由于微服务的流行,分布式事务逐渐成为普通程序员也需要面对的问题。
分布式事务的一些问题
以前单机时代我们使用数据库来搞定事务问题,现在由于涉及的机器增多,我们将不得不亲自面对事务的问题,如何保证分布式事务的ACID特性。
根据事务的定义及其原子特性,要求分布式事务结束时,它的所有操作要么全部执行,要么全部不执行。由于分布式事务涉及到多台服务器,每台服务器都需要独立执行事务的一部分操作, 比如:我们银行转账操作,A给B转账50元,其中A和B的账户分别存储在两个系统上,我们需要先给A账户扣除50,然后给B账户加上50。
如何保证这些分布在多台服务器上的操作全部都执行或者都不执行,这便涉及到了分布式提交中的原子性问题。
除了原子性这个最基本的问题,我们还需要考虑如何在事务的ACID特性和性能之间进行取舍。
CAP定理
CAP定理说的是一个分布式系统只能满足下列三个特性的两个,而不能同时满足:
- C:一致性,所有客户端看到都是同一份数据,即使在数据更新和删除之后
- A:可用性,即使部分节点发生故障,所有客户端也能找到可用的数据备份
- P:分区容忍性,即使发生网络分区故障,系统仍然能够按照预期正常工作
CAP定理在分布式领域至关重要,在构建大型分布式系统的时候我们必须根据自己业务的独特性在三者之间进行权衡。由于网络的各种不确定因素,在构建分布式应用的时候我们不得不考虑分区容忍性,这时候我们只能在一致性和可用性之间进行选择,即在CP和AP之间进行选择。
BASE理论
要实现分布式事务的ACID特性,根据CAP定理就是要保C舍A,即CP模型,通过牺牲可用性来换取一致性。然而在大型互联网应用里面,可用性往往更重要。
于是eBay的架构师提出了BASE理论来替代ACID特性的, BASE是下面三部分的缩写简称:
- BA: Basically Available ,基本可用性
- S: Soft State,软状态
- E:Eventually Consistent,最终一致性
这里的“基本可用”是相对CAP的“完全可用”而言的,即在部分节点出现故障的时候不要求整个系统完全可用,允许系统出现下列的损失:
- 响应时间上的损失:比如网页加载速度从0.5s降到1s或2s
- 部分功能上的损失:比如电商购买场景中引导用户到一个降级页面
软状态则是想断“硬状态”而言,CAP定理里面的一致性要求数据变化要立即反映到所有的节点副本上去,是一种强一致性。“软状态”则不要求立即反映变化,允许存在一个中间状态进行过渡,比如允许放大数据副本的同步延时等。
最终一致性则是相对强一致性而言,它不要求系统数据始终保持一致的状态,只要求系统经过一段时间后最终会达到一致状态即可。
ACID和BASE可以说是所谓的“酸碱理论”,依据CAP定理,它们往往是对立的,
方案概述
事务最基本的特性便是原子性,可以通过分布式提交协议来解决分布式领域的原子性问题。事务的另一个重要特性便是一致性,根据CAP定理,我们需要在可用性和一致性上做抉择,根据不同的场景来选择不同强度的一致性模型。
可以从两个角度来阐述一致性模型,以数据为中心的一致性和以客户为中心的一致性。以数据为中心的一致性模型有线性一致性、顺讯一致性、因果一致性等;以客户为中心的一致性模型有前面提到过的最终一致性,还有单调读一致性、单调写一致性等。
关于数据一致性的模型我这里不做过多的介绍,实际应用中我们需要根据我们的实际场景来选择不同的一致性模型。接下来从一致性强度的角度来介绍不同的解决方案。
强一致性方案
强一致性的方案便是前面提到的舍A保C的CP模型,即通过牺牲可用性来保证一致性,这种方案适用于对一致性要求很高的场景,比如金融交易等。
2PC – 二阶段提交协议
二阶段提交协议(Two–Phase Commit protocol)是一种原子提交协议,用来协调参与分布式事务的所有进程是否提交/终止事务。2PC之所以称之为两阶段,是因为进行事务的提交他需要经历两个阶段:准备阶段和提交阶段,而每个阶段又由两步组成。准备阶段分为询问和投票两步,提交阶段分为决策和完成两步。
阶段一:Prepare(准备阶段、投票阶段)
- 询问:协调者询问每个参与者能够进行本地事务提交;
- 投票:参与者根据自身情况向协调者发送Yes/No消息;
阶段二:Commit/Cancel(提交阶段,回滚阶段)
- 决策:如果所有参与者回复Yes则协调者进行全局事务提交,否则全局中止事务;
- 完成:参与者根据协调者的结果进行事务的提交或回滚。
2PC的时序图大致如下:
2PC的状态流状态图如下:
2PC协议是最基本的原子提交协议,是我们理解其它方案的基础,它最大的缺陷就是阻塞范围太广,协议的任何一方出现故障都会导致协议阻塞而不能进行下去。正是由于它同步大范围阻塞的特性,其性能并不是很好,并不是很适用于那些追求性能的高并发场景,但理解它是我们理解后续其它变种协议的基础。
DTP/XA规范
DTP模型是国际X/Open联盟根据2PC协议定义的分布式事务处理模型(DTP: Distributed Transaction Process Model),它定义了三大组件,以及它们之间相互操作的 XA 接口:
- AP:应用程序,定义了事务以及对涉及到的资源(终端或数据库)的一系列操作,并在事务边界内访问资源
- RMs:资源管理器,计算机共享资源的一个特定部分,如数据库管理系统(DBMS)、打印服务等,即参与者
- TM:事务管理器,管理全局事务,协调事务的提交和回滚,并协助进行故障恢复,即协调者
分布式数据库通常会采用该方案来实现自己的分布式事务,比如MySQL就提供了对XA事务的支持。
最终一致性方案
基于2PC的强一致性方案的阻塞特性对性能的影响很大,在CAP定理中属于CP范畴。在互联网应用中为了提升性能和可用性,基于BASE理论,可以使用最终一致性来替代强一致性,通过牺牲部分一致性来换取性能和可用性的提升。
TCC
TCC是基于Base理论的类2PC方案,根据业务的特性对2PC的流程进行了优化。我们来看一个简化版的订销存交易流程:
用户在电商网站下订单后通知库存服务扣减粗存,最后通过积分服务给用户增加积分。整个交易操作应该具有原子性,这些交易步骤要么一起成功,要么一起失败,必须是一个整体性的事务。
假设用户下完订单通知库存服务扣减库存失败时,比如原本是10件商品卖了1件剩余9件,但由于库存DB操作失败,导致库存还是10件,这时就出现了数据不一致的情况,此时如果有其它用户也进行了购买操作,则可能出现超卖的问题。
如果采用2PC的解决方案,在整个交易成功完成或者失败回滚之前,其它用户的操作将会处于阻塞等待的状态,这会大大的降低系统的性能和用户体验。
TCC的操作流程基本和2PC类似,区别在一些步骤的细节上,如下图
TCC的操作也分为两个阶段,Try(尝试)阶段和Confirm/Cancel(确认/取消)阶段,不同于2PC第一阶段的Prepare(准备)阶段,TCC在Try阶段主要是对资源的预留操作,比如对库存数量的冻结操作等,它不需要等待整个事务完成后进行提交,这时其它用户的购买操作可以继续正常进行。
TCC相对2PC协议的XA方案更轻量级,当它要求事务的参与方都必须要提供三个操作接口:Try/Confirm/Cancel。这对一些老旧系统来说会有比较大的改造成本。
本地事务状态表
基于TCC的方案最大的缺点是要求分布式事务的所有参与方都需要提供 Try/Confirm/Cancel 接口,改动成本比较大,某些场景下并不适合。
本地事务状态表的方案则是在调用方调用分布式事务之前将待执行的事务流程及其状态信息存储到数据库中,依赖DB的本地事务的原子特性,这一步操作是原子完成的,这个存储事务执行状态信息的表称为本地事务状态表。
在将事务状态信息存储到DB后,调用方才会开始继续后面的的调用操作,每次调用成功时更新对应的事务状态,某一步失败时则中止执行。后台需要运行一个定时任务来定期扫描事务表,对于没有完成的事务操作重新发起调用,或者执行回滚,或者在失败重试指定次数后触发告警让人工介入进行修复。
本地事务表的方案大概如下图所示:
调用者在将待会要执行的事务状态信息存储到DB后,可以根据自己业务的实际需要来决定是否立即触发后面的事务调用,如果业务对实时性要求较高则可以立即同步执行后面的事务调用;如果是在高并发追求性能的场景,则可以交给定时任务异步调用。
消息中间件
现在使用消息中间件来解耦系统架构设计的方案越来越普遍,基于消息中间件的分布式事务解决方案主要分两类,根据使用的消息中间件是否支持事务消息来划分:
比如使用 Kafka(< 0.11.0) 这类的不支持事务消息的消息中间件,参与事务的系统需要在给消息中间件发送消息之前,把消息的信息和状态存储到本地的消息表中,方案如下图概述:
参与分布式事务的某个系统A接收到请求后,在执行本地事务的同时需要同时将待发送的消息同时记录到事务消息表里面去,将业务表和消息表放在一个数据库事务里,保证两者的原子性;执行完后系统A不直接给消息中间件发消息,而是通过后台的定时任务来扫描消息表来进行发送,定时任务会不断的失败重试,直到消息中间件成功返回 ack 消息并更细消息表状态,从而保证消息的不丢失。
消息中间件收到消息后会给后面的事务执行者系统B发送消息,只有系统B成功应答 Ack 消息后消息中间件才会将系统A发送的消息丢弃。
由于消息会不断的重复发送,所以事务的所有参与者需要自行保证事务执行的幂等性,比如判重表等手段。
如果是基于RocketMQ或Kafka(>=0.11.0)这类的支持事务操作的消息中间件,上述的方案则可以简化,此时上面的的定时任务的工作将交给消息中间件来提供。
事务消息(或者说原子消息)的实现的基本原理是二阶段提交协议(2PC)。它将一个消息的发送操作分为两步,即 Prepare Message(准备消息)和 Confirm Message(确认消息)。如下面的时序图:
详细的操作流程这里不进行赘述,其本质就是将前面介绍的定时任务的工作挪到了消息中间件内部完成:消息中间件会对那些处于 Prepare 状态的消息不断进行询问是否可以进行提交投递。
弱一致性方案
上面基于最终一致性的方案可以很好的满足我们大多数的场景需要,让我们在可用性和一致性之间取得微妙的平衡。但是在一些场景下,我们对系统的性能和可用性反而具有更高的要求。
比如在海量请求超高并发的秒杀场景中,就连保证基本可用性也变的非常困难,除了对秒杀的非核心功能降级服务,增加响应时间等,根据CAP定理,可用性和一致性不可兼得,我们不得不再次放低对一致性的要求,从最终一致性放宽到弱一致性,从而提高系统的性能和可用性。
这便是接下来要介绍的弱一致性方案。
基于状态的补偿
这是一个根据业务特性进行妥协的一种方案,根据实际的业务场景对立面的数据重要性进行划分,放弃传统的全局数据一致,允许其中个别数据出现不一致但不会对业务产生重大影响。
比如在电商网站购物场景中,其中两个主要的步骤是创建订单和扣库存,这分别由两个服务进行处理:订单服务和库存服务。
如果采用前面基于消息的最终一致性方案,创建订单的消息通知库存服务扣除库存,由于异步消息的延迟则会导致超卖;如果采用TCC的方案,每次请求操作都需要Try、Confirm两次请求调用,性能又不能达标;如果采用本地事务状态表,则需要对海量的事务进行状态更新操作,性能和延迟同样是个问题。
于是我们可以依据实际电商购物场景进行取舍:允许少卖,但不能超卖。于是我们可以先扣库存后提交订单,订单创建成功后再关联到库存:
扣库存 | 提交订单 | 返回结果 | 可能结果 | |
1 | √ | √ | √ | |
2 | √ | × | × | 多扣库存 |
3 | × | × | 多扣库存 |
这里所谓的基于状态的补偿,则是根据库存流水记录,查找那些一段时间内未关联订单的记录进行撤销操作。比如我们我们在12306上的提交购买车票,那些30分钟内未支付的车票会进行释放。
重试(+回滚)+告警+人工修复
上面的方案对业务场景的要求比较多,对于那些业务流程复杂,需要维护的状态也很复杂,也就是很难根据状态进行自动补偿的时候,我们可以进一步简化操作:不做状态补偿。
还是拿上面那个订单和库存的例子进行说明,比如先扣库存,然后创建订单,如果订单创建失败则重试,重试还是失败则回滚,回滚失败则触发告警,然后人工根据日志记录进行修复。
这个方案其实并没有什么特别的要求,就是根据业务流程特性一步一步的操作,关键则是详细的操作日志记录和告警,至于是否需要尝试回滚也是可有可无。
说白了这个方案就是放弃一致性的要求,也是成本最低最被动的方案。
事后处理-对账
所有的“过程”都会产生“结果”,对账属于事后处理关注的是“结果”,它根据结果(数据)来反推过程(事务)出了问题,从而对数据就行修补。
比如每隔一段时间对订单进行扫描,对长时间未处理的订单进行告警。
对账的关键是“找出数据背后的数学规律”,有些好找,有些难。它的一个基本要求是数据记录起码是“完备”的,否则谈何“数学规律”。
严格上俩说对账算不上什么方案,更多的是用来辅助人工对数据进行检查,发现其中存在的问题(比如异常、假账)等,然后触发告警。当然我们也可以在设计方案时候根据对账的思路来设计一个自动对账流程来自动修补数据。
总结
这里主要梳理了下分布式事务问题的几个解决方案,其中关键的思路则是围绕CAP定理和BASE理论,从一致性的角度出发来探索其中的解决方案并进行分类:根据一致性的强度从强到弱进行划分。
对数据一致性要求比较高的场景中(金融银行等)我们可以使用2PC一类的强一致性方案;在一些更普遍和常规的互联网应用中,我们需要同时关注可用性和一致性,这个时候可以采用基于BASE理论的最终一致性的几个方案;在一些极端的场景中根据业务特性我们可以退化使用弱一致性方案。
没有完美的方案,只有合适的方案。