从单机的数据库事务变成分布式事务时,原有单机中相对可靠的方法调用以及进程间通信方式已经没有办法使用,同时由于网络通信经常是不稳定的,所以服务之间信息的传递会出现障碍。
模块(或服务)之间通信方式的改变是造成分布式事务复杂的最主要原因,在同一个事务之间的执行多段代码会因为网络的不稳定造成各种奇怪的问题,当我们通过网络请求其他服务的接口时,往往会得到三种结果:正确、失败和超时,无论是成功还是失败,我们都能得到唯一确定的结果,超时代表请求的发起者不能确定接受者是否成功处理了请求,这也是造成诸多问题的诱因。同单机事务一样,各种形式的日志是保证事务几大特性的重要手段。
本地事务
相信大部分人开始接触事务应该都是通过数据库,我们的应用配置一个数据源,直接操作JDBC api或者借助一些框架比如spring的帮助完成事务的操作,这一切都借助于数据库提供的事务能力。这种场景就是最普遍的”单个服务使用单个数据源“的场景。
本地事务(Local Transaction)也叫局部事务,是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。它是能满足ACID的强一致性事务。具体的实现,可以参考Mysql数据库对事务的实现方式。
共享事务
共享事务主要是针对“多个服务使用单个数据源”场景。以我参与的一个微服务项目为例,在项目初期,我们划分出了多个微服务,例如教学服务和实验服务等等,理论上来说,每一个微服务都应该配置自己的数据库实例,但是因为项目早期的流量不会很多,而且配置多个数据库实例维护起来也很麻烦。所以我们的处理方式是所有的微服务共享同一个数据库实例,只不过各个微服务的表都是以各自微服务名为前缀,并且不允许跨不同前缀的表进行join操作。
可以看出来,这是一个过渡期的方案,因为随着微服务数量的增加,数据库链接的压力会变得越来越大,所以最终一定会将各个微服务的数据库实例分离。因此,如果我们的业务有跨微服务的事务需求,那么我们一定会使用分布式事务去解决。
但是,看了周志明老师关于共享事务的文章,提供了另外一种思路。虽然我觉得最终还是不会使用共享事务,但是看一看还是比较有意思的。为了实现共享事务,就必须新增一个“交易服务器”的中间角色,无论是教学服务、实验服务还是内容服务,它们都通过同一台交易服务器来与数据库打交道。如果将交易服务器的对外接口按照JDBC规范来实现的话,那它完全可以视为是一个独立于各个服务的远程数据库连接池,或者直接作为数据库代理来看待。此时三个服务所发出的请求就有可能做到交由交易服务器上的同一个数据库连接,通过本地事务的方式完成。
除了上述方案,还可以使用消息队列服务器的来代替交易服务器,通过消息将所有对数据库的改动传送到消息队列服务器,通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。
分布式事务
在分布式架构的场景下,完成某一个业务可能需要横跨多个服务,操作多个数据源。这其中又大概分为两种场景,一种是“一个服务操作多个数据源”,一种是“多个服务操作多个数据源”。但是无论是哪种场景,都需要一个分布式事务一致性协议来保证所有节点在进行事务提交时保持一致性。分布式事务通常采用二阶段提交协议(2PC),它是几乎所有分布式事务算法的基础。
刚性事务
2PC
在一个分布式系统中,所有的节点虽然都可以知道自己执行操作后的状态,但是无法知道其他节点执行操作的状态,因此需要引入一个作为协调者的组件来统一调度全部的节点,决定是否把操作结果进行真正的提交,这些被调度的节点称为参与者。
两阶段提交的执行过程就跟它的名字一样分为两个阶段,投票阶段和提交阶段。概括来说就是,参与者将操作成败通知协调者(投票阶段),再由协调者根据所有参与者的反馈决定各参与者是否要提交操作或者中止操作(提交阶段)。
2PC能工作是有前提的:
- 该分布式系统中,存在一个节点作为协调者(Coordinator),其他节点作为参与者(Participants)。且节点之间可以进行网络通信。
- 所有节点都采用预写式日志,且日志被写入后即被保持在可靠的存储设备上,即使节点损坏不会导致日志数据的消失。
- 所有节点不会永久性损坏,即使损坏后仍然可以恢复。
对于上面的3个前提,我的理解是,2PC允许在事务执行过程中发生错误,但是这些错误必须都是临时性的错误,无论是进程错误、网络错误等等都应该是可以恢复的。
投票阶段
在投票阶段中,协调者(Coordinator)会向事务的参与者(Cohort)询问是否可以执行操作的请求,并等待参与者的响应。参与者会执行相对应的事务操作,所有执行成功的参与者会向协调者发送 AGREEMENT
或者 ABORT
表示执行操作的结果。
提交阶段
当所有的参与者都返回了确定的结果(同意或者终止)时,两阶段提交就进入了提交阶段,协调者会根据投票阶段的返回情况向将事务状态置为commit
或者abort
状态,然后向所有的参与者发送提交或者回滚的指令。
当事务的所有参与者都决定提交事务时,协调者会向参与者发送 COMMIT
请求,参与者在完成操作并释放资源之后向协调者返回完成消息,协调者在收到所有参与者的完成消息时会结束整个事务;与之相反,当有参与者决定 ABORT
当前事务时,协调者会向事务的参与者发送回滚请求,参与者会进行回滚,在提交阶段,无论当前事务被提交还是回滚,所有的资源都会被释放并且事务也一定会结束。
存在的问题
2PC最大的问题在于它是一个阻塞协议,如果协调者发生了永久性宕机,一些参与者可能永远也无法处理他们的事务。比如,当一个参与者向协调者发送了一个AGREEMENT
,然后协调者宕机了,那么这个参与者会永远阻塞,直到收到COMMIT
或者ABORT
请求。
3PC
2PC协议在这种情况下无法可靠地恢复事务状态:协调者和一个参与者在提交阶段都发生了failure。如果仅仅是协调者宕了,还没有任何一个参与者收到 COMMIT
了请求,那么不会有什么问题,所有参与者回滚就好了。但是如果协调者和一个参与者都发生了宕机,并且这个参与者是第一个被协调者通知COMMIT
或者ABORT
请求的,那么就算新选出一个协调者,整个系统也会出现数据不一致的情况。因为首先上一个协调者宕机了导致投票阶段的结果已经丢失,其次第一个被通知投票结果的参与者也宕机了,这样整个集群永远无法直到上一次投票的结果是什么,剩下的参与者无论做COMMIT
或者ROLLBACK
都有可能和第一个已经宕机的参与者不一致。
上述场景的问题根源在于,所有参与者投票的结果只有协调者知道,这样当协调者宕机并且已经有参与者执行了投票结果并且也发生了宕机之后,其他还未接收到投票结果的参与者就会无所适从。
解决思路也很自然,就是将投票阶段产生的投票结果在协调者和所有参与者上都保存。为了达到这一目的,就需要增加一个pre-commit阶段。
新增的pre-commit阶段完成后,所有的参与者就都知道其他参与者的真实意图了,那么我们再来看看上面2PC发生问题的场景在3PC里会怎么样:当commit阶段开始时,无论是协调者还是有的参与者发生了宕机,继任的协调者都可以通过询问参与者,如果有的参与者已经处于commit阶段了,那就证明上一任协调者已经发送了doCommit
请求,因此继续执行doCommit
即可。如果有任何一个参与者反馈没有受到过preCommit请求,那么证明上一任协调者pre-commit阶段,那么其他参与者也一定没有真正的提交。
XA事务
针对“单个服务使用多个数据源”的场景,可以使用XA协议。XA是一套语言无关的通用规范。可以认为XA是对2PC的传统实现。Java中专门定义JTA(Java Transaction API)对XA进行支持。现在比较轻量的实现有Atomikos。XA协议是根据2PC实现的,内部分为两种角色:事务管理器(TM)相当于协调者,资源管理器(RM)相当于参与者。
XA确实能够保证较强的一致性,但是使用并不广泛,一个是因为性能,一个是因为并不是所有的资源都支持XA协议。
柔性事务
严格的ACID事务对隔离性的要求很高,在事务执行中必须将所有的资源锁定,对于长事务来说,整个事务期间对数据的独占,将严重影响系统并发性能。因此,在高并发场景中,对ACID的部分特性进行放松从而提高性能,这便产生了BASE柔性事务。柔性事务的理念则是通过业务逻辑将互斥锁操作从资源层面上移至业务层面,通过放宽对强一致性要求,来换取系统吞吐量的提升。另外提供自动的异常恢复机制,可以在发生异常后也能确保事务的最终一致。
由于CAP定理的存在,因为P是分布式网络的天然属性,你再不想要也无法丢弃,而A通常是建设分布式的目的,所以除非银行、证券这些涉及金钱交易的服务,其余大部分系统都可以为了增加系统的吞吐量以及可用性逐渐降低了系统对一致性的要求,只要求最终一致性。
需要注意的是,CAP中的一致性和ACID中的一致性并不是一个含义:ACID中的一致性是指在一系列对数据修改的操作中,保证数据的正确性;而分布式环境中的一致性是指对同一个数据多个副本间的读写一致性。我们接下来讨论中的一致性主要是CAP中所代表的一致性。
最大努力交付(Best-effort Delivery)
最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景:如支付通知、短信通知等。
以我做过的一个聚合支付系统为例:
- 用户调用我们的接口进行付款,我们的支付服务会在数据库中生成一条订单的记录,状态置为PENDING,提交事务。然后调用第三方支付平台(比如支付宝)的支付接口。
- 当用户支付完成后,第三方支付平台会回调我们的接口通知我们支付状态,我们会根据回调信息更新数据库,并ACK第三方支付平台,从而达到数据的最终一致性。
- 如果第三方支付平台没有收到我们的ACK,那么会间隔一段时间进行重试。
- 如果因为某些原因,直至超过重试次数,我们依然没有收到第三方支付平台的回调,那么我们会主动调用第三方支付平台的查询接口,从而达到数据的最终一致性。
如果支付平台由我们自己写的话,那可以使用MQ,例如RocketMQ实现上面第三方支付平台实现的通知逻辑。
可靠消息最终一致性
可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。
其核心原理是将两个事务通过消息中间件进行异步解耦。既然通过消息中间件进行解耦,那么就一定会涉及到网络调用。
可靠消息的可靠是指两方面,首先事务发起方的本地事务与消息投递是原子性的,其次消息必须是持久化的。
关于原子性,正常思路自然是如下代码,但是问题就出在第2步发送MQ上,前面说过通过网络请求其他服务的接口时,往往会得到三种结果:正确、失败和超时。如果发生了超时,那么下面的代码就有可能导致不一致。
1 | begin transaction; |
所以就需要通过重试来解决超时等网络问题,下面来看两种解决方案。
本地消息表
本地消息表最早是由eBay的架构师提出的,此处借用朱小厮老师的图描述下本地消息表的流程:
本地消息表的解决方案使用数据库来保证消息的可靠投递,写入业务表的逻辑和写入消息表(记录事务状态)的逻辑在同一个事务中,这样通过本地事务保证了一致性。之后,事务主动方将所要发送的消息发送到消息中间件中(步骤3)。消息在发送过程中丢失了怎么办?这里就体现出消息表的用处了。在上一步中,在消息表中记录的消息状态是“发送中”,事务主动方可以定时扫描消息表,然后将其中状态为“发送中”的消息重新投递到消息中间件即可。只有当最后事务被动方消费完之后,消息的状态才会被设置为“已完成”。
前面3个步骤可以避免“业务处理成功,消息发送失败”或者“消息发送成功,业务处理失败”这种棘手情况的出现,并且也可以保证消息不会丢失。
此方案优点在于简单,而缺点在于本地消息表与业务耦合,并且基于数据库写磁盘,高并发下性能有瓶颈。
RocketMQ事务消息(Transactional Message)
与本地消息表类似,使用MQ的事务消息只是对本地消息表的封装,其MQ内部实现了本地消息表的功能。目前只有RocketMQ可以实现事务消息。
先来看看为什么使用RabbitMQ没有办法实现事务消息。RabbitMQ通过发送方确认机制和事务可以保证消息发送到RabbitMQ中。但是RabbitMQ无法保证本地事务与消息发送的原子性,比如事务发起方在提交本地事务后宕机,那么消息就不会被发送到RabbitMQ中。
RocketMQ在4.3.0版中已经支持分布式事务消息,RocketMQ采用了2PC的思想来实现了提交事务消息,同时增加一个补偿逻辑来处理二阶段超时或者失败的消息。
上图说明了事务消息的大致方案,其中分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程。
- 事务消息发送及提交:
- 发送消息(half消息,对用户不可见)
- 服务端响应消息写入结果
- 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)
- 根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)
- 补偿流程:
- 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
- Producer收到回查消息,检查回查消息对应的本地事务的状态
- 根据本地事务状态,重新Commit或者Rollback
其中,补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况。
与最大努力交付的对比
这两种方案都适合上游事务对下游事务无依赖的场景。只要上游业务(消息中间件之前的业务)完成了,后续就没有失败回滚的概念,只许成功,不许失败。如果确实存在下游业务需要回滚,那么需要业务介入。不同的是最终一致性的达成,可靠消息是由上游业务确保消息的投递,而最大努力交付是靠下游业务在消息投递失败时进行主动查询。
TCC(Try-Confirm-Cancel)
TCC(Try-Confirm-Cancel)最早是由数据库专家Pat Helland在2007年提出。它的优势在于可以提供事务的隔离性,如果业务需要隔离,那架构师通常就应该重点考虑TCC方案,该方案天生适合用于需要强隔离性的分布式事务中。
在具体实现上,TCC较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。它分为以下三个阶段:
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。Confirm阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放Try阶段预留的业务资源。Cancel阶段可能会重复执行,也需要满足幂等性。
以“扣钱”场景为例,如果没有使用TCC,那么只需一条更新账户余额的SQL便能完成。但是使用TCC后,不能再这么干了,因为直接更新账户就不是预留业务资源了,我们需要考虑如何将原来一步就完成的扣钱操作拆成两阶段,实现成三个方法,并且保证Try成功Confirm一定能成功。比如,可以将原来余额字段改为可用余额,然后新增冻结余额字段。然后如下图:
与传统2PC相比,TCC不再需要RM的参与,而是将RM原本的工作(响应TM的Commit/Rollback)交给了业务系统,由业务系统提供相应的提交回滚接口。在2PC中,prepare阶段需要锁住资源,不能进行真正的提交,而TCC在try、confirm、cancle三个阶段都可以本地提交。因此TCC可以根据需要设计资源锁定的粒度,实现了较高的灵活性。
TCC需要注意的三种异常处理
幂等
幂等的含义很简单,重复调用多次产生的业务结果与调用一次产生的业务结果相同。幂等其实不知是TCC需要注意的,一切采用重试机制的分布式事务都需要注意幂等。
空回滚
需要在Cancel方法中处理空回滚的情况。
在Try接口因为丢包时没有收到,事务管理器会触发回滚,这时会触发Cancel接口,这时Cancel执行时发现没有对应的事务 XID或主键时,需要返回回滚成功。让事务服务管理器认为已回滚,否则会不断重试,而Cancel又没有对应的业务数据可以进行回滚。
悬挂
需要在Try方法中处理悬挂的情况。
悬挂的意思是Cancel比Try接口先执行,出现的原因是Try由于网络拥堵而超时,事务管理器生成回滚,触发Cancel接口,而最终又收到了Try接口调用,但是Cancel比Try先到。按照前面允许空回滚的逻辑,回滚会返回成功,事务管理器认为事务已回滚成功,则此时的Try接口不应该执行,否则会产生数据不一致,所以我们在Cancel空回滚返回成功之前先记录该条事务 XID或业务主键,标识这条记录已经回滚过,Try接口先检查这条事务XID或业务主键如果已经标记为回滚成功过,则不执行Try的业务操作。
SAGA
TCC事务具有较强的隔离性,而且其性能一般来说是几种柔性事务模式中最高的,但是它的主要限制是业务侵入性很强,需要所有的服务都是我们自己写的以适配TCC的各个阶段。如果我们需要调用一些外部系统,比如银行,那么往往第一步Try就无法实施,这时SAGA就排上用场了。
SAGA算法与1987年提出,是一种异步的分布式事务解决方案。其理论基础在于,其假设所有事件按照顺序推进,总能达到系统的最终一致性。
与TCC相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。
SAGA必须保证所有子事务都得以提交或者补偿,但SAGA系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。
AT(Automatic Transaction)
AT基于XA事务演进而来,特点是对业务无侵入,是一种改进后的两阶段提交,需要数据库支持,最早出现在阿里巴巴开源的分布式事务框架Seata中。
AT的整体流程是:
- 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
- 二阶段:
- 提交异步化,非常快速地完成。
- 回滚通过一阶段的回滚日志进行反向补偿。
AT的主要原理就是通过代理JDBC,在第一阶段将业务SQL解析把业务数据在更新前后的数据镜像组织成回滚日志,并生成undo log日志。将业务SQL和undo log写入同一个事务中一同提交到数据库中。在第二阶段如果全局进行提交,那么直接删除undo log就好,这对性能提升非常关键,如果全局进行回滚,那么直接使用undo log进行回滚就好了。
AT模式通过将RM从数据库层面抽取到Seata层面,将事务协调的工作转移到了应用层,但是与此同时通过代理JDBC解析SQL,做到了业务无侵入。通过这些方法在分支事务完成之后直接释放资源,极大减少了分支事务对资源的锁定时间,完美避免了XA协议需要同步协调导致资源锁定时间过长的问题。
分布式事务隔离级别
上面这些分布式事务解决方案看下来,在第一阶段就可以进行提交的除了TCC能保证事务的隔离性,其他的都没有办法保证隔离性。那么当两个事务并发修改同样的数据时就会发生脏写等问题。如果想要防止这种情况的发声就需要引入写隔离和读隔离机制。目前Seata是支持这种机制的。
写隔离
写隔离主要是为了解决脏写,看过这篇文章的就知道,所谓脏写是指,一个事务修改了另一个未提交事务修改的数据。在分布式事务中,所谓的数据不是指某个分支事务的数据,而是全部分支的数据,因此需要引入一个全局锁以保证在整个事务执行过程中持有锁从而不会发生脏写。
以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的全局锁,本地提交释放本地锁。 tx2后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的全局锁,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待全局锁。tx1二阶段全局提交,释放全局锁。tx2 拿到全局锁提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。此时,如果 tx2 仍在等待该数据的全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
读隔离
我们知道,不发生脏写(也就是未提交读)是数据库ACID事务中最低的隔离级别。通过全局锁,我们的分布式事务也可以达到未提交读的隔离级别。如果我们的分布式事务想要更高的隔离界别,就需要读隔离机制的介入了。
比脏写稍稍不那么严重的就是脏读了,所谓脏读是指,一个事务读到了另一个未提交事务修改的数据。能够避免脏读的隔离级别是已提交读,如果我们的分布式事务也想要达到已提交读的隔离级别,首先需要数据库的隔离级别是已提交读或以上。这个很好理解,因为分布式事务是由各个分支事务组成的,自然分布式事务的隔离级别是构建在分之事务之上的。
为了实现读已提交,目前Seata的方式是通过SELECT FOR UPDATE
语句的代理。
SELECT FOR UPDATE
语句的执行会申请全局锁,如果全局锁被其他事务持有,则释放本地锁(回滚SELECT FOR UPDATE
语句的本地执行)并重试。这个过程中,查询是被block住的,直到全局锁拿到,即读取的相关数据是已提交的,才返回。