分布式事务:跨越多个系统的一致性
在单个数据库中,事务是一个成熟而可靠的工具。一组操作被包裹在一个事务中,数据库保证它们要么全部成功,要么全部失败,中途发生的任何错误都会被回滚,数据始终处于一致的状态。
然而,当系统演进为由多个服务组成的分布式架构,或者数据被分片存储在多个数据库节点上时,一个业务操作往往需要修改多个独立系统中的数据。以电商下单为例,一次下单可能需要:在订单服务中创建订单,在库存服务中扣减库存,在账户服务中扣除余额,在积分服务中增加积分。这些服务各自拥有独立的数据库,单个数据库的事务无法覆盖它们。
如果扣款成功了,库存扣减却失败了,用户付了钱却买不到商品;如果库存扣减了,订单却没有创建成功,商品就凭空消失了。分布式事务要解决的,就是如何在多个独立的参与者之间,保证一组操作的一致性。
本文介绍分布式事务面临的根本困难,以及解决这一问题的几种主要方法:两阶段提交、三阶段提交、基于共识的提交、Saga 模式、TCC 模式,以及基于可靠消息的最终一致性方案,并讨论它们的适用场景和工程实践中的注意事项。
一、为什么分布式事务很难
单机事务的保障
在单个数据库中,事务的原子性依赖于几个前提:
- 所有数据由同一个系统管理,系统能够完整地看到事务涉及的全部修改;
- 系统通过预写日志记录事务的操作,崩溃后可以根据日志重做已提交的事务、撤销未提交的事务;
- 提交是一个单点决策:只要将提交记录写入日志,事务就提交了。
分布式环境的挑战
在分布式环境中,这些前提都不再成立:
没有单一的决策点。 每个参与者只知道自己的状态,无法直接知道其他参与者是否成功。要让所有参与者做出一致的决定,就需要它们相互通信、协调。
网络不可靠。 消息可能丢失、延迟、重复,甚至乱序到达。当一个参与者没有收到另一个参与者的回复时,它无法区分对方是已经崩溃了、只是处理得很慢,还是回复在网络中丢失了。
节点可能故障。 任何一个参与者都可能在任何时刻崩溃,并且可能在一段时间后恢复,也可能永远不会恢复。崩溃可能发生在协调过程中的任何一步。
部分失败。 在单机系统中,故障通常是整体性的:要么整个系统正常,要么整个系统宕机。而在分布式系统中,一部分节点正常运行而另一部分节点故障,是常态而非例外。
两将军问题
分布式一致性的困难,可以用一个经典的思想实验来说明,即两将军问题。
两支军队分别驻扎在一座城市的两侧,它们必须同时发起进攻才能获胜,任何一方单独进攻都会失败。两位将军只能通过派遣信使穿越敌方控制的山谷来传递消息,而信使可能被俘虏。
将军甲派信使告诉将军乙:"明天黎明进攻。"但甲不知道信使是否成功送达,因此不敢贸然进攻。将军乙收到消息后,需要回复确认;但乙也不知道确认是否送达,如果甲没有收到确认,甲不会进攻,乙单独进攻就会失败。于是甲需要对确认再次确认……这个过程永远无法终止。
可以证明,在通信信道不可靠的情况下,不存在任何协议能够保证两位将军一定能达成一致。这个结论揭示了分布式系统中的一个基本限制:在不可靠的网络上,无法实现绝对可靠的一致性协调。实际的分布式事务协议,都是在这一根本限制下,通过各种假设和权衡来实现尽可能好的保证。
CAP 与一致性权衡
另一个相关的理论结果是 CAP 定理:在发生网络分区时,一个分布式系统无法同时保证一致性和可用性,必须在两者之间做出选择。
对于分布式事务而言,这意味着:如果要求所有参与者在任何时刻都保持严格的一致,那么在网络出现问题时,部分操作必须阻塞等待,系统的可用性会下降;如果要求系统在任何时候都能响应,就必须接受数据在一段时间内可能不一致。
不同的分布式事务方案,正是在这一权衡中做出了不同的选择:
- 强一致性方案,例如两阶段提交,保证事务的原子性和隔离性,但会因为协调和锁定而降低性能和可用性;
- 最终一致性方案,例如 Saga 和可靠消息,允许数据在短时间内不一致,但保证最终会达到一致状态,换取更高的性能和可用性。
二、两阶段提交
两阶段提交(Two-Phase Commit,2PC)是最经典的分布式事务协议,它试图在多个参与者之间实现原子提交。
角色
两阶段提交中有两种角色:
- 协调者(Coordinator):负责发起和协调整个事务,收集各参与者的意见,做出最终决定;
- 参与者(Participant):实际执行事务操作的各个节点,例如各个数据库或服务。
协议流程
第一阶段:准备阶段(也称投票阶段)
- 协调者向所有参与者发送准备请求,询问它们是否能够提交事务;
- 每个参与者执行事务中属于自己的操作,但不提交。参与者将操作所需的全部信息,包括重做和撤销所需的数据,持久化到日志中,并锁定相关的资源;
- 如果参与者确认自己能够提交,它在日志中记录"已准备"状态,然后回复同意。从这一刻起,参与者做出了一个承诺:无论之后发生什么,只要协调者要求提交,它就一定能够提交,即使中途崩溃重启也是如此;
- 如果参与者因为任何原因无法提交,例如违反约束、资源不足、发生错误,它回复拒绝。
第二阶段:提交阶段(也称决定阶段)
- 如果所有参与者都回复同意,协调者将提交决定写入自己的日志,然后向所有参与者发送提交请求;
- 如果有任何一个参与者回复拒绝,或者在超时时间内没有回复,协调者将中止决定写入日志,然后向所有参与者发送回滚请求;
- 参与者根据收到的决定提交或回滚事务,释放锁定的资源,然后向协调者发送确认;
- 协调者收到所有参与者的确认后,事务结束。
关键点:不可撤回的承诺
两阶段提交能够保证原子性,关键在于两个不可撤回的承诺点:
- 参与者一旦回复同意,就失去了单方面中止事务的权利,必须等待协调者的决定;
- 协调者一旦将决定写入日志,这个决定就是最终的,不可更改。
由于所有参与者都已经承诺能够提交,协调者的提交决定一定能够被执行;由于决定在发送之前已经持久化,即使协调者在发送决定的过程中崩溃,恢复后也能根据日志继续完成。
故障处理
两阶段提交需要处理各种故障情况:
- 参与者在准备阶段崩溃:协调者等待超时后,将其视为拒绝,中止整个事务;
- 参与者在回复同意后崩溃:恢复后,参与者从日志中发现自己处于"已准备"状态,但不知道最终决定,需要向协调者询问;
- 协调者在收集投票时崩溃:尚未做出决定,恢复后可以中止事务;
- 协调者在写入决定后、发送给所有参与者之前崩溃:恢复后根据日志中的决定,重新发送给所有参与者。
两阶段提交的问题
两阶段提交在理论上能够保证原子性,但在实践中存在几个严重的问题。
阻塞问题。 这是两阶段提交最致命的缺陷。当参与者回复同意后,它就进入了一个不确定状态:它已经承诺提交,不能单方面回滚;但它又不知道最终决定,也不能单方面提交。如果此时协调者崩溃,并且长时间无法恢复,参与者就只能一直等待。
更糟糕的是,在等待期间,参与者必须一直持有事务涉及的锁。其他所有需要访问这些数据的事务都会被阻塞。一个协调者的故障,可能导致多个参与者上的大量数据长时间不可访问。
即使参与者之间相互询问,也不一定能解决问题:如果所有存活的参与者都处于"已准备"状态,它们都不知道协调者做出了什么决定,而协调者可能已经告诉了某个已经崩溃的参与者"提交",此时任何单方面的决定都可能与之冲突。
性能开销。 两阶段提交需要至少两轮网络往返,以及多次持久化日志的写入。在准备阶段到提交阶段之间,所有参与者都持有锁,事务的持续时间被拉长,并发能力下降。参与者越多、网络延迟越高,这一问题越严重。
协调者单点。 协调者是整个协议的中心,它的可用性直接决定了事务的可用性。
木桶效应。 事务的完成时间取决于最慢的参与者,任何一个参与者的延迟或故障都会影响整个事务。
XA 规范
XA 是一个将两阶段提交标准化的规范,定义了事务管理器(即协调者)与资源管理器(即参与者,通常是数据库)之间的接口。许多关系型数据库和消息中间件都实现了 XA 接口,应用程序可以借助支持 XA 的事务管理器,在多个数据库之间执行分布式事务。
XA 事务提供了与本地事务相似的编程体验,但它继承了两阶段提交的所有问题:性能开销大、锁持有时间长、协调者故障时参与者可能长时间阻塞。此外,不同数据库对 XA 的实现质量参差不齐,在某些故障场景下可能出现需要人工介入处理的悬挂事务。因此,在高并发的互联网场景中,XA 事务的使用相对较少。
三、三阶段提交
三阶段提交(Three-Phase Commit,3PC)试图解决两阶段提交的阻塞问题。
它在两阶段提交的准备阶段和提交阶段之间,增加了一个预提交阶段:
- 询问阶段:协调者询问参与者是否能够执行事务,参与者根据自身状态回复,但此时不执行操作也不锁定资源;
- 预提交阶段:如果所有参与者都回复可以,协调者发送预提交请求。参与者执行事务操作并记录日志,回复确认;
- 提交阶段:协调者收到所有确认后,发送提交请求,参与者完成提交。
三阶段提交的核心思想是:在协调者发出最终的提交命令之前,先确保所有参与者都知道大家都同意了。这样,如果协调者在提交阶段崩溃,处于预提交状态的参与者可以推断出所有参与者都已同意,于是在超时后自行提交,而不必无限期等待。
然而,三阶段提交只在一种理想化的模型下有效:网络不会发生分区,并且消息的延迟有一个已知的上界。在真实的网络环境中,一旦发生网络分区,一部分参与者收到了预提交请求,另一部分没有收到,两边可能分别做出提交和中止的决定,导致不一致。
由于这一局限,加上额外一轮通信带来的开销,三阶段提交在实际系统中很少被使用。
四、基于共识的提交
两阶段提交的阻塞问题,根源在于协调者是单点。如果协调者的决定能够被可靠地复制到多个节点上,即使某个协调者节点崩溃,其他节点也能接替它,告诉参与者最终的决定。
基于共识的提交正是这一思路:使用 Raft 或 Paxos 等共识算法,将协调者的状态复制到一组节点上。只要多数节点存活,协调者就始终可用,事务的决定就不会丢失。
进一步地,每个参与者本身也可以是一个由共识算法复制的组。现代的许多分布式数据库都采用了类似的架构:
- 数据被划分为多个分片,每个分片由一个共识组管理,组内的多个副本保证分片数据的高可用;
- 跨分片的事务使用两阶段提交协调,而协调者的状态和每个参与者的准备状态,都通过共识算法持久化在多个副本上;
- 任何单个节点的故障都不会导致事务阻塞,因为共识组会选出新的主节点继续推进。
在这种架构下,两阶段提交的阻塞问题在很大程度上被消除了。剩下的主要是性能问题:每次跨分片事务都需要多轮共识,以及两阶段提交本身的通信开销。分布式数据库通过各种技术来降低这些开销,例如:
- 单分片优化:只涉及一个分片的事务,直接在分片内部提交,无需两阶段提交;
- 并行提交:将事务的准备和提交在一定程度上重叠,减少一轮网络往返;
- 基于时间戳的并发控制:结合全局时钟或混合逻辑时钟,在保证隔离性的同时减少锁的使用。
对于使用这类分布式数据库的应用,跨分片事务的复杂性被数据库封装起来了,应用可以像使用单机数据库一样使用事务。这是目前在需要强一致性的场景中,解决分布式事务问题的一个重要方向。
然而,这种方式只适用于数据存储在同一个分布式数据库中的情况。在微服务架构中,各服务通常拥有独立的、可能是不同类型的数据存储,并通过网络接口相互调用,此时就需要其他的方法。
五、Saga 模式
基本思想
Saga 模式最初在 1987 年的一篇关于长时间运行事务的论文中提出,近年来在微服务架构中得到了广泛应用。
Saga 的核心思想是:放弃全局事务的原子性和隔离性,将一个长事务拆分为一系列本地事务,每个本地事务在一个服务内部独立提交。为每一个本地事务,定义一个对应的补偿操作,用于在语义上撤销该事务的效果。
- 如果所有本地事务都成功执行,Saga 完成;
- 如果某个本地事务失败,就按照相反的顺序,依次执行此前已经成功的各个本地事务的补偿操作,将系统恢复到一个一致的状态。
以下单为例,可以设计如下的 Saga:
| 步骤 | 本地事务 | 补偿操作 |
|---|---|---|
| 1 | 创建订单,状态为"待处理" | 将订单标记为"已取消" |
| 2 | 扣减库存 | 恢复库存 |
| 3 | 扣除账户余额 | 退还账户余额 |
| 4 | 将订单状态更新为"已确认" | 无需补偿 |
如果第 3 步扣款失败,例如余额不足,就依次执行第 2 步的补偿(恢复库存)和第 1 步的补偿(取消订单)。
补偿不是回滚
需要特别注意的是,补偿操作与数据库回滚有本质的区别:
- 数据库回滚是物理上的撤销,就好像事务从未发生过,其他事务也从未看到过它的中间结果;
- 补偿是语义上的撤销,它本身是一个新的业务操作。原来的本地事务已经提交了,它的效果可能已经被其他事务看到并使用,补偿只是在业务层面抵消它的影响。
这意味着:
- 有些操作无法补偿。例如,已经发送出去的邮件或短信无法撤回,只能再发送一条更正通知;已经交付给物流的商品,补偿可能意味着发起退货流程;
- 补偿操作需要根据业务语义仔细设计。例如,退还余额时,需要考虑这段时间内账户状态是否发生了变化。
编排与协同
实现 Saga 有两种主要的方式。
协同式(Choreography):没有中心的协调者,各个服务通过事件相互驱动。每个服务完成自己的本地事务后,发布一个事件;其他服务订阅这个事件,执行自己的下一步操作。如果某一步失败,就发布一个失败事件,之前的服务订阅该事件并执行补偿。
协同式的优点是服务之间松耦合,没有单点。缺点是整个业务流程分散在多个服务的事件处理逻辑中,难以从全局理解和追踪;当步骤较多时,事件之间的依赖关系会变得错综复杂,容易出现循环依赖,也难以测试。
编排式(Orchestration):由一个专门的编排器集中控制整个 Saga 的执行流程。编排器按照预先定义的步骤,依次调用各个服务执行本地事务;如果某一步失败,编排器负责依次调用补偿操作。
编排式的优点是业务流程集中定义,易于理解、修改和监控;各个服务只需提供执行和补偿接口,不需要了解整个流程。缺点是编排器成为一个核心组件,需要保证其可靠性,并且编排器中可能积累过多的业务逻辑。
在实践中,对于步骤较少、流程简单的场景,协同式较为轻便;对于步骤较多、流程复杂的场景,编排式更易于维护。编排器本身的状态需要持久化,以便在崩溃恢复后能够继续执行未完成的 Saga。一些工作流引擎专门为这类长时间运行、需要可靠执行的流程而设计,可以作为实现编排式 Saga 的基础。
隔离性问题
Saga 放弃了全局的隔离性:在 Saga 执行过程中,每个本地事务提交后,其结果立即对其他事务可见。这可能导致一些并发异常:
- 脏读:一个 Saga 扣减了库存,另一个查询看到了减少后的库存;随后该 Saga 失败,库存被恢复,之前的查询结果实际上基于一个最终不存在的状态;
- 丢失更新:一个 Saga 的补偿操作,可能覆盖了另一个并发事务对同一数据的修改;
- 不可重复读:同一个 Saga 中的两个步骤,读取同一数据时可能得到不同的结果。
常见的应对策略包括:
- 语义锁:在数据上设置一个业务状态标记,表示它正在被某个 Saga 处理。例如,将订单状态设为"处理中",其他操作看到这个状态后,可以选择等待、拒绝或特殊处理。Saga 完成或补偿后,再清除这个标记;
- 交换性更新:将更新设计为与执行顺序无关的形式。例如,余额的扣减和退还使用增减操作,而不是直接设置一个值,这样补偿与其他并发操作的顺序就不会影响最终结果;
- 悲观视图:调整 Saga 中步骤的顺序,将最容易失败或风险最高的操作放在前面,将不可补偿的操作放在最后;
- 重新读取值:在执行更新前重新读取数据,检查其是否被修改过,类似于乐观锁;
- 版本记录:记录对数据执行的所有操作,以便正确处理乱序到达的操作。
可补偿、关键与可重试事务
在设计 Saga 时,可以将其中的步骤分为三类:
- 可补偿事务:可以通过补偿操作撤销的步骤;
- 关键事务:Saga 的转折点。一旦关键事务成功,Saga 就一定要继续执行到底,不再回退;
- 可重试事务:位于关键事务之后的步骤,它们保证最终一定能够成功,失败时只需重试即可,不需要补偿。
一个良好的 Saga 设计通常是:先执行一系列可补偿事务,然后是一个关键事务,最后是若干可重试事务。将不可补偿的操作,例如发送通知、发起物流,放在关键事务之后作为可重试事务,就能避免需要补偿不可补偿操作的困境。
六、TCC 模式
TCC(Try-Confirm-Cancel)是另一种在业务层面实现分布式事务的模式。它将每个参与者的操作拆分为三个阶段:
- Try(尝试):检查业务条件,并预留所需的资源,但不真正执行业务操作。例如,冻结账户中的一部分余额,而不是直接扣除;锁定一部分库存,而不是直接减少;
- Confirm(确认):在所有参与者的 Try 都成功后执行,真正使用预留的资源完成业务操作。例如,将冻结的余额正式扣除;
- Cancel(取消):如果有任何一个参与者的 Try 失败,对所有已经成功执行 Try 的参与者执行 Cancel,释放预留的资源。例如,解冻余额。
TCC 的执行流程类似于两阶段提交:Try 阶段相当于准备阶段,Confirm 和 Cancel 相当于提交和回滚。但不同之处在于,TCC 的每个阶段都是一个独立提交的本地事务,资源的预留是通过业务层面的状态来实现的,而不是通过数据库的锁。因此,Try 完成后,数据库的锁就已经释放了,不会长时间阻塞其他操作。
TCC 与 Saga 的比较
TCC 与 Saga 都在业务层面实现分布式事务,两者的主要区别在于:
- 隔离性:TCC 通过 Try 阶段的资源预留,提供了一定程度的隔离。在 Confirm 之前,被冻结的余额不能被其他交易使用,但其他交易可以看到余额已被冻结;而 Saga 的每一步都是直接执行业务操作,中间状态完全对外可见;
- 实现成本:TCC 要求每个参与者都实现三个接口,并且需要在数据模型中支持资源预留,例如增加一个"冻结金额"字段。对于原有的业务系统,改造成本较高。Saga 只需要提供执行和补偿两个操作,对现有系统的侵入性较小;
- 适用场景:TCC 适合对一致性要求较高、执行时间较短的场景,例如资金交易。Saga 更适合流程较长、涉及较多服务、可以容忍中间状态短暂可见的场景。
TCC 的工程难点
实现 TCC 时,需要特别处理几种异常情况。
幂等性。 由于网络的不确定性,Confirm 和 Cancel 可能会被重复调用。例如,协调者发送 Confirm 后没有收到响应,会进行重试,而实际上第一次调用已经成功了。因此,Confirm 和 Cancel 必须是幂等的。
空回滚。 如果 Try 请求因为网络问题根本没有到达参与者,协调者等待超时后,会对该参与者调用 Cancel。此时参与者从未执行过 Try,没有任何资源被预留,Cancel 不应该做任何事情,但应该返回成功。参与者需要能够识别出这种情况,而不是错误地去释放不存在的资源,或者因为找不到记录而报错。
悬挂。 更麻烦的情况是:Try 请求在网络中延迟了,协调者超时后发送了 Cancel,参与者执行了空回滚。随后,延迟的 Try 请求才到达参与者,参与者执行了资源预留。而此时整个事务已经结束,不会再有 Confirm 或 Cancel 来处理这些预留的资源,它们就被永久地"悬挂"了。
解决空回滚和悬挂问题的常用方法是:为每个分布式事务分配一个唯一的事务标识,参与者维护一张事务状态表:
- 执行 Cancel 时,如果在状态表中找不到对应的 Try 记录,就插入一条"已取消"的记录,然后返回成功,这样就处理了空回滚;
- 执行 Try 时,先检查状态表中是否已存在"已取消"的记录,如果存在,说明 Cancel 已经先执行过了,直接拒绝这次 Try,这样就防止了悬挂;
- 执行 Confirm 和 Cancel 时,通过状态表判断是否已经执行过,实现幂等。
七、基于可靠消息的最终一致性
在许多业务场景中,分布式事务中的各个操作之间并不需要严格地同时成功或失败,只要能保证一旦第一步成功,后续步骤最终一定会执行成功即可。例如,用户下单成功后,积分最终一定会增加;支付成功后,订单状态最终一定会更新。
这类场景可以使用基于可靠消息的最终一致性方案:上游服务完成本地事务后,发送一条消息;下游服务消费这条消息,执行自己的操作。只要保证消息一定会被发出,并且一定会被成功处理,系统最终就会达到一致。
本地事务与消息发送的原子性
这一方案的关键难点在于:如何保证本地事务的提交与消息的发送是原子的?
- 如果先提交本地事务,再发送消息,可能在两者之间发生崩溃,导致事务提交了但消息没有发出;
- 如果先发送消息,再提交本地事务,可能出现消息已经发出、本地事务却失败回滚的情况。
事务发件箱模式是解决这一问题的常用方法:
- 在业务数据库中创建一张消息表;
- 在同一个本地事务中,既执行业务操作,又将要发送的消息写入消息表;
- 由一个独立的进程,持续读取消息表中尚未发送的消息,将其发送到消息队列,发送成功后更新消息的状态。
由于业务操作与消息记录在同一个数据库事务中,两者要么同时成功,要么同时失败。消息表中的消息,至少会被发送一次。
另一种方式是变更数据捕获:直接读取数据库的事务日志,将数据变更转化为消息。这种方式对业务代码没有侵入。
还有一些消息系统提供了事务消息功能:先发送一条对消费者不可见的"半消息",然后执行本地事务,再根据结果确认或撤销这条消息。如果消息系统长时间没有收到确认,会主动回调生产者,查询本地事务的执行结果。
下游的可靠处理
消息发出后,还需要保证下游能够可靠地处理:
- 至少一次投递:消息队列保证消息至少会被投递一次,不会丢失;
- 幂等消费:由于消息可能被重复投递,下游服务必须能够正确处理重复的消息,例如通过消息的唯一标识去重;
- 失败重试:如果下游处理失败,消息会被重新投递,直到处理成功。对于持续失败的消息,需要转入死信队列,并触发告警,由人工介入。
适用场景与局限
基于可靠消息的方案实现相对简单,性能好,各服务之间高度解耦,在互联网系统中应用非常广泛。
但它有一个重要的局限:它只适用于下游操作最终一定能够成功的场景。如果下游操作可能因为业务原因而失败,例如库存不足、余额不足,那么消息方案无法让上游回滚已经提交的操作。在这种情况下,需要结合 Saga 等方案,通过补偿来处理业务上的失败。
最大努力通知
在与外部系统交互的场景中,还有一种更宽松的方案,称为最大努力通知:系统完成操作后,尝试通知对方,如果通知失败就按一定的策略重试,但重试次数有上限。同时,提供一个查询接口,让对方在没有收到通知时可以主动查询结果。
这种方案常见于支付回调等场景:支付平台完成支付后,会多次尝试通知商户的系统;商户也可以主动查询支付结果。它不保证通知一定送达,而是通过"通知加查询"的组合,让双方最终能够对齐状态。
八、方案对比
| 方案 | 一致性 | 隔离性 | 性能 | 实现复杂度 | 业务侵入 | 适用场景 |
|---|---|---|---|---|---|---|
| 两阶段提交 / XA | 强一致 | 有 | 低,锁持有时间长 | 低,由框架实现 | 低 | 少量参与者、低并发、强一致需求 |
| 基于共识的分布式数据库 | 强一致 | 有 | 中等 | 低,由数据库实现 | 低 | 数据存储在同一分布式数据库中 |
| Saga | 最终一致 | 无 | 高 | 中等 | 中等,需设计补偿 | 长流程、多服务、可容忍中间态 |
| TCC | 最终一致 | 部分,通过资源预留 | 较高 | 高 | 高,需三个接口和预留模型 | 资金等对一致性要求较高的短事务 |
| 可靠消息 | 最终一致 | 无 | 高 | 中等 | 低 | 下游最终一定能成功的异步场景 |
| 最大努力通知 | 弱,依赖查询对账 | 无 | 高 | 低 | 低 | 与外部系统的交互 |
九、工程实践中的原则
首先考虑能否避免分布式事务
分布式事务的每一种方案都有其代价。在引入分布式事务之前,首先应该思考:能否通过调整设计,避免跨服务的事务?
- 重新审视服务边界:如果两个服务之间频繁需要事务性的协调,可能说明它们的边界划分不合理,这些数据或许应该属于同一个服务;
- 将强一致性需求收敛到单个服务内:识别业务中真正需要强一致性的核心数据,尽量让它们由同一个服务、同一个数据库管理,使用本地事务;
- 接受最终一致性:许多业务场景并不需要强一致性。用户下单后积分晚几秒到账,通常不会造成任何问题。与业务方沟通,明确真正的一致性需求,往往能发现许多可以放宽的地方。
幂等是基础
无论采用哪种方案,由于网络的不确定性,请求都可能被重复执行。幂等性是所有分布式事务方案的基础:
- 为每个业务操作分配一个唯一的请求标识,并在处理时进行去重;
- 尽量使用天然幂等的操作,例如设置某个状态,而不是递增某个值;
- 利用数据库的唯一约束防止重复插入;
- 使用状态机限制状态的流转,只允许合法的状态转换。
设计清晰的状态机
分布式事务中的每个业务实体,都应当有一个清晰的状态机,明确定义所有可能的状态和合法的转换。例如,订单可能处于待支付、支付中、已支付、已取消、已退款等状态,每次更新都检查当前状态是否允许转换到目标状态。
清晰的状态机使得系统在面对重复请求、乱序消息、补偿操作时,能够做出正确的判断,也使得排查问题时能够清楚地了解每个实体当前处于什么阶段。
超时与重试
分布式事务中的每一次远程调用都必须设置合理的超时时间,避免因为某个参与者无响应而无限期等待。
超时之后的处理需要格外小心:超时并不意味着操作失败,操作可能已经成功,只是响应没有返回。因此,超时后的重试必须基于幂等的接口,或者先查询操作的实际结果,再决定下一步的动作。
对账与人工兜底
无论设计得多么完善,分布式系统中总会出现一些意料之外的情况:程序缺陷、长时间的网络分区、依赖系统的数据错误。因此,需要有对账机制作为最后的保障:
- 定期比对各个系统之间的数据,例如订单系统中的已支付订单与支付系统中的支付记录,找出不一致的地方;
- 对于自动重试无法解决的问题,例如进入死信队列的消息、长时间处于中间状态的事务,及时触发告警;
- 提供人工处理的工具和流程,让运营或开发人员能够查看问题、修复数据。
在资金等关键领域,对账是一项不可或缺的常规工作,它是保证数据最终正确的最后一道防线。
可观测性
分布式事务跨越多个服务,一旦出现问题,排查的难度远高于单体应用。需要:
- 在整个事务的所有步骤中传递全局的事务标识和追踪标识,以便将分散在各个服务中的日志关联起来;
- 记录每个步骤的执行状态、耗时和结果;
- 监控处于中间状态的事务数量和持续时间、补偿操作的执行次数、消息的积压和失败情况。
结语
分布式事务要解决的问题,是在没有共享内存、没有全局时钟、网络不可靠、节点随时可能故障的环境中,让多个独立的参与者对一组操作的结果达成一致。两将军问题告诉我们,这在理论上无法做到绝对完美;CAP 定理告诉我们,必须在一致性与可用性之间做出选择。
两阶段提交以协调和锁定为代价,提供了强一致性,但面临阻塞和性能的问题;基于共识的分布式数据库通过复制协调者的状态,在很大程度上解决了阻塞问题,将强一致的事务封装在数据库内部。Saga、TCC 和可靠消息等方案,则放弃了全局的原子性和隔离性,在业务层面通过补偿、资源预留和可靠的异步通知,实现最终一致性,换取更高的性能和更松的耦合。
没有一种方案适用于所有场景。在实际系统中,首先应当尽量通过合理的服务划分,避免不必要的分布式事务;对于确实需要的场景,根据业务对一致性的真实需求、操作是否可补偿、执行时间的长短、对性能的要求,选择合适的方案;同时以幂等设计、清晰的状态机、完善的超时重试、定期的对账和充分的可观测性作为保障。
分布式事务的设计,归根结底是对业务语义的深入理解与对技术约束的清醒认识之间的平衡。
- 点赞
- 收藏
- 关注作者
评论(0)