一文详解分布式事务
文章目录
- 前置知识
- CAP 定理
- 分布式事务与 CAP 定理的关系
- 分布式事务与 ACID
- 分布式事务的常见解决方案
- 两阶段提交与 XA 事务
- TCC 事务
- SAGA 事务(不保证隔离性)
- TCC 事务的问题
- SAGA 事务 (数据补偿代替回滚\释放资源)
- AT 事务(SAGA 事务的一种特殊形态,隔离性需要全局锁)
- 解决 XA 2 PC 的缺陷(资源锁定)
- 缺点
主要参考英文的维基百科、<<凤凰架构>>
前置知识
CAP 定理
分布式系统中有一个著名 CAP 定理,这个定理里描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:
- 一致性:每次读取操作都会收到最近一次写入操作的结果或错误(任意节点上)。CAP 定理中定义的一致性与ACID 数据库事务 中保证的一致性截然不同。
- 可用性:系统中每个非故障节点收到的请求都必须得到响应。这是吉尔伯特和林奇定义的 CAP 定理中可用性的定义。CAP 定理中定义的可用性与软件架构中的高可用性 有所不同。
- 分区容差:即使网络中节点间丢失(或延迟)任意数量的消息,系统仍能继续运行。
当发生网络分区 故障时,必须决定执行以下操作之一:
- 取消操作,从而降低可用性,但可确保一致性。
- 继续执行操作可以保证系统可用性,但存在系统不一致的风险。
因此,如果存在网络分区,就必须在一致性和可用性之间做出选择。
选择放弃一致性的 AP 系统目前是设计分布式系统的主流选择,因为 P 是分布式网络的天然属性,你再不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值。
除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。
CAP 中的一致性和 ACID 中的一致性,虽然单词相同,但实际含义不同。
分布式事务与 CAP 定理的关系

但是在实际系统中,由于网络分区(P)的存在,CAP 告诉我们保证可用性的同时无法保证一致性(副本一致性),无法保证同时写入成功。
同样的,在分布式系统中,当操作跨多个节点时,保证系统可用性的同时,无法保证操作同时成功。CAP 仍然成立,但“分区”的含义变成了 服务间通信(网络)不可达,而不是 分布式存储副本(网络)不可达,写入语义变成了事务提交成功。
而分布式事务实现的 C 不是 CAP 定理的 C(副本一致性),而是 ACID 的 C (事务执行前后,数据必须从一个“合法状态”转换到另一个“合法状态”)。
分布式事务与 ACID
分布式事务在分布式环境 中运行,通常涉及网络中的多个节点,具体取决于数据的位置。分布式事务的一个关键方面是原子性,它确保事务要么完全完成,要么根本不执行,进而为实现事务 ACID 特性里的一致性提供基础。
分布式事务会部分遵循 ACID 规范:
- 原子性:严格遵循
- 一致性:事务完成后的一致性严格遵循;事务中的一致性可适当放宽
- 隔离性:并行事务间不可影响;事务中间结果可见性允许安全放宽
- 持久性:严格遵循
因为事务过程中,不是一致的,但事务会最终完成,最终达到一致,所以我们把分布式事务称为“最终一致”。
需要注意的是,分布式事务并不局限于数据库。分布式事务既可以是纯粹多个数据库实例之间的分布式事务,也可以是跨越不同中间件的业务层面上的分布式事务。前者一般是分库分表中间件提供支持,后者一般是独立的第三方中间件提供支持,比如 Seata。
一个 MySQL 实例可以包含多个数据库(db)。而一个“跨库事务”通常是指在同一个实例中的不同数据库之间,或不同实例之间操作多个 db。
分布式事务的常见解决方案
两阶段提交与 XA 事务
两阶段提交协议(Two Phase Commit)是分布式事务中的一种常用协议,算法思路可以概括为参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情况决定各参与者要提交操作还是中止操作。
- 准备阶段,协调者让参与者执行事务,但是并不提交,协调者返回执行情况。这个阶段参与者会记录 Redo 和 Undo 信息,用于后续提交或者回滚。
- 提交阶段,协调者根据准备阶段的情况,要求参与者提交或者回滚,参与者返回提交或者回滚的结果。准备阶段任何一个节点执行失败了,就都会回滚。全部执行成功就提交。
两阶段提交协议的缺点很多。最大缺点是在执行过程中节点都处于阻塞状态。也就是节点之间在等待对方的响应消息时,什么也做不了。特别是如果某个节点在已经占有了某项资源的情况下,为了等待其他节点的响应消息而陷入阻塞状态时,当第三个节点尝试访问该节点占有的资源时,这个节点也会连带着陷入阻塞状态。
此外,协调者也是关键,如果协调者崩溃,整个分布式事务都无法执行。所以,如果协调者是单节点,那么就容易出现单节点故障。而且协调者采用保守策略,如果一个节点在第一阶段没有返回响应,那么协调者会执行回滚。所以这可能会引起不必要的回滚。
XA 则是把两阶段提交协议具像化之后的一个标准。它定义了协调者和参与者之间的接口。用专业的术语来说,就是定义了事务管理器(Transaction Manager)和资源管理器(Resource Manager) 之间的接口(准备和提交阶段)。
TCC 事务
TCC 是 Try-Confirm-Cancel 的缩写,它勉强也算是两阶段提交协议的一种实现。之所以给它一个新名字,完全是因为 TCC 强调的是业务自定义逻辑。也就是说 Try 是执行业务自定义逻辑,Confirm 也是执行业务自定义逻辑,Cancel 同样如此。
TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段(Try 都 OK,则进行最大努力交付),不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
- Cancel:取消执行阶段(如果 Try 有任意一方不满足),释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。
Try 比如执行 XA 事务的准备阶段,confirm 比如执行 XA 事务的提交阶段
TCC 其实有点类似 2 PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。
TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是 TCC 并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(譬如阿里开源的 Seata)去完成,尽量减轻一些编码工作量。
SAGA 事务(不保证隔离性)
TCC 事务的问题
TCC 事务具有较强的隔离性,避免了“超售”的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。
TCC 的最主要限制是它的业务侵入性很强,这里并不是重复上一节提到的它需要开发编码配合所带来的工作量,而更多的是指它所要求的技术可控性上的约束。
譬如,把我们的场景事例修改如下:由于中国网络支付日益盛行,现在用户和商家在书店系统中可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,允许直接在购物时通过 U 盾或扫码支付,在银行账号中划转货款。
这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。
SAGA 事务 (数据补偿代替回滚\释放资源)
所以 TCC 中的第一步 Try 阶段往往无法施行。我们只能考虑采用另外一种柔性事务方案:SAGA 事务。SAGA 在英文中是“长篇故事、长篇记叙、一长串事件”的意思。
某个步骤是插入数据,如果是回滚的话,那么是指插入的时候没有提交,然后在业务失败的时候回滚。如果是反向补偿的话,那么是指插入的时候已经提交了,然后在业务失败的时候执行删除。
原本 SAGA 的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。SAGA 由两部分操作组成。
- 大事务拆分若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T 1,T 2,…,Ti,…,Tn。每个子事务都应该是或者能被视为是原子行为。如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交 Ti 等价。
- 为每一个子事务设计对应的补偿动作,命名为 C 1,C 2,…,Ci,…,Cn。Ti 与 Ci 必须满足以下条件:
- Ti 与 Ci 都具备幂等性。
- Ti 与 Ci 满足交换律(Commutative),即先执行 Ti 还是先执行 Ci,其效果都是一样的。
- Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。
如果 T 1 到 Tn 均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:
- 正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T 1,T 2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
- 反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T 1,T 2,…,Ti(失败),Ci(补偿),…,C 2,C 1。
与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。
譬如,账号余额直接在银行维护的场景,从银行划转货款到 Fenix’s Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是由 Fenix’s Bookstore 系统将货款转回到用户账上作为补偿措施却是完全可行的。
SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。
另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式。
AT 事务(SAGA 事务的一种特殊形态,隔离性需要全局锁)
基于数据补偿来代替回滚的思路,还可以应用在其他事务方案上,这些方案笔者就不开独立小节,放到这里一起来解释。举个具体例子,譬如阿里的 GTS(Global Transaction Service,Seata 由 GTS 开源而来)所提出的“AT 事务模式”就是这样的一种应用。
解决 XA 2 PC 的缺陷(资源锁定)
从整体上看是 AT 事务是参照了 XA 两段提交协议实现的,但针对 XA 2 PC 的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放),设计了针对性的解决方案。
大致的做法是在业务数据提交时自动拦截所有 SQL,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。
如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向 SQL”。
基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。 这种异步提交的模式,相比起 2 PC 极大地提升了系统的吞吐量水平。
缺点
而代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不一定是总能成功的。
譬如,当本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Write),这时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
通常来说,脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写情况一旦发生,人工其实也很难进行有效处理。
所以 GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待,这种设计以牺牲一定性能为代价,避免了有两个分布式事务中包含的本地事务修改了同一个数据,从而避免脏写。
在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能产生脏读(Dirty Read)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。 由此可见,分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法。
