分布式事务
回顾分布式事务
上篇内容我们说到了分布式事务的基本内容,讲到了分布式事务的实现主要有事务协调以及最终一致性两件事情来完成整个逻辑。
那么上个文章我们说过了 2PC、3PC、XA 三种协调事务的协议,这次我们来说事务协调处理完成后,对于我们事务完成的最终一致性如何保证的问题。今天主要说到的是三种最终一致性的方案:本地消息表、TCC、SAGA。
为什么要有最终一致性?
2PC(两阶段提交)和 3PC(三阶段提交)协议虽然旨在提高分布式事务的可靠性,但它们并不能完全消除故障的可能性。2PC可能因为协调器的单点故障而导致事务挂起,而3PC尽管通过引入额外的预提交阶段来减少这种风险,但在网络分区或多个节点同时故障的情况下仍可能遇到问题。XA标准提供了一种分布式事务处理的框架,但实现复杂,且在出现网络问题或参与者故障时也可能导致数据不一致。因此,这些传统协议虽然减少了某些类型故障的影响,但在高可用性、可伸缩性和网络稳定性要求的现代分布式系统中,它们可能不足以完全保证事务的一致性和可靠性。
在分布式事务中,最终一致性是关键,因为它确保在系统的不同部分最终会达到一致的状态,即使在面临网络延迟、系统故障或数据复制延迟等分布式环境固有的问题时。这对于保持数据的整体一致性和可靠性至关重要,特别是在涉及多个服务或数据库的复杂系统中。没有最终一致性,系统的不同部分可能会长时间保持不一致状态,导致数据丢失、应用逻辑错误或用户体验差等问题。因此,设计一个能够确保最终一致性的分布式事务解决方案是维持系统整体健康和功能性的基础。
CAP 与 ACID 中的一致性
了解 CAP 定理和 ACID 属性对学习分布式系统非常有帮助,因为它们为理解分布式系统的基本权衡和设计原则提供了框架。CAP 定理阐明了在分布式系统设计中一致性、可用性和分区容错之间的权衡。ACID 属性定义了数据库事务的四个关键特性:原子性、一致性、隔离性和持久性,这些都是分布式设计的基石。通过先学习这些基本概念,可以更好地理解分布式系统的复杂性和设计挑战,为深入学习打下坚实的基础。
什么是 CAP
CAP 定理(Consistency、Availability、Partition Tolerance Theorem),也称为 Brewer 定理,起源于在 2000 年 7 月,是加州大学伯克利分校的 Eric Brewer 教授于“ACM 分布式计算原理研讨会(PODC)”上提出的一个猜想。
CAP 定理图
CAP 分别代表的含义:
一致性(Consistency): 代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,以后讨论分布式共识算法时,我们还会再提到一致性,那种面向副本复制的一致性与这里面向数据库状态的一致性严格来说并不完全等同,具体差别我们将在后续分布式共识算法中再作探讨。
可用性(Availability): 代表系统不间断地提供服务的能力,理解可用性要先理解与其密切相关两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如 99.9999%可用,即代表平均年故障修复时间为 32 秒。
分区容忍性(Partition Tolerance): 代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。
CAP 的场景说明
下面我们根据一个简单的例子说明为什么 CAP 定理中一般在分布式系统中只能最多实现两个。
想象一个简单的游戏,比如玩具店,有两个门:一个在前面,一个在后面。现在,玩具店决定在每个门安装一个计数器来记录进出的顾客数量。为了保持这两个计数器同步,每当一个顾客进入或离开时,他们需要更新两个计数器。这就是一致性(C)。如果玩具店非常忙,更新两个计数器可能会导致顾客等待,这就牺牲了可用性(A)。如果店里的网络出问题,连接前门和后门的系统可能无法通信,这就是分区容错性(P)。在这种情况下,玩具店必须决定是确保两个计数器完全一致(牺牲P),还是让顾客不用等待(牺牲C),因为同时做到这三点是很困难的。
CAP 相关的影响:
放弃 A(可用性): 如选择 CP 模型,系统在网络分区时优先保持数据一致性,可能导致某些部分暂时不可用。例如,传统的关系型数据库和分布式数据库如 Google Spanner 强调一致性和分区容忍,牺牲了即时的全局可用性。
放弃 C(一致性): 如选择 AP 模型,系统保证可用性和分区容忍,但允许数据暂时不一致。最终一致性模型如 DynamoDB 和 Cassandra 在网络分区或其他故障时仍可接受读写,但数据可能不立即同步。
放弃 P(分区容忍性): 实际上很难完全放弃,因为网络分区在分布式系统中不可避免。选择 CA 模型的系统在无分区情况下保证一致性和可用性,但在网络分区时可能无法维持这两者。大多数传统单体数据库系统如 MySQL 在没有网络分隔的单点部署中运行良好,但不适用于真正的分布式场景。
什么是 ACID
ACID 原则指数据库事务的四个关键属性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
原子性:保证事务中的操作要么全部完成要么全部失败;
一致性:确保事务执行结果使数据库状态保持一致;
隔离性:保证并发执行事务时,事务之间不会互相影响;
持久性:确保一旦事务提交,其结果就永久保存在数据库中。
例如,在银行转账场景中,ACID 原则确保从一个账户扣款和向另一个账户存款要么同时成功要么同时失败,且转账过程中其他事务不能干扰这个操作,最后转账结果会被永久记录。在计算机层面,关系型数据库如 PostgreSQL、MySQL 通过日志、锁机制等技术实现 ACID 特性,保障数据的安全性和一致性。
引出的结论
读到这里,不知道你是否对 选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择 这个结论感到一丝无奈,本章讨论的话题 “事务” 原本的目的就是获得 “一致性”,而在分布式环境中,“一致性”却不得不成为通常被牺牲、被放弃的那一项属性。但无论如何,我们建设信息系统,终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。为此,人们又重新给一致性下了定义,将前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为 “线性一致性”(Linearizability,通常是在讨论共识算法的场景中),而把牺牲了 C 的 AP 系统又要尽可能获得正确的结果的行为称为追求“弱一致性”。不过,如果单纯只说“弱一致性”那其实就是“不保证一致性”的意思。语言这东西真的是博大精深。在弱一致性里,人们又总结出了一种稍微强一点的特例,也是我们要说的主题,它被称为 最终一致性”(Eventual Consistency),
最终一致性: 如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。
我们讨论的主题“分布式事务”中,同样也不得不从之前三种事务模式追求的强一致性,降低为追求获得“最终一致性”。由于一致性的定义变动,“事务”一词的含义其实也同样被拓展了,人们把使用 ACID 的事务称为“刚性事务”,而下面将要介绍几种分布式事务的常见做法统称为“柔性事务”。也可以说是最终一致性保证的做饭。
最终一致性方案
最终一致性的概念是 eBay 的系统架构师 Dan Pritchett 在 2008 年在 ACM 发表的论文《Base: An Acid Alternative》中提出的,该论文总结了一种独立于 ACID 获得的强一致性之外的、使用 BASE 来达成一致性目的的途径。BASE 分别是基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent) 的缩写。BASE 这提法简直是把数据库科学家酷爱凑缩写的恶趣味发挥到淋漓尽致,不过有 ACID vs BASE(酸 vs 碱)这个朗朗上口的梗,该论文的影响力的确传播得足够快。在这里就不多谈 BASE 中的概念问题了,虽然调侃它是恶趣味,但这篇论文本身作为最终一致性的概念起源,并系统性地总结了一种针对分布式事务的技术手段,是非常有价值的。
本地消息表、可靠事件队列(最大努力交付)
最大努力交付(Best Effort Delivery)是分布式事务中一种常见的最终一致性实现方法,它通过本地事务和可靠事件队列相结合的方式来确保操作的最终一致性。在这种模式下,系统尽最大努力确保消息或事件被送达和处理,但不保证100%的可靠性。
例如,在一个电商系统中,用户下单操作完成后,系统会将订单事件发送到事件队列。即使在面对网络问题或服务故障时,系统也会尝试重新发送消息,以确保订单处理逻辑最终被执行。
可靠消息队列: 在分布式事务中,使用可靠事件队列可以因为其重试机制来确保消息最终送达。只要消息提交不成功,那么就可以一直重试,直到消息成功。
本地消息表: 本地消息表的使用则是因为它避免了网络分区问题,提高了消息处理的可靠性。这种方法通过在本地事务中记录事件,并利用事件队列异步传递这些事件到其他服务,结合重试机制保证了即使在服务暂时不可用或网络问题的情况下,这些事件最终也能被正确处理,从而实现系统的最终一致性。
相信有一定基础的计算机同学也很好理解,这里就不再细说消息队列和本地消息表实现的最大努力交付了。
TCC
TCC 是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写,是由数据库专家 Pat Helland 在 2007 年撰写的论文《Life beyond Distributed Transactions: An Apostate’s Opinion》中提出。
前面介绍的可靠消息队列虽然能保证最终的结果是相对可靠的,过程也足够简单(相对于 TCC 来说),但整个过程完全没有任何隔离性可言,有一些业务中隔离性是无关紧要的,但有一些业务中缺乏隔离性就会带来许多麻烦。
例如在本章的场景事例中,缺乏隔离性会带来的一个显而易见的问题便是“超售”:完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,譬如,以上场景就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点,这部分属于数据库本地事务方面的知识。如果业务需要隔离,那架构师通常就应该重点考虑 TCC 方案,该方案天生适合用于需要强隔离性的分布式事务中。
TCC 实现
在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。
Try: 尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
Confirm: 确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
Cancel: 取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。
这里我们按照凤凰架构书里的 TCC 买卖书籍图片为例。
- 最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
- 创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:
- 用户服务:检查业务可行性,可行的话,将该用户的 100 元设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
- 仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 Java 虚拟机》设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
- 商家服务:检查业务可行性,不需要冻结资源。
- 如果第 2 步所有业务均反馈业务可行,将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:
- 用户服务:完成业务操作(扣减那被冻结的 100 元)。
- 仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,扣减相应库存)。
- 商家服务:完成业务操作(收款 100 元)。
- 第 3 步如果全部完成,事务宣告正常结束,如果第 3 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Confirm 操作,即进行最大努力交付。
- 如果第 2 步有任意一方反馈业务不可行,或任意一方超时,将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
- 用户服务:取消业务操作(释放被冻结的 100 元)。
- 仓库服务:取消业务操作(释放被冻结的 1 本书)。
- 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。
- 第 5 步如果全部完成,事务宣告以失败回滚结束,如果第 5 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的 Cancel 操作,即进行最大努力交付。
由上述操作过程可见,TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是 TCC 并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,尽量减轻一些编码工作量。
TCC 与 2PC
想象一个在线购物平台的微服务架构,其中包括订单服务、支付服务和库存服务。使用 2PC 可以确保当一个用户下单时,库存减少和用户账户扣款这两个操作要么同时成功,要么同时失败,从而保证数据的一致性。然而,2PC 可能会因为其阻塞性质导致服务响应变慢。在这种情况下,可以引入 TCC 来优化用户体验。例如,在支付过程中,首先执行 Try 阶段预留资金,如果用户在一定时间内完成支付,就执行 Confirm 阶段提交事务;如果用户取消或支付超时,则执行 Cancel 阶段释放预留的资金。这样,即便在复杂的业务流程中,也能灵活地保证事务的最终一致性,同时提高系统的响应速度和用户体验。
2PC 与 TCC 区别
2PC 和 TCC 分别关注事务的不同方面。2PC 主要关注事务的协调,确保所有参与者要么全部提交,要么全部回滚,强调一致性。TCC 则关注如何通过补偿操作来实现最终一致性,允许更多的灵活性和容错。在实践中,它们通常不会在同一事务中同时使用,但可以根据不同的业务需求和场景灵活选择使用2PC或TCC。
SAGA
SAGA 事务。SAGA 在英文中是 “长篇故事、长篇记叙、一长串事件” 的意思。
SAGA 事务模式的历史十分悠久,还早于分布式事务概念的提出。它源于 1987 年普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)。文中提出了一种提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本 SAGA 的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。SAGA 由两部分操作组成。
- 大事务拆分若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为是原子行为。如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交 Ti等价。
- 为每一个子事务设计对应的补偿动作,命名为 C1,C2,…,Ci,…,Cn。Ti与 Ci必须满足以下条件:
- Ti与 Ci都具备幂等性。
- Ti与 Ci满足交换律(Commutative),即先执行 Ti还是先执行 Ci,其效果都是一样的。
- Ci必须能成功提交,即不考虑 Ci本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。
如果 T1到 Tn均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:
- 正向恢复(Forward Recovery): 如果 Ti事务提交失败,则一直对 Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
- 反向恢复(Backward Recovery): 如果 Ti事务提交失败,则一直执行 Ci对 Ti进行补偿,直至成功为止(最大努力交付)。这里要求 Ci必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。
与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。譬如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到 Fenix’s Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是由 Fenix’s Bookstore 系统将货款转回到用户账上作为补偿措施却是完全可行的。
SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式。
基于数据补偿来代替回滚的思路,还可以应用在其他事务方案上,这些方案笔者就不开独立小节,放到这里一起来解释。举个具体例子,譬如阿里的 GTS(Global Transaction Service,Seata 由 GTS 开源而来)所提出的“AT 事务模式”就是这样的一种应用。
从整体上看是 AT 事务是参照了 XA 两段提交协议实现的,但针对 XA 2PC 的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放),设计了针对性的解决方案。大致的做法是在业务数据提交时自动拦截所有 SQL,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向 SQL”。基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。这种异步提交的模式,相比起 2PC 极大地提升了系统的吞吐量水平。而代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不一定是总能成功的。譬如,当本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Write),这时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
通常来说,脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写情况一旦发生,人工其实也很难进行有效处理。所以 GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待,这种设计以牺牲一定性能为代价,避免了有两个分布式事务中包含的本地事务修改了同一个数据,从而避免脏写。在读隔离方面,AT 事务默认的隔离级别是 读未提交(Read Uncommitted) ,这意味着可能产生脏读(Dirty Read)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。由此可见,分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法。
业务场景
SAGA 适用于长期运行的业务流程,其中包含多个步骤或服务调用,且每个步骤都可能成功或失败。典型场景包括电子商务的订单处理、银行的跨服务资金转账等。使用 SAGA 时,重要的是设计可靠的补偿事务来处理步骤失败的情况,确保系统能够恢复到一致状态。此外,要考虑事务中各个步骤的隔离级别和并发问题,避免产生不一致性或数据竞态。
总结
分布式事务处理的共同原理包括确保跨多个服务或数据库的操作具有原子性、一致性、隔离性和持久性。特性方面,2PC 和 3PC 提供了严格的一致性保证但可能影响可用性,XA 协议标准化了分布式事务的处理,而 TCC 和 SAGA 则通过业务逻辑的补偿操作支持更灵活的事务管理和最终一致性。在选择分布式事务解决方案时,重要的是考虑业务场景的具体需求,包括对一致性和系统性能的要求。
引用
凤凰架构