你好,我是王磊,你也可以叫我 Ivan。

今天这一讲是我们这门课的第二个答疑篇,我会带你回顾第 9 讲到第 14 讲的主要内容,集中解答留给你的思考题,同时回复一些留言区的热点问题。这几讲涉及的也是数据库的核心话题“事务”,咱们一定得学扎实了。

第 9 讲:原子性提交协议

在第 9 讲,我们首先讨论了两种不同的原子性协议,分别是面向应用层的协议 TCC 和面向资源层的协议 2PC。

使用 TCC 协议时,由应用系统负责协议的实现,数据库没有额外工作。所以,TCC 更加灵活,但对业务的侵入性更高。反之,使用 2PC 协议时,主要靠数据库来实现,应用层工作很少,所以业务侵入少,但是存在同步阻塞、单点故障和数据不一致这三个问题。

针对 2PC 协议的这些问题,我又介绍了两种改进型协议。一种是 Percolator 模型,在 TiDB 和 CockroachDB 中有具体落地。另一种是 GoldenDB 中实现的“一阶段提交”协议。它们都较好地解决了单点故障和数据不一致的问题。而有关同步阻塞带来的高延迟问题,我们没有展开,而是留到了第 10 讲。

这一讲的是思考题是:2PC 第一阶段“准备阶段”也被称为“投票阶段”,和 Paxos 协议处理阶段的命名相近,你觉得 2PC 和 Paxos 协议有没有关系,如果有又是什么关系呢?

“Chenchukun”“tt”“李鑫磊”“piboye”和“Bryant.C”等几位同学的留言都很好地回答了这个问题。总的来说,就是 Paxos 是对单值或者一种状态达成共识的过程,而 2PC 是对多个不同数据项的变更或者多个状态,达成一致的过程。它们是有区别的,Paxos 不能替换 2PC,但它们也是有某种联系的。那到底是什么联系呢?

别着急,我们先看看“Lost Horizon”和“平风造雨”两位同学的提问。

“Lost Horizon”在第 9 讲提出的问题是:如果向单元 A 发出 Confirm 操作成功且收到成功应答,向单元 B 发出 Confirm 操作成功,但没有收到成功应答,是否应该先确认 B 的状态,然后再决定是否需要回滚(或者补偿)A 的变更呢?

“平风造雨”在第 10 讲提出的问题是:如果客户端没有在一定的时间内得到所有意向写的反馈(不知道反馈是成功还是失败),要如何处理?

这两位同学的问题虽然不一样但有一个共同点,就是事务协调者收不到事务参与者的反馈怎么办

为什么会收不到呢?多数情况是和网络故障有关。那么,Paxos 协议这个针对网络分区而设计的共识算法,能不能帮助解决这个问题呢?

Paxos 确实有帮助,但真正能解决问题的是在其之上衍生的 Paxos Commit 协议,这是一个融合了 Paxos 算法的原子提交协议,也是我们前面所说的 2PC 和 Paxos 的联系所在。

Paxos Commit 协议

Paxos Commit 协议是 2006 年在论文“Consensus on Transaction Commit”中首次提出的。值得一提的是,这篇论文的两位作者,正是我们课程中多次提到的 Jim Gray 和 Leslie Lamport。他们分别是数据库和分布式领域的图灵奖获得者,也分别是 2PC 和 Paxos 的提出者。

我们结合论文中的配图,简单学习一下 Paxos Commit 的思路。

Paxos Commit 协议中有四个角色,有两个与 2PC 对应,分别是 TM(Transaction Manager,事务管理者)也就是事务协调者,RM(Resource Manager,资源管理者)也就是事务参与者;另外两个角色与 Paxos 对应,一个是 Leader,一个是 Acceptor。其中,TM 和 Leader 在逻辑上是不可能分的,所以在图中隐去了。因为 Leader 是选举出来的,所以第一个 Leader 标识为 Initial Leader。

下面,我们来描述下这个处理过程。

  1. 首先由 RM1,就是某个 RM,向 Leader 发送 Begin Commit 指令。这个操作和第 9 讲介绍的 2PC 稍有不同,但和客户端向 Leader 发送指令在效果上是大致相同的。同时,RM1 要向所有 Acceptor 发送 Prepared 指令。因为把事务触发也算进去了,所以整个协议有三个阶段构成,Prepare 是其中的第二阶段,而 RM 对 Prepare 指令的响应过程又拆分成了 a 和 b 两个子阶段。所以,这里的 Prepared 指令用 2a Prepared 表示,要注意这是一个完成时,表示已经准备完毕。
  2. Leader 向除 RM1 外的所有 RM,发送 Prepare 指令。RM 执行指令后,向所有 Acceptor 发送消息 2a Prepared。这里的关键点是,一个 2a Prepared 消息里只包含一个 RM 的执行情况。而每个 Acceptor 都会收到所有 RM 发送的消息,从而得到全局 RM 的执行情况。
  3. 每个 Acceptor 向 Leader 汇报自己掌握的全局状态,载体是消息 2b Prepared。2b Prepared 是对 2a Prepared 的合并,每个消息都记录了所有 RM 的执行情况。最后,Leader 基于多数派得出了最终的全局状态。这一点和 2PC 完全不同,事务的状态完全由投票决定,Leader 也就是事务协调者,是没有独立判断逻辑的。
  4. Leader 基于已知的全局状态,向所有 RM 发送 Commit 指令。

这个过程中,如果 Acceptor 总数是 2F+1,那么每个 RM 就有 2F+1 条路径与 Leader 通讯。只要保证其中 F+1 条路径是畅通的,整个协议就可以正常运行。因此,Paxos Commit 协议的优势之一,就是在很大程度上避免了网络故障对系统的影响。但是,相比于 2PC 来说,它的消息数量大幅增加,而且多了一次消息延迟。目前,实际产品中还很少有使用 Paxos Commit 协议的。

第 10 讲:2PC 的延迟优化

第 10 讲的核心内容是 2PC 的低延迟技术,我们先是分析了延迟的主要构成,发现延迟时间与事务中的写入操作数量线性相关,然后又将延迟时间的计量单位统一为共识算法延迟 Lc​,最后得到了下面的延迟计算公式:

Ltxn​=(W+1)∗Lc​

随后,我为你讲解了三种优化技术,都是基于 Percolator 模型的,分别是缓存提交写、管道和并行提交。TiDB 采用“缓存提交写”达到了 2 倍共识算法延迟,但这个方案的缺点是缓存 SQL 的节点会出现瓶颈,而且不再是交互事务。CockroachDB 采用了管道和并行提交技术,整体延迟缩短到了 1 倍共识算法延迟,可能是目前最极致的优化方法了。

这一讲的是思考题是:虽然 CockroachDB 的优化已经比较极致了,但还有些优化方法也很有趣,请你介绍下自己了解的 2PC 优化方法。

关于这个问题,我们刚刚讲的 Paxos Commit 其实已经是一种 2PC 的优化方法了。另外,在 Spanner 论文“Spanner: Google’s Globally-Distributed Database”中也介绍了它的 2PC 优化方式。Spanner 的 2PC 优化特点在于由客户端负责第一段协调,发送 prepare 指令,减少了节点间的通讯。具体的内容,你可以参考下这篇论文。

在留言区,我看到很多同学对并行提交有不同的理解。我要再提示一下,并行提交中的异步写事务日志只是根据每个数据项的写入情况,追溯出事务的状态,然后落盘保存,整个过程并没有任何重试或者回滚的操作。这是因为,在之前的同步操作过程中,负责管道写入的同步线程,已经明确知道了每个数据项的写入情况,也就是确定了事务的状态,不同步落盘只是为了避免由此带来的共识算法延迟。

第 11 讲:读写冲突、MVCC 与快照

在第 11 讲中,我们介绍如何避免读写冲突的解决方案,其中很重要的概念就是 MVCC 和快照。MVCC 是单体数据库普遍使用的一种技术,通过记录数据项历史版本的方式,提升系统应对多事务访问的并发处理能力。

在 MVCC 出现前读写操作是相互阻塞的,并行能力受到很大影响。而使用 MVCC 可以实现读写无阻塞,并能够达到 RC(读已提交)隔离级别。基于 MVCC 还可以构建快照,使用快照则能够更容易地实现 RR(可重复读)和 SI(快照隔离)两个隔离级别。

首先,我们学习了 PGXC 风格分布式数据库的读写冲突处理方案。PGXC 因为使用单体数据库作为数据节点,所以沿用了 MVCC 来实现 RC。但如果要实现 RR 级别,则需要全局事务管理器(GTM)承担产生事务 ID 和记录事务状态的职责。

然后,我们介绍了 TiDB 和 CockroachDB 这两种 NewSQL 风格分布式数据库的读写冲突处理方案。TiDB 没有设置全局事务列表,所以读写是相互阻塞的。CockroachDB 虽然有全局事务列表,但由于它的目标隔离级别是可串行化,所以也没有采用快照方式,读写也是相互阻塞的。

这一讲的思考题是:在介绍的几种读写冲突的处理方案中,时间都是非常重要的因素,但时间是有误差的,那么你觉得时间误差会影响读写冲突的处理吗?

其实,这个问题就是引导你思考,以便更好地理解第 12 讲的内容。MVCC 机制是用时间戳作为重要依据来判别哪个数据版本是可读取的。但是,如果这个时间戳本身有误差,就需要特定的机制来管理这个误差,从而读取到正确的数据版本。更详细的内容,你可以去学习下第 12 讲。

“真名不叫黄金”同学的答案非常准确,抓住了时钟置信区间这个关键点,分析思路也很清晰,点赞。

第 12 讲:读写操作与时间误差

在第 12 讲中,我们给出了时间误差的具体控制手段,也就是写等待和读等待。

Spanner 采用了写等待方案,也就是 Commit Wait,理论上每个写事务都要等待一个时间置信区间。对 Spanner 来说这个区间最大是 7 毫秒,均值是 4 毫秒。但是,由于 Spanner 的 2PC 设计,需要再增加一个时间置信区间,来确保提交时间戳晚于预备时间戳。所以,实际上 Spanner 的写等待时间就是两倍时间置信区间,均值达到了 8 毫秒。传说中,Spanner 的 TPS 是 125 就是用这个均值计算的(1 秒 /8 毫秒),但如果事务之间操作的数据不重叠,其实是不受这个限制的。

CockroachDB 采用了读等待方式,就是在所有的读操作执行前处理时间置信区间。读等待的优点是偶发,只有读操作落入写操作的置信区间才需要重启,进行等待。但是,重启后的读操作可能继续落入其他写操作的置信区间,引发多次重启。所以,读等待的缺点是等待时间可能比较长。

这一讲的思考题是:读等待和写等待都是通过等待的方式,度过不确定的时间误差,从而给出确定性的读写顺序,但性能会明显下降。那么在什么情况下,不用“等待”也能达到线性一致性或因果一致性呢?”

我为这个问题准备了两个答案。

第一个答案是要复习第 5 讲的相关知识。如果分布式数据库使用了 TSO,保证全局时钟的单向递增,那么就不再需要等待了,因为在事件发生时已经按照全序排列并进行了记录。

第二个答案是就时间的话题做下延展。“等待”是为了让事件先后关系明确,消除模糊的边界,但这个思路还是站在上帝视角。

我们试想一种场景,事件 1 是小明正在北京的饭馆里与人谈论小刚的大学趣事,事件 2 是小刚在温哥华的公寓里打了一个喷嚏。如果事件 1 发生后 4 毫秒,事件 2 才发生,那么从绝对时间,也就是全序上看,两者是有先后关系的。但是从因果关系上,也就是偏序上看,事件 1 与事件 2 是没有联系的。因为科学承认的最快速度是光速,从北京到温哥华,即使是光速也无法在 4 毫秒内到达。那么,从偏序关系上看,事件 1 和事件 2 是并发的,因为事件 1 没有机会影响到事件 2。

当然,这个理论不是我看多了科幻电影想出来的,它来自 Lamport 的论文“ Time, Clocks, and the Ordering of Events in a Distributed System”。

所以,假设两个事件发生地的距离除以光速得到一个时间 X,两个事件的时间戳间隔是 Y,时钟误差是 Z。如果 X>Y+Z,那么可以确定两个事件是并行发生的,事件 2 就不用读等待了。这是因为既然事件是并行的,事件 2 看不到事件 1 的结果也就是正常的了。

第 13 讲:广义乐观和狭义乐观

在第 13 讲中,我们开始探讨“写写冲突”的控制技术,这也是并发控制最核心的内容。大型系统之所以能够承载海量并发,就在于底层数据库有强大的并发处理能力。

并发控制分为乐观协议和悲观协议两大类,单体数据库大多使用悲观协议。TiDB 和 CockroachDB 都在早期版本中提供了乐观协议,但在后来的产品演进又改回了悲观协议,其主要原因是事务竞争激烈和对遗留应用系统的兼容。

我们还从经典理论教材中提取了并发控制的四阶段,忽略掉计算(C)阶段后,悲观协议与乐观协议的区别在于有效性验证(V)、读(R)、写(W)这三阶段的排序不同。在分布式架构下,有效性验证又分为局部有效性验证和全局有效性验证。因此,乐观又分为狭义乐观和广义乐观,而狭义乐观就是学术领域常说的 OCC。TiDB 的乐观锁,因为没有全局有效性验证,不严格符合 VRW 悲观协议排序,所以是广义乐观。而 TiDB 后来增加的悲观锁,增加了全局有效性验证,是严格的 VRW,所以是悲观协议。

这一讲的思考题是:在了解乐观协议及 TiDB 乐观转悲观的设计后,请你来推测下 CockroachDB 向悲观协转换大概会采用什么方式?

这个问题是为了引出第 14 讲的主题。CockroachDB 早期的乐观协议也是广义乐观,在局部看是悲观协议,使用了串行化图检测(SGT)的方式。SGT 是区别于锁的另一种控制技术,具有更好的性能。CockroachDB 的改良方式是增加了全局的锁表(Lock Table),局部保留了原有的 SGT。

第 14 讲:悲观协议

在第 14 讲中,我们首先讨论了完整的并发控制技术体系,选择了“Transactional Information Systems”定义狭义乐观和其他悲观协议这种的组织形式,而后对 2PL 的定义和各种变体进行了说明。我们根据 S2PL 的定义,可以推导出 Percolator 模型属于 S2PL 的结论。S2PL 虽然使用广泛,但不能在生产级支持可串行化隔离。

PostgreSQL 的 SSI 设计给出了另一种实现,它的理论基础是 SGT。CockroachDB 在此基础设计了读时间戳缓存(RTC),降低了原有 SIREAD 的开销,达到了生产级性能要求。最后,我还和你一起学习了 CockroachDB 采用全局锁表实现悲观协议的原理。

这一讲的思考题是:我们之前已经介绍过 MVCC,它是一项重要的并发控制技术,你觉得该如何理解它和乐观协议、悲观协议的关系?

就像我们在第 11 讲中所说的,MVCC 已经是数据库的底层技术,与乐观协议、悲观协议下的各项技术是两个不同的维度,最后形成了 MVTO、MV2PL、MVSGT 等技术。这些技术考虑了多版本情况下的处理,但遵循的基本原理还是一样的。

小结

正如我在这一讲开头提到的,第 9 到第 14 这 6 讲的内容,都是围绕着分布式数据库的事务展开的,重点就是原子性和隔离性的协议、算法和工程实现。

对于原子性,我们主要关注非功能性指标背后的架构优化和理论创新,尤其是 NewSQL 风格分布式数据库在 2PC 的三个传统难题,也就是同步阻塞、单点故障、数据一致性上都取得的突破。

隔离性则不同,早在单体数据库时代,架构设计就在正确性,也就是隔离级别上作了妥协,换取性能。而分布式数据库在重新挑战了隔离性这个难题,CockroachDB 在这方面的探索更是意义重大,它实践了一种兼顾正确性和性能的技术方案。

如果你对今天的内容有任何疑问,欢迎在评论区留言和我一起讨论。要是你身边的朋友也对分布式数据库的事务处理这个话题感兴趣,你也可以把今天这一讲分享给他,我们一起讨论。

分布式数据全景图 2/4

学习资料

James C. Corbett et al.: Spanner: Google’s Globally-Distributed Database

Jim Gray and Leslie Lamport: Consensus on Transaction Commit

Leslie Lamport: Time, Clocks, and the Ordering of Events in a Distributed System

Gerhard Weikum and Gottfried Vossen: Transactional Information Systems