网络是不稳定的,延迟是不可预测的,带宽是有限的,拓扑是动态的,一切都会失败。
事务(transaction)最早指本地事务,也就是对数据库的多个读写操作捆绑为一个操作单元。该操作单元作为一个执行整体要么全部成功,要么全部中止,从而保证某些极端情况下(进程崩溃、网络中断、节点宕机)数据一致性。随着分布式系统的广泛应用,所有需要保证数据一致性的应用场景,包括但不限于缓存、消息队列、存储、微服务架构之下的数据一致性保证等等,都需要用到事务的机制进行处理。
在单体系统时代,如何实现事务仅仅是个编码问题。但在分布式系统时代,事务操作跨越了多个节点,保证多个节点间的数据一致性便成了架构设计问题。2000 年以前,人们曾经希望基于“两阶段提交”(2PC, Two-Phase Commit Protocol)[2]的事务机制,也能在现代分布式系统中良好运行,但这个愿望被 CAP 定理粉碎。本章,我们深入理解分布式环境下数据一致性和可用性的矛盾,掌握各个分布式事务模型原理。

数据一致性
引入事务的目的,是为了保证数据的“一致性”(Consistency)。
这里的一致性指的是,对数据有特定的预期状态,任何数据更改操作必须满足这些状态约束(或者恒等条件)。例如,处理一个转账业务,其中 A 向 B 转账 ¥50 元。无论是转账前、转账过程中、还是转账完成后,A 和 B 的总金额要求始终保持不变。这意味着数据在整个过程中都保持一致,符合业务约束。
根据数据库的经典理论,想要达成数据的一致性,需要 3 个方面的努力。
- 原子性(Atomic):“原子”通常指不可分解为更小粒度的东西。这里原子性描述的是,客户端发起一个请求(请求包含多个操作)在异常情况下的行为。例如,只完成了一部分写入操作,系统出现故障了(进程崩溃、网络中断、节点宕机)。把多个操作纳入到一个原子事务,万一出现上述故障导致无法完成最终提交时,则中止事务,丢弃或者撤销那些局部修改。
- 隔离性(Isolation): 同时运行的事务不应互相干扰。例如,当一个事务执行多次写入操作时,其他事务应仅能观察到该事务的最终完成结果,而非中间状态。隔离性旨在防止多个事务交叉操作导致的数据不一致问题。
- 持久性(Durability):事务处理完成后,对数据的修改应当是永久性的,即使系统发生故障也不会丢失。在单节点数据库中,持久性意味着数据已写入存储设备(如硬盘或 SSD)。而在分布式数据库中,持久性要求数据成功复制到多个节点。为确保持久性,数据库必须在完成数据复制后,才能确认事务已成功提交。
这也就是常说的事务的“ACID 特性”。值得一提的是,对于一致性而言,更多的是指数据在应用层的外部表现。应用程序借助数据库提供的原子性、隔离性和持久性,来实现一致性目标。也就是说,A、I、D 是手段,C 是 3 者协作的目标,弄到一块完全是为了读起来更顺口。
当事务仅涉及本地操作时,一致性通过代码实现起来水到渠成。但倘若事务的操作对象扩展到外部系统,例如跨越多个微服务、数据源甚至数据中心时,再依赖传统的 A、I、D 手段来解决一致性问题变得非常困难。但是,一致性又是在分布式系统中不可回避且必须解决的核心问题。这种情况下,我们就需要转变观念,将一致性视为一个多维度的问题,而非简单的“是或否”的二元问题。根据不同场景的需求,对一致性的强度进行分级,在确保代价可承受的前提下,尽可能保障系统的一致性。
一致性的强弱程度直接影响系统设计权衡。由此,事务从一个具体操作层面的“编程问题”转变成一个需要全局视角的“架构问题”。在探索这些架构设计的过程中,出现了许多思路和理论,其中最著名的便是一致性与可用性之间的权衡 —— 即 CAP 定理。
一致性与可用性的权衡
CAP 是一致性与可用性权衡的理论,是理解分布式系统的起点。
1999 年,美国工程院院士 Eric A.Brewer 发表了论文《Harvest, Yield, and Scalable Tolerant Systems》[1] ,首次提出了“CAP 原理”(CAP Principle)。不过,彼时的 CAP 仅是一种猜想,尚未得到理论上的证明。2002 年,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 用严谨的数学推理证明了 CAP 的正确性。此后,CAP 从原理转变成定理,在分布式系统领域产生了深远的影响。

CAP 定理描述的是一个分布式系统中,涉及共享数据问题时,以下三个特性最多只能满足两个。
- 一致性(Consistency):意味着数据在任何时刻、任何节点上看到的都是符合预期的。为了确保定义的严谨性,学术研究中通常将一致性定义为“强一致性”(Strong Consistency),也称为“线性一致性”(Linearizability)。
- 可用性(Availability):意味着即使部分节点故障,系统仍然能够接受和处理请求,并在有限时间内返回结果。也就是说,系统不能出现无限期的等待或超时。
- 分区容错性(Partition tolerance):当部分节点由于网络故障或通信中断而无法相互联系,形成“网络分区”时,系统仍能够继续正确地提供服务。
由于 CAP 定理已有严格的证明,我们不再探讨为何 CAP 不可兼得,直接分析舍弃 C、A、P 时所带来的不同影响。
- 放弃分区容忍性(CA without P):意味着我们将假设节点之间通信永远可靠。永远可靠的通信在分布式系统中必定不成立,只要依赖网络共享数据,分区现象就不可避免地存在。如果没有 P(分区容错性),也就谈不上是真正的分布式系统。
- 放弃可用性(CP without A):意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制延长。在现实中,选择放弃可用性系统(又称为 CP 系统)适用于对数据一致性有严格要求的场景,如金融系统、库存管理系统等。这些应用场景中,数据的一致性和准确性通常比系统的可用性更为重要。
- 放弃一致性(AP without C):意味着在网络分区发生时,节点之间的数据可能会出现不一致。这种情况下,系统会优先保证可用性,而不是一致性。选择放弃一致性系统(又称 AP 系统)已经成为设计分布式系统的主流选择,因为分区容错性(P)是分布式网络的固有属性,不可避免;而可用性(A)通常是建设分布式系统的目标。如果系统在节点数量增加时可用性降低,则其分布式设计的价值也会受到质疑。除了像银行和证券这样的金融交易服务,这些场景中数据一致性至关重要,通常需要保证一致性而可能接受部分中断之外,大多数系统更倾向于在节点增多时保持高可用性,而不是牺牲可用性以维持一致性。
额外知识
对于分布式系统而言,必须实现分区容错性(P)。因此,CAP 定理实际上要求在可用性(A)和一致性(C)之间选择,即在 AP 和 CP 之间权衡取舍。
从上述分析可以看出,原本事务的主要目的是保证“一致性”,但在分布式环境中,一致性往往不得不成为牺牲的属性,AP 类型的系统反而成为了分布式系统的主流。
但无论如何,我们设计系统终究还是要确保操作结果至少在最终交付的时刻是正确的,这个意思是允许数据中间不一致,但应该在输出时被修正过来。为此,工程师们又重新给一致性下了定义,将 CAP、ACID 中讨论的一致性(C)称为“强一致性”,而把牺牲了 C 的 AP 系统但又要尽可能获得正确结果的行为称为追求“弱一致性”。不过,若只是单纯地谈论“弱一致性”,通常意味着不保证一致性。在弱一致性中,工程师们进一步总结出了一种较强的特例,称为“最终一致性”(Eventual Consistency),它由 eBay 的系统架构师 Dan Pritchett 在 BASE 理论中提出。
额外知识
ACID 在英文中有的“酸”的含义,强调强一致性。BASE 在英文中有“碱”的含义,强调放弃强一致性保证可用性。酸 vs 碱衍生出 AP 型可用性架构和 CP 型强一致性架构。所以,CAP 理论又被戏称度量分布式系统的“ph试纸”。
分布式事务模型
既然一致性被重新定义了,事务的概念自然也被拓展了。人们把符合 ACID 特性的事务称为“刚性事务”,把后面将要介绍的可靠事件队列、TCC、Saga 实现的事务,统称为“柔性事务”。
可靠事件队列
2008 年,eBay 架构师 Dan Pritchett 在 ACM 发表了论文《Base: An Acid Alternative》[1],在文中,作者总结了基于实践经验的一种独立于 ACID 的数据一致性技术方案,利用消息队列和幂等机制实现数据一致性,并首次提出了“最终一致性”这一概念。
从论文标题可以看出,最终一致性的概念与 ACID 强一致性对立。因为 ACID 在英文中有的“酸”的含义,这一事务模型的名字刻意拼凑成 BASE(BASE 在英文中有碱的含义)。有酸 vs 碱这个浑然天成的梗加成,《Base: An Acid Alternative》论文被广泛传播,BASE 理论和最终一致性的概念也被大家熟悉。
BASE 是“Basically Available”、“Soft State”和“Eventually Consistent”的缩写。
- 基本可用(Basically Available):系统保证在大多数情况下能够提供服务,即使某些节点出现故障时,仍尽可能保持可用性。这意味着系统优先保障可用性,而非一致性。
- 柔性状态(Soft state):系统状态允许在一段时间内处于不一致状态。与 ACID 强一致性的要求不同,BASE 允许系统在更新过程处于“柔性”状态,即数据在某些节点上可以暂时不一致。
- 最终一致性(Eventually consistent):最终一致性强调,即使在网络分区或系统故障的情况下,在经过足够的时间和多次数据同步操作后,所有节点的数据一定会一致。
总结 BASE 理论是对 CAP 定理中 AP(可用性和分区容错性)方案的进一步发展,强调即使无法实现强一致性,分布式系统也可以通过适当的机制最终达到一致性。适当的机制可概括为基于可靠事件队列的事件驱动模式。接下来,以一个具体的例子帮助你理解“可靠事件队列”的具体做法。
假设有一个电商系统,下单操作依赖于三个服务:支付服务(进行银行扣款)、库存服务(扣减商品库存)和积分服务(为用户增加积分)。下单过程中,我们优先处理最核心、风险最高的服务,按照支付扣款、仓库出库以及为用户增加积分的顺序执行。下单的整个流程如图 5-2 所示。

首先,用户向商店发送了一个交易请求,如购买一件价值 ¥100 的商品。
接着,支付服务创建一个本地扣款事务。如果扣款事务执行成功,系统将在消息队列中新增一条待处理消息。消息的大致结构如下:
struct Message { 事务 ID; 扣款 ¥100(状态:已完成); 仓库出库(状态:待处理); 赠送积分(状态:待处理) }
系统中有一个持续运行的服务,定期轮询消息队列,检查是否存在待处理的消息。如果发现待处理消息,它将通知库存服务和积分服务进行相应的处理。
此时,会出现以下几种情况:
- 仓库服务和积分服务顺利完成任务:这两个服务成功执行了出库和积分操作,并将结果反馈给支付服务。随后,支付服务将消息状态更新为“已完成”,整个事务顺利完成,最终实现一致性。
- 网络问题导致消息未送达:如果仓库服务或积分服务因网络问题未收到支付服务的消息,此时,支付服务中的消息状态将保持为“待处理”。消息服务会在每次轮询时继续向未响应的服务节点重复发送消息,直到通信恢复正常。为了确保出库和积分操作仅被执行一次,所有接收消息的服务必须具备幂等性(有关幂等性的设计,详见5.4节)。
- 服务无法完成操作:如果仓库服务或积分服务由于某种原因无法完成操作(例如,仓库库存不足),消息服务将持续发送消息,直到操作成功(如库存补充)或通过人工干预终止。
由此可见,在可靠消息队列方案中,一旦第一步扣款成功,就不再考虑失败回滚的情况,后面只有成功一条路可选。
这种依赖持续重试来确保可靠性的解决方案在计算机领域被广泛应用,它还有专有的名称 —— “最大努力交付”(Best-Effort Delivery)。因此,可靠事件队列也称为“最大努力一次提交”(Best-Effort 1PC)机制,也就是将最容易出错的业务通过本地事务完成后,借助不断重试的机制促使同一个事务中其他操作也顺利完成。
TCC
TCC(Try、Confirm、Cancel)事务模型源自 Pat Helland 在论文《Life beyond Distributed Transactions: an Apostate’s Opinion》[1] 中提出的概念。TCC 引入了一种新的事务模型,允许业务层自定义事务,并根据业务需求控制锁的粒度,从而解决了复杂业务中跨表、跨库等大粒度资源锁定的问题。
如同 TCC 事务模型的名字,它由三个阶段组成:
- Try 阶段:该阶段的主要任务是预留资源或执行初步操作,但不提交事务。Try 阶段确保所有相关操作可以成功执行且没有资源冲突。例如,在预订系统中,这一阶段可能包括检查商品库存并暂时锁定商品。
- Confirm 阶段:如果 Try 阶段成功,系统进入 Confirm 阶段。在此阶段,系统会提交所有操作,确保事务最终生效。由于 Try 阶段已保证资源的可用性和一致性,Confirm 阶段的执行是无条件的,不会发生失败。
- Cancel 阶段:如果 Try 阶段失败,或需要回滚事务,系统进入 Cancel 阶段。此时,系统会撤销 Try 阶段中的所有预留操作并释放资源。Cancel 阶段确保事务无法完成时,系统能够恢复最初的状态。
以一个具体的例子帮助你理解 TCC 事务模型。我们沿用 5.3.1 节下单的案例,稍微简化下单的逻辑,去除积分服务(不重要),只保留支付和仓库服务。

首先,用户向商店发送购买某商品的交易请求,金额为 ¥100。请看下面的过程:
- Try 阶段:创建事务,生成事务 ID,并记录在事务日志中,进入 Try 阶段。该阶段主要预留业务资源,以及做一些初始化工作:
- 与支付服务通信,确认用户是否有足够的余额。若余额足够,将用户的 100 元设置为冻结状态,并通知进行 Confirm 阶段;如果不可行,通知进入 Cancel 阶段。
- 与仓库服务通信,确认商品的库存是否满足。若库存充足,将仓库中该商品的一条库存设置为冻结状态,并通知进行 Confirm 阶段;如果不可行,通知进入 Cancel 阶段。
- Confirm 阶段:如果所有服务反馈业务可行,将事务日志状态更新为 Confirm,进入 Confirm 阶段。
- 支付服务:扣除冻结的 100 元。
- 仓库服务:标记冻结的库存为出库状态,并扣减库存。
- Cancel 阶段:如果 Try 阶段任何一方反馈失败,将事务日志状态更新为 Cancel,进入 Cancel 阶段:
- 支付服务:释放被冻结的 100 元。
- 仓库服务:释放被冻结的库存。
值得注意的是,按照 TCC 事务模型的规定,Confirm 和 Cancel 阶段只返回成功,不会返回失败。如果 Try 阶段之后,出现网络问题或者服务器宕机,那么事务管理器要不断重试 Confirm 阶段或者 Cancel 阶段,直至完成整个事务流程。
由上述操作过程可见,TCC 事务模型其实有点类似两阶段提交(2PC)的准备阶段和提交阶段,但 TCC 位于业务层面,而不是数据库层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。
不过,感知各个阶段的执行情况以及推进执行下一个阶段需要编写大量的逻辑代码,不仅仅是调用一下 Confirm/Cancel 接口那么简单。通常的情况,我们没必要裸编码实现 TCC 事务模型,而是利用分布式事务中间件(如 Seata、ByteTCC)降低编码工作,提升开发效率。
Saga
Saga 源于1987年普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 在 ACM 发表的论文《SAGAS》[1]。该论文提出了一种改善“长时间事务”(Long Lived Transaction)效率的方法,核心思路是将大事务拆分为多个可并行执行的子事务,并在每个子事务中引入补偿操作。补偿(也称为逆向恢复)是在分布式事务发生异常时,通过一系列操作将事务状态回滚到之前的状态,从而避免不一致的情况发生。
Saga 事务模型由两部分组成:
- 一部分是将大事务 T 拆分成若干小事务,命名为 T1,T2,Tn,每个子事务都具备原子性。如果分布式事务 T 能够正常提交,那么它对数据的影响应该与连续按顺序成功提交子事务 Ti 等价。
- 另一部分是为每个子事务设计对应的补偿动作,命名为 C1,C2,Cn。Ti 与 Ci 满足以下条件:
- Ti 与 Ci 具备幂等性。
- Ti 与 Ci 满足交换律,即无论先执行 Ti 还是先执行 Ci,其结果都是一样的。
- Ci 必须保证成功提交,即不考虑 Ci 的失败回滚情况。如果出现失败,则持续重试直至成功或者被人工介入为止。
如果 T1 到 Tn 均执行成功,那么整个事务顺利完成,否则根据下面两种机制之一进行事务恢复:
- 正向操作(Forward Recovery):如果 Ti 提交失败,则一直对 Ti 进行重试,直至成功为止(使用最大努力交付机制)。这种恢复方式不需要进行补偿,适用于事务最终都要执行成功的情况。如订单服务中银行已经扣款,那么就一定要发货。
- 逆向恢复(Backward Recovery):如果 Ti 提交失败,则执行对应的补偿 Ci,直至恢复到 Ti 之前的状态,这里要求 Ci 必须成功(使用最大努力交付机制)。
图 Saga 事务模型

Saga 非常适合处理流程较长、且需要保证事务最终一致性的业务场景。例如,在一个旅游预订平台中,用户可能同时预订机票、酒店和租车服务,这些服务可能由不同的微服务或第三方供应商提供。在这种场景下,Saga 事务模型允许系统逐步执行每个操作,并在任一步骤失败时有序地执行补偿操作,从而确保系统的一致性并提升用户体验。
与 TCC 相比,Saga 通常采用事件驱动设计,即每个服务都是异步执行的,无需设计资源的冻结状态或处理撤销冻结的操作。但缺点是不具备隔离性,多个 Saga 小事务操作同一数据源时,无法保证操作的原子性,可能出现数据被覆盖的情况。
最后,尽管补偿操作较易实现,但确保正向操作与补偿操作的严格执行仍需要大量精力。因此,Saga 事务通常不通过裸编码实现,而是在事务中间件的支持下完成。前面提到的 Seata 中间件也支持 Saga 事务模型。
服务幂等性设计
幂等性是一个数学概念,后被引入计算机领域,用于描述某个操作可以安全地重试,并且无论执行多少次,结果始终保持一致。
在前文中提到的柔性事务通常基于“最大努力交付”机制,这意味着在网络故障、节点宕机或进程崩溃时,系统会通过重复请求来实现容错。因此,如果某些关键服务不具备幂等性,重复请求可能会导致数据不一致或其他问题。例如,重复请求一个不具备幂等性的退款接口,可能会导致重复退款。
接下来,笔者将介绍两种实现服务幂等性的方法,供读者参考。
全局唯一 ID 方案
全局唯一 ID 方案的核心思想是为每个操作生成一个独一无二的标识符,用以判断该操作是否已经执行过,避免重复执行。
全局唯一 ID 方案的操作步骤如下:
- 生成唯一 ID:每次执行操作前,根据业务操作生成一个全局唯一ID,这个 ID 可以利用 UUID、雪花算法(Snowflake) 、Uidgenerator 或 Leaf 等算法生成。
- 附加到请求: 将生成的唯一 ID 附加到请求中,作为请求的一个参数、HTTP 头或请求体的一部分。
- 处理请求: 服务器端接收到请求后,首先检查唯一 ID:
- 如果 ID 已存在:说明该请求已经被处理过,服务器直接返回之前的响应结果,避免重复处理。
- 如果 ID 不存在:执行请求的操作,并将操作结果和该 ID 存储在数据存储中(如数据库、缓存等),以供后续请求检查。
值得一提的是,snowflake 算法取自世界上没有两片相同的雪花之意。使用分布式部署的 Snowflake 每秒可生成数百万个唯一且递增的 ID,广泛应用于需要生成唯一标识符的各类场景。
乐观锁方案
接下来,再看数据库中关于修改数据的操作。
假设有一个账户表 accounts,包含字段 id(账户ID)和 balance(账户余额)。现在要给账户 ID 为 1 的账户增加余额
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
如果这个SQL语句执行一次,那么账户的余额会增加 100。但由于某些原因(比如网络重试或者程序逻辑错误),这个 SQL 语句被执行了两次,账户的余额将会增加 200,而不是预期的 100。
每次执行这个语句都会对账户余额产生不同的影响,属于典型的非幂等性操作。对于此类的非幂等性操作,我们来看使用乐观锁(Optimistic Locking)如何解决。
乐观锁基本思想是,假设并发操作发生冲突的概率较低,允许多个事务或线程在不加锁的情况下同时读取数据,但在写入数据时再进行冲突检测。如果在写入前检测到数据已被其他事务修改,则放弃当前操作,避免数据不一致的情况。
结合上述增加余额的 SQL,请看下面具体的操作:
- 增加版本号字段:在涉及更新的数据表中增加一个 version 字段,更新数据时,版本号随之增加。
- 更新时检查版本号:执行更新操作时,通过 WHERE 子句检查当前版本号是否与读取时的版本号一致,如果一致则执行更新,并更新版本号。
- 重试机制:如果更新操作失败,意味着数据库内的数据已经被修改。此时,业务层面请求最新的数据,更新本地 version 并发起重试,直至成功或达到最大重试次数。
UPDATE accounts SET balance = balance + ?, version = version + 1 WHERE id = ? AND version = ?;
上面乐观锁的操作模式,是一种典型的 CAS(Compare And Swap | Compare And Set,比较并交换)操作。
CAS 有时也被称为“轻量级事务”。由于乐观锁不需要在读取和写入时持有锁,在并发冲突不频繁的情况下(也就是读多写少的场景),使用乐观锁除保证一致性之外,还可提供更好的并发性能。
小结
通过本章的内容,你是否已经领会到“分布式事务的思想”?无论是 BASE、TCC 还是 SAGA,它们的核心想想是将“事务逻辑”从数据库资源层转移到业务层,将事务拆分为多个“子事务”,减少资源锁定,从而提高系统可用性。
分布式事务能够保证数据最终达到一致性,但这种保证非常脆弱,它无法确定何时能够达到一致性。在一致性达成之前,读请求可能返回任意值或失败,这对业务工程师来说是一个重大挑战。