分布式架构:事务
事务几乎存在于每一个信息系统中,其保证了系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾(一致性)。
按照数据库的经典理论,要达成这个目标,需要三方面共同努力来保障:
原子性(Atomic):在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。
隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
持久性(Durability):事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。
如今,事务的概念已不再局限于数据库本身,所有需要保证数据一致性的应用场景,包括但不限于数据库、事务内存、缓存、消息队列、分布式存储,等等,都有可能会用到事务。
- 当一个服务只使用一个数据源时,通过 A、I、D 来获得一致性是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数据能够被数据源感知是否存在冲突,并发事务的读写在时间线上的最终顺序是由数据源来确定的,这种事务间一致性被称为 “内部一致性”。
- 当一个服务使用到多个不同的数据源,甚至多个不同服务同时涉及多个不同的数据源时,问题就变得相对困难了许多。此时,并发执行甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据源来决定,这种涉及多个数据源的事务间一致性被称为 “外部一致性”。
外部一致性问题通常很难再使用 A、I、D 来解决,因为这样需要付出很大乃至不切实际的代价;但是外部一致性又是分布式系统中必然会遇到且必须要解决的问题,为此我们要转变观念,将一致性从“是或否”的二元属性转变为可以按不同强度分开讨论的多元属性,在确保代价可承受的前提下获得强度尽可能高的一致性保障,也正因如此,事务处理才从一个具体操作上的“编程问题”上升成一个需要全局权衡的“架构问题”。
本地事务
本地事务是最基础的一种事务解决方案,只适用于单个服务使用单个数据源的场景。从应用角度看,它是直接依赖于数据源本身提供的事务能力来工作的,在程序代码层面,最多只能对事务接口做一层标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程当中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与应用代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能工作。
全局事务
全局事务(Global Transaction)是一种在分布式环境中仍追求强一致性的事务处理方案。
全局事务涉及两个核心概念:
- 事务管理器(Transaction Manager):全局存在,用于协调全局事务。
- 资源管理器(Resource Manager):局部存在,用于驱动本地事务。
一个事务管理器和多个资源管理器之间可以形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚。
Java中的JTA,即JSR 907 Java Transaction API,实际上也是使用该思路实现的全局事务,其涉及到两个接口:
- 事务管理器的接口:javax.transaction.TransactionManager。这套接口是给 Java EE 服务器提供容器事务(由容器自动负责事务管理)使用的,还提供了另外一套javax.transaction.UserTransaction接口,用于通过程序代码手动开启、提交和回滚事务。
- 满足 XA 规范的资源定义接口:javax.transaction.xa.XAResource,任何资源(JDBC、JMS 等等)如果想要支持 JTA,只要实现 XAResource 接口中的方法即可。
(XA事务处理框架由X/Open组织提出,其定义了事务管理器和资源管理器之间的通信接口。)
场景说明
我们将假设一个场景,用于让事务处理从本地事务向全局事务过渡。
现在存在用户余额、商品库存、物流系统三个相关数据源,购物的流程是:
- 用户购买货物,减去余额。
- 仓库取出货物,减去库存。
- 物流系统发送货物,创建相关信息。
使用伪代码描述如果是本地事务时的全过程:
1 |
|
如果购物、取货、发货的过程都是成功的,那么提交事务即可;如果发生异常,那么回滚即可。
但是如果存在多个数据源,则该代码结构会存在问题:
1 |
|
看上去没有问题,但是如果在提交事务时抛出异常,比如执行logisticsTransaction.commit();
出现异常,进行回滚时由于userTransaction
和userTransaction
已经commit
了,会导致回滚无效。
两段式提交
为了解决提交期间可能出现的异常,XA将事务提交拆分成为两阶段过程:
- 准备阶段:又称投票阶段。在这一阶段,事务管理器询问事务的所有资源管理器是否准备好提交,资源管理器如果已经准备好提交则回复
Prepared
,否则回复Non-Prepared
。对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条Commit Record而已。 - 提交阶段:又称执行阶段,事务管理器如果在上一阶段收到所有事务资源管理器回复的
Prepared
消息,则先自己在本地持久化事务状态为Commit
,在此操作完成后向所有资源管理器发送Commit
指令,所有资源管理器立即执行提交操作;否则,任意一个资源管理器回复了Non-Prepared
消息,或任意一个资源管理器超时未回复,事务管理器将自己的事务状态持久化为Abort
之后,向所有资源管理器发送Abort
指令,资源管理器立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条Commit Record
而已,通常能够快速完成,只有收到Abort
指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载的操作。
示意图:
以上这两个过程被称为两段式提交(2 Phase Commit,2PC)协议。该协议要求:
- 网络在提交阶段的短时间内是可靠的,即提交阶段不会丢失消息。(或可以丢失消息,但不会传递错误的消息)两段式提交中投票阶段失败了可以补救(回滚),而提交阶段失败了无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而此阶段耗时应尽可能短,这也是为了尽量控制网络风险的考虑。
- 因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,并向事务管理器查询该事务的状态,确定下一步应该进行提交还是回滚操作。
二阶段提交的缺点:
- 单点问题:资源管理器宕机,可以一段时间后从事务管理器中获得事务的执行情况进行回滚或提交,不会影响整体执行情况;但是事务管理器宕机,会导致所有事务无法执行,所有资源管理器必须等待事务管理器恢复。
- 性能问题:两段提交过程中,涉及两次远程服务调用,三次数据持久化(准备阶段写重做日志,事务管理器做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到资源管理器集群中最慢的那一个处理操作结束为止。
- 一致性风险:提交阶段,如果事务管理器刚刚记录Commit阶段,这时候网络忽然被断开(或者事务管理器宕机),无法向所有资源管理器发出
Commit
指令,会导致部分数据(事务管理器的)已提交,但部分数据(资源管理器的)既未提交,也没有办法回滚,产生了数据不一致的问题。- 存在一段强一致性无法达成的时间,并且会根据资源管理器处理超时的不同策略导致不同程度的数据不一致风险:如果此时未收到指令的资源管理器进入等待状态,维持事务的锁定或预备状态,并等待事务管理器恢复或者新的事务管理器接管,可最大程度减少数据不一致风险;如果直接进行回滚操作,就会导致数据的不一致。
FLP不可能原理:如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。
三段式提交
为了缓解两段式提交协议的一部分缺陷,后续又发展出了 “三段式提交”(3 Phase Commit,3PC)协议。
三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为CanCommit
、PreCommit
,把提交阶段改称为DoCommit
阶段。
CanCommit:事务管理器让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。
将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦事务管理器发出开始准备的消息,每个资源管理器都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个资源管理器宣告无法完成提交,相当于大家都白做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个资源管理器提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。
示意图:
三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。譬如,进入 PreCommit 阶段之后,事务管理器发出的指令不是Commit而是Abort,而此时因网络问题,有部分资源管理器直至超时都未能收到事务管理器的Abort指令的话,这些资源管理器将会错误地提交事务,这就产生了不同资源管理器之间数据不一致的问题(当然,如果超时后还是会锁定资源并等待重新连接,一样可以尽可能地保证数据一致性)。
代码示范
在全局事务处理中,存在这样一个层次结构:
- 全局事务管理器:它负责协调跨越多个服务或数据源的整个事务过程。全局事务管理器并不直接与各个数据源交互,而是通过与每个服务的本地事务管理器沟通来间接控制这些数据源上的操作。它主要执行的任务包括发起Prepare请求到所有参与者,并根据所有参与者的反馈决定最终是提交还是回滚全局事务。
- 本地事务管理器:每个服务(如UserService、WarehouseService、LogisticsService)内部都有一个本地事务管理器,它直接管理该服务所依赖的数据源(资源管理器)。本地事务管理器负责响应全局事务管理器的Prepare和Commit/Rollback命令,对本服务内的数据库或其他资源执行相应的事务操作。
- 资源管理器:这是最底层,直接管理数据库或其他数据存储的地方,如MySQL数据库、缓存服务等。它们根据本地事务管理器的指令执行实际的读写操作。
全局事务管理器
首先定义一个全局事务管理器接口和实现,它将负责协调各个服务的本地事务:
1 |
|
本地事务管理器
接下来,模拟三个服务的本地事务管理器,它们实现了TransactionParticipant接口:
1 |
|
使用全局事务管理器执行购物操作
最后使用全局事务管理器来协调一个模拟的购物事务:
1 |
|
注意:这个示例极度简化,实际应用中的二阶段提交会涉及到复杂的网络通信、超时处理、异常恢复以及可能的分布式锁机制等,而且通常会使用成熟的分布式事务框架(如XA、Seata等)来实现,而不是手动编码。
分布式事务
基于CAP理论,XA的事务机制无法在分布式环境中良好地应用。
CAP
CAP 定理(Consistency、Availability、Partition Tolerance Theorem)表示,在一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:
- 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。
- 可用性(Availability):代表系统不间断地提供服务的能力。
- 理解可用性要先理解与其密切相关两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如 99.9999%可用,即代表平均年故障修复时间为 32 秒。
- 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。
场景举例
依旧使用上述的那个购物流程来说明案例。
- 从单个服务角度出发:(比如减去用户余额,用户的余额数据存储在多个节点中,并存在冗余)
- 一致性:每个节点都需要得知该余额的减少,不能存在某个节点不知道;如果无法达成一致性,就可能导致两次购物使用了两个不同的节点,而导致双重支付的可能。
- 可用性:如果要避免上述一致性问题,就需要先暂停对该用户的余额服务,等到达成一致后再提供服务,期间是无法提供服务的,这就会导致可用性问题。
- 分区容忍性:如果余额服务中的一部分节点出现问题,整个集群能否继续提供正常的服务。
- 从整个购物服务出发:
- 一致性:一个用户账户扣款之后,全局的库存节点中的库存都要减去。
- 可用性:某个商品的交易正在进行,可能导致购买用户、该商品的交易服务都需要临时锁定。
- 分区容忍性:如果任意或多个服务中的一部分节点出现问题,整个集群能否继续提供正常的服务。
舍弃C、A、P时所带来的不同影响
证明CAP理论需自行了解,本文不做介绍。
舍弃 C、A、P 时所带来的不同影响:
放弃分区容忍性(CA without P):要在保证一致性的同时保证可用性,则要求节点之间通信永远是可靠的,但这难以实现。因此当舍弃分区容忍性时,通常会导致一个分布式系统会严重受限于对网络分区的敏感性,进而演变成单体系统以避免由网络不稳定导致分区问题。在实际应用中,很少有系统会选择完全放弃分区容忍性,因为网络通信的不可靠性是分布式计算的基本假设之一。
常见的CA系统有Oracle的RAC集群。Oracle RAC的工作原理是通过多个节点共享相同的数据库存储(通常是通过高速SAN或集群文件系统实现),每个节点运行一个数据库实例,这些实例共同访问同一份数据库数据。如果集群中的某个节点(实例)发生故障,其他节点可以继续提供服务,因为所有节点都能访问共享的存储,从而保证数据库服务不间断。这种机制有效地消除了单点故障,因为即使单个服务器或实例出现问题,数据库仍然可以继续运行。此外,由于使用的是共享的相同数据库存储,同时可以保证一致性。但是RAC集群在设计上提前假设了所有节点都能够访问共享存储,这就隐含了对网络连通性的高度依赖。如果网络出现分区,即某些节点无法访问共享存储,这将违反了RAC的基本运作前提,可能导致数据不一致或服务中断。因此,RAC在设计上并未追求在面对网络分区时仍能维持服务,而是通过硬件冗余(如多路径存储访问)、网络冗余和快速故障检测转移机制来减少分区发生的可能性,并确保在单一数据中心内的高可用性。
放弃可用性(CP without A):这意味着一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,近似于之前全局事务中的2PC/3PC的情况。在现实中,选择放弃可用性的 CP 系统情况一般用于对数据质量要求很高的场合中。
放弃一致性(AP without C):这意味着一旦网络发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的AP系统目前是设计分布式系统的主流选择,因为P是分布式网络的天然属性,无法丢弃;而A通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数 NoSQL 库和支持分布式的缓存框架都是 AP 系统,以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。
在分布式环境中,“一致性”通常不得不被放弃,但是终究还是要确保操作结果至少在最终交付的时候是正确的。因此人们又重新给一致性下了定义,在CAP、ACID中讨论的一致性称为强一致性(Strong Consistency),而把牺牲了C的AP系统又要尽可能获得正确的结果的行为称为追求最终一致性(Eventual Consistency)。最终一致性是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。
BASE
BASE是一种独立于ACID获得的强一致性之外的达成一致性目的的途径。
BASE理论的核心思想是:尽管在分布式系统中很难同时实现强一致性、高可用性和分区容忍性,但每个应用可以根据自身的业务需求,采用适当的方法来达到最终的一致性。BASE理论包含三个基本要素:
- 基本可用(Basically Available):这意味着分布式系统在面对部分故障时,仍然能够提供一定程度的服务。尽管可能不是所有操作都能完成,但系统的核心功能应当能够持续可用。例如,在电商系统中,即使库存更新服务暂时不可用,用户浏览商品和加入购物车的功能仍然可以正常工作。
- 软状态(Soft State):软状态指的是系统中的数据可以处于中间状态,而且这个中间状态对于用户来说是可以接受的,不会直接影响到系统的整体可用性。在数据同步过程中,不同节点间的数据副本可能存在不一致的情况,这种不一致性被视为暂时的、可以接受的状态。例如,用户下单后,订单状态可能在“待确认”和“已确认”之间短暂地处于不确定状态。
- 最终一致性(Eventually Consistent):最终一致性保证系统中的所有数据副本,在经过一段时间之后,没有更多的更新操作发生时,最终能够达到一致的状态。这个时间的长短取决于系统的设计、网络延迟以及数据复制策略等因素。在分布式系统中,数据可能通过异步复制或事件驱动的方式在各个节点间传播,确保所有节点最终达成一致。
接下来将介绍三种基于BASE理论指导的分布式事务处理方案。
可靠事件队列
我们继续以本文的场景事例来解释可靠事件队列是如何实现最终一致性的。
现在存在用户余额、商品库存、物流系统三个相关数据源,购物的流程是:
- 用户购买货物,减去余额。
- 仓库取出货物,减去库存。
- 物流系统发送货物,创建相关信息。
sequenceDiagram
participant 整体系统
participant 用户服务
participant 消息队列
participant 仓库服务
participant 发货服务
整体系统->>用户服务: 启动事务
用户服务->>用户服务: 扣除余额
用户服务->>消息队列: 发送相关业务消息
loop 循环直至成功
消息队列->>仓库服务: 处理业务消息,扣除库存
alt 扣减成功
仓库服务-->>消息队列: 成功
else 扣减失败
仓库服务-->>消息队列: 失败
end
end
loop 循环直至成功
消息队列->>发货服务: 处理业务消息,发货
alt 发货成功
发货服务-->>消息队列: 成功
else 发货失败
发货服务-->>消息队列: 失败
end
end
过程说明:
- 用户发出购物请求。
- 用户服务进行扣款,扣款成功后发送送货消息和减库存消息到消息队列。(并在数据库中记录:xxx商品,已付款,未减库存,未发货)
- 库存服务对消息进行消费。(并在数据库中记录:xxx商品,已付款,已减库存,未发货)
- 发货服务对消息进行消费。(并在数据库中记录:xxx商品,已付款,已减库存,已发货)
如果该过程中,任意一条消息发生消费失败,则重新进行消息消费,直至操作成功,或者被人工介入为止。(可靠事件队列只要第一步业务完成了,后续就没有失败回滚的概念,只许成功,不许失败。)
以上是一种靠着持续重试来保证可靠性的解决方案,也叫最大努力交付(Best-Effort Delivery)。
上述的形式是可靠事件队列的一种更普通的形式,被称为最大努力一次提交(Best-Effort 1PC),指的就是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。(当然也可以直接发送三条消息进行消费)
TCC事务
TCC(Try-Confirm-Cancel)是另一种常见的分布式事务机制。
之前讲的可靠消息队列虽然是一种分布式事务处理方案,但是存在一个缺陷——缺乏隔离性。
比如有多个用户购买同一个货物时,如果减库存消息的数量大于总库存,就会导致库存不足,如果是限量版的商品,就会引发很严重的问题…
因此,如果业务需要隔离,就通常就应该重点考虑TCC方案,因为该方案天生适合用于需要强隔离性的分布式事务中。
在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为预留业务资源和确认/释放消费资源两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。
我们继续以本文的场景事例来解释TCC事务是如何实现最终一致性的。
上述流程说明:
- 用户发出购物请求。
- 系统尝试预留资源。
- 冻结余额
- 冻结库存
- 创建物流信息
- 如果预留成功,执行Confirm业务逻辑,直至执行成功。
- 如果预留失败,执行Cancel业务逻辑,直至执行成功。
伪代码示范:
1 |
|
TCC位于用户代码层面,可以根据需要设计资源锁定的粒度,在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力;但是TCC有较高的开发成本和业务侵入性,因此通常不会裸编码实现,而是使用分布式事务中间件如Seata来实现。
SAGA事务
TCC事务实现了较强的隔离性,但是由于其业务侵入性很强,如果Try阶段的所需相关资源是由第三方接口把控的,无法实现类似冻结、解冻操作,则需要引入新的事务处理方案。
SAGA事务是另外一种柔性事务方案,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合:
- 大事务拆分若干个小事务,将整个分布式事务$T$分解为$n$个子事务,命名为$T_1$,$T_2$,$…$,$T_i$,$…$,$T_n$。每个子事务都应该是或者能被视为是原子行为。如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交$T_i$等价。
- 为每一个子事务设计对应的补偿动作,命名为$C_1$,$C_2$,$…$,$C_i$,$…$,$C_n$。$T_i$与$C_i$必须满足以下条件:
- $T_i$与$C_i$都具备幂等性。
- $T_i$与$C_i$满足交换律(Commutative),即先执行$T_i$还是先执行$C_i$,其效果都是一样的。
- $C_i$必须能成功提交,即不考虑$C_i$本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。
- 如果$T_1$到$T_n$均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:
- 正向恢复(Forward Recovery):如果$T_i$事务提交失败,则一直对$T_i$进行重试,直至成功为止(最大努力交付)。(这种恢复方式不需要补偿,适用于事务最终都要成功的场景)
- 反向恢复(Backward Recovery):如果$T_i$事务提交失败,则一直执行$C_i$对$T_i$进行补偿,直至成功为止(最大努力交付)。这里要求$C_i$必须(在持续重试后)执行成功。反向恢复的执行模式为:$T_1$,$T_2$,$…$,$T_i$(失败),$C_i$(补偿),$…$,$C_2$,$C_1$。
代码示例:
1 |
|
由于SAGA系统本身也有可能会崩溃,所以其必须设计成与数据库类似的日志机制(被称为SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况。
另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式。