Skip to content

Lec 10 分布式事务

[toc]

阅读参考

《Principles of Computer design》 §9.1.5、 §9.1.6、 §9.5.2 、§9.5.3 、§9.6.4,其中两阶段锁(2PL)和两阶段提交(2PC)是最主要的。

本节主题是, 分布式事务。 简单来说, 分布式事务 = 并发控制 + 原子提交。

到目前为止,课程主要关注点在容错型的分布式系统,即多个服务器协作以呈现一个可靠的服务。但如今我们转向了性能导向的分布式系统:将数据分片(shared)分布在多个服务器上以实现并行性。这种设计在客户端每次只访问一个数据项时运作良好,但若一次操作涉及多个分片(如银行转账、社交图中的双向链接建立,或记录插入与索引更新),就必须应对失败处理与原子性问题。 涉及到原子性地更新多条记录的操作,黄金标准就是使用事务,往往是数据库提供,通常是两阶段锁(2PL) + Logging实现。 当事务内涉及的记录存储在不同的位置(比如在分片存储系统),就需要用到分布式事务,通常需要添加两阶段提交(2PC)协议,这个思想、协议确实很有效。后面将继续遇到(Spanner和FaRM)。Spanner 分布式数据库领域的标杆,是Google Cloud基础设施的核心之一,它树立了现代云数据库的方向。FaRM是乐观并发控制(OCC)的系统。

思考题

  • 在什么情况下,两阶段锁比简单锁具备更高性能?

  • 我们的网络在可能出现消息重排序的情况下, 我们如何处理?

  • 两阶段提交(2PC)协议 是什么? 解决了什么问题? 如何工作?需要的成本多大?

  • 2PC协议中,正常流程是怎么样,即没有丢失或失败?

    • 协调者向机器发送哪些消息?向客户端发送哪些消息?
    • 什么是提交点(commit point)?
  • 为什么这个协议有两个阶段而不是一个?

  • 2PC存在哪些性能问题?是什么原因导致这些问题?

  • 2PC能用Raft替代吗?

    • Solution:2PC产生的结果是,不同的计算机做了 不同 的事,并且他们 都在做自己事,或者谁都没有做。2PC 在有人没到达目标的情况下,系统是无法访问的,因为他们必须等待所有人都完成各自的事务的那一部分任务; 而Raft产生的是大部分人都做了 同样的事,对于Raft,只要求 大部分 参与者(副本)做完自己事,因此我们再面临失败时,可以让系统保持可用性。
  • 2PC 和 2PL 有什么区别?

    • Solution: 两个除了有两个阶段相同外, 其他毫无相关。2PC是这样的模式,在多个机器上执行一个事务,保证了每个机器做了属于他的事务一部分; 2PL是事务中获取锁模式,获取锁的目的是访问记录,保证在不同事务使用同一个记录时,不会干扰到彼此。在分布式和非分布式场景都适用。

Outline

  1. 问题背景
  2. 两阶段锁
  3. 两阶段提交
  4. 分布式事务
  5. 当下 & 趋势

1. 问题背景

数据库中早已存在一个经典问题,银行转账。

假设有两个银行账户xy,每个账户初始余额为 $10,存在以下两个事务:T1:从 x 转账 $1 到 yT2:审计,计算银行的总金额。传统的解决方案是事务,其核心思想是由程序员标记事务的开始和结束,而系统自动确保事务具备良好的行为。END-X 表示事务想要提交。但可能会成功,也可能失败,取决于系统的调度和故障情况。

T1:             T2:
BEGIN-X         BEGIN-X
  add(x, 1)       tmp1 = get(x)
  add(y, -1)      tmp2 = get(y)
END-X             print tmp1, tmp2
                END-X

对于具备良好的行事务(正确的),通常由ACID 四个特性定义:

  • Atomic原子性 —— 全写或全无,即便发生故障
  • Consistent 一致性 —— 遵循应用特定的不变量
  • Isolated 隔离性—— 并发事务的执行结果必须与某种串行顺序的执行结果等效(可串行化)
  • Durable持久性——写入是永久的

满足ACID 的事务如同魔法,也是本节课的目标,如下所示

  • 程序员只编写简单的顺序代码
  • 系统自动添加正确的锁,确保并发安全
  • 系统自动提供故障恢复,保证数据不会丢失

当今的存储系统有些提供事务,有些并不提供,因为有些应用能在事务中受益,有些并不能够。SQL数据库提供事务,但是事务很慢,特别是对于分片数据。因此有一段时间,简单的K/V存储 能够获得青睐,但是只能在单个记录上,PUT和GET。不管怎么说,事实是事务正在回来了。

今天的目标是如何满足在多个服务器下对分片数据的分布式事务的ACID特性。

我们知道, 可串行化(serializable) 事务隔离级别,是说并发的操作执行结果,与某一种顺序执行的结果保持一致。串行意思,就是同一时刻只执行一条。(这个定义应该让你想起另外一个叫可线性化linearizability,无论系统内部多么并发,对外表现的结果必须等价于某个顺序的执行, 但可线性化关注的是单个操作,要求瞬间生效,必须复合真实事件顺序)

验证(test)执行结果是否是可串行化一种方法是,找到一种执行顺序能够达到同样的结果。即便两个操作是并行执行,他们也会产生像是顺序执行的结果。

分布式事务的实现主要依赖两个核心组件:并发控制原子提交。前者负责确保事务之间的隔离性可串行化,后者在系统故障的情况下仍能实现事务的原子性。我们首先来看并发控制,其目标是在单个数据库服务器上,实现多个并发事务的隔离与串行等效执行。事务的并发控制的两种类型:

  • 悲观并发控制(Pessimistic)。其特点是,在访问记录前先加锁; 冲突会导致延迟(等待获取锁)
  • 乐观并发控制(Optimistic):在不加锁的前提下直接读取/修改记录,直到"validation"阶段再检查是否存在读写冲突(判断是否可串行化);若检测到冲突则Abort并重试。该方法也被称为乐观并发控制(OCC)
    • 在"validation"阶段会持有锁,但是比悲观并发控制会短很多。

如果事务冲突频繁,前者比较快;如果事务冲突很少,则后者比较快。

2. 两阶段锁

两阶段锁是实现 可串行化 的事务隔离等级 的经典方法,数据库每个记录都关联一个锁,规则如下:

  • 事务在使用某个记录之前必须获取该记录的锁,并且
  • 必须一直持有这些锁直到事务提交或中止之后才能释放。

还是银行取钱的例子, 假设 T1 和 T2 同时开始,事务系统会自动获取所需要的锁。首先获取到锁的,才能用x,另一个事务只能等待第一个事务完全结束(达到 END-X)后才能继续。这种机制有效地防止了非串行化的交叉执行。

具体细节如下:

  • 事务在运行过程中按需自动加锁,例如 add()get() 操作会隐性地获取该记录的锁;而 END-X() 会自动释放所有锁。
  • 所有的锁都具有排他性(这个讨论,没有读写锁存在),全程叫强严格两阶段锁(Strong Strict 2PL),强严格 = 持有锁直到COMMIT后
  • 远比比Golang锁更加结构化
    • 程序猿必须提供BEGIN-X / END-X
    • DB自动对首次使用的每条记录上锁
    • DB在事务结束时自动释放锁
    • DB可能会自动 ABORT 来解决死锁

为什么一定要等到提交或中止后再释放锁?

Solution: 如果在用完某个记录后立即释放,会导致不一致的读取。例如,如果 T2 在读取 x 后立即释放锁,而此时 T1 执行并修改了 x 和 y,再轮到 T2 读取 y,就可能得到 (10, 9) 的结果,这无法对应任何一个串行化顺序(既不是 T1;T2,也不是 T2;T1),从而破坏了事务的隔离性。

两阶段锁也会有死锁吗?系统如何检测并打破的?

Solution: 当然。比如

T1   		T2
get(x)	get(y)
get(y)  get(x)

系统需要检测并打破死锁。主要有以下方法

  • 检测循环依赖(detect cycles):系统维护一个等待图(wait-for graph),如果检测到图中有环,就说明发生了死锁,可以选择中止其中一个事务来打破循环。但这个方法在分布式系统中很难实现,,因为锁分散在不同服务器上,收集和分析信息的开销大,延迟高。
  • 超时。
  • Wound-wait策略。给每个事务分配一个时间戳(启动时间),时间越早,优先级越高。如果一个事务请求的锁被一个“更年轻的事务”占着,它就“wound”(伤害)那个年轻事务,直接把它中止。如果锁被一个“更年长的事务”持有,那么这个事务只能老实等待(wait)

描述一个场景,两阶段锁比普通锁更加高效。

Solution:Simple Locking在事务开始时就一次性锁住所有可能用到的记录,直到事务结束才释放;2PL在使用记录时才获取锁,直到事务结束才释放。

场景:银行系统里有很多账户记录。T1 事务要遍历账户,查找余额大于 10,000 的账户,最多只查 3 个,找到就停止。T2、T3 是其他事务,也在读或更新一些账户记录。

SImple Locking的行为: T1 在开始时会锁住所有账户记录,因为它不知道自己会用哪些记录(即使最终只用了前 3 个)。这会阻塞 T2 和 T3,对性能影响非常大。

2PL的行为: T1 只在实际用到一个账户时才锁住它,找到 3 个合适账户后立刻停止,锁的数量和时间都更少。因此 T2 和 T3 有更大机会并行进行,提高了系统吞吐量。

经验: 如果事务的“用到哪些记录”取决于运行时的条件(而不是提前知道的静态集合),那么 2PL 就能减少锁的数量和时间,从而提高性能。□

”两阶段“是指事务获取和释放锁的过程分为两个阶段:

  • 增长阶段(Growing Phase):在食物到达锁点之前,事务可以不断获取锁,但不能释放任何锁。
  • 收缩阶段(Shrinking Phase):在事务到达锁点之后,事务可以释放已持有的锁,但不能再获取新锁

3. 两阶段提交

下面我们介绍一个分布式事务如何处理失败的情况。

还是刚刚银行转账例子

  • 假设X、Y在不同的存储服务器上,X的服务器加1,但是Y在做减法之前就Crash了
  • 或者是X服务器加1,但是Y所在服务器发现到这个账户Y并不存在
  • 或者是X 和 Y 都能做到自己负责的部分,但是不确定对方是否会这样做
T1:             T2:
BEGIN-X         BEGIN-X
  add(x, 1)       tmp1 = get(x)
  add(y, -1)      tmp2 = get(y)
END-X             print tmp1, tmp2
                END-X

实质上我们想要一个原子提交:在一组计算机合作完成某些任务,每个计算机都有不同的角色, 我们想要原子性,即要么全部执行,要么没人任何机器执行。这里的挑战就是,失败性能问题。

3.1 设置

协议的运行背景如下:

  • 数据被分片存储在多个服务器上。

  • 每个事务由一个 事务协调器(Transaction Coordinator, TC) 负责管理。

  • 对数据的每次读写操作,TC 都会通过 RPC 向相关的数据分片服务器发送请求。

    • 每个分片服务器称为一个 参与者(participant)
    • 每个参与者负责管理本地数据的锁。
  • 系统中可能同时存在很多并发事务和多个 TCs,

    • 每个事务由 TC 分配一个唯一的事务 ID(TID)。
    • 所有消息、状态等都会标记 TID,以防不同事务之间混淆。

3.2 无失败下的2PC

首先,事务协调器(TC)向参与的分片服务器(如 A 和 B)发送一系列操作请求,例如 put()get() 等 RPC。这些服务器在收到请求后,会对涉及的记录加锁(如果已经被其他事务锁定,则需等待),并在一个临时副本上执行修改。此时修改尚未生效,只有在最终提交后才会写入真实数据库。当 TC 执行完事务所有的操作后,它会向所有参与者(如 A 和 B)发送 PREPARE 消息。此时:

  • 如果一个参与者能够成功提交(例如锁都拿到了,验证无误等),它会回复 YES,并进入“已准备(prepared)”状态。
  • 如果无法提交,它会回复 NO

TC 收集所有参与者的响应后:

  • 如果 所有参与者都回复 YES,TC 就会向它们发送 COMMIT 消息;
  • 如果 任意一个参与者回复 NO,TC 则向所有参与者发送 ABORT 消息。

收到 COMMIT 消息的参与者会将之前的临时修改正式写入数据库,并释放该事务加的锁。收到 ABORT 消息的参与者则会丢弃临时修改,并同样释放锁。所有参与者在完成后会向 TC 发送确认(acknowledge)。

高层次角度

截屏2024-09-04 20.08.34

​ 注:”FW(Force Write)“ 意思是强制写入

  • 阶段1(准备阶段): 事务协调者(TC)询问每个工作者是否完成与该事务相关的任务并准备好提交。每个工作者返回“是”或“否”。

  • 阶段2(提交阶段或中止): 协调者统计所有的响应。如果每个工作者都回应“是”,则该事务将被提交;否则,将回滚。协调者会将最终的提交发送给每个工作者,并接受确认回复。

为什么这个过程是正确的? 换句话说,如何保证一致性

Solution: 只有TC和参与者都同意的情况下才会提交事务,因此可以保证事务的原子性。□

协调者角度

  1. 日志记录事务的开始,以确保事务的启动被正确记录
  2. 在参与者上执行事务
  3. 记录每个参与者的“最终决定”
  4. 一旦所有的参与者都准备好,需要强制写入事务COMMIT
    • 如果有任何参与者在准备阶段失败,则向每个参与者发送中止请求,以撤销事务
  5. 给每个参与者发送COMMIT请求
  6. 确认所有事务已完成,用Logging记录DONE状态。

事务协调者TC何时可以完全忘记已经提交的事务?

Solution:当接收到所有参与者发送COMMIT确认时,TC可以安全忘记该事务,因为没有节点会再次请求事务状态。□

参与者角度

在准备阶段:

  1. Logging 事务的开始,以确保事务的启动被正确记录
  2. 需要Logging记录上锁的对象,以及在回复“PREPARE”状态请求之前将日志强制写入磁盘。这是因为,假如准备状态崩溃,需要恢复到"PREPARE"状态(比如重新获取之前持有的锁),并在在回复 PREPARE 请求之前, Logging记录它已经“YES”。
  3. 回复协调者"PREPARE"状态的询问请求
  4. 直到收到Commit或者Abort指令之前,都需要持有锁,需要确保资源的状态不会发生变化,以避免事务的不一致性。

可以看出,每个工作者对事务都拥有“否决权”。

参与者何时可以完全忘记已经提交的事务?

Solution:在向 TC 发送 COMMIT 确认 后,可以忘记事务。如果再次收到同一事务的 COMMIT 指令,且已无记录,说明该事务已经提交并被遗忘,参与者可以直接再次确认 □

3.3 有异常下2PC

参与者角度

参与者崩溃并重启,有如下情况

  • 如果参与者在收到 PREPARE 之前崩溃,他可以安全地忘掉该事务。(你不能相信之前自己做了啥,是否成功等,因为也没有写进磁盘)
  • 如果参与者在回复"PREPARE"请求且为 YES 之后崩溃,他必须记住该事务的状态,因为其他参与者可能已经接收到COMMIT并提交了。为了保证正确性,B必须能够在重启后恢复,并决定是否提交。
    • 参与者在发送YES之前将事务状态(包括锁和临时修改数据)写入持久性存储(磁盘)。如果该参与者在重启时发现磁盘上有YES,但还没收到COMMIT,他必须联系协调者TC询问最终决定,或者等待TC重新发送 PREPARE请求 。同时,必须继续持有该事务的锁。

协调者角度

假如协调者崩溃或者重启, 首先,日志会告诉我们哪个事务当时正在运行,其次,TC必须记住在崩溃前有没有发送过COMMIT,因为参与者可能已经提交事务了(如果发送过的话),因此,TC 在发送 COMMIT 消息前,必须先将 COMMIT 写入磁盘。如果 TC 重启,要能重发 COMMIT 或 ABORT 消息,或者在参与者询问时提供结果。参与者需要根据事务 ID (TID) 去重,避免重复提交。

  • 如果崩溃在协调者COMMIT之前。结果是,所有的参与者都应该中止,有些可能已经“准备好了”,有些可能没有,且参与者可能会被要求终止“未准备”的事务

  • 如果崩溃在协调者COMMIT之后,但是还没有Done,那么所有参与者应该Commit,工作者有些可能已经提交了,有些可能没有。不管怎样,参与者必须要求重新提交事务。

如果如果 TC 一直收不到 某个参与者的 YES/NO(如 参与者崩溃或网络故障)

Sulution: TC 可以设置超时并选择中止事务(因为尚未决定 COMMIT),这样可以释放锁资源。

如果某个参与者在等待TC的PREPAR消息时发送超时或者Crash?

Solution: 因为B还没回复Prepar,因此TC不会提交,所以B可以单方面中止,并释放锁,会未来可能的"PREPARE"消息说NO

假如某个参与者说了YES,但是没有收到COMMIT / ABORT?

Solution: 这种情况,B没有单方面宣布的权利了。他必须阻塞,等待TC的决定。

流程角度

截屏2024-09-04 20.08.34

(下面的序号对应上图的失败的位置)

  1. TC 在发送 PREPARE 之前崩溃并重启(或者是部分发送了)

    • TC :中止事务,丢弃所有状态。
      • 由于没有事务的记录,如果参与者询问,将告知参与者中止事务。
    • 参与者:轮询TC ,获取未完成事务的状态。此时会收到TC abort的处理结果。
  2. 参与者在收到 PREPARE 之前崩溃:

    • TC : 永远不会收到其回复,因此会中止事务。
    • 参与者: 会在重启时回滚事务。
  3. 参与者在 PREPARE 之后崩溃:

    • TC : 正常等待ACK,超时则ABORT

    • 参与者:必须轮询TC ,获取确定事务的结果。有两种情况:

      1. 参与者已ACK,TC 在等待确认,可以询问TC 得知事务的最终结果。

      2. 参与者未发送ACK,在这种情况下,TC 可能已经或可能没有超时。

        • 如果TC 没有超时,参与者可以ACK。
        • 如果TC 已经超时,它肯定已经中止事务,并会通知参与者这一结果。
  4. TC 在收到所有投票之前崩溃:

    • TC : 在恢复时中止事务,并通知参与者。
    • 参与者:对于已经准备好的参与者,必须等待TC 重新启动,以得知事务的结果;
  5. TC 在写入 COMMIT 之后崩溃:

    • TC : 没有 DONE 记录,TC 会向所有参与者发送COMMIT指令。
    • 参与者:必须等待结果的通知。
  6. 参与者在收到 COMMIT / ABORT 之前崩溃:

    • 参与者:恢复进程会轮询事务结果。
    • TC : 尚未收到确认,它仍然知道事务的状态。
  7. 参与者在写入 COMMIT 记录后、ACK COMMIT前崩溃:

    • 参与者: 将恢复,事务将被提交。
    • TC : 会定期发送 COMMIT 消息,参与者会ACK 而无需写入额外的状态。
  8. TC 在收到部分确认后崩溃:

    • TC 会向所有参与者发送 COMMIT / ABORT 指令,参与者会确认。

协调者何时可以忘记已经提交的事务?

Solution: 当收到所有参与者的COMMIT 回复之后,此时没有参与者会再次询问,因此可以忘记。

参与者何时可以完全忘记已经提交的事务?

Solution: 当 参与者 给 TC 发送 ACK 之后,就可以把这条事务的状态清掉。如果未来 TC 因为网络丢包或重试,又发来一次 COMMIT,而此时 参与 已经把事务日志忘掉了:(说明我之前一定已经提交过(并且忘记了),所以我可以 再给一个 ACK。)

只读参与者

如果一个是参与者是只读,不涉及任何数据的修改,因此他的事务处理比普通的参与者更加高效和简单。因为只要涉及数据修改,才需要将数据的更改记录到日志中,以便崩溃后恢复,否则,事务的结果对它没有影响。一旦它发送了PREPARE确认,就可以立即忘记该事务。同时,协调者可以忽略只读参与者,不需要发送最终结果,减少通信开销。

假设这里有参与者日志,且该参与者从crash恢复过来了,请问事务T1-T3的结果怎么?

W X T1 W Y T2 W Z T3 PREPARE T1 PREPARE T2 COMMIT T1

Solution: T1: Committed; T2: 未知(需要与TC 通信确认); T3: 中止

3.4 复杂度分析

工作者在阶段1中投票“是”之前拥有否决权。一旦投票“是”,就不能更改其投票结果。但如果工作者在投票“是”后立即崩溃,从而可能认为自己仍然有否决权,并可能会中止事务。为了防止这种情况,工作者必须在发送“是”投票前将其投票结果持久化写入

假如参与者有的N个。

对于正常的更新

  • 协调者:2次写入 (准备 和 DONE记录),1次强制写入(COMMIT/ABORT),2N次消息传递(PREPARE 和 COMMIT/ABORT)
  • 工作者:2次强制写入(准备 和 Commit/Abort),2个消息传递 (准备ACK、COMMIT/ABORT的ACK)

对于只读,

  • 协调者: 1次写入(准备),1次强制写入(COMMIT/ABORT),1N消息传递(PREPARE)
  • 工作者:0次(强制)写入,1个消息传递(ACK)

3.5 2PC的局限性

在 分片数据库 (sharded DBs) 里,如果一个事务涉及多个 shard,就需要一个协议来保证跨 shard 的原子提交,2PC 就是用来保证这种跨节点/跨分片事务原子性的经典协议。但 2PC 在实践中声誉并不好,主要原因有以下几点:

  • 多轮消息交换,2N次网络往返
  • 慢:磁盘写入
  • 持锁时间长,在PREPARE和COMMIT阶段,参与者需要保持锁,阻塞其他事务。
  • 协调者崩溃会导致无限阻塞 (indefinite blocking): 如果 TC 在 commit/abort 决定阶段崩溃,参与者就卡在 prepared 状态。
    • 为了一致性牺牲了系统的可用性

因此,2PC 通常只在一个小的、受控的系统域内使用,比如同一个数据库集群内部。不适用于银行之间、航空公司之间,或广域网中的事务。

破局思路

已经解决了原子问题, 那如果我们想要高可用 + 原子性呢?

既然单点会导致阻塞, 那就把TC和各个参与者都复制成一个高可用的副本,每个副本内部通过Raft(or Paxos)保证强一致性和容错。副本组之间,运行 2PC,保证全局原子提交。 Spanner就是这样设计的典型代表。

2PC也涌现出一些针对特定场景下的优化版:

  • 三阶段提交,允许参与者在TC崩溃情况下提交或中止。
    • 当参与者能够可靠地区分TC是宕机和网络未传送数据包时,三阶段提交才能正常工作。例如如果存在网络分区,三阶段提交无法正常工作。在大多数网络中,无法区分宕机还是网络故障

3.6 示例: Postgres

协调者代码

python
all_prepared = True
# log Start, Get TID
tid = logger.start_coord_txn()  # FW
for worker in workers:
  logger.start_worker_txn(worker, tid)
  do_work(worker)
  result = logger.prepare(worker, tid)
  all_prepared  = all_prepared & result
if (all_prepared):
  logger.log(tid, "COMMIT")   # FW
else:
  logger.log(tid, "ABORT")
  
for worker in workers:
  if (all_prepared):
    logger.commit(worker, tid)
  else:
    logger.abort(worker, tid)
    
logger.log(tid, "DONE")   # FW(W?)

Logger

python
def start_coord_txn(self):
  cur_tid = self.tid
  self.log(cur_tid, 'Start')
  self.tid = self.tid + 1
  return cur_tid

def start_worker_txn(slef, cursor, tid):
  cursor.execute("BEGIN TRANSACTION")

def prepare(self, cursor, tid):  #RT
  try:
    cursor.execute("prepare transaction '%s'" % (t_name%(tid)))
    return True
  except psycopg2.DatabaseError as error:
    return False

def commit(self, cursor, tid):    #RT
  cursor.execute("commit prepared '%s'" %(t_name%(tid)))

故障恢复

python
def reover(self):
  to_abort = []
  to_commit = []
  max_tid = 0
  for (tid, cmd) in self.log_lines()
  	if cmd = 'Start':
      to_abort.append(tid)
      max_tid = max(self.tid, tid)
    if cmd = 'Commit':
      to_abort.remove(tid)
      to_commit.append(tid)
    if cmd = 'Done':
      if tid in to_abort:
        to_abort.remove(tid)
      if tid in to_commit:
        to_commit.remove(tid)
  if (len(to_abort) > 0) or (len(to_commit) > 0):
    workers = self.get_workers()
    for txn in to_abort:
      for worker in workers:
        self.abort(worker, txn)
      self.log(txn, "DONE")
    for txn in to_commit:
      for worker in workers:
        self.commit(worker, txn)
      self.log(txn, "DONE")
    for worker in workers:
      worker.close()
  self.tid = max_tid + 1 #为了确保下一个新事务的 ID 是唯一且递增的

参与者没有发送消息或者运行恢复过程;协调者做了所有的恢复

4. 趋势

http://dbmsmusings.blogspot.com/2019/01/its-time-to-move-on-from-two-phase.html

两阶段提交协议(2PC)在企业级系统中已沿用三十余年,是保障跨分区/分片事务原子性与持久性的核心协议。2PC的核心问题是阻塞问题、拥塞问题(较少被讨论)——事务必须等待2PC完成才能知道结果,阻塞了冲突事务

上一篇文章的思路具有革命意义:它挑战了数十年来"事务可能在任何时候因任何原因中止"的基本假设。通过重新设计系统架构,让事务要么因数据逻辑失败,要么必须成功,从而完全消除了复杂的分布式提交协议的需要。】=

作者用一个很好的例子说明:

传统方式(需要2PC):

Worker 1: X = 42
Worker 2: if (Y > 0) Y = Y - 1; else ABORT

新方式(无需2PC):

Worker 1: temp = Do_Remote_Read(Y); if (temp > 0) X = 42
Worker 2: if (Y > 0) Y = Y - 1; Z = Y

这样两个工作节点都基于Y的值来做决定,消除了协调的需要。

作者提到了两类中止:

  1. 数据状态导致的中止 - 可以通过条件逻辑处理
  2. 系统导致的中止(如故障、死锁)- 需要完全消除

摒弃系统级中止、将应用逻辑与状态检查解耦——我们既能保留强一致性,又无需2PC的开销。这一范式已被Calvin、FaunaDB等系统验证,现需在更广泛的分布式系统中推广。