剑桥分布式系统笔记-全-
剑桥分布式系统笔记(全)
001:引言
在本节课中,我们将要学习分布式系统的基本概念,了解它与并发系统的区别,并探讨构建分布式系统的动机与挑战。
上一节我们介绍了课程的整体背景,本节中我们来看看分布式系统的具体定义。
什么是分布式系统?

在并发与分布式系统课程的第一部分,我们学习了单进程内的并发。例如,当单个进程内有多个线程并发执行操作时,我们学习了如何处理互斥以及确保安全并发的各种算法。在这种并发系统中,所有线程通常共享一个单一的地址空间。因此,你可以直接将一个变量从一个线程传递给另一个线程,该变量可以包含指向任意对象的指针或引用。这是可行的,因为一个线程可以访问的内存地址与另一个线程可以访问的内存地址是相同的。
当我们转向分布式系统时,情况发生了变化。在分布式系统中,我们仍然存在并发性,仍然有多个进程和多个线程可能并发执行操作。但我们还面临一个额外的挑战:我们现在讨论的不是在单台计算机上运行的单个程序,而是在多台计算机上运行的多个程序,这些计算机通过网络进行通信。引入网络这一方面是使系统成为“分布式”的原因之一。另一个方面是我们不再拥有单一的共享地址空间。因此,在一个进程中有效的指针,如果你通过网络将其发送给运行在不同计算机上的另一个进程,该指针对消息接收者来说可能毫无意义。所以,我们必须思考在这些并发实体之间共享数据的不同方式。

莱斯利·兰波特给出了一个略带玩笑的分布式系统定义:一个分布式系统是指,一个你甚至不知道其存在的计算机发生故障,都可能导致你自己的计算机无法使用的系统。无论这是否是一个好的定义,我们都可以讨论。莱斯利·兰波特是分布式系统领域的传奇人物,我们将在后续讲座中看到他多年来的多项工作成果。当然,该领域还有许多其他人的贡献。

更普遍地说,我认为可以将分布式系统定义为:一个由多台计算机或计算设备组成的系统。这些设备可能是智能手机、机器人、自动驾驶汽车、台式机或数据中心中的服务器。任何类型的计算设备都可以。这些计算设备通过某种网络进行通信,例如互联网。它们试图共同完成某项任务。分布式系统的研究,实质上就是研究如何协调这些不同设备的活动,使它们能够共同完成试图实现的任务。
课程背景与关联
上一节我们定义了分布式系统,本节中我们来看看本课程的相关背景和与其他课程的关联。
首先是一些管理事项。如果你希望获得本课程的进一步背景阅读材料,虽然讲义内容本身应该足够完整,但如果你感兴趣,书籍中提供了更多细节。以下是几种不同风格的参考书:

- 《分布式系统:原理与范式》(范·斯蒂恩、坦嫩鲍姆著):这是一本系统层面的概述书籍,从实际应用的角度探讨分布式系统,讨论其工作原理和各种实现细节。它不像其他一些书籍那样深入理论,但更偏向实践。
- 《分布式系统导论》(凯姆·加里、罗德里格斯著):这是一本优秀但相当详细的分布式系统理论概述。它比本课程有时间讨论的内容要深入得多。如果你想了解更多理论,这是一本极好的书。
- 《数据密集型应用系统设计》(作者本人著):这本书更偏向分布式数据库系统,不仅仅是广义的分布式系统,还包含许多其他数据库内容。它更面向工业界的软件工程师,对于专业从事分布式数据库工作的人来说可能很有用。
- 《操作系统:三易其稿》(培根、哈里斯著):这本书在本课程的并发系统部分已被推荐,它真正提供了操作系统与分布式系统之间的联系。
本课程还与三一课程体系中的其他多门课程紧密相连:
- 并发系统:本课程是其一部分。
- 操作系统:你们去年学习过。其中关于进程、进程间通信和进程调度的所有背景知识都与本课程相关。
- 数据库:正如刚才提到的,许多现代数据库实际上是分布式的,它们使用了我们将在本课程中讨论的许多技术。因此,使用现代数据库通常也涉及使用分布式系统。
- 计算机网络:分布式系统与计算机网络有很强的联系,因为分布式系统通常涉及网络通信。两者的区别在于,计算机网络课程主要关注如何实际通过线路将字节从一台设备传输到另一台设备,即实现设备通信的网络协议是什么样的。而分布式系统则建立在计算机网络之上,探讨在假设我们已有设备通信机制的前提下,如何确保这些设备按照我们期望的方式运行。
- 进阶Java:包含涉及分布式计算的编程练习。
- 安全:分布式系统的安全方面非常有趣,在复活节学期有一门专门讨论安全主题的课程会涵盖。
- 云计算:明年的云计算课程建立在分布式系统之上,因为云计算的核心就是能够灵活处理大量数据。当数据量足够大时,通常需要一个分布式系统来处理。
为何构建分布式系统?

上一节我们了解了课程的背景,本节中我们来看看构建分布式系统的动机。
我们简要定义了什么是分布式系统。接下来的问题是:为什么?我们为什么要费尽心思构建分布式系统?为什么不能只用一台计算机,让事情保持简单?
一个原因是,某些应用、某些你想做的事情本质上是分布式的。例如,如果你想从手机向朋友的手机发送一条消息,这必然涉及两部不同的手机和一个网络。这就是一个分布式系统。无法绕过这一点,构建一个非分布式的消息系统是不可能的。如果你想在不同设备间通信,就必须如此。
构建这类软件是我们对分布式系统感兴趣的一个原因,但还有许多其他原因。
另一个对分布式系统感兴趣的好理由是,它实际上可以提高系统的可靠性。原因是,假设你有多个计算机,每台都执行某项工作的部分任务。如果其中一台出现故障(例如需要重启或发生硬件故障),那么剩余的计算机或许可以接管故障计算机的工作。这样,即使参与系统的一台计算机宕机,整个系统也能继续运行。
我们可能希望使系统分布式的另一个原因是为了获得更好的性能。例如,在互联网分布式系统中,用户可能遍布全球,有的在英国,有的在美国,有的在新西兰,有的在南非等等。这些不同地点之间距离遥远,因此从一个地点到另一个地点的网络通信总是需要一些时间。由于光速限制,跨大陆通信至少需要100毫秒。因此,人们对分布式系统感兴趣的一个原因是通过将数据放在离用户更近的地方来使系统更快。如果你在多个大洲都有用户,你可以在多个大洲部署计算机。如果每个用户都与离他们最近的计算机通信,那么就可以避免长距离的跨大陆通信带来的延迟。因此,使系统分布式有可能让它们更快。
最后,人们构建分布式系统的另一个原因是为了解决单台计算机无法处理的大型问题。有些计算问题规模非常庞大。以科学计算为例,比如瑞士的欧洲核子研究中心粒子加速器(包含大型强子对撞机)。他们拥有庞大的计算基础设施,涉及一百万个CPU核心和数不清的硬盘,仅仅是为了获取从粒子加速器收集的所有数据并进行处理、分析,试图以此发现新粒子。这种规模的任务根本无法在单台计算机上完成,因为没有足够大的超级计算机能够处理如此海量的数据。因此,分析如此庞大数据的任务必须在由许多小型计算机组成的网络上完成,这些计算机必须在它们之间分配工作,并协同合作以完成科学家试图实现的任务。这确实是我们可以使用分布式系统的另一个关键领域,即解决更大的问题。
分布式系统的挑战
上一节我们探讨了分布式系统的优势,本节中我们来看看其面临的重大挑战。
以上是使系统分布式的优点。不幸的是,也存在一些显著的缺点。主要缺点大家都很熟悉:每当你试图通过网络做某事时,网络可能因某种原因无法工作。每个人都经历过互联网中断、Wi-Fi信号弱或在乡村地区蜂窝数据信号弱导致无法加载地图等情况。每个人都经历过这种挫败感。而分布式系统的研究,很大程度上就是研究这种挫败感。


因此,我们在构建涉及网络通信的分布式系统时,总是假设网络并非完全可靠。通信总是可能失败。原因可能是:如果是Wi-Fi网络,你可能超出了范围;如果是有线网络,可能有人拔错了线缆;任何类型的网络都可能暂时过载,从而丢弃消息;甚至可能有人恶意试图干扰网络,导致丢包,例如发起拒绝服务攻击。通信时不时失效的原因有很多。因此,我们必须构建健壮的系统,即使通信偶尔中断,整个系统也能以某种正确的方式继续运行(当然,我们可以定义“正确”的含义),我们希望它能继续运行。
分布式系统中可能出错的另一件事是,运行代码的某些进程可能会崩溃。例如,如果你有一个由多台计算机组成的系统,并且你重启了其中一台,你可能希望剩余的计算机继续承担服务用户请求的任务。因此,我们这里希望的是,即使一个进程暂时停止工作,整个系统仍然能够继续运行。
最后,所有这些故障都可能非确定性地发生。也就是说,我们根本不知道它们何时发生,它们可能在任何不可预测的时刻发生,而我们仍然必须确保软件能够正常工作。
因此,我们在分布式系统中经常追求的是所谓的容错性。这意味着即使系统的某些部分出现故障、某些部分无法工作,整个系统仍然继续为用户提供服务。所以,这是分布式系统的主要挑战之一:我们如何构建能够容忍故障的系统?
事实上,使系统分布式和容错是如此困难,以至于许多专业从事分布式系统工作的人认为:如果你能在单台计算机上解决问题,那基本上就很简单。所以,如果你能在单台计算机上解决问题,最好就让它保持在一台计算机上,不要把事情复杂化,不要进入分布式系统的领域。因此,对于分布式系统带来的所有问题(这个潘多拉魔盒),我们只有在确实必须时才应该尝试打开它。但正如我们之前所说,有很多充分的理由可能迫使你打开那个盒子。
总结

本节课中我们一起学习了分布式系统的基本介绍。我们了解了分布式系统与单机并发系统的核心区别,即引入了网络通信和缺乏共享地址空间。我们探讨了构建分布式系统的四大动机:处理本质分布式的应用、提高系统可靠性、提升性能(尤其是通过地理分布减少延迟)以及解决单机无法处理的大规模问题。同时,我们也认识到分布式系统面临的主要挑战是网络和节点的不可靠性,以及由此带来的容错需求。理解这些基本概念是深入学习后续分布式系统技术和算法的基础。
002:分布式系统与计算机网络
在本节课中,我们将探讨分布式系统与计算机网络之间的关系。我们将从分布式系统的基本通信抽象开始,了解不同类型的网络和通信机制,并通过分析万维网(Web)这一具体实例,来理解分布式系统中的消息传递是如何在实际中运作的。
节点与消息:分布式系统的基本抽象
在分布式系统中,任何参与计算的设备都被称为一个节点。节点可以是手机、笔记本电脑、数据中心里的服务器,或是我们之前讨论过的任何其他设备。
分布式系统最根本的通信抽象是:一个节点可以向另一个节点发送一条消息。这就是分布式系统的全部基础。这个抽象涵盖了所有可能发生的通信模式。
多样化的网络与通信机制
在实践中,存在多种不同类型的网络和消息传递方式。以下是一些例子:
- 如果你在大学,可能使用大学或学院提供的Wi-Fi。
- 如果你在家,可能使用家庭宽带连接。
- 如果你在咖啡馆,可能使用那里的Wi-Fi。
- 如果你在户外,可能使用手机上的蜂窝数据。
这些只是你直接连接的网络。在这些网络之上,还有构成互联网的主干网络,由各种公司运营,形式多样:有些通过铜缆,有些通过光纤,有些通过海底电缆,有些通过卫星等等。
此外,还有各种物理通信机制,例如电线上的电脉冲、无线电波、光纤中的激光,甚至卡车里的硬盘。卡车里的硬盘?这听起来不像网络连接,但我认为它也是一种将消息从A点传递到B点的机制。
如果你有大量数据需要发送给他人(例如传输到另一个数据中心),通过互联网传输可能既慢又昂贵。实际上,将所有数据加载到一堆硬盘上,把硬盘装进卡车,然后开车运到目的地,可能更有意义。当卡车到达后,你可以将数据从硬盘下载并存储到需要的系统中。这在实践中确实有人这么做。例如,亚马逊网络服务(AWS)就提供这样的服务:你可以从他们那里租用一个硬盘盒,通过快递服务寄送,将数据加载到上面(一个盒子大约能装50TB),然后再寄回。当然,这是一种非常慢的网络形式,因为数据可能需要一两天才能到达。但它也是一种高带宽的网络形式,可以传输大量数据。从分布式系统的角度来看,这只是另一种消息传递通道,是另一种将消息从A点送到B点的方式。区别仅在于不同网络通道的延迟和带宽。
延迟与带宽:网络通道的关键指标
延迟因网络类型而异,变化巨大。
- 在家庭Wi-Fi内的设备之间,或同一数据中心内的服务器之间,延迟可能在1毫秒左右,甚至更少(取决于网络拥塞程度)。
- 如果通过互联网与位于不同大洲的服务器通信,延迟可能在100毫秒的量级。
- 卡车里的硬盘可能需要一两天,但它仍然只是一个网络通道。
带宽同样变化巨大。
- 3G手机的信号好时可能达到1兆比特/秒,信号差时可能只有100千比特/秒。
- 家庭宽带可能在10兆比特/秒左右(具体取决于你与供应商的距离、是否使用光纤等因素)。
- 粗略估计,卡车里的硬盘带宽:如果你使用一个盒子,整个过程(加载数据、运输、卸载数据)需要几天时间,其带宽大约为1吉比特/秒。当然,你可以发送多个盒子来提高这个数字。
这些只是非常粗略的数字,旨在让你了解我们讨论的数量级。
案例分析:万维网(Web)
现在,我想展示一个具体的分布式系统实例,这是一个你每天都在使用的系统:万维网。我们可以通过分布式系统的视角来分析它,看看在网页浏览器中加载网站时到底发生了什么。
在这个过程中,有两个通信实体:
- 客户端:运行在你电脑或手机上的网页浏览器。
- 服务器:通常位于某个数据中心。


当客户端想要显示一个网页时,它会发出一个请求。请求只是一个通过网络发送的消息,它指向特定的服务器,并包含你试图加载的URL路径。
在URL中,http://或https://之后,第一个/之前的部分是服务器名,它是消息发送的目的地。第一个/之后的部分是路径,它包含在发送给目的地的消息中。
如果你想加载一个网页,我们称这种消息为 GET请求,它包含路径。服务器随后会用该网页的内容进行响应。这可能是一个HTML页面(如果是纯网页),也可能是一张图片、一个视频文件、一个PDF文档等等。
因此,我们这里有一个简单的协议,由两条消息组成:一条从客户端到服务器的请求,以及一条从服务器返回客户端的响应(包含客户端请求的数据)。

网络消息捕获演示
接下来,我想通过演示来展示这在实际中的样子。当然,你知道网页浏览器长什么样。这里我加载了课程网页,可以上下滚动。
现在,我将使用一个网络捕获工具来记录通过网络发送和接收的消息。这里我将使用一个名为 Charles 的工具,并将其置于录制模式。它会开始录制,然后我回到浏览器,重新加载网站。



页面重新加载后,回到Charles,我们可以看到已经积累了一大堆请求。这里的每一行都是一个从网页浏览器发送到服务器的请求消息,同时也包含了从服务器返回客户端的响应。
例如,这里的第一个请求是针对这个路径的,它与浏览器URL栏中的路径匹配。你可以看到这是一个加载特定网站的GET请求,还能看到一堆信息,比如这里的用户代理,它本质上标识了我正在使用的网页浏览器版本。这里的Accept头部,表示网页浏览器能理解哪些文件类型(如HTML和各种图像格式等)。还有很多其他信息,比如它接受哪种压缩方式、我说什么语言等等。
在这个面板中,你看到的是请求消息。在下面的面板中,你看到的是响应消息。对这个请求的响应包含了一些HTML,你可以看到整个HTML页面的内容(虽然有点枯燥)。我们还可以看看其他请求,比如这个看起来像一张图片,这里是一些CSS样式文件等等。
因此,在概念层面上,我们拥有这个非常简单的请求-响应消息协议。在分布式系统层面,我们只讨论一条消息和一条响应。
物理网络层面的复杂性

然而,在底层物理网络层面发生的情况要复杂一些。我们可以使用另一个网络捕获工具来观察物理层面发生的事。这里我将使用一个名为 Wireshark 的工具。它将捕获单个网络数据包,而不是在HTTP层面捕获请求和响应。
让我们看看这里发生了什么。我开始录制,然后回到网页浏览器再次重新加载。完成后,回到Wireshark停止录制。现在我们看到,哦,又是一大堆活动。
在Wireshark中,表格中的每一行都是一个单个网络数据包。你可以看到一个数据包从一个IP地址发送到另一个IP地址。例如,192.168... 是我录制视频的笔记本电脑的IP地址,128.232... 是我正在连接的计算机实验室网站的服务器IP地址。
你可以看到它使用TCP协议进行连接。不必过于担心这个细节,你会在计算机网络课程中学到更多关于TCP的知识。但这里的要点是,有很多来回的通信。然后这里发生了一些加密,因为这是一个HTTPS连接,你可以看到这里是我正在连接的网站名称等等。
因此,在物理网络层面,发生的情况是所有这些网络数据包来回传输。我们有很多数据包的原因是每个数据包都有最大尺寸限制。你可以在这里的“长度”列看到每个数据包的大小(以字节为单位),长度最多大约到1500字节,没有比这更大的了。这是因为网络有数据包的最大尺寸限制。
所以,如果你想发送一条大于单个数据包尺寸的消息,就必须将其拆分成多个数据包。这正是TCP协议所做的事情。HTTP运行在TCP之上。HTTP有请求消息-响应消息的模式,但一个请求可能大于单个网络数据包,一个响应也很可能大于单个网络数据包。因此,HTTP使用底层的TCP,TCP将这些大消息分解成足够小的网络数据包,以便网络能够传输它们。然后在接收方一侧,TCP将所有网络数据包重新组装起来,形成一个大的字节块。
分布式系统的视角:消息与数据包
这是分布式系统中需要牢记的一个重要概念:当我们谈论发送和接收消息时,我们谈论的不是单个数据包,因为一条消息很可能大于单个网络数据包。因此,消息必须被分解成小的网络数据包。消息是节点之间来回传递的更高层次的概念对象。
至于如何精确地将其分解到数据包层面,那是计算机网络领域人员关心的事情。从分布式系统的角度来看,我们可以假设我们拥有这种机制,通过它我们可以发送这些请求和响应,而不必过多关心这些在物理网络数据包层面是如何具体表示的。我们只需假设我们拥有这种通信机制。
总结



在本节课中,我们一起学习了分布式系统与计算机网络的关系。我们了解到,分布式系统的核心抽象是节点间的消息传递。我们探讨了现实中多样的网络类型(如Wi-Fi、蜂窝网络、光纤乃至“卡车里的硬盘”)及其关键指标——延迟与带宽的巨大差异。通过分析万维网这一实例,我们看到了客户端与服务器之间简单的请求-响应协议是如何在高层运作的。最后,我们认识到,在物理网络层面,这些高层消息会被拆分为更小的数据包进行传输,但对于分布式系统设计而言,我们更关注消息层面的抽象,而将数据包处理的细节留给底层的网络协议。
003:远程过程调用 (RPC) 🖥️➡️🌐
在本节课中,我们将要学习分布式系统中的一种核心通信机制——远程过程调用。我们将通过一个在线支付的例子,了解RPC的工作原理、实现方式以及它在现代软件架构中的应用。
概述
上一节我们介绍了客户端-服务器模型作为分布式系统的一个例子。本节中我们来看看另一个非常实用的例子:在线购物支付系统。这个例子清晰地展示了不同公司提供的服务如何通过网络协同工作。
在线支付:一个分布式系统实例
想象你正在使用一个在线商店购物。你选择商品后,需要输入信用卡信息进行支付,然后商品才会被寄送给你。这里发生的情况是:实际处理信用卡支付的支付服务,通常由一家与运营在线商店不同的公司提供。
因为处理信用卡支付是一项非常专业化的活动。所以,有公司专门从事信用卡支付业务,而另一家公司则专注于提供商店和商品,这是合理的。
那么具体过程是怎样的?当你在某个在线商店输入信用卡号时,在线商店会向支付服务发送一条消息。该支付服务运行在由另一家公司提供的不同节点上。
这条消息将包含你的卡号详情、扣款金额以及其他各种信息,例如账单地址等。所有这些信息都被发送到支付服务。

支付服务随后会进行一系列复杂的操作:它与卡支付网络(如Visa、Mastercard)通信,卡支付网络再与发卡银行通信。它们确保你的账户有足够的资金,并确认可以完成扣款。如果成功,它们会反向完成所有流程,并可能进行额外检查,例如让你输入密码或发送短信验证。最终,支付服务会向在线商店返回一条消息,指示支付是否成功。这是一个每天都在真实使用的、非常具体的分布式系统实例。
代码视角:远程过程调用
现在让我们看看代码可能是什么样子的。假设你是为在线商店方工作的程序员之一。
你正在开发运行在线商店的软件,并编写处理信用卡支付以及与支付服务通信的代码。你写的代码可能类似这样:假设代码库中有一个Card对象,你可以创建它的新实例,并传入信用卡号、有效期和背面三位安全码,将这些信息打包成一个Card对象。
let card = new Card(number, expiry, cvv);
然后你调用一个函数。你有一个包含支付服务的对象,并调用其上的processPayment方法。你传入想要扣款的卡对象、扣款金额以及货币类型。然后,根据操作是否成功,你执行订单履约。

这里发生的事情很有趣。因为仔细想想,支付服务运行在不同的节点上,由不同的公司运营,它并不属于在线商店的代码库。
所以,当你调用这个processPayment函数时,该函数的实现并不在你的程序中。其实现位于互联网连接另一端的某个节点上。实际上,这个看起来像函数调用或方法调用的操作,在底层被转换成了某种网络通信。
这被称为远程过程调用。也许“远程函数调用”是更好的名字,但“远程过程调用”是这个概念的常用术语,我们将沿用这个名称。Java称之为远程方法调用,这本质上是同一个概念的不同说法。

RPC 的工作原理
让我们看看RPC是如何工作的。RPC通常由一个称为RPC框架(有时也称为中间件)的软件实现。这个软件负责在你的编程语言中的函数调用和网络的消息传递之间进行转换。
当在线商店的代码调用processPayment函数时,它无法直接调用另一个节点上的函数,因为你的编程语言通常不支持这样做。相反,RPC框架提供了一个称为存根的东西。
这个存根函数具有与你想调用的远程节点上的函数相同的类型签名,看起来也一样。你想调用processPayment函数,我们创建的就是这个存根函数。它并不实际处理支付,而是向实际处理支付的服务发送消息。
因此,在线商店调用这里的存根函数,RPC客户端需要获取传递给该函数的参数,并将它们转换为可以通过网络发送的消息。这个从编程语言参数到网络消息的转换过程称为编组。
编组(我也称之为编码)接收函数参数,并以某种可以发送到网络的方式进行编码。例如,它可能使用JSON。在这个例子中它使用了JSON,也可以使用某种二进制格式。具体格式并不重要,但它最终变成了一串字节,我们可以将其打包成消息通过网络发送。

在接收方,RPC服务器将收到这条消息,并将其转换回服务器端的函数调用。现在,这里才是processPayment函数实际实现所在的地方。在RPC服务器端,这个函数将被调用,并执行处理支付所需的所有操作,这可能包括与卡网络和银行通信、更新数据库以及执行一系列额外操作。
最终,这个函数将返回,并希望它能告诉我们信用卡支付是否成功。同样,这个函数的返回值也需要被编组。我们以相反的顺序执行完全相同的操作:RPC服务器编组函数的返回值,将其作为消息M2发送回网络;RPC客户端解组它,将其转换回你所用编程语言的数据类型;然后函数最终返回。
这样,我们就实现了一种“伪装”,让我们感觉像是在本地调用函数,但实际上底层是通过消息与另一个服务进行通信。
位置透明性与现实挑战
我们在这里追求的理想状态是:对一个不同服务的函数调用,看起来就像一个本地函数调用。因为我们知道编程语言中的函数是什么样子。我们非常希望RPC看起来就像那样。这个原则被称为位置透明性。
资源的位置——即你调用的processPayment对象是在你自己进程的本地地址空间中,还是在互联网连接的另一端——我们希望这是透明的,我们希望无法区分这两者。
然而,现实并非如此简单。正如开头所说,网络的问题在于它可能失败。你可能通过网络发送了一条消息,但消息没有到达。当然,你可以重发,但那样你会多次扣款吗?如果我们打算重发消息,必须非常小心。
消息可能丢失,我们如何处理重试?消息也可能延迟。可能消息实际上已经送达,但由于某种原因,网络中的某个小故障导致消息延迟了一段时间,这也可能发生。还可能发生的情况是,处理此函数的服务在执行我们函数的过程中崩溃了。在这种情况下,它将无法发回响应,但它可能已经部分处理了我们的请求。
我想我们可以在这里使用事务,是的,ACID。可以看到这里有很多开放性问题。从根本上说,调用远程资源的函数调用似乎与调用本地函数完全不同。它们根本就是两件不同的事。我们可以让它们看起来有些相似,可以给函数类似的类型签名,但是调用远程函数有所有这些在本地执行中根本不会出现的错误情况。

RPC 的发展历程
但这并没有阻止人们长期以来尝试构建看起来像在调用本地函数的RPC框架,即使你是在调用另一个节点上的东西。这可以追溯到80年代,当时“RPC”这个术语首次被创造出来。
像一些微系统就有这个东西,它被用来构建NFS(网络文件系统),我们将在课程后面更多地看到它。然后在1990年代,这是一个非常热门的话题,人们谈论面向对象的中间件,出现了像CORBA这样的东西,当时非常热门,但现在没人用了。后来出现了一系列竞争技术,比如Java的远程方法调用,与CORBA属于类似类型。
我们不会深入所有这些细节,只是在这里提一下名字,因为你可能在职业生涯中遇到它们,至少以前见过是好的。就在2015年,谷歌推出了另一个RPC框架,叫做gRPC。如果你想做RPC,你可以使用它,也可以使用其他东西,这并不重要。
如今非常常见的是被称为REST的东西。它代表“表述性状态转移”,有点拗口,但它实际上是一套关于如何使用HTTP的原则。REST的狂热爱好者不喜欢将REST视为RPC,他们喜欢将REST和RPC视为两种不同的哲学。但本质上它们是同一种东西。它们仍然是关于通过向远程服务发起网络请求来调用代码并使其发生某些事情,并以编程语言中合理优雅的方式将整个过程打包。
RESTful 风格的 RPC
这种构建RPC的RESTful方法变得如此流行的一个原因是,网络浏览器支持发起HTTP请求。你可以在网络浏览器中运行一些JavaScript代码,这些代码可以向服务器发起HTTP请求,构造它想要在请求中发送的任何数据,发送出去,并可以接收服务器返回的响应。这种技术有时也被称为AJAX。
它看起来像这样:如果我想用JavaScript实现这个信用卡支付的例子,我可能首先创建一个包含RPC函数调用参数的对象args,其中包含金额、货币、卡号、账单地址等。

let args = {
amount: 100,
currency: "USD",
card: {number: "1234...", expiry: "12/25", cvv: "123"},
billingAddress: "..."
};
然后我构造这个请求对象,指定我想使用的HTTP方法。POST是通常用于提交表单、在服务器上更改某些状态的方法。我现在将使用JSON.stringify函数将参数转换为JSON字符串,并可以告诉服务器我提供的数据类型是JSON。
let request = {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(args)
};
fetch("https://example.com/payments", request)
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error(`HTTP ${response.status}`);
}
})
.then(data => success(data))
.catch(error => failure(error));
然后我们在这里有两个处理程序。then情况下的回调函数在网络请求收到成功响应时被调用。如果我们从服务器得到了响应,那么这里的response变量就变成了那个响应,然后我们执行这里的代码。我们可以查看从服务器得到的响应,看它回复的状态码是否表示成功。如果表示成功,响应可能又是JSON,我们可以通过调用response.json()将其解组回JavaScript对象,然后调用success函数来指示我们从服务器得到了成功的状态。否则,如果状态码不成功,我们可以用状态码调用failure函数。当然,我们可以做更详细的错误处理,但这只是基本思路。
这是我们从服务器得到响应的情况。当然,消息可能丢失。所以我们可能没有从服务器得到响应。在这种情况下,例如,请求可能只是等待30秒,如果30秒内没有收到任何东西,它就会说超时。我们放弃。消息可能已经送达,也可能没有,我们并不真正知道,但我们会给你一个网络错误。这就是这里发生的情况。
所以在这个catch情况下,我们有另一个回调函数,如果网络请求没有从服务器得到响应,它就会被调用。在那种情况下,我将再次调用那个failure函数并传递错误。在实践中,你可能想向用户显示消息并解释出了问题。
无论如何,这就是你如何进行RPC的基础知识,以及如何在JavaScript中通过HTTP使用REST API。正如我所说,这种模式在网络浏览器中变得非常流行。现在大多数网站都会使用这种技术向服务器发起HTTP请求而无需重新加载整个页面。它只是在你的网络浏览器中运行的JavaScript,就像一个客户端程序,执行这些网络请求。
企业级系统中的 RPC
但非常类似的技术也用于服务器间的通信。这种技术在大型公司中尤其流行。
在大型企业中,你有这些运行公司所有运营的庞大软件系统。这些软件系统太大、太复杂,无法仅仅作为一个程序运行在一台计算机上。有时仅仅因为其巨大的复杂性,有时也因为它们必须处理的大量数据,它们是分布式系统。所以你有多台计算机上的多个节点,共同提供这个大型组织(可能是一家银行或零售商)的部分基础设施。

这一大堆软件被分解成所谓的服务。将一大块软件分解成服务的方法被称为面向服务的架构,或者现在一个更现代的术语是称之为微服务。
这只是让程序运行在不同的节点上,通过RPC进行通信。因此,RPC是允许这些软件片段相互交互的关键机制。
让我告诉你更多关于在这种设置下的RPC。我们已经在网络浏览器中见过它,但在面向服务的架构中,你通常谈论的是在数据中心内运行的服务。它们可能运行在同一个数据中心的多台不同服务器上,但通常它们都是通过数据中心网络进行通信的服务。这些不是运行在你的终端用户设备(如手机或笔记本电脑)上的服务,而是数据中心端的服务。但无论终端用户设备与服务器通信,还是服务器与其他服务器通信,我们都可以同样好地使用RPC。
在这些大规模企业系统中,你会遇到它们使用一大堆不同的编程语言,因为它们有一些旧的系统。它们可能有一些用COBOL写的旧系统,然后有一些用C++写的稍新的系统,接着有一些用Java写的更新的系统,然后还有用当前最新、最时尚的编程语言写的最新系统。这些系统需要相互通信,因此RPC实际上可以提供一种机制,实现这些用不同编程语言编写的系统之间的互操作性。
接口定义语言
这确实意味着,如果函数调用者和被调用的代码是用不同的编程语言编写的,你现在必须确保类型签名能以某种方式匹配,因此必须执行一些数据类型转换。


这通常通过一种称为接口定义语言的东西来实现。IDL本质上是一种用于指定函数调用类型签名的方式,它不特定于任何一种编程语言。
这是一个IDL的例子。这个例子取自gRPC,谷歌的RPC框架,它使用一种叫做Protocol Buffers的IDL。细节并不重要,我只是想让你感受一下它是什么样子。
在这里,你可以有一个消息类型的规范。我们可以有一个PaymentRequest消息和一个PaymentStatus消息作为两种不同的消息类型。然后在这里,我们有一个服务的规范。一个PaymentService提供一个名为ProcessPayment的RPC函数调用。它以PaymentRequest作为参数,并返回PaymentStatus作为其返回值。
message PaymentRequest {
Card card = 1;
int32 amount = 2;
Currency currency = 3;
}
message Card {
string number = 1;
string expiry = 2;
string cvv = 3;
}
enum Currency {
USD = 0;
EUR = 1;
GBP = 2;
}
message PaymentStatus {
bool success = 1;
string transaction_id = 2;
}


service PaymentService {
rpc ProcessPayment (PaymentRequest) returns (PaymentStatus);
}
然后在这里我们可以说,PaymentRequest由什么组成?它由一个Card、amount和currency组成。而Card又由卡号、有效期和背面三位安全码组成。currency可能是一个枚举,指示可能的值。忽略这里的12、34这些值,其细节对本课程的目的并不重要。
但你可以看到,我在这里使用像int32这样的数据类型来表示这是一个32位有符号整数,这是一种相当通用的数据类型,你会期望大多数编程语言都支持这种东西。同样,string数据类型非常常见。枚举,如果语言不支持枚举,可以将其转换为整数之类的东西。bool也是一种常见的数据类型。你可以争论卡号应该是字符串还是足够位数的整数,以便容纳16位数字,无论我们怎么考虑。但你明白了基本原则:这是一个独立于语言的规范,描述了RPC的样子。
RPC框架可以获取这个IDL规范,并在你所有喜欢的编程语言中生成代码。这样,它就可以为RPC客户端和RPC服务器生成存根,使得在调用方和被调用服务方编写执行RPC的代码变得容易。
总结

本节课中我们一起学习了远程过程调用。我们从一个在线支付的实例开始,了解了RPC如何让不同节点上的服务像调用本地函数一样进行交互。我们探讨了RPC的基本工作原理,包括存根、编组和解组过程,以及追求“位置透明性”的理想与现实网络不可靠性带来的挑战。我们还回顾了RPC的发展历程,从早期的CORBA到现代的gRPC和广泛使用的RESTful API。最后,我们看到了RPC在大型企业微服务架构中的关键作用,以及如何使用接口定义语言来实现跨编程语言的互操作性。RPC是构建分布式系统,尤其是服务间通信的基石。下次课,我们将更深入地探讨构建可靠分布式系统所面临的基本问题。
004:系统模型与两将军问题 🧠
在本节课中,我们将从具体的分布式系统实例转向更抽象、更通用的模型。我们将探讨分布式系统的系统模型,即设计分布式算法时所做的一系列假设。系统模型至关重要,因为它精确地定义了系统中可能发生和不可能发生的故障类型。
上一节我们介绍了客户机-服务器系统,本节中我们来看看两个经典的分布式系统思想实验:两将军问题和拜占庭将军问题。我们将从两将军问题开始。
两将军问题 ⚔️
这个问题的背景是一个军事类比。有两支军队,每支军队由一位将军指挥。他们计划共同进攻并占领一座城市。



这座城市防御坚固。如果只有一支军队单独进攻,它将被击败。因此,至关重要的是,如果两位将军决定进攻,他们必须同时行动,这样两支军队才能获胜。
以下是可能发生的情况:

| 将军A进攻? | 将军B进攻? | 结果 |
|---|---|---|
| 否 | 否 | 无事发生 |
| 是 | 否 | 将军A战败 |
| 否 | 是 | 将军B战败 |
| 是 | 是 | 胜利 |

从真值表可以看出,我们真正希望的是:一支军队进攻当且仅当另一支军队也进攻。即双方必须共同行动。
公式表示:
进攻(将军A) == 进攻(将军B)
然而,困难在于两位将军无法直接对话来商定进攻计划,他们只能通过信使进行通信。信使在穿越森林时,可能会被城市守军捕获。


因此,每当一位将军向另一位将军派遣信使时,消息可能送达,也可能丢失。发送方除了收到回复外,无法知道消息是否成功送达。
通信的不确定性 🔄
让我们设想一个场景。例如,将军1决定在11月10日进攻。他发送消息给将军2:“我们将在11月10日进攻,你同意吗?”
将军2收到消息后回复:“同意,我们将在11月10日共同进攻。” 但不幸的是,回复消息被捕获了。因此,将军1没有收到回复。

这是可能发生的一种情况。另一种情况是:将军1发送的初始消息“11月10日进攻”在途中丢失,从未送达将军2。将军2没有收到任何消息,因此也不会回复。最终结果同样是将军1没有收到回复。
在这两种情况下,将军1观察到的唯一现象都是没有回复。但将军1无法区分:究竟是因为初始消息未送达,还是因为回复消息丢失了?这两种情况对将军2而言截然不同:第一种情况将军2已同意进攻;第二种情况将军2甚至不知道进攻计划。
设计协议的困境 🤔
让我们尝试设计一个能让两位将军达成一致的算法。
首先,从将军1的角度看,他有两个基本选择:
- 无论是否收到回复,将军1都总是进攻。
- 将军1等待并只在收到将军2的肯定回复后才进攻。




考虑第一种选择:将军1总是进攻。
为了让将军2也进攻,将军1可能会向将军2发送大量信使,反复传递进攻消息。如果其中一条消息送达,将军2知道将军1总会进攻,因此将军2参战是安全的。然而,有可能所有信使都丢失了。这种情况下,将军2不知道进攻计划,将军1将孤军奋战并失败。因此,这个选择并不理想。
考虑第二种选择:将军1只在收到肯定回复后进攻。
这样将军1是安全的,因为他知道只有在将军2也参战时自己才会进攻。但现在,从将军2的角度看,问题出现了。将军2知道,将军1只有在收到自己的回复后才会进攻。因此,将军2陷入了与第一种选择中将军1相同的困境:要么承诺总是进攻(有孤军奋战的风险),要么等待将军1的回复。
这可能导致一个潜在的无限消息链:“我会进攻。”“好的,如果你进攻我就进攻。”“我说了我会进攻。”“但只有在你进攻的前提下我才进攻。”…… 他们需要来回发送消息,在获得双方必将共同进攻的绝对确定性之前,可能会形成无限循环。
问题的核心:缺乏共同知识 🧩

在分布式系统中,这被称为缺乏共同知识的问题。系统中没有一种知识状态,使得一个节点知道某事,并且知道另一个节点也知道此事,并且知道另一个节点知道我知道此事…… 如此循环。无论我们来回发送多少有限序列的消息,都无法获得绝对的确定性,保证将军1进攻当且仅当将军2进攻。
核心结论: 在可能丢失消息的异步通信模型中,无法确保两个分布式进程就一项协作行动达成完全一致的共识。


现实世界的类比:在线支付系统 💳
现在,让我们将这个抽象的思想实验应用到一个具体例子中。上一讲我们提到了在线商店通过RPC调用支付服务来扣款的例子。



我们真正希望的是:在线商店发货,当且仅当客户成功付款。

因为:
- 如果商店发货了,但支付服务未扣款,商店会蒙受损失(免费送出了商品)。
- 如果商店未发货,但支付服务扣了款,客户会不满(被收费却未收到商品)。


这看起来与两将军问题极其相似:发货(商店) == 扣款(支付服务)。

在线商店与支付服务之间的RPC调用,就像两将军问题中穿越森林的信使。消息(请求或响应)可能丢失。因此,实际上在线商店和支付服务无法绝对确定地保证“一个动作发生当且仅当另一个动作发生”。

实践中的解决方案 🛡️
然而在实践中,在线购物系统确实能工作。原因是存在一系列二级保障机制来确保一个合理的结果。
以下是几种常见的处理方式:
- 撤销操作:支付扣款是一个可撤销的操作。如果商店缺货无法发货,它可以向客户道歉并退款,使系统回到安全状态(既未发货也未有效扣款)。
- 对账与查询:当网络恢复后,在线商店可以主动查询支付服务:“我之前请求的那笔扣款,到底成功了没有?” 支付服务可以告知结果,双方据此协调状态。
- 倾向于执行并补偿:在某些设计中,支付服务可能倾向于先执行扣款,即使它不确定商店是否会发货。因为如果后续出现问题,仍然可以通过退款来补偿。这引入了一个“最终一致性”的模型,而非强一致性。



正是这些机制,使得在线购物问题在实践中不等同于无解的两将军问题。但两将军问题依然深刻地揭示了分布式系统中,由于消息传递的不确定性而带来的根本性挑战。
总结 📚
本节课我们一起学习了分布式系统中的一个基础思想实验——两将军问题。我们了解到:
- 在异步通信且消息可能丢失的模型下,两个进程无法就一项协作行动达成完全确定的共识。
- 问题的核心在于缺乏共同知识,以及确认消息的确认消息本身也可能丢失,导致无限递归。
- 该问题揭示了分布式系统协调行动的根本困难。
- 在现实系统中,我们通过引入可撤销操作(如事务)、对账机制和补偿流程等方法来规避这一理论上的不可能性,实现业务的最终一致性。



理解两将军问题,是理解更复杂的分布式共识算法(如Paxos、Raft)和系统模型的重要基础。在下一讲中,我们将探讨另一个经典问题:拜占庭将军问题。
005:拜占庭将军问题 🏰
在本节课中,我们将要学习分布式系统中的一个经典思想实验——拜占庭将军问题。我们将探讨其基本设定、核心挑战,以及它在现实世界中的实际意义。
概述
拜占庭将军问题是一个关于在存在恶意或不可靠节点的情况下,如何达成共识的分布式系统模型。它比之前讨论的两将军问题更复杂,因为它引入了“叛徒”(即恶意节点)的概念。
问题设定
上一节我们介绍了两将军问题,本节中我们来看看一个更复杂的场景。拜占庭将军问题最初看起来与两将军问题相似。我们同样有将军率领军队,军队希望攻击一座城市。但在这个问题中,我们可能有三支或更多的军队,数量不限。
与之前一样,将军们通过信使进行通信,并且我们希望军队就攻击日期达成一致,以确保它们同时发起攻击。
现在,我们在一个方面使问题变得更容易,在另一个方面则使其变得更难。首先,更容易的方面是我们假设消息传递是可靠的。在这种情况下,我们假设信使不会被俘获,任何被发送的消息都会被适当的接收者收到。

为了使问题变得更难,我们现在假设一些将军实际上并不忠诚,他们是叛徒。他们是恶意的,会试图积极破坏其他将军的计划。他们会撒谎、欺骗,并以他们希望的任何方式胡作非为,他们甚至可能协同工作。尽管如此,我们希望诚实的将军们能就攻城计划达成一致。
核心挑战
让我举一个例子,说明由于某些将军的恶意行为可能引发的问题。

在这个例子中,将军1向将军2和将军3发送了“进攻”的消息。将军3如预期收到了该消息。将军2收到了“进攻”消息,然后却向将军3发送了一条消息,说“撤退”。
这里的将军2正在撒谎。将军2声称将军1发送了“撤退”消息,而实际上将军1发送的是“进攻”消息。所以在这种情况下,将军2是恶意的。
不幸的是,从将军3的角度来看,很难分辨到底发生了什么。因为从将军3的角度看,同样可能发生的是另一种情况。


也可能是将军1向将军3发送了“进攻”消息,而向将军2发送了“撤退”消息,然后将军2只是诚实地转达了它从将军1那里收到的“撤退”消息。在这种情况下,将军2是诚实的,因为它只是如实报告了将军1所说的话。而将军1才是恶意的,因为它向将军2和将军3发送了相互矛盾的命令。
这就是问题的核心所在。从将军3的角度来看,无法区分这两种情况,因此将军3无法判断是将军1还是将军2是恶意的。
问题定义
鉴于节点可能撒谎的场景,拜占庭将军问题,正如我所说,其核心在于我们希望所有诚实的将军能达成一致协议。
现在的问题是,诚实的将军不知道谁是恶意的将军,但我们将假设恶意将军的数量有一个上限。假设在总共N名将军中,最多有F名是恶意的。
诚实的将军不知道谁是恶意的,但恶意的将军可能知道彼此是谁。因此,他们甚至可能以某种协调的方式合作,试图欺骗和愚弄诚实的将军。尽管如此,我们的要求是诚实的将军们就一个计划达成一致。
我们无法声称能让恶意的将军同意计划的任何部分,因为我们假设他们可能以任意方式胡作非为。因此,我们无法对他们的行为做出任何断言。但我们可以对诚实的将军将要做什么做出断言,那就是我们希望他们都在同一天发起进攻。
解决方案与限制
拜占庭将军问题有几种不同的变体,其细节设置各不相同。但关于其中一些变体,一个典型的结果是:如果我们最多有F个恶意将军,那么我们需要至少 3F + 1 名将军来容忍这F个恶意将军的存在。
这意味着恶意将军的数量必须少于总将军数的三分之一。例如:
- 如果我们有三名将军,其中一名是恶意的,那么问题无法解决。
- 为了容忍一个恶意将军(F=1),我们至少需要四名将军(三名诚实的和一名恶意的)。
- 如果我们希望一个系统中可能有两名恶意将军(F=2),我们实际上需要七名将军(五名诚实的和两名恶意的)。
密码学的作用
如果假设我们可以使用密码学,问题会变得稍微容易一些。本课程不会深入探讨其细节,你将在明年的安全课程和密码学课程中了解更多。
但我们可以在这里使用一种叫做数字签名的技术。这是一种可以证明某个特定方发送了特定消息的消息形式。例如,在这种情况下,它将允许将军2向将军3证明将军1发送给将军2的命令是什么,并以一种让将军3确信将军2确实是诚实的方式来证明。
因此,密码学确实有帮助,但它并不能神奇地让拜占庭将军问题变得简单。即使我们假设可以使用密码学,问题仍然很困难。
现实意义
你可能会问,这到底有没有实际意义?让我们来调整一下之前多次提到的在线商店和支付服务的例子。

这次,我们把客户作为第三方加入进来。这样,我们就在商店、支付服务和客户之间形成了一种三方关系。我们希望这三方都能就交易状态或订单状态达成一致。
我们希望在线商店只有在它与支付服务就“付款确实已发生”达成一致,并且客户确实同意他们订购了此商品,客户也同意从其卡中扣除的金额等情况下,才发货。
所有这些参与方都需要达成一致。在现实生活中,这三方之间的信任关系可能相当复杂。

从在线商店的角度来思考:
如果存在某种方式让人们可以在不付款的情况下从在线商店订购商品,你可以想象欺诈者会很快利用这一点,他们会订购各种昂贵的东西而不付款。那么在线商店就不会高兴。因此,从在线商店的角度来看,客户需要被视为潜在的恶意方,因为如果盲目信任客户,他们可能会开始进行此类欺诈活动。所以在这种情况下,我们确实存在这种不信任的关系。
在线商店和支付服务之间的关系呢?你可以想象,也许支付服务并不完全信任在线商店,否则有人可能会建立一个欺诈性的在线商店,使用(例如)被盗的信用卡号来处理交易,在不实际运送任何真实商品的情况下获取资金。因此,在这种情况下,可能支付服务并不完全信任在线商店,但商店可能信任支付服务。所以这可能是一种不对称的关系。
正如你在这里看到的,现实生活中的信任关系变得相当复杂。你最终会陷入一方不信任另一方,但他们仍然希望完成某件事的情况。从这个意义上说,拜占庭行为是真实且具有实际意义的。
当然,拜占庭将军问题是这类场景的简化,例如,它将所有将军视为对称的。尽管如此,拜占庭将军问题是研究参与者不完全信任彼此的这类情况的一个有用的起点。
历史渊源
在结束关于拜占庭将军问题的讨论之前,再讲一点历史题外话。你可能想知道“拜占庭”这个名字从何而来。它来源于拜占庭帝国,也称为拜占庭,即东罗马帝国。在罗马帝国崩溃后,这是其东部部分,其首都曾是君士坦丁堡,以前称为拜占庭,现在位于土耳其的伊斯坦布尔。
出于某种原因,在20世纪早期,我认为“拜占庭”一词开始被用来描述那些过度复杂、极其官僚主义,甚至可能诡诈的场景,例如税法之类的东西。不完全确定“拜占庭”这个词为何具有这种含义,因为没有历史证据表明拜占庭帝国比其他帝国更恶意或更不恶意,但无论如何,这就是它的来源。因此,“拜占庭”一词在用于计算领域之前很久就有了这个含义。在计算领域之外,它长期以来一直具有这个含义。


总结
本节课中我们一起学习了拜占庭将军问题。我们了解到,这是一个在存在恶意节点(“叛徒将军”)的情况下,如何使诚实节点达成共识的分布式系统模型。其核心挑战在于诚实节点难以区分信息矛盾是源于发送方的恶意,还是中间转发方的恶意。我们探讨了解决该问题的基本条件:要容忍F个恶意节点,系统至少需要 3F + 1 个节点。最后,我们看到了该问题在电子商务等多方互不信任的复杂现实场景中的实际应用,并了解了“拜占庭”一词的历史渊源。


006:系统模型 🧩

在本节课中,我们将学习分布式系统设计的基石——系统模型。我们将探讨如何对网络、节点和系统时序的行为做出假设,这些假设是设计任何分布式算法的基础。
网络行为模型 🌐

上一节我们讨论了拜占庭将军问题,其中假设网络可靠但节点可能不诚实。现在,我们将把这两个方面结合起来,构建一个更全面的系统模型。首先,我们来看看网络可能的行为方式。
网络是不可靠的,消息可能因各种原因丢失,例如网络拥塞、硬件故障,甚至一些意想不到的事件(如鲨鱼咬断海底光缆或牛踩踏地面光缆)。为了在更抽象的层面进行推理,我们通常假设点对点通信,并选择以下三种可靠性模型之一:
以下是三种主要的网络链路模型:
- 可靠链路:这是最简单的模型。假设消息总是能送达。如果发送了消息,它就会被接收;如果接收了消息,那它一定曾被发送过。链路不会丢失消息,也不会凭空伪造消息。公式表示为:
发送(m) ⇒ 最终接收(m)且接收(m) ⇒ 曾发送(m)。但消息可能被重新排序。 - 公平丢失链路:这是一个更现实的模型。每次发送消息时,它都有非零的概率被送达。如果消息未被送达,发送方可以不断重试。我们假设在无限长的执行时间中,只要持续重试,每条消息最终都会在某个有限时间内被送达。公式表示为:
持续重试(发送(m)) ⇒ 最终接收(m)。 - 任意链路:这是最弱的假设,模拟了存在主动恶意对手(如不安全的公共WiFi所有者)的网络。对手可以任意丢弃、篡改、重放或伪造消息。这实际上是当今互联网一个合理的部分模型。
此外,还有一个重要概念是网络分区,即节点本身运行正常,但它们之间的通信链路在一段有限时间内被中断。分区最终会被修复,但中断时间可能很长。
有趣的是,我们几乎可以将一种网络模型转换为另一种。例如,通过持续重试和接收端去重,可以将公平丢失链路提升为可靠链路。同样,通过使用TLS等加密协议,我们几乎可以将任意链路转换为公平丢失链路(除非对手完全阻断所有通信)。
节点行为模型 💻
了解了网络模型后,我们来看看分布式系统中的另一个核心组件——节点的行为模型。节点也可能以多种方式发生故障。
以下是三种主要的节点故障模型:
- 崩溃-停止:这是最简单的模型。我们假设一个进程可能在任何时刻崩溃,并且永远不会恢复。崩溃后,其所有内存中的状态都会丢失。这模拟了硬件被永久摧毁的情况。
- 崩溃-恢复:在这个模型中,节点可能崩溃,但之后可能会恢复。崩溃时,内存中的状态会丢失,但已写入稳定存储(如磁盘)的数据在恢复后仍然存在。节点也可能永远无法恢复,因此崩溃-停止是此模型的一个子集。
- 拜占庭故障:正如拜占庭将军问题中所描述的,拜占庭故障节点可能以任意方式偏离指定的算法。它可能表现出恶意行为,同时试图伪装成正常节点。我们对这类节点的行为不做任何限制。
我们通常将节点分类为故障的或正确的。一个节点如果是故障的(例如崩溃或表现出拜占庭行为),则它不再遵循算法;否则,它就是正确的。一个节点通常无法直接知道另一个节点是否正确。
时序模型 ⏱️
最后,我们来探讨分布式系统的第三个关键方面——时序模型。系统的时序假设深刻影响着算法的设计和可行性。
以下是三种主要的系统时序模型:
- 同步模型:这是一个非常强的假设。我们假设消息传递和节点执行步骤都有一个已知的、确定的上界时间。例如,任何消息的延迟都不会超过某个最大值,每个计算步骤的执行时间也有上限。在此模型下设计算法相对简单,但假设过于理想化。
- 异步模型:这是另一个极端,我们不做任何时序保证。消息可能在任何时间(甚至多年后)到达,节点的执行也可能在任何点被无限期暂停。这为算法设计带来了巨大挑战,但做出的假设最弱,也最安全。
- 部分同步模型:这是前两者的折中,也是现实系统最常采用的模型。系统在大部分时间表现得像同步模型(消息延迟和处理速度有界),但偶尔会进入异步状态(例如,由于网络分区、垃圾回收暂停或系统负载过高导致延迟激增)。算法设计者不知道当前系统处于哪种状态。
假设同步模型是危险的,因为现实中的延迟可能远超预期。例如,网络分区修复可能需要数小时,垃圾回收可能导致进程暂停数分钟,操作系统的调度也可能引入不可预测的延迟。因此,大多数实用的分布式算法都需要在部分同步甚至异步模型下保持正确性。
总结 📝

本节课我们一起学习了构建分布式系统模型的三个核心维度:
- 网络模型:我们根据可靠性需求,可以选择可靠链路、公平丢失链路或任意链路模型。通过重试和加密技术,可以在不同模型间进行一定程度的转换。
- 节点模型:我们根据节点可能发生的故障类型,可以选择崩溃-停止、崩溃-恢复或拜占庭故障模型。这决定了算法需要容忍何种程度的节点异常。
- 时序模型:我们根据对系统延迟的把握程度,可以选择同步、异步或更符合现实的部分同步模型。错误的时序假设可能导致算法在现实环境中彻底失效。

这些抽象选择至关重要。设计分布式算法时,必须明确并确保其系统模型假设与实际运行环境相符。例如,若假设了崩溃-停止模型但实际存在拜占庭节点,算法将无法正常工作;若假设了同步模型但系统偶尔出现高延迟,算法的所有保证都可能被破坏。理解并正确运用这些模型,是设计健壮、可靠分布式系统的第一步。
007:容错与高可用性
在本节课中,我们将要学习分布式系统中的容错与高可用性。我们将探讨如何定义服务的可用性,理解故障的概念,并学习如何通过故障检测器来构建能够容忍部分故障的系统。
概述
在讲座的最后部分,我们将讨论一个实际生活中的故障处理场景:构建高可用服务。想象一下,你正在运营一个在线商店,你很可能希望这个商店能够7天24小时不间断地提供服务。因为无法预知客户会在何时访问你的商店并购买商品。任何服务不可用的时段都意味着潜在的收入损失。在某些情况下,服务甚至可能与客户签订合同,明确规定服务需要达到的可用性百分比。
可用性模型
我们通常用服务正常运行时间的比例来衡量其可用性。例如,一个服务99%的时间正常运行,意味着一年中大约有3到4天服务不可用。当然,这可能是由多次较小的故障累积而成。如果将可用性提升到99.9%,则每年最多允许9小时的停机时间。你可以继续增加“9”的数量来追求更高的可用性。例如,传统的固定电话网络据称设计为“五个9”(99.999%)的可用性。电话网络通过非常保守的设计来实现这种极高的可靠性,而移动网络和互联网通常无法达到这种水平。

在可用性上下文中,常见的术语是SLO和SLA。SLO(服务等级目标)是你为自己设定的服务可用性目标。它可能规定需要获得正确响应的请求百分比、允许的最大响应时间(例如200毫秒)以及测量的时间周期(例如,在一天内所有请求中达到99.9%)。而SLA(服务等级协议)则是服务与其客户或消费者之间的合同,明确了预期的服务等级。
实现高可用性:容错
为了实现这种高可用性,在分布式系统中我们通常采用的方法是容错。故障是指系统的某个部分无法正常工作。我们讨论过节点故障(例如崩溃)和网络故障(例如网络分区)。我们希望系统能够容忍一定数量的故障。说系统能容忍所有故障是没有意义的,因为如果所有节点同时崩溃且所有网络链路同时中断,系统显然无法做任何事情。

但我们可以设定,例如,在少于半数的节点崩溃时,系统整体仍能继续工作。这意味着在三节点系统中允许一个节点崩溃,或在五节点系统中允许两个节点崩溃,剩余节点仍能继续运行服务。
在一个允许部分节点或网络链路故障的系统中,我们避免了所谓的单点故障。单点故障是指,如果某一个特定节点崩溃,就会导致整个系统不可用。如果我们能设计一个没有单点故障的系统,就意味着我们可以移除系统中的任何一个组件,而系统整体仍有望继续工作。
故障检测
为了实现容错,我们通常首先要做的是检测故障,然后才能处理它。检测故障的机制被称为故障检测器。这个术语有点奇怪,实际上叫“故障检测器”会更合理,但“故障检测器”是常用的术语,所以我们沿用。
故障检测器可以是一个软件算法、一块硬件或其他任何用于检测另一个节点是否故障的机制。理想情况下,我们希望拥有一个完美的故障检测器,即总能准确告诉我们另一个节点是否故障的机制。
我们通常实现故障检测器的方式是使用超时机制。我们向一个节点发送消息,要求它如果存活就回复。如果我们在一定时间内没有收到回复,我们就认为该节点已经死亡(崩溃了)。这种方法很实用。但不幸的是,正如我们在系统模型中看到的,如果我们假设一个部分同步甚至异步的系统,那么超时并不一定意味着节点已经崩溃。超时也可能是因为我们发送的消息或回复在网络中丢失、被延迟,或者节点本身还活着,只是正经历一次长时间的垃圾回收暂停,一分钟后才会回复你的消息。
发送检查消息的一方无法区分没有回复是由于网络问题、随机延迟,还是节点确实已经崩溃。如果我们有一个同步系统模型,并且只假设崩溃-停止故障(而非拜占庭行为),我们可以构建一个完美的故障检测器。但是,一旦进入部分同步模型,超时就不再是检测故障的精确方法。
最终完美故障检测器
在部分同步系统中,我们能做到的最好情况是构建一个最终完美故障检测器。这个术语很有趣,它意味着故障检测器有时可能会出错。它可能因为消息恰好被延迟了一点而检测到超时,即使另一个节点实际上还没有崩溃。这意味着超时并不能准确指示崩溃已经发生。同时,故障检测也不是即时的。如果崩溃发生,我们可能需要一段时间才能检测到它。因此,在一段时间内,我们可能会有误报(认为正常节点故障)和漏报(未检测到已故障节点)。

但是,最终,故障检测器会将一个节点标记为崩溃,当且仅当它真的崩溃了。这意味着任何暂时怀疑另一个节点故障的情况都会停止,只要节点确实正常,我们就会恢复认为它是正确的。同样,如果一个节点故障了,我们最终也会检测到它。这就是我们在故障检测方面能做到的极限,但它仍然非常有用。即使我们只有这种最终完美的故障检测器,它也足以构建一些有用的算法,我们将在未来的讲座中看到。
总结

本节课中,我们一起学习了分布式系统中的容错与高可用性。我们了解了如何用正常运行时间的比例(如“几个9”)来衡量服务可用性,并认识了SLO和SLA。为了实现高可用,系统需要能够容忍故障,避免单点故障。我们探讨了故障检测器的作用,特别是在部分同步系统中,我们只能构建“最终完美”的故障检测器,它虽然不完美,但仍然是构建可靠分布式算法的重要基础。
008:物理时间 ⏰
在本节课中,我们将要学习分布式系统中的物理时间。我们将探讨时间在计算机系统中的重要性、时钟的工作原理、时间的定义方式,以及一个由时间同步问题引发的真实案例。理解物理时间是理解分布式系统中事件排序和协调的基础。
时间在系统中的用途
时间在操作系统和分布式系统中扮演着至关重要的角色。以下是几个关键用途:
- 调度与超时:操作系统调度器需要测量时间片(例如10毫秒)来进行进程切换。网络通信中的超时重传机制也需要精确计时。
- 故障检测:上节课讨论的故障探测器,依赖于在特定时间段内是否收到响应来判断节点状态。
- 性能分析:测量进程运行时间、CPU使用率等性能指标,都离不开精确的时间测量。
- 日志记录:在日志文件中记录事件发生的具体日期和时间,例如用户登录时间。
- 数据库时间戳:记录数据操作(如用户购买)发生的精确时间点。
- 数据有效期:许多数据具有时效性。例如,DNS记录中的生存时间字段,允许缓存该记录一段时间(如7000秒),过期后需重新查询以获取最新数据。
- 安全证书验证:HTTPS使用的TLS证书有明确的有效期。客户端需要知道当前时间,才能验证证书是否在有效期内。
- 事件排序:在分布式系统中,确定事件发生的先后顺序是核心挑战之一,时间测量为此提供了基础。
时钟的类型与工作原理



在分布式系统中,我们主要区分两种时钟:物理时钟和逻辑时钟。本节课我们聚焦于物理时钟,它就是我们日常使用的、能告诉我们具体日期和时间的时钟。

需要澄清一个术语:在数字电子学中,“时钟”通常指产生固定频率脉冲的信号。而在分布式系统中,“时钟”指的是一个可以查询时间戳的组件,时间戳代表了当前的日期和时间。
石英时钟

现代计算机大多使用石英晶体来实现时钟。石英是一种压电材料,施加电场会引起机械形变,反之亦然。通过精密切割石英晶体,可以使其以特定频率机械共振,从而产生非常稳定的振荡信号。
石英时钟的精度通常用百万分率来衡量。例如,1 ppm的误差意味着每年偏差约32秒。典型的石英时钟误差在几十ppm的量级。然而,石英的共振频率会受温度影响,在高温服务器环境中,时钟速度可能会显著变慢。

原子钟与GPS
对于更高精度的需求,可以使用原子钟。原子钟基于量子力学原理,利用铯原子特定能级间的跃迁频率(约9,192,631,770 Hz)来定义时间。国际单位制中的“秒”正是基于此定义。
原子钟非常昂贵,但我们可以通过GPS系统间接获得高精度时间。GPS卫星搭载原子钟并广播其时间信号。接收器通过计算信号传播时间差来定位,同时也能获得极其精确的时间信息。数据中心常通过在屋顶架设GPS天线来同步时间。
时间的定义:UTC与闰秒
我们日常使用的协调世界时UTC,是国际参考时间。它的定义融合了两种时间:
- 国际原子时:基于全球数百个原子钟的平均值,极其稳定精确。
- 天文时:基于地球自转观测(如历史上的格林尼治标准时间GMT)。
由于地球自转速度并不恒定(受地震、潮汐等影响),原子时和天文时会产生微小偏差。为了保持UTC与太阳日的同步,引入了闰秒机制。
闰秒可能在每年6月30日或12月31日的最后一分钟被插入或删除。例如,在插入闰秒时,时钟会显示 23:59:59 -> 23:59:60 -> 00:00:00。
计算机中的时间表示与闰秒问题
计算机程序通常使用两种时间格式:
- Unix时间戳:一个整数,表示从 Unix纪元(1970年1月1日)开始所经过的秒数。其标准通常忽略闰秒。
- ISO 8601格式:人类可读的日期时间格式,如
2023-10-27T14:30:00Z。

在两种格式间转换时,需要处理复杂的日历规则(闰年)和闰秒。然而,大量软件选择忽略闰秒,这导致了严重问题。
案例分析:2012年6月30日的服务器故障
现在,我们可以解答课程开头提出的谜题:为何在2012年6月30日,众多公司的服务器在同一时间宕机?

答案正是闰秒。当时,操作系统内核(如Linux)在处理插入的闰秒时出现了Bug,导致系统进入活锁状态,CPU占用率达到100%且无法处理任何请求。即使重启服务器也无法解决问题。最终,系统管理员发现需要通过重置系统时钟来清除内核中的错误状态。
这个事件凸显了物理时间同步在分布式系统中的关键性和脆弱性。
解决方案:闰秒弥散
鉴于大量遗留软件无法正确处理闰秒,一种实用的解决方案是闰秒弥散。其核心思想是:不在某一瞬间插入或删除一整秒,而是在闰秒事件前后的一段时间(例如24小时)内,将时钟速度略微调慢或调快,从而将这一秒的调整“平滑”地分摊掉。
虽然这更像一种“打补丁”式的方案,但在现有软件生态下,它可能是最可行的 pragmatic 选择。
总结

本节课我们一起学习了分布式系统中的物理时间。我们了解了时间在系统调度、日志记录、安全验证等方面的重要作用;探讨了石英时钟和原子钟的工作原理及其精度;明确了UTC时间的定义以及令人头疼的闰秒机制。最后,通过2012年的真实故障案例,我们看到了时间同步问题可能带来的严重后果,并了解了“闰秒弥散”这一现实中的应对策略。理解物理时间的特性和局限,是构建健壮分布式系统的重要基础。在下一讲中,我们将探讨不依赖绝对时间的逻辑时钟,看看如何仅通过事件间的相对关系来排序。
009:时钟同步 ⏰
在本节课中,我们将要学习分布式系统中的时钟同步。我们将探讨为什么计算机时钟会存在误差,以及如何使用网络时间协议(NTP)来同步不同计算机上的时钟,使其尽可能保持一致。
石英时钟与时钟漂移
上一节我们讨论了分布式系统中的时间概念,本节中我们来看看如何让不同计算机的时钟保持一致。


每台计算机内部通常都包含一个石英时钟,而非原子钟,因为原子钟过于昂贵和笨重。因此,我们需要利用这些精度较低的石英时钟,并设法使其达到合理的准确性。
这些石英时钟会产生漂移,因为它们的计时速率与正确的时钟滴答率并不完全匹配。当我们比较同一时刻两个不同时钟的时间戳时,它们之间的差值被称为时钟偏差。时钟同步的目标就是尽可能减小这个偏差。
在异步或部分同步网络中,不可能将时钟偏差减小到零。我们所能做的是尽力减小它,但总会存在一定的误差容限。
网络时间协议(NTP)简介

为了同步时钟,实践中通常使用一个名为网络时间协议的协议。NTP的工作原理是:假设存在一个拥有精确时钟源(如原子钟或GPS接收器)的服务器,客户端可以向服务器查询其当前时间,然后根据从服务器获得的时间来调整自己的时钟。
NTP被广泛部署,几乎所有现代操作系统都内置了NTP客户端。例如,在Mac操作系统中,设置对话框允许用户选择NTP服务器。大多数其他操作系统也有类似的设置界面。
NTP的层级结构与优化技术
NTP服务器被组织成所谓的层级。以下是NTP的层级结构:
- 层级0:精确的时间源,如原子钟或GPS接收器。
- 层级1:直接连接到层级0时间源的服务器。
- 层级2:不直接连接精确时间源,但从层级1服务器获取时间的服务器。
- 层级3:从层级2服务器获取时间的服务器,依此类推。
NTP使用多种统计技术和技巧来提高时间估计的质量。以下是其中一些关键方法:
- 查询多个服务器:如果可能,NTP会查询多个服务器。这有助于识别因配置错误或故障而报告极不准确时间的服务器,并排除异常值。
- 多次采样:NTP会在几分钟内多次查询同一服务器。这有助于过滤掉网络延迟中的随机波动,从而减少随机误差。
通过NTP,在高质量的网络连接下,可以将时钟同步到与服务器相差几毫秒以内。然而,在质量较差的网络连接上,准确性可能会差得多。因此,在假设时钟已同步时,必须非常小心。
NTP如何估计时钟偏差
现在,让我们看看NTP如何估计客户端与服务器之间的时钟偏差。它通过网络发送消息来工作。
客户端向服务器发送请求消息时,会记录其本地时钟的时间戳 T1,并将 T1 放入请求消息中。服务器收到消息时,会记录其本地时钟的时间戳 T2。服务器处理消息后,会发送回复,并记录发送回复时其本地时钟的时间戳 T3。回复消息中包含 T1、T2 和 T3。最后,客户端收到回复时,会记录其本地时钟的时间戳 T4。

现在我们有了四个时间戳,可以尝试计算两个时钟之间的偏差。
首先,我们可以计算出消息在网络中传输的总时间,称为总网络延迟 Δ。从客户端的角度看,总时间为 T4 - T1。减去服务器处理消息的时间 T3 - T2,剩余的时间就是请求和响应在网络中花费的总时间。
我们不知道请求和响应各自花费了多少时间。要计算单向网络延迟,需要同步的时钟,而这正是我们试图解决的问题。因此,我们只能测量两个方向网络延迟的总和。
我们假设网络延迟在两个方向上是对称的,即请求延迟等于响应延迟。如果这个假设成立,那么响应延迟就是 Δ / 2。
基于此,我们可以估计在客户端收到响应的那一刻,服务器时钟应该显示的时间。从客户端的角度看是 T4,但服务器是在 T3 时刻发送响应的。我们估计响应花费了 Δ / 2 的时间在网络中传输。因此,我们估计当客户端收到响应时,服务器时钟的读数为 T3 + Δ / 2。

现在我们有了服务器时钟的估计值,就可以估计时钟偏差了。时钟偏差 θ 的估计公式为:
θ = (T2 - T1 + T3 - T4) / 2
这是我们对时钟偏差的最佳估计。

时钟校正策略
在计算出两个时钟之间的偏差后,客户端可以校正自己的时钟,尝试与服务器同步。客户端采取的行动取决于时钟偏差的大小。
以下是NTP的三种校正策略:
- 偏差较小(例如 < 125毫秒):客户端会通过微调时钟速率来逐渐对齐服务器时间。它会以每秒最多0.5毫秒的幅度加快或减慢时钟,使两个时钟在几分钟内逐渐收敛。这称为驯服时钟。
- 偏差较大:NTP会步进时钟,即强制将时钟直接调整到估计的服务器时间。这可能导致时钟向前或向后跳跃,造成时间上的不连续性,之后再进行平滑调整。
- 偏差极大:NTP会拒绝调整时钟,进入“恐慌”状态,将问题留给人工操作员解决。这意味着如果软件依赖时钟同步,必须主动测量时钟偏差,防止其变得过大,否则NTP客户端可能停止同步,导致时钟严重错误。
驯服时钟示例
这是一个驯服时钟的实际例子。图中红线表示两个时钟之间的偏差,开始时大约有10毫秒的偏移。应用校正后,偏差最终趋近于零。蓝线表示客户端时钟的运行速率。客户端通过将时钟速率减慢约45 ppm(百万分之四十五),使其石英晶振的速率与服务器大致对齐,从而两个时钟不再漂移。
编程注意事项:单调时钟与挂钟时间

NTP的存在要求我们在编写某些代码时必须非常小心。例如,一个常见的需求是测量某段代码的执行时间。一种直观但错误的方法是使用获取当前日期时间的函数(如Java的 System.currentTimeMillis())在代码段前后取时间戳并计算差值。
问题在于,NTP可能在代码执行期间步进时钟。如果时钟被向后调整,计算出的时间差可能为负值;如果向前调整,时间差可能异常巨大,这可能导致软件崩溃或行为异常。
正确的做法是使用单调时钟。例如,Java提供了 System.nanoTime() 函数。单调时钟的关键特性是:NTP的步进调整不会影响它。这个时钟只会以大致恒定的速率向前走,不会突然跳跃。因此,使用单调时钟来测量经过的时间才是正确的方法。
单调时钟与挂钟时间(即日期时间)有重要区别:
- 挂钟时间:如
System.currentTimeMillis(),给出自某个固定参考点(如1970年1月1日)以来的时间。它可以跨计算机比较,用于验证证书有效期等场景,但受NTP步进和闰秒影响,不适合精确测量时间间隔。 - 单调时钟:如
System.nanoTime(),其返回值是自某个任意点(如计算机启动)以来的时间。时间戳本身没有跨机器的可比性,但用于计算同一台机器上的时间间隔是准确可靠的,因为它不受NTP步进影响。
许多编程语言和操作系统都提供了相应的API。例如,在Linux中,可以使用 clock_gettime 调用,并通过参数指定需要的是实时时钟(挂钟时间)还是单调时钟。在编写需要测量时间或依赖时间戳的代码时,理解并正确选择这两种时钟至关重要。

本节课中我们一起学习了时钟同步的重要性、NTP协议的工作原理及其层级结构、时钟偏差的估计算法,以及NTP的三种校正策略。我们还重点区分了单调时钟与挂钟时间在编程中的不同用途,这是构建健壮分布式系统应用的关键知识。
010:因果关系与“先于发生”关系
在本节课中,我们将学习分布式系统中不同消息和事件的相对顺序,以及如何在此背景下理解和使用“时间”的概念。
概述

上一节我们讨论了分布式系统中的时间问题。本节中,我们将探讨一个核心概念:“先于发生”关系。这是一种不依赖物理时钟,而是基于事件间逻辑因果关系来定义顺序的数学方法。我们将通过一个例子开始,理解为什么需要这个概念,然后学习其形式化定义,并探讨它与物理学中因果关系的联系。
从一个例子开始
让我们从一个互联网讨论帖的例子开始。假设用户A发送了一条消息M1:“月亮是奶酪做的”,给用户B和C。我们假设网络链路可靠,但消息可能被延迟或乱序。

用户B可能很快收到M1,而用户C由于网络延迟稍晚收到。这很正常。接着,用户B回复M1,发送消息M2:“不,月亮不是奶酪做的”,给用户A和C。

现在可能出现图中所示的情况:用户C在收到M1之前先收到了M2。这会造成困惑:用户C先看到回复“不,月亮不是奶酪做的”,然后才看到原始论点“月亮是奶酪做的”。尽管M2本应是M1的回复,但它们的顺序被颠倒了。
这显然是个问题。我们需要思考如何形式化地描述这个问题,并创建算法来解决它。
物理时间戳的局限性
我们可能会想到为每条消息附加时间戳。假设用户A在发送M1时,用其本地时钟生成时间戳T1并附加到消息上。用户B在发送M2时,用其本地时钟生成时间戳T2。


即使我们使用类似NTP的协议同步了时钟,仍然可能发生T2 < T1的情况。这是因为系统中两个节点间的时钟偏差可能大于它们之间的单向网络延迟。即使时钟同步,也无法完全排除时钟偏差大于网络延迟的可能性。
在这种情况下,M1的延迟小于A和B之间的时钟偏差。如果B的时钟稍慢,而A的时钟稍快,就可能导致T2小于T1。我们使用时间戳的目的是确保消息按正确的时间顺序排列,但即使使用了时间戳,我们仍可能得到与逻辑因果关系不一致的顺序。

那么,我们如何解决这个问题呢?
“先于发生”关系
在分布式系统中,我们使用一种称为“先于发生”关系的数学模型来描述系统中的活动顺序。
首先,我们需要定义系统中发生的事情,我们称之为事件。一个事件可以是:
- 一个节点执行的一个本地步骤。
- 一个节点发送一条消息(发送事件)。
- 一个节点接收一条消息(接收事件)。
“先于发生”关系是一个事件对的集合,描述事件间如何关联。如果事件A先于事件B发生,我们写作 A → B。
我们说 A → B 成立,当且仅当满足以下三个条件之一:
- 同一节点上的顺序:事件A和B发生在同一个节点上,并且在该节点的本地执行顺序中,A在B之前执行。我们假设每个节点是单线程的,因此单个节点上的事件顺序是明确的总序。
- 消息的发送与接收:事件A是发送某条唯一消息M的事件,事件B是接收同一条消息M的事件。为确保唯一性,实践中可以为每条消息附加发送者ID和序列号或一个长随机数。
- 传递性:如果存在某个事件C,使得 A → C 且 C → B,那么 A → B 也成立。这实质上是前两点的传递闭包,将关系扩展为一个偏序。


偏序类似于“小于”关系,它允许我们比较两个事件并确定谁先发生。但也可能存在一些事件是不可比较的,即既不是 A → B,也不是 B → A。在这种情况下,我们称事件A和B是并发的,记作 A || B。



对于任意两个事件A和B,只有三种可能性:
- A → B
- B → A
- A || B




关系示例
让我们看一个“先于发生”关系的例子。图中事件用黑点表示,消息通过网络箭头传递。




根据定义:
- 同一节点顺序:在节点A上,a在b之前执行,所以 a → b。同理,c → d, e → f。
- 消息发送与接收:消息M1的发送(b)先于其接收(c),所以 b → c。消息M2的发送(d)先于其接收(f),所以 d → f。
- 传递性:利用传递性,我们可以推导出更多关系。例如,因为 a → b 且 b → c,所以 a → c。进一步,因为 a → c 且 c → d,所以 a → d。最终,我们可以得到 a → f。
然而,我们无法从a到达e,因为那需要在节点C上逆时间方向移动(从f到e),这是不允许的。因此,a 和 e 是并发的 (a || e)。同样,b, c, d 都与 e 并发。
并发并不意味着同时发生,它只意味着两个事件发生时彼此不知道对方的存在,它们之间没有信息流动。
与因果关系的联系
“先于发生”关系与分布式系统中的因果关系概念紧密相连。如果 A → B,那么A可能以某种方式导致或影响了B,即存在从A到B的信息流。反之,如果 A || B,那么我们知道A不可能导致B,B也不可能导致A,两者之间没有因果关系。
这个概念源自物理学。考虑发生在空间上相距很远、时间上接近的两个事件。我们可以绘制一个时空图。

假设从事件A发生时,向所有方向发出一束光(信息),它以光速传播。


发生在A的光锥(图中锥形区域)内的事件可能受到A的影响。但事件B位于A的光锥之外,同时A也位于B的光锥之外。



这意味着在B发生时,来自A的信息不可能到达B(反之亦然),因为这要求信息传播速度超过光速。因此,A和B没有因果关系,它们是并发的。而事件C则可能受到A或B的影响。


分布式系统中的“先于发生”关系与此类似,只不过信息是通过网络消息而非光来传播。其核心思想都是推理哪些事件可能相互影响。
因果顺序
回到我们最初的例子,我们希望“月亮是奶酪做的”(M1) 在对话线程中排在前面,而其回复(M2)排在后面。




由于M1和M2之间存在因果关系(M1 → M2),我们希望任何对消息的排序都与因果关系一致。


这种排序称为因果顺序。它是一种将系统事件排序的方式,要求如果 A → B,那么在排序中A必须出现在B之前。对于并发的事件(A || B),它们可以以任意顺序出现,因为没有确定的先后关系。



这是一个非常有用的概念。在下一讲中,我们将基于“先于发生”关系,构建实现这种因果顺序的实际分布式算法。
最后提醒一点:注意单词 causal (因果的) 和 casual (随意的) 很容易混淆,写作时请仔细检查。



总结

本节课我们一起学习了分布式系统中的“先于发生”关系。我们首先通过一个消息乱序的例子,认识到物理时间戳在保证逻辑顺序上的局限性。接着,我们形式化地定义了“先于发生”关系,它基于事件在同一节点的顺序和消息的发送/接收关系,并通过传递性构成一个偏序。我们了解到事件间要么存在“先于发生”关系,要么是并发的。这个概念与物理学中的因果关系(光锥)紧密相连,用于描述事件间潜在的影响。最后,我们引出了因果顺序的概念,即一种与“先于发生”关系一致的全局事件排序方式,为后续构建分布式算法奠定了基础。下节课我们将继续深入。
011:逻辑时间

在本节课中,我们将要学习分布式系统中的逻辑时间概念。我们将探讨物理时钟的局限性,并介绍两种逻辑时钟方案:Lamport时钟和向量时钟。这些方案旨在捕获系统中事件之间的因果关系,而非物理时间流逝。
背景:物理时钟的局限性
上一节我们介绍了物理时钟同步,本节中我们来看看物理时钟在捕获因果关系时可能存在的问题。

考虑一个场景:用户A发送消息M1:“月亮是奶酪做的”。随后,用户B回复消息M2:“不,它不是”。直观上,M1应该发生在M2之前。然而,即使在使用NTP同步后,物理时钟仍可能为M1分配一个晚于M2的时间戳。这导致时间戳与我们期望的因果顺序不一致。


这种与因果关系的不一致是物理时钟的问题。逻辑时钟正是为解决此问题而设计的。
什么是逻辑时间?
逻辑时钟是分布式系统中使用的一种替代性时钟定义,专门用于捕获系统中事件之间的因果关系。


逻辑时钟并非测量物理时间(如地球自转或公转),而是关注事件发生的数量。逻辑时间戳本质上是计数器,每当有事件发生时递增。这意味着它们随时间向前推进,但与物理时间没有直接关系。
逻辑时钟的核心目标
逻辑时钟的关键目标是捕获因果关系,特别是“发生在先”关系。如果事件A发生在事件B之前,我们希望事件A的时间戳小于事件B的时间戳。这是我们对逻辑时钟的基本期望。

我们将探讨两种逻辑时钟构造:Lamport时钟和向量时钟。两者都满足捕获因果关系的需求,但在其他方面具有略微不同的特性。
Lamport时钟
我们从Lamport时钟开始,它以Leslie Lamport的名字命名,该算法大约在1978年发表。

Lamport时钟算法相当简单。以下是其工作原理:


在每个节点上,我们有一个变量 T,系统启动时初始化为0。每个节点都有自己的 T 副本。每当节点上发生事件(如本地执行步骤、发送消息或接收消息)时,我们就递增 T。
以下是算法的具体步骤:


- 本地事件:任何本地事件发生时,递增
T。 - 发送消息:发送消息前,先递增
T(作为本地事件)。然后将递增后的T值附加到消息上。因此,发送的消息是(时间戳, 实际消息)对。 - 接收消息:当接收到一个带时间戳
T'的消息时,接收节点查看自己的本地变量T,并更新T为max(T, T') + 1(加1是因为接收消息本身也是一个事件)。更新后,将消息传递给应用程序。
Lamport时钟的性质
让我们更深入地看看Lamport时钟的性质。

每个事件 E 都与一个Lamport时间戳 L(E) 关联。该方案保证了以下性质:
- 因果一致性:如果事件A发生在事件B之前,则
L(A) < L(B)。 - 逆命题不成立:如果
L(A) < L(B),这不意味着A发生在B之前。A和B也可能是并发的。我们只能断定B没有发生在A之前。 - 时间戳可能重复:不同的事件可能具有相同的Lamport时间戳。
示例与唯一标识



让我们通过一个具体例子来理解。




图中展示了事件和消息流。注意,存在时间戳相同的事件(例如时间戳1和3的事件各有两个)。
为了使时间戳全局唯一,我们可以将Lamport时间戳与事件发生节点的名称组合。由于在单个节点内,时间戳是唯一递增的,因此 (时间戳, 节点名) 对可以唯一标识系统中的任何事件。



定义全序关系
Lamport时间戳的一个非常有用的特性是可以用它们定义全序关系。


对于两个事件A和B,我们定义全序 ≺ 如下:
A ≺ B 当且仅当:
L(A) < L(B),或者L(A) == L(B)且节点名(A) < 节点名(B)(假设节点名可按字典序比较)。
这个顺序是一个全序,意味着对于任意两个不同事件,我们总能确定谁先谁后。并且,这个全序与因果关系一致:如果A发生在B之前,则必有 A ≺ B。
因此,Lamport时钟为我们提供了一种为系统所有事件附加时间戳的方法,能够捕获“发生在先”关系,并允许我们基于时间戳定义事件的全序。
Lamport时钟的局限性与向量时钟

尽管有用,Lamport时钟仍有局限。


主要局限是:给定 L(A) < L(B),我们无法区分是“A发生在B之前”还是“A和B并发”。我们只知道B没有发生在A之前。
有时,能够区分并发关系和先后关系是很有用的。这就是向量时钟的用武之地。向量时钟是另一种逻辑时间方案,允许我们进行这种区分。
向量时钟
向量时钟的工作原理如下。首先,我们假设知道系统中所有 n 个节点的名称。


时间戳现在是一个向量(或数组),包含 n 个整数,每个整数对应一个节点。

向量 T = <T1, T2, ..., Tn> 中,Ti 表示我们已观测到的在节点 i 上发生的事件数量。
向量时钟算法
每个节点本地维护一个向量 T,初始化为全零。


算法规则如下:
- 本地事件:当在节点
i上发生本地事件时,递增向量中对应节点i的条目:T[i]++。 - 发送消息:发送消息前,先递增本地节点条目,然后将整个向量
T附加到消息上发送。 - 接收消息:当收到一个带向量时间戳
T'的消息时:- 合并:对向量的每个元素
j,设置T[j] = max(T[j], T'[j])。 - 递增:将本地节点(接收方)对应的条目递增:
T[接收节点索引]++。 - 交付:将消息传递给应用程序。
- 合并:对向量的每个元素
示例与语义
让我们看一个例子。



图中展示了向量时钟如何更新。向量时间戳具有深刻的语义:一个向量时间戳 T 代表一个事件集合,即该事件本身及其所有前驱事件。




例如,时间戳 <2,2,0> 表示:包含了节点A的前2个事件、节点B的前2个事件,以及节点C的0个事件(即未包含C的任何事件)。这可以通过在事件图中从该事件反向追溯得到验证。




比较向量时间戳
基于向量的语义,我们可以定义比较操作:
- 相等:
T == T'当且仅当所有对应元素相等。 - 小于等于:
T ≤ T'当且仅当对于所有i,有T[i] ≤ T'[i]。 - 严格小于:
T < T'当且仅当T ≤ T'且T ≠ T'。 - 不可比:如果
T ≤ T'和T' ≤ T都不成立,则T和T'不可比。
关键联系在于:向量时间戳上的 ≤ 关系精确对应了它们所代表事件集合的子集关系。
向量时钟的强大性质
向量时钟提供了比Lamport时钟更强的性质。

对于事件A和B,设其向量时间戳分别为 V(A) 和 V(B),则有:
- 捕获因果关系:
V(A) < V(B)当且仅当 事件A发生在事件B之前。这是一个双向蕴含关系。 - 唯一标识事件:
V(A) == V(B)当且仅当 A和B是同一个事件。 - 检测并发:
V(A)和V(B)不可比 当且仅当 事件A和B是并发的。
因此,向量时钟不仅捕获了因果关系,还提供了一个算法来精确计算“发生在先”关系,并能明确判断两个事件是否并发。这是Lamport时钟所不具备的关键能力。
总结
本节课中我们一起学习了分布式系统中的逻辑时间。
- 我们首先指出了物理时钟在维护因果关系上的不足。
- 接着,我们介绍了Lamport时钟,它是一种简单的逻辑时钟,通过递增计数器和在消息中传递时间戳来保证:如果事件A发生在B之前,则
L(A) < L(B)。我们可以用(时间戳, 节点名)对唯一标识事件,并基于此定义与因果一致的全序。 - 然后,我们探讨了Lamport时钟的局限:无法区分“先后”与“并发”。
- 最后,我们深入学习了向量时钟。向量时钟为每个节点维护一个计数器,通过向量合并和递增来更新。向量时间戳对应事件及其前驱的集合。通过比较向量时间戳,我们可以精确判断两个事件是“先后发生” (
V(A) < V(B))、是“同一事件” (V(A) == V(B)),还是“并发” (V(A)与V(B)不可比)。向量时钟提供了对“发生在先”关系的完整算法化刻画。

逻辑时钟是理解分布式系统事件顺序和构建一致性协议的重要基础工具。
012:广播排序 📡
在本节课中,我们将要学习分布式系统中的广播协议。我们将探讨广播如何扩展点对点通信,并介绍几种不同类型的广播,它们的主要区别在于消息的交付顺序。理解这些概念对于构建可靠且一致的分布式应用至关重要。

上一节我们讨论了逻辑时间,本节中我们来看看广播协议。这看起来像是逻辑时间之后的一个跳跃,但实际上,两者之间有着紧密的联系。广播协议将我们在分布式系统中可以进行的网络通信类型进行了泛化。
到目前为止,我们考虑的都是点对点通信,即一个发送者通过网络向一个接收者发送消息。消息可能到达也可能丢失,但这仍然是两个节点之间的一对一通信。在广播或多播通信中,我们将其泛化到一个节点组,使得一个节点可以同时向组内的所有节点发送消息。

在本课程中,我们主要假设组内的节点集合是固定且预先已知的。但在一般情况下,系统当然可能允许节点加入、离开或被管理员添加或移除,这是实际协议需要处理的问题。
广播协议的一个特别要求是容错性。这意味着,假设组内有一个节点发生故障,所有剩余节点应仍能像以前一样继续广播和交付消息。我们不希望单个故障节点导致整个系统停滞。
广播或多播有几种不同的实现方式。在计算机网络背景下,你可能遇到过一种:某些局域网在硬件层面支持广播或多播。这在局域网上非常有用。但通常,这种多播在公共互联网上不起作用。因此,在本课程中,我们将考虑更通用的广播或多播形式,我们假设底层网络只提供点对点消息传递(单播),并在此基础上构建广播协议。底层网络只允许我们将消息从一个节点发送到另一个节点,但在此之上,我们可以将广播构建为一个抽象,供更高层的协议使用。






回想一下第2讲中关于系统模型的讨论,我们区分了公平丢失链路和可靠链路。当然,大多数网络链路是公平丢失的,但你可以通过重传消息使其变得可靠。广播协议中存在完全相同的区别,只是在这里,可能丢失消息的网络我们通常称为尽力而为广播。

尽力而为广播意味着一个节点尝试向所有其他节点发送消息,但消息可能到达也可能不到达,特别是如果发送者崩溃,消息可能无法到达所有其他节点。



或者我们可以使其变得可靠,这同样依赖于重传丢失的消息。


与系统模型背景类似,我们将假设一个异步系统模型,而不是同步系统模型,这意味着我们不假设消息延迟有任何上限。因此,在可靠广播协议中,我们可以说消息最终会到达,但我们不承诺消息到达可能需要多长时间。

以上就是广播的背景。在深入细节之前,我应该介绍一些术语。在点对点网络链路的背景下,我们讨论过通过网络发送和接收消息。而在广播协议的背景下,我们讨论广播消息然后交付它作为对应操作。具体过程是:一个节点上的应用程序希望向整个组广播一条消息,我们称这个动作为广播。

然后,底层的广播算法可能会通过网络发送或接收多条消息。这些是发送或接收的点对点消息。在其他节点上,这些消息可能被接收,再次被广播算法拾取,然后广播算法在某个时刻将消息交付给应用程序。因此,这里的交付是广播的对应操作。这里发生的一个细节是,当广播算法收到一条消息时,它可能不会立即将其交付给应用程序,它可能实际上需要等待一小段时间,因为我们需要确保消息以某种顺序交付。


这只是术语。现在我们将看几种不同的广播抽象,我将分别讨论每一种,所以现在不会深入太多细节。这里的重点是,这些都是可靠广播的形式,因此我们假设有一个重传机制,确保所有消息最终都能到达。现在,我们希望根据消息交付的顺序来区分不同类型的广播。


我们要看的第一个是 FIFO广播。在FIFO广播中,排序属性是:如果两条消息由同一个节点广播,那么所有节点都将按照它们被广播的顺序交付这些消息。因此,对于从某个特定节点广播的任何消息,它们保持顺序。但对于由两个不同节点广播的任何消息,不保证它们可能到达的顺序。

让我们看一个例子。这里我们有三个节点A、B和C。A希望向组广播消息M1。因此,它通过网络将该消息发送给C和B。请注意,A也将消息发送给自己。

这里用这个小回环箭头表示。这看起来可能没有必要,因为发送者已经知道自己发送了什么消息,不需要再次接收。节点向自己交付消息的原因在我们稍后讨论全序广播时会变得明显。现在,只需将其视为广播协议的一个小特点:它总是保证将消息交付给自己。
在M1被交付后,节点B决定向组广播消息M2。因此,它通过网络将M2发送给A和C,并且也将M2交付给自己。最后,节点A希望广播消息M3。同样,它通过网络将该消息发送给B和C,并将M3交付给自己。
这就是FIFO广播。正如刚才所说,对于FIFO广播,因为M1和M3都是由同一个节点A广播的,我们要求所有节点在交付M3之前先交付M1。我们可以检查这里的情况:A在这里交付M1,然后交付M3;B交付M1,然后交付M3;C也交付M1,然后交付M3。所有节点都以正确的顺序交付这些消息。
实际上,在FIFO广播下,有几种可能的顺序是有效的。特别是,由B发送的消息M2,相对于A的消息可以任意排序,因为我们不保证不同节点之间的顺序,而M2是由B发送的。这意味着M2可以在M1和M3之前交付,或在两者之间交付,或在两者之后交付,所有这些都可以。例如,这里我们可以考虑C以不同顺序交付消息的两种不同执行情况。
在第一种情况下,C先交付M1,然后M2,然后M3。在第二种情况下,C先交付M2,然后M1,然后M3。这两种顺序在FIFO广播下都是有效的,即使在第二种情况下,从因果意义上说,消息M2发生在M1之后,但FIFO广播并不保证M2在M1之后交付。
如果你确实想要那种排序属性,你需要因果广播。因果广播确保消息按因果顺序交付。这意味着,如果一条消息的广播在“先于”关系的意义上先于另一条消息的广播,那么这些消息必须按照匹配该“先于”关系的顺序交付。
这就是我们这里的情况。首先,M1由A广播,被B和C接收,并在这里被B交付。


然后,在B交付M1之后,B希望广播M2。因此,M2可能对M1有因果依赖,因此我们要求所有节点必须先交付M1,然后交付M2。
接下来,节点A可能广播消息M3。同样,它通过网络发送给其他两个节点,并将消息交付给自己。在这种情况下,请注意M2的广播与M3的广播是并发的,因为在A上,M2是在M3广播之后交付的;在B上,M3是在M2广播之后交付的。因此,这两条消息彼此不依赖。在这种情况下,我们要求任何具有因果依赖关系的消息都按因果顺序交付。因此,这里M1和M2之间存在因果依赖,M1和M3之间也存在因果依赖(因为M1和M3由同一个节点A广播),但M2和M3之间没有因果依赖。这意味着M2和M3可以按任意顺序交付。
因此,这里C又有两种不同的交付顺序。在第一种情况下,C先交付M1,然后M3,然后M2。在第二种情况下,C先交付M1,然后M2,然后M3。这两种顺序在因果广播下都是可以的。我们可以交换M2和M3的交付顺序。




请注意,例如,A先交付M1,然后M3,然后M2;而B先交付M1,然后M2,然后M3。这两种顺序都是有效的因果顺序,但它们并不相同。在某些情况下,我们实际上要求所有节点以相同的顺序交付消息。如果这是我们想要的,我们需要转向所谓的全序广播。
全序广播将所有在系统中广播的消息放入一个全序中,这意味着总是清楚哪一条消息先出现。它确保所有节点以相同的顺序交付消息,因此所有节点就消息应被交付的顺序达成一致。


在这个例子中,同样,我们从A广播M1开始,然后有B广播的M2(发生在M1之后),最后有A再次广播的消息M3。


在这种情况下,某个地方必须决定这些消息的交付顺序。假设决定的顺序是先交付M1,然后M2,然后M3。我们可以看到,例如在C上,情况就是如此:C先交付M1,然后交付M2,然后交付M3。C以正确的顺序交付它们。让我们看看这里的B:B先交付M1,然后将M2交付给自己,然后交付M3。B也以正确的顺序交付消息。让我们看看节点A:节点A先将M1交付给自己,然后广播M3。但是等等,它还不能立即将M3交付给自己,因为决定的顺序是M2必须在M3之前交付。因此,节点A不能立即将M3交付给自己,相反,它必须首先等待M2到达并被A交付,然后才被允许将M3交付给自己。
这就是向自己交付变得非常有趣和关键的地方,因为你可以看到有时必须存在这种延迟,如果一个节点想向自己交付一条消息。这里M1、M2、然后M3是交付这些消息的一种可能顺序。

也可能我们决定一个不同的顺序,例如交付顺序为M1、M3、M2。只要所有节点以相同的顺序交付消息,这也是可以的。这里同样,我们从C开始:C先交付M1,然后M3,然后M2。请注意,C可能通过网络在收到M3之前就收到了M2,在这种情况下,C将不得不延迟M2的交付,直到M3到达之后。这在广播算法中称为暂缓,如果消息尚未准备好交付,因为我们在等待其他消息先到达,通常有必要暂缓消息,这在C的情况下发生了。

对于节点B,交付顺序为1、3、2。首先我们交付M1,然后M3,但我们不能立即将M2交付给自己。我们必须先等待M3被交付,然后才被允许在B将M2交付给自己。在节点A上,事情现在更简单了:首先我们将M1交付给自己,然后将M3交付给自己。最后,M2通过网络到达,我们将M2交付给自己。
因此,这两种情况都是全序广播的有效执行。关键点在于,无论消息以何种顺序交付,该顺序对于所有节点必须相同。

最后,还有一种称为 FIFO全序广播 的东西,它只是将全序广播与FIFO排序结合起来,这意味着所有节点必须以相同的顺序交付消息,并且由同一节点发送的消息必须按照它们被发送的顺序交付。
事实证明,FIFO全序广播严格强于因果广播。因此,FIFO全序广播的每一次执行也满足因果广播的要求。而因果广播的每一次执行也满足FIFO广播的要求,每一个FIFO广播都满足可靠广播的要求,依此类推。实际上,本讲的讲义中有一个练习,要求你证明这些广播模型之间的一些关系。


在下一节中,我们将看看如何实际实现其中一些广播算法。

本节课中我们一起学习了分布式系统中的广播协议。我们从广播作为点对点通信的泛化开始,介绍了尽力而为广播和可靠广播的区别。然后,我们深入探讨了三种主要的排序广播类型:FIFO广播(保证同一发送者的消息顺序)、因果广播(保证因果相关的消息顺序)以及全序广播(保证所有节点以完全相同的顺序交付所有消息)。我们还简要提到了结合两者的FIFO全序广播。理解这些不同级别的排序保证对于设计满足特定一致性要求的分布式系统至关重要。
013:广播算法 🧩
在本节课中,我们将学习如何实际实现不同类型的广播模型。我们将分两个阶段进行:首先,展示如何将尽力而为广播转变为可靠广播;其次,在可靠广播的基础上,通过添加交付顺序模块,实现FIFO广播、因果广播或全序广播。


从尽力而为广播到可靠广播


上一节我们介绍了广播的不同模型,本节中我们来看看如何实现可靠广播。
最直观的想法是:希望广播消息的节点通过点对点链路,将消息单独发送给其他每个节点,并确保这些链路是可靠的。如果消息在传输过程中丢失,发送方会重传。通过重传,消息最终会到达所有其他节点。
然而,这种方法存在缺陷。

假设节点A尝试向节点B和C广播消息M1。消息成功到达C,但在前往B的途中丢失。因此,A需要向B重传消息。但如果A在完成重传前崩溃了,那么将没有人会再次尝试向B发送消息M1。结果,C交付了一个B永远不会收到的消息,导致系统状态不一致。
因此,我们必须设计一个健壮的算法,即使消息的发送方崩溃,所有剩余节点仍能就哪些消息被实际交付达成一致。


积极可靠广播算法


我们可以使用一种称为积极可靠广播的技术。其核心思想是让每个收到消息的节点都向其他所有节点转发该消息。
具体过程如下:
- 节点A希望向节点B和C广播消息M1。
- 当节点B首次收到M1时,它通过点对点链路将同一消息再次广播给其他所有节点。
- 同样,当节点C首次收到M1时,它也将消息再次广播给其他所有节点。


这个算法确实能确保可靠性,即使节点崩溃,所有未崩溃的节点也能收到消息。但是,它的代价很高。
以下是该算法的核心流程描述:
当节点 i 收到来自节点 s 的消息 m 时:
如果这是首次收到 m:
将 m 交付给应用层
向除 i 和 s 外的所有其他节点发送 m
每个收到消息的节点都会将该消息再次发送给其他 n-1 个节点。因此,对于节点想要广播的每一条消息,总共会产生大约 n² 数量级的消息发送量。我们获得了可靠性,但代价是相当高的网络带宽消耗。



流言协议

许多算法被设计用来优化上述过程,其中一类特别有趣的算法称为流言协议。它们模仿了流言在人群中传播的方式:一个人从某人那里听到流言,然后告诉另外几个人,最终信息通过群体传播开来。它们有时也被称为流行病协议,因为其传播方式类似于传染病在人群中的扩散。
在这个例子中,每个小点代表系统中的一个节点。该算法的参数设定为每个节点将消息发送给三个随机选择的其他节点。
- 红色节点将消息发送给三个其他节点。
- 这三个节点各自收到消息后,又分别将消息再次广播给三个随机选择的其他节点。
- 这些消息的接收者再次将消息进一步传播出去。

可以看到,有时消息会发送给之前已经收到过消息的节点,有时消息甚至会发回给过去某个时刻曾持有该消息的节点。但最终,经过几轮协议后,消息极有可能传播到所有节点。

在这个包含30个节点的例子中,经过大约六轮通信,所有节点都收到了消息。


流言协议有许多可调的细节,例如如何决定何时发送消息、何时停止发送等。但其基本原理是:它们是实现可靠广播的一种方式,并且非常健壮。即使某些节点崩溃或某些消息在网络中丢失,我们仍然有很高的概率保证所有消息都能到达所有节点。
实现顺序广播
上一节我们获得了可靠性,接下来看看如何实现顺序广播。

FIFO广播算法 🧾


如果我们想实现FIFO广播,就必须确保来自同一发送者的所有消息都按正确顺序交付。
以下是实现FIFO广播的一种算法,每个节点维护三个变量:
sender_seq:一个整数,该节点每广播一条消息就递增一次。delivered[]:一个整数向量,记录我们从每个特定发送者那里已经交付了多少条消息。buffer:一个暂存队列,用于存放尚未准备好交付的消息。
以下是算法的核心步骤:
广播消息时(在节点 i):
seq = sender_seq[i]
将消息 (i, seq, m) 通过可靠广播发送给所有节点
sender_seq[i] = sender_seq[i] + 1
接收消息时(在节点 j):
将消息 (s, seq, m) 放入 buffer
while buffer 中存在消息 (s, seq, m) 使得 seq == delivered[s] + 1:
将消息 m 交付给应用层
delivered[s] = delivered[s] + 1
从 buffer 中移除该消息
该算法确保了来自同一发送者的消息按发送顺序交付,从而实现了FIFO广播。


因果广播算法 ⛓️
因果广播算法的结构与FIFO广播非常相似。我们同样从三个变量开始:sender_seq、delivered向量和暂存buffer。


广播消息时(在节点 i):
dependencies = copy(delivered) // 捕获此消息的因果依赖
dependencies[i] = sender_seq[i] // 更新自身条目为当前序列号
将消息 (i, dependencies, m) 通过可靠广播发送给所有节点
sender_seq[i] = sender_seq[i] + 1
接收消息时(在节点 j):
将消息 (s, deps, m) 放入 buffer
while buffer 中存在消息 (s, deps, m) 使得 deps <= delivered:
将消息 m 交付给应用层
delivered[s] = delivered[s] + 1
从 buffer 中移除该消息
这里的关键是 deps <= delivered 这个比较操作。它使用了我们之前定义的向量时钟“小于等于”比较,来判断此消息的所有因果依赖(即deps向量中指定的消息)是否都已被本地交付。如果满足,则该消息的因果顺序条件已满足,可以交付。

这确保了因果广播的正确性。
全序广播算法 🏆
全序广播更具挑战性,因为我们需要就消息交付的总顺序达成一致。



1. 基于领导者的方法
一种简单的方法是选择一个节点作为领导者(或称为定序器),由它负责决定消息的交付顺序。
- 当任何节点想要广播消息时,它不直接广播,而是将消息发送给领导者。
- 领导者通过FIFO广播将消息广播给整个组。
- 由于所有消息都经过领导者,领导者广播消息的顺序就是所有其他节点交付消息的顺序。
这种方法的问题在于,如果领导者崩溃,就无法再交付新消息。我们需要一种在领导者崩溃时安全切换领导者的方法,但这在保证全序的前提下比较困难。我们将在后续关于共识的讲座中探讨安全实现领导者变更的方法。

2. 基于Lamport时间戳的方法
另一种实现全序广播的方法不依赖于领导者,而是利用Lamport时间戳。
- 每条消息都附加一个Lamport时间戳。
- 消息通过某种可靠广播进行传播。
- 每个节点确保按照Lamport时间戳递增的顺序交付消息。



但这依赖于一个关键知识:节点在交付一条消息时,必须确信将来不会有时间戳比当前消息更小的消息到达。因为如果它现在交付了时间戳为 t 的消息,之后又来了一个时间戳为 t-1 的消息,交付顺序就错了。
我们可以利用FIFO链路的特性来获得这种确信:如果我们在FIFO链路上从每个其他节点都收到了消息,并且知道每个节点发送的消息其Lamport时间戳是递增的,那么当我们从每个节点都收到至少一条时间戳大于某个值 T 的消息后,我们就可以确信将来不会再有时间戳小于等于 T 的消息出现,从而可以安全交付时间戳为 T 的消息。
遗憾的是,这种方法也不是容错的。如果有一个节点崩溃,它将不再发送消息,时间戳就无法继续推进,可能导致整个系统无法交付新消息。


综上所述,这两种算法都能工作,但都不是完全容错的。在后续课程中,我们将看到实现容错的全序广播的方法。




总结
本节课中我们一起学习了实现各类广播模型的具体算法:
- 我们首先探讨了如何通过积极可靠广播和流言协议在不可靠网络上实现可靠广播。
- 接着,我们在可靠广播的基础上,通过为消息附加序列号和向量依赖,并利用暂存缓冲区,分别实现了FIFO广播和因果广播。
- 最后,我们分析了两种全序广播的实现思路:基于领导者定序和基于Lamport时间戳排序,并指出了它们在容错性方面的当前局限。

这些算法是构建更复杂分布式协议的基础模块。
014:复制技术概述 🧩

在本节课中,我们将要学习分布式系统中的核心概念之一:复制。复制是指在分布式系统的多个节点上保存相同数据的副本。我们将探讨复制的目的、面临的挑战以及解决这些挑战的基本方法。
什么是复制? 🔄


复制简单来说,就是在分布式系统的多个节点上拥有相同数据的副本。复制技术被广泛使用,例如:
- 许多分布式数据库将复制作为其标准配置的一部分。
- 许多分布式文件系统允许你在多个不同节点上拥有某些文件的副本。
- 许多缓存系统本质上也是复制的一种形式。
我们将讨论适用于所有这些类型系统的算法。
在分布式系统中,任何拥有数据副本的节点都被称为一个副本。




为什么需要复制? 🤔

上一节我们介绍了复制的基本概念,本节中我们来看看为什么我们需要在系统中引入复制。以下是几个主要原因:
1. 容错性
最重要的原因之一是容错。如果某些副本由于某种原因无法访问,你仍然可以使用其他副本和数据副本来处理用户请求。副本可能无法访问的原因有很多:
- 软件问题导致崩溃。
- 硬件问题导致停止工作。
- 网络问题,例如网络分区,导致你只能与部分副本通信,而无法与其他副本(尤其是分布在远程位置的副本)通信。
2. 维护与可用性
另一个原因是你可以有意地让节点暂时不可访问。例如,为了安装软件更新而选择重启节点。在计算机重启期间,它无法处理任何用户请求或消息。我们希望整个服务(例如数据库)即使在部分节点不可用时也能继续工作。复制是实现这种容错性的机制之一,它非常有用,尤其是在需要进行系统维护时,因为这意味着你可以一次重启一个节点,只要不同时关闭所有节点,剩余节点就可以继续处理请求。



3. 负载均衡
另一个原因是如果负载非常重。例如,全球有大量不同的用户都想要同一份数据,那么单个节点可能无法处理。在这种情况下,在多个节点上拥有该数据的副本可以让你处理来自更多用户的更多请求,这对于互联网规模的系统非常重要。






复制的挑战:动态数据 📈
如果复制的数据不发生变化,复制就非常容易。对于静态数据,你只需在所有节点上做一次拷贝即可。本讲座我们将重点关注会发生变化的数据,因为这部分是困难的,需要最多的关注。

你可能在操作系统上下文中遇到过一种称为RAID的复制形式。这是指将多个硬盘连接到同一台计算机,可以使用RAID将数据冗余存储在多个硬盘上。例如,在RAID 1中,你有两个独立的磁盘互为镜像,写入一个磁盘的任何文件也会写入另一个磁盘。这意味着如果其中一个磁盘发生硬件故障,你仍然可以在另一个磁盘上获得数据。
我们在分布式系统中讨论的复制与此有些相似,但用于RAID的技术不能直接应用于分布式系统,因为RAID是为单台计算机设计的,有一个单一的控制器管理所有数据。而在分布式系统中,正如我们在之前的讲座中看到的,每个节点独立运行,节点之间是不可靠的网络。节点可能分布在世界各地,位于不同的大陆并通过互联网通信,因此我们必须处理这种分布式设置带来的所有挑战。


问题示例:重复请求与幂等性 ⚠️
让我们看一个例子来开始。假设有一个数据库存储着某些状态,并且该状态会发生变化。
这里有一个社交网络的例子,某个用户发布了一条更新说“月亮其实不是奶酪做的”,而你想为这条更新点赞。你点击“赞”按钮这个事实需要被存储在某处,同时有多少人点击了“赞”也需要被存储在某处,这个“某处”通常是运行在社交媒体提供商服务器上的数据库。

现在考虑点赞计数。当你按下点赞按钮时,客户端需要告诉存储在某处的计数器:“哦,你需要增加计数器,因为又有一个人喜欢这个特定的更新了。”但由于网络不可靠,请求或响应可能会丢失。考虑这个例子:请求成功到达服务器,但返回给客户端的确认(“好的,我增加了计数器”)在网络中丢失了。客户端认为请求没有成功,于是尝试再次发送增加计数的请求。服务器再次收到该请求,最终导致计数器增加了两次。因此,计数从12300变为12302,尽管你实际上只按了一次按钮。


这看起来像是一个假设性的例子,但它确实会发生。我们首先可以尝试通过去重来防止这种情况,我们之前在广播协议的上下文中见过。然而,对于数据库,我们通常希望支持崩溃恢复类型的系统模型,这意味着节点崩溃后不会永远离线,它会恢复。任何在崩溃和重启过程中需要保留的数据都必须以某种持久形式存储在磁盘上。如果你想以一种即使在崩溃和重启后也能工作的方式对数据库请求进行去重,这意味着所有请求实际上也需要存储在数据库中,最终可能导致数据量很大。


你可能会认为这只是个假设性问题,但它确实会发生。这是一个几年前我在Twitter上截取的真实截图,显示一个用户关注了 -20 个人。这应该是不可能的,因为在现实生活中你只能关注正数的人,没有负数的用户。虽然我不确切知道这里发生了什么,但我怀疑情况与这个例子非常相似。可能的情况是:用户原本关注了一些人,然后取消了关注。在取消关注的过程中,记录此人关注人数的计数器需要递减。由于某处网络故障,这个递减操作被重复执行,最终递减的次数超过了实际关注的人数,因此我们得到了一个负数计数器。
解决方案:幂等操作
那么,我们如何在系统中处理这类问题呢?一个非常有用的工具是使操作幂等。
幂等性是数学中的一个概念,意味着如果你对某个参数应用一次函数,其效果与应用两次、三次或任何次数相同。一个非幂等函数的例子就是我们刚才看到的增加计数器,因为增加一次和增加两次是不同的。然而,向集合中添加一个元素是幂等的,因为如果你向集合中添加一个元素,然后再添加同一个元素,由于集合不计算元素出现的次数(元素要么在,要么不在),所以执行集合并集操作是幂等的。
这意味着,如果我们将点赞(或Twitter中的用户关注)表示为集合而不是计数器,那么我们就可以安全地重试请求,因为即使请求丢失,我们也可以再次发送添加元素到集合的请求,应用两次不会造成危害,效果与应用一次相同。
这引出了分布式系统中几种不同的语义,你可能见过这些术语:
- 至多一次:你通过网络发送一次请求,它可能到达也可能不到达,但你不打算重试。
- 至少一次:你将重试直到收到确认,这意味着请求可能到达多次。
- 恰好一次:操作恰好生效一次。效果就像操作只被接收了一次,尽管实际上由于重试和消息丢失,它可能被应用了不同的次数。

如果我们重试并且操作是幂等的,那么我们就可以实现恰好一次语义。这意味着即使操作被应用了多次,效果也如同它只被应用了一次。当然,我们也可以通过去重来实现恰好一次,只要我们愿意以某种形式存储已经真正发生的请求。
幂等性的局限性与时间戳 🕐
让我们看另一个问题,它突显了幂等操作的局限性。
这次我们有两个不同的客户端。客户端1想要添加一个“赞”(或将你的用户ID添加到喜欢某个特定更新的用户集合中)。正如我们所见,这是一个幂等操作。同时,客户端2从数据库读取当前喜欢该更新的用户集合,并决定“取消点赞”,这意味着从喜欢某个特定更新的用户集合中移除一个特定的用户ID。

客户端2发出这个移除请求G并发送到服务器,服务器确认了它。然而,客户端1发出的第一个请求的确认在网络中丢失了,因此客户端1将重试这个请求。于是,客户端1再次发送添加“赞”的请求到服务器。但服务器实际上刚刚从用户ID集合中移除了这个“赞”,所以它又会把它加回去。在这里,即使函数F(添加)是幂等的(应用两次“赞”与应用一次效果相同),但在同时存在这个“取消点赞”操作G的上下文中,幂等性就不完全适用了。因为如果我们先应用F,然后G,再应用F,这与只应用F和G不同,因为第二次应用F会将元素重新加回集合。用户的意图是这条更新不应该被点赞,因为用户先点了赞,然后又取消了。这个重试的“添加赞”在因果上仍然属于第一个请求,而不是一个单独的重新添加请求,但实际意图是最终结果应该是“未点赞”状态。

引入时间戳与防墓碑
让我们看看一种可以用来解决这个问题的方法,我将用另一个可能发生在复制中的问题来说明。
假设一个客户端想要向一个集合添加元素X。首先,用户添加它,然后用户又想移除它(因为人们可以改变主意)。第一个添加请求发送到副本A和B。然后我们有一个移除请求。不幸的是,移除请求在从客户端到副本B的网络中丢失了。所以副本A收到了添加和移除请求,但副本B只收到了添加请求,没有收到移除请求。最终结果是:元素X在副本B的集合中(因为它没有被移除),但X不在副本A的集合中(因为它被移除了)。
考虑另一种情况:客户端只想添加X到集合。添加操作成功到达副本B,但在前往副本A的途中丢失。同样,我们得到相同的状态:X存在于B,但不存在于A。这两个场景中两个副本的最终状态是相同的,但实际上我们不希望它们相同,因为用户的意图不同。在第一个场景中,用户先添加后移除,所以用户的意图是最终状态应该是X不在集合中。而在第二个场景中,用户只添加了,没有移除任何东西,所以用户的意图是X在集合中。因此,从副本的角度看,这两种结果看起来一样,但我们需要区分它们。

我们可以通过为操作添加时间戳来实现这一点。我们将重用之前学到的关于逻辑时间戳的知识,让客户端在向两个副本发出请求时生成一个逻辑时间戳。
客户端生成时间戳T1,并将其附加到发送给A和B的添加请求上。现在A和B都存储这个时间戳T1,并将X与一个值true关联(true表示X存在于集合中)。接下来,客户端发出移除请求,因为这是一个新请求,它生成一个新的逻辑时间戳T2,将T2附加到请求上,然后发送给A和B。由于这是一个逻辑时间戳,并且移除发生在添加之后,这意味着T1小于T2。
副本A收到移除X的请求,它并不是真正从数据库中删除X,而是将X标记为false(false意味着将元素X视为不在集合中,因为用户要求移除它)。但我们实际上不会删除它,否则我们就无法知道用户是添加了还是移除了它。我们将在数据库中保留这个标记为false的记录,并记录删除发生的时间戳T2。

这种不实际删除某物,而只是将其标记为已删除的结构称为墓碑。它出现在各种不同的分布式算法中,用于处理删除。墓碑意味着某物应该被删除,但由于内部原因,我们必须实际保留它(也许墓碑可以在以后的某个时间点被垃圾回收)。
现在,在B上,X仍然存在,并且具有最后修改时间戳T1。在A上,X被视为不存在,并具有时间戳T2。这样,A和B就不再处于相同状态,因为A收到了带有时间戳T2的删除X的请求,而B没有收到(因为请求在网络中丢失了)。

反熵协议
现在,两个副本可以运行一个协议来比较它们拥有的数据。这个协议被称为反熵协议,旨在消除系统中的混乱和不确定性,并将所有副本带回相同状态。


当A和B协调状态并执行此反熵协议时,它们会比较时间戳。由于每个数据项都附加了逻辑时间戳,它们可以意识到T1小于T2。因此,B拥有的这个值一定更旧,A拥有的值一定更新。所以,B上的记录将被来自A的记录替换(来自A的是更新的)。在这里,时间戳帮助了我们,因为它们告诉我们这些更新的新旧程度和相对顺序。


通常,我们可能会为数据库中的每个给定键或每个对象使用一个时间戳,因为这是我们将进行协调的粒度。

时间戳的选择:兰波特时钟 vs 向量时钟 ⏱️

那么,我们在这里使用哪种逻辑时间戳呢?在上次讲座中,我们看到了兰波特时钟和向量时钟之间的区别。事实上,我们可以在数据库中使用两者,并且会得到略有不同的行为,这实际上取决于我们想要哪种行为。


当我们考虑两个客户端同时向这个复制数据库进行写入时,这一点就变得很明显了。
客户端1想要更新与某个键X关联的值。X是键,V1是客户端1想要附加给X的值,此操作将具有逻辑时间戳T1。同时,客户端2也想设置X的值,它想将其设置为不同的值V2。客户端2也生成逻辑时间戳T2,并将T2附加到“将键X设置为值V2”的操作上。
注意,副本A首先收到客户端2的请求,然后收到客户端1的请求。而副本B以相反的顺序接收:先收到客户端1的请求,然后收到客户端2的请求。这意味着副本不能仅仅使用它们看到请求的顺序来确定V1或V2谁是X的最终值,因为如果它们只使用请求到达的顺序,那么它们最终将处于不同的状态,最终会不一致。在一个复制系统中,我们希望所有副本最终处于相同的状态,否则就不是复制了。这就是为什么我们需要在这里使用时间戳,因为时间戳提供了这两个操作的相对顺序。
选项一:兰波特时钟
如果我们使用兰波特时钟,它为我们提供全序。全序意味着对于系统中存在的任何两个时间戳,我们总是可以比较它们,并且总是一个小于另一个。在这种情况下,例如,我们可以说T1小于T2,但也可能反过来,因为这两个操作是并发的,所以兰波特时间戳提供的顺序本质上是任意的。




我们可以做的是比较T1和T2,然后只保留时间戳更高的版本/值。这是可行的,因为当值被覆盖时,我们知道覆盖操作发生在先前的操作之后,因此它将始终具有更大的时间戳。所以,用更大的时间戳覆盖更小的时间戳是可以的。
这种行为被称为最后写入者获胜,因为我们假设最后一个写入者是具有最高时间戳的那个。因此,如果有几个具有不同时间戳的并发更新,最高时间戳获胜。


但请注意,这意味着如果T2大于T1,我们保留V2(由客户端2写入),而直接丢弃V1。值V1就消失了,我们甚至不把它存储在任何地方,它直接被覆盖和遗忘。这可能没问题,取决于应用程序。有些应用程序中,如果有两个并发写入,只保留一个并丢弃另一个是可以接受的。如果有五个并发写入,我们将保留一个并丢弃四个。

选项二:向量时钟
然而,也可能存在一些场景,如果有多个并发写入,我们希望保留所有,并明确解决这种情况。如果我们想要这样,我们就使用向量时钟而不是兰波特时钟。


如果我们使用向量时钟,并且生成两个不同的时间戳T1和T2,且这两个操作是并发的,那么向量时钟也将是不可比较的,T1和T2将不可比较。这允许我们检测到这两个操作是并发的事实。
在这种情况下,我们只会在一个值的向量时间戳严格大于另一个值的情况下,才用后者覆盖前者(根据我们之前定义的“严格大于”关系)。但如果我们有两个并发更新,那么我们将同时保留两者。在这种情况下,键X附加的不是单个值,而是多个值(V1和V2)。然后我们可以将这两个值返回给应用程序,应用程序可以解决这个冲突。应用程序可以有明确的冲突解决逻辑,这样如果这两个更新并发发生,并且由不同客户端写入了两个不同的值,我们就可以避免丢弃数据,我们可以将这两个并发写入的值中的信息合并成一个单一的值,再写回数据库,从而确保每个人都满意且没有数据丢失。
因此,我们在这里有一个选择:使用全序的兰波特时间戳,或使用偏序的向量时间戳,这取决于我们实际想要哪种行为。不同的数据库系统实际上会根据不同应用程序的适用性,在要使用哪种时间戳方面做出不同的选择。




总结 📚
本节课中我们一起学习了分布式系统中的复制技术。我们首先了解了复制的定义及其主要目的:容错、维护可用性和负载均衡。然后,我们探讨了处理动态数据复制时的核心挑战,特别是重复请求和操作顺序问题。
我们引入了幂等性作为解决重复请求、实现“恰好一次”语义的关键工具。接着,我们发现了幂等性在并发操作下的局限性,并通过引入逻辑时间戳和墓碑技术来解决状态分歧问题。时间戳允许副本通过反熵协议来协调状态,判断更新的新旧。



最后,我们比较了兰波特时钟和向量时钟两种时间戳机制。兰波特时钟提供全序,实现“最后写入者获胜”的简单冲突解决,但可能丢弃数据。向量时钟提供偏序,能检测并发更新并保留冲突数据,将冲突解决交给应用程序,提供了更大的灵活性。


理解这些基础概念是设计健壮、一致的分布式复制系统的关键。下一节,我们将进一步探讨复制中的容错性。
015:法定人数(Quorums)🏛️
在本节课中,我们将要学习分布式系统中数据复制的核心概念——法定人数(Quorums)。我们将探讨为何需要复制、如何通过法定人数机制在保证系统可用性的同时,实现读写一致性。
复制与可靠性
我们进行数据复制的主要目的是提高系统的可靠性。一个节点可能因各种原因变得不可用,例如崩溃、硬件故障或例行维护重启。
假设在任意给定时间,一个节点不可用的概率为 P。同时,我们假设一个节点是否可用与另一个节点是否可用是相互独立的。虽然实际研究表明故障往往是相关的,但为了进行粗略估算,我们暂时假设它们是独立的。
如果 p 是单个节点发生故障的概率,那么所有 n 个副本同时故障的概率就是 p^n。我们可以计算出一个或多个副本故障的概率。从下表的“至少一个节点故障”列可以看出,随着副本数量的增加,在任意时刻至少有一个副本故障的概率几乎变为必然。


从这个意义上说,随着副本数量的增加,系统似乎变得更不可靠,因为节点越多,总有一个可能故障。然而,如果我们考虑所有副本都故障的概率,这个概率会随着副本数量的增加呈指数级下降。即使我们考虑的不是全部故障,而是半数副本故障的概率,这个概率同样会随着副本数量增加而指数级下降。

因此,只要系统在大多数副本可用时就能继续工作,我们就能实现容错,并达到非常高的可靠性。
读写一致性

现在,让我们来看一个容错复制系统需要具备的一个特定属性:读写一致性。
读写一致性是指,一个客户端写入一些数据后,立即读取相同的数据,它应该能看到自己刚刚写入的数据。
考虑以下场景:客户端希望将键 X 的值设置为 V1,并为该操作附加时间戳 T1。它将更新发送到数据库的两个副本 A 和 B。副本 B 接收并处理了更新,而副本 A 不可用,因此更新未被 A 处理。随后,客户端再次读取 X。但此时情况反转,A 可用而 B 不可用。于是,客户端从 A 得到的响应并不反映已写入的值 V1,而是该场景开始前副本持有的旧值 V0。

在这种情况下,我们违反了读写一致性。这会造成混乱,因为写入的数据似乎消失了,之后又可能重新出现。
简单方案及其问题
为了解决这个问题,一个直观的想法是让读和写操作都访问所有副本。这样就能避免只有一个副本看到更新,而读取却从另一个副本进行的问题。


但是,如果要求读写都必须访问所有副本,那么系统就不再是容错的。因为只要两个副本中有一个不可用,我们就无法处理任何读写请求。



法定人数方案
我们可以使用“法定人数”来解决这个问题。以下是一个“三中取二”的法定人数示例。
当客户端想要写入时,它将请求发送给所有三个副本。假设请求到达了其中两个副本(B 和 C),但没有到达 A。B 和 C 会响应客户端,确认收到了更新键 X 的请求。一旦客户端收集到两个肯定的响应,它就认为写入成功。因此,请求没有到达 A 是可以接受的,只要 B 和 C 有响应即可。
随后,客户端进行读取。它再次将读请求发送给所有三个副本。这次,假设只有两个副本(A 和 B)收到了请求,而 C 没有。客户端将从 A 和 B 收到响应。它会从 B 得到正确的响应(因为 B 看到了写入),从 A 得到一个过时的响应(因为 A 没收到写入)。但客户端收到的响应中,至少有一个包含了更新后的值。

现在,客户端可以利用时间戳 T0 和 T1 来判断哪个是最近的更新。这使得客户端能够知道正确的响应应该是什么,并将其返回给应用程序,从而满足读写一致性。
法定人数算法
具体来说,我们需要做的是使用法定人数。每当你想向一些副本写入时,只要写入操作得到至少 W 个副本的确认,就被认为是成功的。这被称为写入法定人数。
当进行读取时,如果你从至少 R 个副本得到响应,就可以处理。这被称为读取法定人数。
W 和 R 是这个算法的两个参数。我们要求 W 和 R 的和严格大于系统中的副本总数 N。

用公式表示即:
W + R > N
在我们的例子中,W=2,R=2,N=3。2+2 > 3,因此我们可以保证读取操作能看到之前写入的值(或者被另一个客户端并发覆盖的最新值)。如果 R + W ≤ N,则可能无法读到预期的值。

这个机制的核心思想是:写入操作访问一部分节点,读取操作访问另一部分节点,我们希望这两个节点子集有重叠。只要满足 W + R > N 这个法定人数条件,我们就能保证它们至少在一个副本上重叠。换句话说,这两个节点子集必须有一个非空的交集。
例如,如果我们有5个节点,可以要求写入法定人数 W=3,读取法定人数 R=3,这满足条件。在这种情况下,这两个节点子集总是保证至少有一个公共元素。

多数法定人数
一种常见的构造法定人数的方法是“多数法定人数”。我们假设节点数为奇数,只要读写法定人数严格超过节点总数的一半即可。
这意味着:
- 对于3个节点,需要
2个。 - 对于5个节点,需要
3个。 - 对于7个节点,需要
4个。
以此类推。


这样,在“三中取二”的情况下,只要另外两个节点响应,我们可以容忍一个节点不可用。在“五中取三”的情况下,只要剩余三个节点响应,我们可以容忍两个副本不可用。
读修复


在完成法定人数读写后,客户端可以帮助不同的副本重新同步。
在上述例子中,客户端从 A 收到了过时的值,从 B 收到了最新值,从 C 没有收到任何响应。客户端已经知道 A、B、C 彼此不一致。
客户端可以通过将更新再次发送给 A 和 C 来帮助清理这种情况。C 可能不需要,因为它可能已经有最新值只是没响应,但再次发送也无妨。我们肯定需要将更新发送给 A,这样 A 和 B 就都有了更新后的值 V1。
注意,这里使用的是原始时间戳,因为这本质上只是重试,不是一个新的设置操作。因此,使用原始时间戳是正确的。客户端可以借此帮助在副本间传播值。

之前讨论的反熵是副本重新同步的一种机制,而这里的“读修复”过程是另一种帮助副本彼此同步的方式。
总结

本节课中我们一起学习了分布式系统中的法定人数机制。我们了解到,数据复制虽然引入了副本不一致的风险,但通过精心设计读写法定人数(要求 W + R > N),可以在保证系统高可用性的同时,实现读写一致性。多数法定人数是一种常见且有效的实现方式。此外,客户端参与的“读修复”机制可以辅助副本间达成最终一致。法定人数是构建可靠、容错分布式系统的基石之一。
016:状态机复制 📡


在本节课中,我们将学习如何利用广播协议来实现数据复制。我们将重点探讨状态机复制这一核心概念,并了解如何通过不同强度的广播协议来构建复制系统。




上一节我们介绍了广播协议,其核心目标是让所有节点以某种顺序接收一组消息。而数据复制的目标是让所有副本处理相同的更新。你可能会想,我们是否可以利用广播协议来实现复制?答案是肯定的。
从全序广播到状态机复制 🔄






首先,让我们从全序广播开始,这是我们讨论过的最强广播协议。全序广播确保所有节点以完全相同的顺序传递相同的消息集合,这正是复制所需要的。这种利用全序广播进行复制的方法被称为状态机复制。





其算法非常简单:
- 当一个副本想要更新其状态时,它使用全序广播来广播该更新。
- 所有副本在传递到该更新时,都会处理并应用此更新到自己的状态。
其核心在于,应用更新的逻辑必须是确定性的。你可以将每个副本视为一个状态机。副本的状态就是它存储的所有数据。每个副本都从一个固定的初始状态(通常是一个空数据库)开始。确定性的状态更新意味着,如果两个副本处于相同的状态,并且给它们相同的输入,它们将转换到相同的下一个状态。






由于全序广播确保每个副本以相同的顺序传递相同的更新,如果我们把这些传递的更新作为状态机的输入,那么每个副本上的状态机都将经历相同的状态转换序列,并最终达到相同的状态。这正是状态机复制的目标。


状态机复制的应用与优势 💡


这种思想在许多不同的复制系统中被广泛使用。例如,一些分布式数据库使用这种方法来复制事务。只要更新逻辑是确定性的,它可以是任意复杂的业务逻辑。你可以将整个事务打包进去,并通过全序广播确保事务的输入被传递到所有副本,然后每个副本独立执行此事务,最终将达到相同的状态。


一个非常相似的原则构成了区块链、分布式账本和智能合约的基础。区块链中的区块链实际上就是由全序广播协议传递的消息序列。一旦你有了这个序列,每个副本都会以相同的顺序处理这些确定性的交易,从而最终达到相同的状态(例如,一个记录谁拥有哪些代币的账本)。
状态机复制的局限性与权衡 ⚖️


状态机复制的缺点源于全序广播的缺点。当一个节点想要广播消息时,它不能立即将该消息传递给自己,它必须与其他节点协调以决定消息的传递顺序。这意味着副本不能立即更新自己的状态,它必须经过全序广播过程来确定更新应用的顺序。因此,我们需要一个全序广播协议,这将在下一讲中详细研究。


利用更弱的广播协议进行复制 📡

我们之前也讨论过更弱的广播形式,如因果广播、可靠广播和尽力而为广播。那么,我们是否也能用它们来实现复制呢?答案是肯定的,只是需要更谨慎一些。
这里我们需要一个概念:交换性。如果两个更新可以以任意顺序应用,并且最终结果相同,那么我们就说这两个更新是可交换的。显然,并非所有更新都是可交换的,但我们可以专门设计算法,使更新具有交换性。





如果我们能做到这一点,那么就可以使用因果广播。因果广播保证,如果一个消息发生在另一个消息之前,那么这些消息将按其“发生在前”的顺序传递,但两个并发消息可能以任意顺序传递。因此,要确保所有副本最终达到相同的状态,我们必须确保任何两个并发更新的状态更新函数必须是可交换的。这样,因果广播就能以正确的顺序处理所有不可交换的更新,而对于那些可能被重新排序的更新,由于它们是可交换的,以任意顺序应用它们都会达到相同的状态。
我们可以进一步推广。对于可靠广播,它完全不提供顺序保证,只保证每个消息恰好被传递一次。在这种情况下,所有的更新都必须可交换,因为广播协议不提供任何顺序保证。我们甚至可以弱化到尽力而为广播,在这种情况下,我们甚至不能保证消息被传递的次数,消息可能丢失或重复传递。此时,状态更新函数必须能够容忍消息丢失,并且通过幂等性来容忍消息被多次传递。






由此可见,实际上我们可以利用所有这些不同的广播模型来构建复制系统。
总结 📝







本节课中,我们一起学习了如何利用广播协议实现数据复制。我们深入探讨了状态机复制这一核心方法,它通过全序广播确保所有副本以相同顺序处理更新,从而保持状态一致。我们还了解到,通过设计具有交换性的更新操作,也可以利用更弱的广播协议(如因果广播、可靠广播)来实现复制。每种方法都在性能、复杂性和一致性保证之间提供了不同的权衡。在下一讲中,我们将深入研究如何可靠地实现全序广播。
017:共识问题概述
在本节课中,我们将要学习分布式系统中最棘手也最重要的问题之一:共识。我们将探讨共识与全序广播的关系,理解为什么需要共识算法来自动处理领导者故障,并介绍共识算法的基本模型和核心思想。

上一讲我们讨论了状态机复制,这是一种将数据从一个副本复制到另一个副本的有效方法。为了实现状态机复制,我们需要确保所有副本以相同的顺序看到相同的更新,这可以通过全序广播来实现。这就引出了一个问题:我们如何实现全序广播?
实现全序广播的一种方法
我们之前看到的一种方法是,通过一个特定的专用节点(我们称之为领导者)来发送所有消息。这个节点将对所有消息进行排序,并确保它们以相同的顺序在所有节点上被传递。
然而,如果该领导者崩溃,或因网络问题、硬件故障等原因变得不可用,那么这种全序广播方法就会停止工作。现在,核心问题是如何处理领导者不可用的情况。
处理领导者故障的选项

一种选择是手动切换到新的领导者,这个过程称为故障转移。手动意味着有一个人类操作员在领导者故障时收到通知,然后由他来选择一个新的节点作为领导者,并重新配置系统。

以下是手动故障转移的步骤:
- 操作员收到领导者故障的通知。
- 操作员选择一个新节点作为新领导者。
- 操作员重新配置系统,将所有跟随者指向新领导者。
- 新领导者开始承担全序广播的职责。


如果你能提前知道领导者将要下线(例如,计划内的维护),这种方法可以很好地工作。你可以提前将领导者角色转移到另一个节点,然后安全地关闭原节点。
不幸的是,当领导者意外崩溃或突然出现硬件故障时,这种方法就无能为力了。在这种情况下,很可能在最不方便的时间(例如凌晨三点)发生故障,需要有人被叫醒并登录系统进行手动重新配置。
虽然一些数据库系统确实以这种方式实现复制,但这引出了一个问题:我们能否以某种方式自动完成这个领导者切换过程,从而避免在凌晨三点把人叫醒?
共识算法的作用


切换到新领导者的过程,正是共识算法的核心所在。


共识和全序广播是两个不同的问题,但它们实际上密切相关。传统上,分布式系统中的共识问题被表述为:多个节点各自可以提议一个值,你希望所有节点就同一个值达成一致。这就像几个朋友决定今天去哪里吃午餐,他们提议不同的餐厅,然后通过某种过程,其中一个提议会被选中,然后所有人都去同一家餐厅。

你可以将其视为等同于全序广播。在全序广播中,我们希望所有节点以相同的顺序传递相同的消息。节点需要达成的一致,就是关于在这个全序中下一个要传递什么消息的一致。你可以为每个需要传递的消息执行一次共识,共识将保证所有节点做出相同的决定。当一个节点决定了一个特定值时,在全序广播术语中,就意味着它传递了那条消息。
共识保证了一旦一个值被决定,就不会再改变主意。对于全序广播,这意味着一旦一条消息被传递,它就在这个全序中占据了一个确定的位置。

因此,共识问题和全序广播在形式上是等价的。这意味着如果你有一个解决其中一种问题的算法,你就可以将其转换为解决另一种问题的算法,反之亦然。


共识算法简介
经典的共识算法叫做Paxos。Paxos实际上只对单个值进行共识。但有一个Paxos的扩展叫做Multi-Paxos,它实际上提供了全序广播,即对一系列值(一系列要传递的消息)达成一致。
还有一系列其他的共识算法,包括Viewstamped Replication、ZooKeeper的ZooKeeper Atomic Broadcast(Zab)。这些算法实际上被设计为直接提供全序广播。
在本讲中,我们将更详细地研究Raft算法,因为它可能是最容易理解的,尽管所有这些算法都相当复杂和微妙。

系统模型的重要性

现在,请记住我们在第二讲中讨论过的不同系统模型,这在讨论共识时变得非常重要。
关于节点行为,我们可以在崩溃-停止、崩溃-恢复或拜占庭(节点可能任意恶意行为)之间选择。关于网络,我们是否假设公平丢失?关于同步性,我们假设异步系统、部分同步系统还是同步系统?
Paxos、Raft和所有这些流行的共识算法选择的是部分同步的崩溃-恢复系统模型。你可能会问为什么选择这些特定的模型。特别是同步性问题很重要,因为我们已经说过完全同步的模型是不现实的,因为在真实系统中确实存在网络延迟和各种延迟,所以我们不能依赖消息延迟的任何上限。我们真正能期望的最好情况是部分同步,即大多数时间网络表现良好,但偶尔会出现消息长时间延迟或节点长时间暂停执行的情况。
理想情况下,我们希望共识即使在异步系统中也能工作,在那里我们不做任何时间假设。但事实证明,这实际上是不可能的。有一个称为FLP不可能性的证明,证明了在异步系统中实现一个总能终止的共识算法是不可能的。即使你只允许崩溃-停止故障,也总会存在共识算法的某些执行过程,使得算法无法终止。


因此,如果你想要终止性,即希望算法取得进展并最终决定某个值,或者希望全序广播最终传递一条消息,那么我们必须做出这些时间假设。我们必须至少假设部分同步,这实际上意味着我们使用时钟进行超时,或者换句话说,我们使用基于超时的故障检测器来判断领导者是否失败。
重要的是,算法的正确性实际上并不依赖于时间。即使系统中的时间变得非常奇怪,共识算法仍然保证相同的消息序列以相同的顺序被传递。时间只影响传递何时发生。因此,如果系统中发生了很多奇怪的延迟,那么消息的传递可能会延迟一段时间,直到系统自行恢复。所以,时间确实影响系统的活性,但不影响安全性。

拜占庭容错共识

也可以弱化关于崩溃-恢复系统模型的假设。实际上,可以一直弱化到拜占庭系统模型,即我们假设某些节点可能行为恶意。在这种系统中进行共识是可能的,这类拜占庭容错共识算法被用于一些加密货币和区块链等领域。
然而,这些拜占庭容错算法比崩溃-恢复算法复杂得多,效率也低得多。因此,在本课程中,我们将只关注Raft,它是一种非拜占庭算法,只假设崩溃-恢复模型。我们将暂时把拜占庭算法排除在讨论范围之外。
共识的核心:领导者选举
如前所述,共识的关键是需要选举一个领导者。一旦我们有了领导者,该领导者就可以决定消息的传递顺序。我刚才提到的所有共识算法实际上都使用领导者来对消息进行排序。关于它们如何使用领导者以及如何选举领导者的细节,因算法而异。我将重点介绍Raft的具体做法。
我们使用超时,即使用本地时钟来怀疑领导者是否失败。请记住,在部分同步系统中,故障检测器并不总是准确的。可能我们认为领导者崩溃了,而实际上领导者工作正常,只是存在网络问题阻止了我们与领导者的通信。这就是为什么我说我们只能怀疑领导者崩溃,而永远无法确定超时发生时是否真的是领导者故障。
我们希望共识算法能够防止同时出现两个领导者,因为如果有两个领导者,它们可能会做出矛盾的决定,那么我们算法的所有保证都将失效。这种你有两个不同的领导者都认为自己是领导者的情形被称为脑裂。如果这种情况发生在分布式系统中,通常意味着坏消息,很可能导致数据损坏或丢失。
因此,我们希望避免脑裂。共识算法必须设计得非常小心,以避免出现脑裂。
Raft中的任期与投票




Raft实现这一点的方式是引入了任期的概念。任期只是一个整数变量。每次进行领导者选举时,该选举都在某个特定的任期内进行。每次我们开始一次新的领导者选举,我们只需递增任期。Raft中领导者选举提供的保证是:在给定的任期内,最多只能选举出一个领导者。有可能一次领导者选举失败,导致在该任期内没有选出领导者,但绝不会在一个任期内有超过一个领导者。


Raft确保这一点的方法是制定了一条规则:在每个任期内,一个节点只允许投票一次。这就像议会选举中你只能投一票一样。假设我们有一个五节点的系统,其中A、B、C三个节点投票赞成选举一个新领导者。之后,如果C、D、E试图在同一任期内选举一个不同的领导者,它们将无法成功,因为C知道它已经在这个特定任期内投过一次票了,因此它不会在同一任期内为另一个候选者再次投票。在新的任期中,开始新的领导者选举,那么每个节点都可以再次投票。
法定人数与容错
我们通常要求在共识算法中,每次我们想要选举一个领导者时,都需要一个法定人数的节点在该特定任期内投票赞成该领导者。例如,如果我们有五个节点,那么我们可以有一个多数法定人数,即五分之三。任何三个节点都能够选举出一个新领导者,这意味着系统可以容忍两个节点不可用,而剩下的三个节点仍然可以取得进展,并在必要时选举出新领导者。这就是我们获得容错能力的方式。

这种每个任期只允许投票一次的方法确保了在给定任期内,存在唯一的领导者。

处理不同任期的领导者
然而,这并不能保证我们不会出现多个领导者。因为可能发生的情况是,不同任期内有不同的领导者,而它们彼此并不知道。
想象这样一个场景:我们有三个节点1、2、3。假设节点1在任期T被选为领导者。但随后发生了网络问题,节点1无法再与节点2和3通信。节点1仍然存活,节点1仍然是领导者。但现在节点2和3听不到来自领导者的任何消息了。因此,在某个时刻,节点2和3会说,我们的领导者可能死了,所以我们要在一个新的任期(比如任期T+1)选举一个新领导者。节点2和3将在它们之间选举一个新领导者,例如节点2成为新领导者。现在系统实际上有两个领导者:节点1是任期T的领导者,节点2是任期T+1的领导者。这看起来像是脑裂,对吧?我们有两个领导者。这很糟糕,我们不希望处于这种情况。但不幸的是,由于网络中断,节点1无法知道它已经被取代,不再是领导者了,因为通信根本无法通过。
因此,我们实际上需要处理这种可能性:系统中可能同时存在不同任期的不同领导者,这些领导者可能在一小段时间内做出矛盾的决定。尽管如此,我们必须确保全序广播的属性不被违反。
两阶段决策原则

共识算法处理这个问题的方式是:如果一个领导者想要决定传递某条消息,该领导者不能独自做出这个决定。相反,领导者必须再次去询问一个法定人数的节点,它们是否同意传递某条消息。
通信流程大致如下:首先,左边的节点需要被选举为领导者,因此它会联系其他节点,询问它们是否同意它成为新领导者。其他节点回应同意。现在它获得了赞成成为领导者的法定票数,因此左边的节点在一轮选举后成为领导者。现在,领导者想要传递一条消息。但如前所述,领导者不能简单地自行决定传递一条消息,而是必须再次去找跟随者,询问它们是否同意在任期T中将消息M作为下一条消息传递。只有当法定人数的跟随者再次回应表示同意(“好的,我们同意这条消息M在任期T中”),并且跟随者只有在没有听说过任期T+1的任何其他领导者时才会做出积极回应,领导者才能安全地决定传递M,并可以告诉其他节点,M现在是全序广播中要传递的下一条消息。

这就是我们拥有的两阶段投票原则:首先,一个节点需要获得法定人数节点的投票当选为领导者;然后进行第二轮,领导者检查是否可以决定将某条消息作为下一条消息。这实际上是所有我们见过的共识算法的基本原理。

在下一节中,我们将详细探讨Raft如何实现这个特定的系统。
总结

本节课中,我们一起学习了分布式系统中的共识问题。我们了解到共识与全序广播的等价关系,以及为什么需要共识算法来自动处理领导者故障,避免手动干预。我们介绍了共识算法通常基于部分同步和崩溃-恢复的系统模型,并解释了FLP不可能性定理为何要求我们做出时间假设。我们探讨了Raft算法通过引入任期、单次投票和法定人数机制来确保领导者唯一性的核心思想,并最终理解了共识算法通过两阶段投票(领导者选举和日志提交)来保证一致性和避免脑裂的基本原理。这些概念为理解具体的共识算法(如Raft)奠定了坚实的基础。
018:Raft共识算法详解 🧩
在本节课中,我们将要学习Raft共识算法的完整流程,以实现全序广播。Raft是一个相当复杂的算法,它通过一个包含九个步骤的伪代码状态机来定义。我们不要求你记住所有细节,但希望你能够仔细理解算法的工作原理和原因,因为Raft算法的设计原则对于更广泛地思考分布式系统中的状态管理非常有启发性。
核心状态机
Raft算法的核心是一个状态机,其中每个节点都处于以下三种状态之一:跟随者、候选人或领导者。所有状态都存在于一个特定的任期中,每个任期都有一个编号。节点在特定任期内可以成为领导者。
当一个节点首次启动时,它总是进入跟随者状态。同样,如果一个节点崩溃后恢复,无论崩溃前处于何种状态,它重启后也总是回到跟随者状态。

然而,所有节点都从跟随者开始,最终我们需要某个节点成为领导者。节点成为领导者的方式是首先成为候选人。
选举过程


当节点检测到在一段时间内没有收到来自领导者的消息(即超时)时,它将决定在某个特定的任期编号中作为候选人参加选举。在该任期内,它将尝试从系统中的其他节点获取投票。如果它成功从多数派节点获得赞成票,那么它就能够过渡到领导者状态。


但是,在选举过程中,候选人可能会发现存在一个具有更高任期编号的节点。如果发生这种情况,候选人会立即退回到跟随者状态,并与具有更高任期编号的节点协作。更高的任期编号具有优先权。
也可能发生以下情况:节点处于候选人状态正在进行选举,但可能暂时无法与多数派副本(节点)通信,因此在规定时间内没有收到多数派的赞成票而超时。如果是这样,它将使用更高的任期编号重新开始选举,保持候选人状态并以更高的任期编号重新开始。




同样,如果领导者正在履行其职责,它可能会发现存在一个具有更高任期编号的节点(例如,系统发生分区,部分节点在此期间选举出了具有更高任期编号的新领导者)。一旦发现其他节点具有更高的任期编号,领导者将立即退回到跟随者状态。
因此,在Raft算法中,权力总是从较低任期编号和平地过渡到较高任期编号。


算法伪代码解析
现在,让我们来运行实际的代码。我们从通常的变量初始化开始。现在我不会解释每一个变量,但会在算法使用它们时进行解释。


这里需要指出,前四个变量(currentTerm、votedFor、log、commitLength)必须存储在稳定存储(即磁盘)中,而其余五个变量可以存储在易失存储中,它们可能在崩溃时丢失。在崩溃恢复时,前四个变量必须被保留,而其余五个变量则被重置为新的初始状态。

如前所述,当节点怀疑领导者失效时,它将成为候选人并开始选举。节点在怀疑领导者失效之前使用的超时时间通常是随机的,目的是避免多个节点同时尝试成为候选人(如果所有节点同时启动并具有相同的选举超时时间,就会发生这种情况)。随机化超时时间降低了同时发生多次选举的概率。

当节点决定是时候开始选举时,它会递增当前任期,将其当前角色设置为候选人状态。就像在任何一场好的选举中一样,每个人都会在选举中投票给自己。在Raft中也是如此。刚刚成为候选人的节点通过将votedFor变量设置为其自身的节点ID来为自己投票,并维护一个记录其作为候选人所收到的赞成票的节点ID集合,并将其自身的节点ID作为该集合的第一个成员。


lastTerm变量初始为0。但如果日志长度大于0,我们会设置这个lastTerm变量。让我先解释一下什么是日志。日志是一个数组或列表,是一个日志条目序列。每个日志条目由两部分组成:首先是一条消息(这是我们希望通过全序广播传递的消息,也是整个算法的目的),其次是每个日志条目上的一个任期编号。这个任期编号是领导者将该特定条目添加到日志时的任期编号,我们稍后会看到为什么这个任期编号很重要。
log.length是日志中日志条目的数量。如果日志非空(即log.length > 0),那么我们将lastTerm设置为最后一个日志条目的任期字段。log.length - 1是日志中最后一个条目的索引,我们取该最后一个日志条目的任期字段,并将其设置为lastTerm变量的值。



然后,我们构造一条将发送给所有节点的消息。候选人向所有节点发送相同的消息。这是一条标记为VoteRequest类型的消息,意思是“请投票给我”。消息包含几个字段:候选人的节点ID、递增后的当前任期编号、候选人日志的长度以及lastTerm变量(即候选人日志中最后一个日志条目的任期字段)。它将这条相同的消息发送给所有节点,并启动一个计时器,如果未能成功完成选举,该计时器最终可能会超时。

处理投票请求
现在,让我们看看接收这些请求消息的其他节点会发生什么。假设我们是收到候选人消息的其他节点之一,我们拥有候选人的ID、候选人的任期编号、候选人日志的长度以及候选人最后一个日志条目的任期编号。
接收VoteRequest消息时,我们要做的第一件事是将候选人的任期与接收消息的节点的当前任期进行比较。如果候选人的任期大于接收者的当前任期,那么我们将前进并加入候选人的任期。我们通过将接收者的当前任期设置为候选人的任期来实现这一点,无论节点之前处于什么状态,都过渡到跟随者状态,并且votedFor变量被设置为null,这意味着我们在当前任期内尚未投票给任何候选人,因为我们刚刚更改了当前任期。
接下来,我们设置lastTerm变量,方式与在候选人节点上类似。我们查看此投票请求消息接收者上的最后一个日志条目,获取该最后一个日志条目的任期编号,并将其设置为lastTerm变量。
现在,我们需要做的是检查候选人的日志是否与我们自己的日志一样新。原因是,我们不希望拥有过时日志的候选人成为领导者。“过时”的确切含义稍后会变得清晰,但关键是我们希望候选人的日志能反映至少多数派节点已经看到的所有更新,以避免最终出现一个日志非常过时的领导者,因为这可能导致某些消息丢失。

我们有一个logOk布尔变量,它告诉我们当前节点是否认为候选人的日志可以投票赞成。这里有两个标准:首先,如果候选人最后一个日志条目的任期编号大于此投票请求消息接收者日志条目的任期编号,那么我们认为日志是合格的。其次,另一种使日志合格的情况是,最后一个日志条目的任期编号相同,但候选人的日志长度至少与接收此投票请求消息的节点的日志长度一样长。这两个条件中有一个为真即可,如果为真,则日志合格。

现在,如果三个条件都成立,我们将投票给这个候选人:第一,候选人的任期等于当前任期(即,它不是某个过时的任期,我们还没有看到任何其他具有更高任期编号的节点);第二,候选人的日志合格;第三,我们在当前任期内尚未投票给任何其他候选人。如果我们在当前任期内尚未投票给任何候选人,或者我们已经投票给同一个候选人,那么votedFor变量将为null或候选人的ID。在同一任期内多次投票给同一个候选人是可以的,但我们不能在同一任期内投票给两个不同的候选人。
如果所有这三个条件都为真,那么我们可以投票赞成这个候选人。我们首先将votedFor变量设置为候选人的ID,以记住我们已经投票给该候选人的事实,然后我们向候选人发送一条VoteResponse消息。这条消息将包含投票节点的节点ID、当前任期编号以及值true,表示这是对该候选人的赞成票。
另一方面,如果上述任何条件不成立,那么我们向候选人发送一个带有false字段的VoteResponse,告诉它我们不投票赞成这个候选人。
收集选票与成为领导者
现在,我们回到候选人节点,收集我们收到的选票。每当我们收到一条VoteResponse消息时,就会发生以下情况。我们有一个包含投票节点ID、投票发生的任期编号以及告诉我们该投票是否被授予的布尔值的VoteResponse消息。


我们要做的第一件事是检查任期编号。如果VoteResponse消息中的任期编号大于我们的当前任期,我们就像往常一样接受该任期编号作为我们自己的任期,过渡到跟随者状态,忘记我们在之前任期内投票给了哪个节点,并取消任何选举计时器。这只是将我们移回状态机中的跟随者状态。
然而,如果我们仍然处于候选人状态,并且我们刚刚收到的消息是针对我们正在进行选举的正确任期,并且granted字段为true,那么我们就获得了一张赞成我们作为候选人的选票。我们将维护这个votesReceived集合,并将投票者的ID添加到我们收到的选票集合中。请注意,此更新是幂等的。因此,即使此VoteResponse消息在网络中某处被复制,也不是问题,我们不会多次计算同一张选票。我们仍然需要来自多数派不同节点的选票。

为了确定我们是否已经获得多数派,我们可以检查votesReceived集合的大小。如果我们收到的选票数大于等于多数派(即系统节点数加一除以二并向上取整),那么我们就获得了多数派节点的法定人数,此时我们可以成为领导者。
我们过渡到领导者状态,取消选举计时器,现在我们需要向所有节点(现在是跟随者)发送一条消息,告诉它们:“嘿,我是你们的新领导者。”并给出一些信息。这里的replicateLog函数将实际发送该消息,我们稍后会看到该函数。
我们还需要为所有跟随者设置一些变量。sentLength和ackedLength是从节点ID到整数的映射。对于每个跟随者节点,我们将初始化这些变量。sentLength将被初始化为领导者日志的长度,其含义是我们已经发送给特定节点的日志记录数量。这里我们假设跟随者已经收到了我们所有的日志条目,如果不是这种情况,我们稍后将通过一个过程来纠正。
其次,ackedLength是从头开始计算的、该特定跟随者确认已收到的日志条目数量,我们将其初始化为0。稍后,当我们收到确认时,我们将增加这个数字。
全序广播



现在我们已经有了领导者,实际上可以开始讨论全序广播了。我们这里有一个函数,可以由客户端调用,表示我们希望通过全序广播来广播一条消息,确保所有节点以相同的顺序传递消息。这些消息将被传递的顺序实际上就是日志的顺序,这就是日志的全部目的。唯一可以向日志添加条目的是领导者。
首先,如果广播消息的请求不是发生在领导者节点上,那么它必须通过一个PIO链接转发给当前领导者。使用PIO链接只是为了确保我们最终获得的是PIO全序广播,而不仅仅是全序广播,这是确保PIO特性唯一需要做的事情。
如果当前节点是领导者(我们可能刚刚收到一个转发的广播消息请求),那么我们要做的第一件事是向日志追加一条记录,包含我们想要广播的消息以及领导者的当前任期编号。我们还注意到领导者本身已经确认了该消息的传递(这就像为我们自己投票的另一个实例),因此我们更新ackedLength变量以包含新追加的记录。
现在,我们再次为每个跟随者(系统中除领导者外的所有节点)调用replicateLog函数。此外,我们还会定期调用这个replicateLog函数。即使领导者最近没有向日志追加任何新消息,它仍然会定期为每个跟随者调用replicateLog,这有几个目的:一是作为一种心跳,调用replicateLog会向跟随者发送消息,告诉它们领导者仍然存活,因此它们不需要开始新的领导者选举;二是确保如果任何消息丢失,它们将被重新传递;三是确保如果领导者已经提交了任何日志条目,它们也将在跟随者上被提交(提交的过程我们稍后会看到)。
日志复制与一致性检查

让我们看看我们刚刚三次调用的replicateLog函数。这个函数被调用时,带有领导者的节点ID和跟随者的节点ID,并且我们可以访问之前的所有变量。
首先,我们现在将领导者上的日志拆分为一个前缀和一个后缀。前缀将包含我们认为已经发送给跟随者的所有日志条目,后缀将包含我们认为尚未发送给此特定跟随者的所有新日志条目。我们这里有sentLength变量,它记录了对于每个节点ID,我们认为已经发送给该节点的日志条目数量。
现在,我们将获取日志的后缀,即从索引sentLength开始一直到日志末尾的所有剩余日志条目。如果sentLength确实等于整个日志的长度,那么后缀将只是一个空数组。




接下来,我们设置prefixTerm变量。现在,我们将查看日志条目,查看前缀内的最后一个日志条目,并查看该日志条目的任期编号。这有点类似于我们之前在VoteRequest消息中所做的,但这里我们查看的不是整个日志中的最后一个日志条目,而是前缀中的最后一个日志条目,并获取其任期编号。
现在,我们将向跟随者发送一个包含所有这些各种变量的LogRequest消息。我们将在消息中包含领导者的ID、它作为领导者的任期编号、我们不发送的前缀长度、前缀中最后一个日志条目的任期编号、commitLength变量(稍后我们会看到,这只是另一个整数),然后是suffix,即我们想要添加到日志中的新日志条目数组。

跟随者处理日志请求

现在,我们在跟随者节点上,将看到如何处理这些LogRequest消息之一。我们拥有刚刚提到的所有变量。
首先,我们需要像往常一样检查任期编号。如果消息中的任期编号大于跟随者的任期编号,那么我们将执行通常的状态机操作:接受当前任期,忘记我们在之前任期内投票给了哪个候选人,取消选举计时器,并且我们也会进入第二个if条件,将当前角色设置为跟随者,并接受当前领导者作为此LogRequest消息的发送者。
我们进入第二种情况是因为在这里我们将currentTerm更新为等于消息中的term,所以在第二个条件中它仍然等于currentTerm。第二个条件也在这里,因为可能发生接收LogRequest的节点实际上是同一任期编号中的候选人,在这种情况下,即使我们不更改任期编号,我们也希望进入跟随者状态,但我们仍然希望接受该领导者作为此LogRequest消息的发送者。

接下来,我们需要做另一个检查,看日志是否合格。这有点类似于之前在领导者选举期间发生的情况,但条件略有不同。首先,我们要确保接收者确实拥有发送者认为我们可能拥有的前缀。我们必须检查跟随者日志的长度至少与发送者省略的前缀一样长。如果不是这种情况,那就意味着日志中存在一个间隙,有一些日志条目发送者(领导者)没有发送给我们,而我们作为跟随者也没有。在这种情况下,日志就不合格,我们必须先填补这个间隙。
第二个要求是,要么前缀长度为0,要么如果前缀长度不为0,那么我们必须查看跟随者上前缀中的最后一个日志条目,查看其任期编号,并希望该任期编号等于领导者上相应日志条目的任期编号。这个prefixTerm只是作为消息一部分发送的变量。
这样做的原因是,Raft保证了一个特定的不变量:如果系统中有两个节点在某个日志索引处拥有相同的日志条目(特别是该日志条目具有相同的任期编号),那么Raft将保证直到并包括该索引的整个日志是相同的。日志条目中的任期编号实际上使我们能够有效地判断两个节点是否具有相同的日志,因为两个节点最终拥有不同日志的唯一方式是在某个日志条目上具有不同的任期编号。因此,这里保证的是,如果这个logOk条件为真,那就意味着领导者的日志前缀与跟随者相同长度的日志前缀是相同的。这对于确保所有节点最终传递相同的消息至关重要。


如果日志合格,并且消息是针对正确的任期,那么我们可以继续处理LogRequest消息中包含的新日志条目。我们将调用appendEntries函数,并传递后缀(新日志条目)和其他一些变量。我们还将向领导者发送一个确认,表示我们收到了你的日志条目,一切正常。这个确认将只是另一个整数,它告诉我们从日志开始已经成功从领导者接收了多少个日志条目,即未发送的前缀长度加上已发送的后缀中的日志条目数量。这是我们处理此消息后从领导者获得的总日志条目数,我们将把这个整数打包到一条消息中。因此,我们将创建一个LogResponse消息发送回领导者,其中包含日志消息发送者的ID、当前任期编号、刚刚解释的确认数以及true,表示跟随者成功处理了此LogRequest消息。


另一方面,如果某些地方不合格(例如任期编号错误或logOk为false),那么我们发回一个LogResponse消息,其中最后一个元素是false,这告诉领导者我们无法正确处理该LogRequest消息。

追加日志条目与提交

让我们看看刚刚提到的appendEntries函数。如果我们已经检查过日志合格并且想要将LogRequest消息中的新条目追加到跟随者的日志中,就会调用此函数。
首先,我们现在需要检查跟随者是否已经拥有此消息中包含的一些日志条目。如果是,我们需要检查它们是否相同。首先,如果后缀非空(即LogRequest消息确实包含新的日志条目),并且跟随者日志的长度大于前缀长度(即跟随者已经拥有一些超出前缀的日志条目),那么此时,新的日志条目(后缀)与跟随者已有的日志条目重叠。
在这种情况下,我们将找到可以比较跟随者状态和领导者状态之间的最后一个日志条目,这将是跟随者日志长度减1,或者是前缀长度加上后缀长度减1,取两者中较小的那个。这将是我们要比较的日志条目的索引。




对于该特定索引,我们将比较跟随者日志中的任期编号与我们从领导者收到的新日志条目(后缀)中相应条目的任期编号。如果这些任期编号不相同,那就意味着日志中存在不一致,我们必须截断日志。我们将只保留跟随者日志直到前缀长度的部分,并丢弃前缀长度之后的所有内容。这是正确的做法,因为这些先前的日志条目可能来自先前的领导者,并且尚未提交。Raft确保的一个关键属性是,如果一个日志条目被提交,那么它将永远不会改变,也不会被丢弃,也不会在此截断过程中被移除。但尚未提交的日志条目可能会作为此截断的一部分被移除。
如果这些任期编号相同,那么正如我之前解释的,Raft保证到该点(包括该特定索引)的日志是相同的,在这种情况下,我们不需要截断日志,因为我们知道领导者日志和跟随者日志彼此一致。
无论我们是否需要截断,我们现在都可以将任何新的日志条目追加到日志中。后缀可能包含一些跟随者已经拥有的日志条目,在这种情况下,我们不需要再次追加它们,我们将从后缀中跟随者尚未拥有的第一个条目开始,一直到后缀中的最后一个条目,并将它们中的每一个追加到跟随者的日志中。
现在出现一个名为leaderCommit的变量。我还没有详细讨论过这个,但现在当我们讨论提交日志条目时,它将变得重要。leaderCommit只是一个整数,它告诉我们领导者已提交了多少个日志条目。这个leaderCommit变量作为LogRequest消息的一部分从领导者传来。commitLength是存储在跟随者上的一个变量,它跟踪到目前为止我们从日志开始计算已经提交了多少个日志条目。
如果领导者已提交的日志条目数量大于跟随者已提交的日志条目数量,那就意味着更多的日志条目已准备好被提交。由领导者通过增加这个leaderCommit变量来决定何时可以提交日志条目。因此,如果leaderCommit更大,那么我们现在可以获取从commitLength开始一直到leaderCommit - 1的日志索引,这些条目已准备好被提交。提交在这里意味着,首先,我们将commitLength设置为包含这些索引,同时我们将这些消息传递给应用程序。这是在跟随者上发生的全序广播传递。领导者上的全序广播传递在下一张幻灯片上单独发生。
领导者处理日志响应与提交决策




如果你还记得,跟随者向领导者发送了一条LogResponse消息,告诉它跟随者是否能够接受新的日志条目并将其追加到日志中。现在,当领导者从跟随者那里收到这些LogResponse消息时,领导者需要决定哪些日志条目已准备好被提交。一个日志条目如果被系统中的多数派节点确认,就准备好被提交。
当领导者收到这些LogResponse消息之一时,像往常一样,接收消息时首先要做的是检查任期编号。如果LogResponse消息中的任期编号大于领导者的任期,那就意味着存在一个具有更高任期编号的新节点,像往常一样,我们必须接受该任期编号并过渡到跟随者状态,忘记我们在之前任期内投票给了谁,并取消选举计时器。
然而,在任期编号正确的情况下(即任期编号与领导者的任期编号匹配,并且领导者实际上仍然是领导者),那么它可以继续。首先,它必须检查success字段是否为真(这是它从跟随者收到的LogResponse消息中的success字段)。如果这个success字段为真,并且消息中包含的确认数至少是之前同一跟随者确认的数量,那就意味着跟随者正在确认新的消息。如果消息传递顺序错乱,可能会发生带有较高确认数的消息先到达,然后带有较低确认数的消息后到达的情况。这确保了无论消息以何种顺序传递,最终一切都会正常。

在确认数大于先前确认数的预期情况下,领导者现在可以记录它已收到来自此特定跟随者的确认这一事实。它通过将sentLength变量和ackedLength变量更新为该整数(即来自该特定跟随者的已确认日志条目数)来实现这一点,然后它调用commitLogEntries函数(将在下一张幻灯片上)来实际决定哪些日志条目已准备好被提交。
另一方面,如果此LogResponse不成功,那么我们必须检查sentLength。如果不成功,可能是日志中存在间隙。请记住,我们将日志拆分为前缀和后缀,前缀我们假设跟随者已经拥有。但如果跟随者丢失了部分日志,并且实际上存在一个间隙,它没有完整的前缀,这就是我们在这里处理的情况。在这种情况下,如果success不为真,那么我们将只为该跟随者的sentLength变量减1。这意味着现在我们将前缀的长度缩小1,并在下一次尝试时向跟随者多发送一个日志条目。
如果跟随者拥有的日志与领导者拥有的日志之间存在较大间隙,这可能需要多次迭代,每次我们只将前缀长度减少1。你可以想象更有效的方法,但现在我们保持简单,不使其过于复杂。我们只是将sentLength减1,然后再次调用replicateLog函数。这将确保最终我们将填补日志中的任何间隙,并且最终跟随者将能够应用新的日志条目,因为我们已经将领导者上所有缺失的日志条目发送给了跟随者。
提交日志条目
这就是领导者端的情况,除了commitLogEntries函数。这是我们需要查看的最后一个函数。

这个函数现在查看ackedLength变量。在ackedLength变量中,我们记录了每个跟随者确认已收到的日志条目数量。这里,我们将从变量commitLength开始,即我们已经从日志开始计算提交的日志条目数量。现在,我们想看看是否有任何额外的日志条目也已准备好被提交。
只要commitLength小于日志的长度,我们就初始化x为0。然后,这个x变量将计算我们从多少个节点(包括领导者和所有跟随者)获得了确认。
我们将遍历系统中的所有节点(领导者和所有跟随者),并检查每个节点的ackedLength变量。如果它的值大于commitLength,那就意味着从该特定节点的角度来看,将commitLength向前移动是可以的,因为它已经确认了超出当前commitLength的更多日志条目。因此,我们将把它算作一个确认。
现在,我们将查看在索引commitLength处的日志条目获得的确认数。如果该数量大于多数派,那么该日志条目就准备好被提交。在这种情况下,我们现在将对索引为commitLength的日志条目中的消息进行全序广播传递,将其传递给应用程序(这是在领导者上)。我们将增加commitLength变量的值。这个新变量将在下次我们调用replicateLog函数时发送给所有跟随者。
只要我们能达到这个多数派法定人数,我们就继续在这个循环中进行并提交日志条目。如果在某个时刻,我们还没有获得多数派确认,那么我们就跳出循环,因为这意味着在我们可以进行进一步提交之前,必须等待更多的LogResponse消息。
总结

本节课中,我们一起学习了Raft共识算法的完整流程。我们详细探讨了节点在跟随者、候选人和领导者三种状态间的转换,以及通过随机化超时和任期编号机制来确保选举的安全性与活性。我们深入分析了日志复制、一致性检查、日志条目追加与提交的关键步骤,理解了Raft如何通过维护日志一致性和多数派确认来实现全序广播。虽然算法细节繁多,但其核心在于领导者与跟随者之间严谨的交互和任期编号的严格比较,从而在分布式系统中达成可靠的一致状态。希望这次讲解能帮助你理解Raft的工作原理及其在分布式系统中的重要意义。
019:副本一致性模型与两阶段提交
在本节课中,我们将要学习分布式系统中的副本一致性概念,并深入探讨一个实现分布式事务原子性的核心算法——两阶段提交。我们将了解一致性在不同语境下的多种含义,以及如何通过特定协议确保跨多个节点的操作要么全部成功,要么全部失败。
一致性概念的多样性
“一致性”这个词在计算机科学中含义多样,具体取决于上下文。
在数据库事务的语境中,ACID中的C代表一致性。它指的是数据库状态必须满足应用程序定义的某些约束或不变性。例如,在一个大学数据库中,可能要求“如果一门课程至少有一名学生注册,那么它必须有一名讲师”。这种一致性是数据库状态的属性。

然而,在分布式系统,特别是数据复制的语境中,我们常说的“一致性”通常指副本之间状态的关系。例如,我们希望一个副本与另一个副本保持一致。但这又引出了新的问题:它们需要在什么时间点保持一致?是同一时刻状态完全相同,还是在不同时间点最终达到相同状态?我们可以通过读取操作应返回何种结果来定义一致性。
实际上,存在多种不同的一致性模型,并没有一个唯一的“正确”定义。本节课我们将探讨其中一些模型,了解它们的定义及其适用的场景。
分布式事务与原子提交问题
现在,让我们从分布式事务开始。大家已在并发系统课程的前半部分学习过事务。回忆一下ACID属性,其中的A代表原子性。这意味着如果一个事务对数据库进行了一系列更新,那么即使数据库崩溃或出现错误,也要么所有更新都被应用(事务提交),要么所有更新都不生效(事务中止)。我们不会得到一种“半完成”的状态。

原子性非常重要,因为它是确保ACID意义上一致性的基础。如果两个需要协调的更改只发生了一个,系统就会进入不一致状态。
在分布式系统中,一个事务可能涉及分布式数据库中的多个节点。在这种系统中,我们必须确保参与事务的所有节点都具有原子性。事务必须在所有节点上要么全部提交,要么全部中止,这样才能保证整个分布式事务的原子性。
此外,如果参与事务的某个节点崩溃,我们也必须确保在所有其他节点上中止该事务,因为崩溃的节点无法完成提交。这在系统中被称为原子提交问题。
你可能会觉得,这看起来有点像共识问题,因为我们希望所有节点就“提交还是中止”达成一致。表面上看确实如此,但仔细分析,原子提交与共识有很大不同。

原子提交与共识的区别
以下是原子提交与共识的主要区别:
- 提议与投票:在共识中,一个或多个节点可以提议某个值,共识算法会决定采用其中一个提议的值。而在原子提交中,约束更强:所有节点都必须投票表明自己是否能够提交事务,并且必须考虑所有投票。
- 决策规则:在共识中,可以简单地选择任何一个被提议的值。在原子提交中,决策规则非常明确:如果所有节点都投票赞成提交,则事务必须提交;如果任何一个节点投票赞成中止,则所有节点都必须中止。原子提交算法必须做出的决策受到更多约束。
- 容错性:共识算法(如Raft)只要可访问的节点达到法定数量(多数)就能继续工作。而原子提交要求所有节点都必须投票并达成一致,这意味着即使只有一个节点崩溃,也会导致整个事务中止。因此,原子提交无法容忍任何故障节点,而像Raft这样的容错共识算法可以容忍少数故障节点。
两阶段提交协议
实现原子提交的典型方法是使用一个称为两阶段提交的算法。请注意,它听起来像“两阶段锁”,但两者完全不同:两阶段锁关乎可串行化隔离级别,而两阶段提交关乎实现原子性。
以下是两阶段提交的工作流程:
- 事务执行:客户端希望在多个数据库节点上开始一个事务。它像往常一样启动事务,向这些节点发送事务标识符(如T1),然后执行读写操作。
- 提交请求:当准备提交事务时,客户端不直接通知数据库节点,而是将提交请求发送给一个称为事务协调者的新节点。
- 第一阶段:准备:协调者向所有参与事务的数据库节点发送
prepare消息。当数据库节点收到prepare消息时,它必须:- 将该事务的所有更改写入磁盘。
- 检查任何约束以确保数据库一致性。
- 回复“是”或“否”,表明它是否愿意提交该事务。
- 如果回复“是”,则该节点承诺将来如果被要求提交,它一定能够提交。此时,它已将决策权交给了协调者。
- 协调者决策:在收到所有节点的投票后,协调者做出决定:如果所有节点都投票“是”,则决定提交;如果任何节点投票“否”,则决定中止。这是协议的关键时刻。
- 第二阶段:提交/中止:协调者将其决定(
commit或abort)发送给所有参与节点。节点收到后执行相应操作(提交或中止),释放锁,完成事务。

协调者故障与阻塞问题
现在考虑节点故障的影响。如果数据库节点崩溃,协调者会超时并决定中止事务,这是可以处理的。
关键问题是:如果协调者崩溃了怎么办?
协调者可以在崩溃前将它的决定写入磁盘。恢复后,它可以读取决定并通知各节点。如果磁盘上没有决定记录,它可以安全地选择中止。
但是,如果协调者在发出 prepare 消息之后、发送最终决定(commit/abort)之前崩溃,就会导致严重问题。此时,所有数据库节点都处于“不确定”状态:它们已承诺可以提交,但不知道最终决定是什么。它们不能自行提交或中止,因为那可能破坏原子性(例如,如果协调者已决定提交并通知了部分节点)。因此,整个算法会被阻塞,直到协调者恢复。如果协调者所在机器发生硬件故障,系统可能长时间不可用。

基于全序广播的容错两阶段提交
幸运的是,有一种方法可以解决协调者单点故障问题。我们可以利用上一讲中学到的全序广播(即共识算法)来实现一个容错的两阶段提交变体。
这个算法的核心思想是:使用全序广播算法来传播每个节点关于“中止还是提交”的投票,让所有参与者共同做出决定,从而消除对单一协调者的依赖。
以下是算法步骤:
- 状态初始化:每个节点为每个事务维护状态,包括:参与该事务的所有副本ID集合
R、已投票赞成提交的副本ID集合votes,以及一个表示是否已做出最终决定的标志decided。 - 准备阶段:协调者(或任何发起者)照常向所有参与节点发送
prepare消息。节点收到后,知晓参与集合R,并检查自己能否提交。 - 广播投票:节点不将投票结果直接回复给协调者,而是使用全序广播,将自己的投票(
true表示赞成提交,false表示反对)广播给所有参与节点。这样,所有节点都能看到彼此的投票。 - 处理慢节点或故障节点:为了避免因某个节点崩溃或过慢而永远等待其投票,我们引入一个故障检测器。如果任何节点怀疑某个参与者在超时后仍未广播投票,它可以代表该疑似故障节点,通过全序广播投出一张
false票(即提议中止)。 - 处理重复投票:由于故障检测和网络延迟,可能同时出现来自同一节点的真实投票和代表其投出的
false票。得益于全序广播的所有节点以相同顺序传递相同消息的特性,我们可以规定:只考虑来自任一节点的第一张投票,忽略后续重复或冲突的投票。这确保了所有节点对“每个节点的第一票是什么”达成一致。 - 做出决定:当节点通过全序广播接收到一张投票时,它执行以下逻辑:
- 如果该投票来自事务
T的参与者,且事务T尚未做出决定:- 如果投票是
true(赞成),将该节点ID加入votes集合。如果此时votes集合等于全体参与者集合R,则意味着全体一致赞成,节点可以决定提交事务,并设置decided = true。 - 如果投票是
false(反对),则一票否决,节点可以立即决定中止事务,并设置decided = true。
- 如果投票是
- 如果该投票来自事务
通过这个机制,所有参与者节点基于相同的、按全序广播传递的投票信息,独立地应用相同的决策逻辑,最终必然会对事务的提交或中止达成完全一致的决定,且无需依赖一个可能故障的单一协调者。
总结
本节课中我们一起学习了分布式系统中的一致性概念和两阶段提交协议。

我们首先认识到“一致性”一词的多义性,在分布式复制语境下,它主要关注副本间的状态关系。接着,我们探讨了分布式事务中的原子提交问题,并指出其与共识问题的区别:原子提交要求全体一致且不容忍任何节点故障。
然后,我们详细分析了经典的两阶段提交协议,它通过“准备-提交”两个阶段,借助一个事务协调者来确保跨节点事务的原子性。然而,我们也指出了其关键缺陷:协调者可能成为单点故障,导致整个系统在协调者崩溃时阻塞。
最后,我们介绍了一种基于全序广播的容错两阶段提交变体。该算法利用全序广播传播投票,结合故障检测器和“只认第一票”的规则,使得所有参与节点能独立、一致地做出提交或中止的决定,从而消除了对单一协调者的依赖,实现了更高的可用性。

这个例子展示了如何运用像全序广播这样的基础分布式抽象,以相对简单的方式解决另一个复杂的分布式问题。
020:线性一致性 🧩
在本节课中,我们将要学习一种称为“线性一致性”的一致性模型。我们将探讨其核心思想、它与实时性的关系,以及如何在分布式系统中实现它。
概述

之前我们讨论了两阶段提交和原子提交,它们确保了在节点崩溃情况下的数据一致性。然而,在并发场景下,当多个节点同时读写数据时,我们同样需要确保操作之间的一致性。线性一致性就是为并发系统定义的一种特定的一致性模型。它是目前广泛使用的最强一致性模型。



线性一致性的核心思想
线性一致性的核心思想是:整个系统表现得就像它没有被复制或分布式一样。换句话说,系统表现得好像数据只有一个副本,并且所有操作都在这个单一副本上原子性地发生。
从客户端的视角来看,即使系统中有多个副本,它看起来也像是只有一个数据副本。这非常有利于编程,因为它将分布式系统的复杂性简化为一个易于管理的、类似单一副本的模型。
核心公式:系统行为 ≈ 单一数据副本 + 原子操作

线性一致性的含义与重要性
线性一致性的一个直接结果是:当你读取数据时,保证能获得一个“最新”的值(我们稍后会定义“最新”)。这有时也被称为“强一致性”,但“强一致性”这个术语定义模糊。我们将坚持使用有明确定义的“线性一致性”。
有趣的是,线性一致性不仅对分布式系统重要,在单台计算机的共享内存并发场景中同样适用。原因在于,多核CPU的每个核心都有自己的缓存。一个线程写入内存的值可能先进入其核心的缓存,而另一个在不同核心上运行的线程读取同一内存位置时,可能看不到第一个线程写入的值,因为它的缓存尚未更新。因此,在单台计算机内部,由于多级缓存的存在,也会出现类似分布式系统中副本不一致的行为。

线性一致性与可串行化的区别

需要特别注意的术语是:线性一致性和可串行化不是一回事,尽管它们看起来相似,但含义完全不同。
- 可串行化是事务之间的一种隔离形式,它关注的是多个事务的执行效果等同于某种串行顺序。
- 线性一致性是关于多个副本表现得像单一副本。

它们是两个非常不同的概念。

通过读写一致性理解线性一致性
为了理解线性一致性的含义,让我们回顾之前讲座中提到的“写后读一致性”。在读写仲裁的背景下,如果一个客户端写入一个值V1到对象X,并向一个仲裁组发送写请求,成功后,同一个客户端再发起读请求。从客户端的视角,每个操作都有一个执行时间段(从发送请求到收到足够响应)。

我们可以抽象掉内部实现细节,仅从客户端的视角来定义一致性模型。这很有用,因为它意味着我们的模型不依赖于特定的系统实现或分布式算法。
在上面的例子中,读操作在写操作完成后才开始,存在一个时间上的先后依赖。因此,我们期望读操作能看到至少和之前写操作一样新的值。


跨客户端的实时依赖
我们可以将这种时间依赖推广到多个客户端之间。例如,客户端2的读操作在客户端1的写操作完成后才开始。根据线性一致性,由于存在这种实时依赖,我们期望客户端2也能看到最新的值V1。这正是单一数据副本上原子操作应有的行为。
这种时间依赖看起来像“happens-before”关系,但两者不同。
- Happens-before 是根据消息的发送和接收来定义的,与物理时间无关。
- 线性一致性 是根据实时来定义的。我们假设存在一个能精确观察操作开始和结束时间的观察者。即使两个客户端之间没有直接通信,只要一个操作在另一个操作结束后才开始,它们之间就存在实时依赖,后者必须能看到前者的更新。
另一方面,如果两个操作在时间上重叠,那么它们可以以任意顺序生效。例如,一个写操作和一个重叠的读操作,读操作可能返回旧值或新值,这两种情况在线性一致性下都是允许的。因为线性一致性只要求一个操作在其开始之后、结束之前的某个时间点原子性地生效。
仲裁读写足以保证线性一致性吗?


我们讨论了使用仲裁写和仲裁读来实现写后读一致性。你可能会问,这足以保证线性一致性吗?答案是否定的。

以下是一个反例:
- 客户端1发起写操作
set(x, v1)。由于网络延迟,只有副本A快速收到了更新,副本B和C尚未更新。 - 客户端2发起读操作
get(x),它从副本A和B(一个仲裁)获得响应。通过比较时间戳,它看到了来自A的新值v1并返回。 - 接着,客户端3发起读操作
get(x),它从副本B和C(另一个仲裁)获得响应。此时B和C都还没有v1,因此客户端3读到旧值v0。
问题在于:客户端3的读操作在客户端2的读操作结束后才开始,存在实时依赖。根据线性一致性,客户端3应该读到至少和客户端2一样新的值(即v1),但它却读到了v0。这违反了线性一致性。


如何修复:带读修复的仲裁读
我们可以修复这个算法使其满足线性一致性,方法是在读操作中加入读修复。
流程如下:
- 客户端2发起读请求,从仲裁(A和B)获得响应,并识别出
v1是最新值。 - 客户端2不立即返回,而是将最新值
v1(连同其时间戳)重新发送给那些拥有旧值的副本(本例中是B和C)。 - 客户端2等待至少一个副本确认更新(确保
v1存在于一个仲裁中),然后才将v1返回给调用者。

通过这种方式,在读请求完成时,最新值已经确保存在于一个仲裁组中。因此,任何后续的读请求只要访问一个仲裁,就一定能看到这个最新值。这个算法(ABD算法)可以保证get和set操作的线性一致性。
实现线性化的比较并交换操作
上述算法处理的是无条件的“盲写”。但在并发写入时,可能会发生覆盖冲突。我们通常还需要compare-and-swap这样的原子操作。


在单机系统中,CAS是CPU提供的原子指令。在分布式系统中,我们也能实现线性化的CAS操作,但需要使用不同的算法——全序广播。



实现线性化的get和CAS操作的算法非常简单:
- 无论是
get还是CAS操作,客户端都将其包装成一条消息,通过全序广播发送给所有副本。 - 客户端等待该消息被全序广播协议送达。
- 当消息被送达时,所有副本以相同的顺序处理这些消息。
- 对于
get消息,副本读取本地状态并返回结果。 - 对于
CAS消息,副本检查当前值是否等于预期旧值,如果是则原子性地更新为新值并返回成功,否则返回失败。
由于全序广播保证了所有副本以相同顺序处理所有请求,这本质上等同于状态机复制。所有副本的状态保持一致,从而实现了线性一致性。这是一个非常简洁的构造。
总结

本节课我们一起学习了线性一致性。我们了解到:
- 线性一致性是一种强一致性模型,要求系统表现得像单一数据副本。
- 它基于实时定义操作之间的顺序依赖。
- 简单的仲裁读写不足以保证线性一致性,需要引入读修复机制。
- 对于更复杂的原子操作(如CAS),可以基于全序广播来实现,这提供了所有副本上操作的全序视图,是构建线性化系统的强大工具。

理解线性一致性是设计可预测、易于推理的分布式系统的关键一步。
021:最终一致性 📡
在本节课中,我们将要学习线性一致性之外的另一种分布式系统一致性模型——最终一致性。我们将探讨其定义、工作原理、优缺点,并通过一个日历应用的例子来理解它在实际场景中的应用。
概述
上一节我们介绍了线性一致性,它是一种非常强的一致性模型。本节中,我们来看看最终一致性。最终一致性是一种较弱的一致性模型,它牺牲了强一致性,以换取更高的可用性和性能。当网络出现分区或节点无法通信时,系统仍能继续处理操作。
线性一致性的局限性
线性一致性虽然易于使用,但它存在一些显著的局限性,使其并非适用于所有场景。

以下是线性一致性的几个主要问题:
- 性能开销高:实现线性一致性需要大量消息往返。大多数请求可能需要大约两轮网络往返,这会导致操作变慢。
- 可扩展性受限:在使用类似Raft的共识算法时,所有更新都必须通过领导者节点排序。这个单一的领导者节点可能成为系统的瓶颈。
- 可用性问题:每个操作都需要联系一个法定数量的节点。如果因网络断开等原因无法联系到法定数量的节点,那么任何读写操作都无法进行。
正因如此,人们开发了具有不同权衡取舍的替代一致性模型。最终一致性就是其中之一,它在某些方面更好,在其他方面则有所不足。
最终一致性示例:日历应用
为了理解最终一致性,让我们考虑一个日历应用的例子。
假设左侧是运行在我电脑上的日历应用,右侧是运行在我手机上的日历应用。这是一个典型的复制系统:我的电脑和手机上各有一份日历数据的副本。
- 我可以在电脑上添加一个事件,几秒钟后,这个事件会同步到手机上。
- 同样,我也可以在手机上添加事件,并同步到电脑。

现在,让我们思考一个更复杂的场景。如果我将手机设置为飞行模式,两台设备之间的通信就被切断了。
- 在电脑上,我将“分布式系统讲座”的时间改为上午10点。这个更改无法同步到手机。
- 在手机上,我将同一个事件的标题改为“分布式系统讲座 1”。这个更改也无法同步回电脑。
当我关闭飞行模式后,两台设备重新开始同步。此时,我们看到了一个冲突:两个设备在断开连接时并发编辑了同一个日历事件。我的日历应用采用了“最后写入胜出”的冲突解决策略。在这个案例中,时间改为上午10点的更新覆盖了标题改为“讲座 1”的更新,导致标题的更改丢失了。
尽管有些数据丢失了,但最终两台设备还是达到了一致的状态。这就是“最终一致性”的体现:系统不保证立即一致,但保证在没有新更新的情况下,所有副本最终会收敛到相同的状态。
CAP定理与一致性选择
像日历应用这样的系统,可以用分布式系统中的CAP定理来形式化理解。
其背后的思想很简单:当系统发生网络分区时(即一部分节点无法与另一部分节点通信),我们必须做出选择。
- 选择一(CP):继续提供线性一致性,但部分节点将无法处理请求(即牺牲可用性)。
- 选择二(AP):允许操作在不联系法定节点数的情况下继续进行,但这可能违反线性一致性(即牺牲强一致性)。
最终一致性属于后一种选择。如果我们希望每个副本都能仅基于其本地状态处理操作,而不必等待与法定数量节点的通信,那么我们就无法实现线性一致性,但可以实现像最终一致性这样的较弱模型。
最终一致性与强最终一致性
最终一致性的通常定义如下:
- 任何读请求,副本都可以仅基于其本地状态处理。
- 当一个副本应用了某些更新后,这些更新最终会通过网络传播到其他副本。我们保证,如果更新停止,那么最终所有副本都会达到相同的状态。
这个定义比较弱。因此,人们定义了一个稍强的变体,称为强最终一致性,它包含两个属性:
- 事件传递:在一个副本上进行的任何更新,最终也会在另一个副本上应用。
- 收敛性:任何两个处理了相同更新集的副本,必须处于相同的状态,因此对任何读请求都会返回相同的结果。即使这些更新在不同副本上以不同的顺序应用,此属性也必须成立。
这回到了我们之前看到的基于因果广播的复制协议。如果你有可交换的状态更新操作,那么不同的副本可以以不同的顺序更新状态,并最终收敛到相同的最终状态。收敛性是最终一致性和强最终一致性的核心思想。
最终一致性的实现与冲突
实现最终一致性模型有一些非常好的特性:
- 无需等待:操作永远不需要等待与其他副本的通信。副本可以始终在本地处理读写操作。当然,后台最终需要进行通信,但操作本身不必等待。
- 快速可靠:操作总是快速的(无需等待网络往返),并且总是可靠的(即使网络中断,副本也能继续处理操作)。
我们可以使用因果广播或更弱的广播协议作为在副本间分发更新的方式。
最终一致性系统可能出现的一个问题是,多个副本可能并发更新同一个对象,这可能导致冲突。冲突必须以某种方式解决。
- 最后写入胜出:就像日历应用的例子,后到达的更新覆盖先前的更新。
- 更复杂的合并算法:可以设计更复杂的冲突解决策略,例如合并算法,它能将并发更新的数据结构合并成一个清晰的最终状态以解决冲突。
一致性模型与算法总结
现在,让我们按照对系统模型假设的强弱顺序,来回顾本讲座中讨论过的一致性模型和算法。
-
原子提交(如两阶段提交):
- 假设最强:事务提交必须与所有参与节点通信并获得同意。任何节点崩溃都将导致无法提交事务。需要部分同步系统模型(因为有超时和故障检测)。
-
共识与全序广播:
- 假设稍弱:FLP结果指出,这也需要部分同步模型。但在通信方面,它只要求与法定数量节点通信,因此可以容忍少数节点不可用。
-
线性化的比较并交换:
- 可通过全序广播实现,并且可证明其与共识等价。因此,它同样需要法定数量通信和部分同步性。
-
线性化的读取与设置:
- 假设更弱:可以使用ABD算法(法定数量写入和读取,配合读修复)。该算法仍然需要与法定数量节点通信,但它是完全异步的,不依赖时钟或超时。
-
最终一致性:
- 假设最弱:每个副本都可以在不等待任何通信的情况下更新和读取其本地状态。通信需求降至最低,且不做任何时序假设。因果广播、可靠广播等协议也具有此属性,节点可以立即将消息传递给自己,从而本地应用更新。
总结

本节课中,我们一起学习了最终一致性。我们了解到,最终一致性是一种弱一致性模型,它通过牺牲强一致性来换取高可用性、低延迟和更好的分区容忍性。我们通过日历应用的例子看到了它的实际运作和可能出现的冲突。最后,我们比较了从原子提交到最终一致性等多种模型对系统假设的强弱要求,认识到最终一致性位于假设最弱的一端,适用于对可用性要求极高、能容忍短暂不一致的场景。
022:协作软件
在本节课中,我们将学习协作软件,例如 Google Docs 或日历同步应用。这类软件允许多个用户同时编辑同一份文档或数据,并需要处理并发更新带来的挑战。我们将探讨两种主要的算法家族:冲突无复制数据类型和操作转换,它们能确保系统最终达到一致状态。

协作软件的挑战
上一节我们介绍了分布式系统的基本概念,本节中我们来看看协作软件面临的核心问题。协作软件允许多个用户通过各自的设备访问共享文件或数据库。每个设备都维护着数据的一个本地副本(即副本)。用户可以随时更新自己的副本,即使处于离线状态。当网络恢复后,这些更新需要与服务器及其他客户端同步。

挑战在于,当多人同时更新同一文档时,我们必须协调这些并发更新,确保所有副本最终达到一致状态,并且软件行为符合预期。
以下是协作软件的两个主要特点:
- 多个用户通过设备访问共享数据。
- 每个设备有自己的数据副本,可离线更新,并在联网后同步。
冲突无复制数据类型
为了解决上述挑战,我们引入冲突无复制数据类型。CRDTs 是一类算法,用于在无需中心协调的情况下,确保分布式副本最终一致。它们主要分为两种:基于操作的 CRDT 和基于状态的 CRDT。
基于操作的 CRDT
让我们从日历同步的例子开始。假设手机和电脑上都有一个日历事件,两者离线时,分别在电脑上修改了事件标题,在手机上修改了事件时间。网络恢复后,我们希望两个设备能合并这两项更新,最终都显示修改后的标题和时间。
一种实现方式是使用基于操作的 CRDT。其数据模型是一个映射,即从键到值的集合。我们使用最后写入者获胜的策略来确保每个键只有一个值。

以下是该算法的核心伪代码:
# 初始化:每个副本维护一个集合 `values`,包含三元组 (timestamp, key, value)
values = set()
# 读取键 K 的值
def read(K):
for (t, k, v) in values:
if k == K:
return v
return null
# 设置键 K 的值为 V
def set(K, V):
t = generate_unique_timestamp() # 例如 Lamport 时间戳
broadcast_message((t, K, V)) # 通过可靠广播发送
# 处理接收到的消息 (t, K, V)
def on_receive(t, K, V):
# 查找键 K 的现有条目
previous = {(t_prev, k_prev, v_prev) for (t_prev, k_prev, v_prev) in values if k_prev == K}
if not previous or t > t_prev for (t_prev, _, _) in previous:
values.remove(previous) # 移除旧条目(如果存在)
values.add((t, K, V)) # 添加新条目
该算法通过可靠广播发送操作(即键值更新)。它满足强最终一致性的两个条件:1) 每个更新最终会被每个存活的副本接收;2) 处理了相同更新的任意两个副本状态相同。操作的可交换性确保了无论操作以何种顺序应用,最终状态都一致。
基于状态的 CRDT
基于操作的 CRDT 需要可靠广播。另一种方法是基于状态的 CRDT,它广播的是整个副本状态,而非单个操作。


以下是基于状态的映射 CRDT 的核心伪代码:



# 初始化
values = set()

# 定义合并函数 merge (⊔)
def merge(S1, S2):
union = S1 ∪ S2
# 对于每个键,只保留时间戳最大的条目
result = set()
for (t, k, v) in union:
if not any((t2, k2, v2) in union for t2 > t and k2 == k):
result.add((t, k, v))
return result



# 设置键 K 的值为 V
def set(K, V):
t = generate_unique_timestamp()
# 移除键 K 的旧条目,添加新条目
values = {(t2, k2, v2) for (t2, k2, v2) in values if k2 != K}
values.add((t, K, V))
broadcast_state(values) # 通过尽力而为广播发送整个状态
# 处理接收到的状态 S
def on_receive_state(S):
values = merge(values, S) # 合并本地状态与接收到的状态
基于状态的 CRDT 依赖于一个满足可交换性、结合律和幂等性的合并函数。其优势在于可以使用尽力而为广播,即使消息丢失或重复,只要最终有消息到达,副本仍能收敛。缺点是每次广播的数据量较大。
操作转换算法
上一节我们介绍了用于键值存储的 CRDT,本节中我们来看看用于协同文本编辑(如 Google Docs)的算法。当多个用户同时编辑文本文档时,直接应用操作(如“在位置 2 插入字符”)会导致冲突,因为并发插入会改变字符位置的含义。
操作转换 算法通过转换操作来解决这个问题。其核心思想是,当收到一个远程操作时,根据本地已执行的并发操作对其进行调整,然后再应用。
假设文档初始为 “BC”。用户 A 在位置 0 插入 ‘A’,用户 B 在位置 2 插入 ‘D’。如果直接将用户 B 的操作 “在位置 2 插入 D” 应用到用户 A 的本地状态(此时已是 “ABC”),会导致错误。OT 算法会检测到用户 A 的插入操作是并发的,并将用户 B 的操作转换为 “在位置 3 插入 D”,从而得到正确结果 “ABCD”。
OT 算法通常需要全序广播来确保所有节点以相同顺序处理操作,这带来了额外的开销。
用于文本编辑的 CRDT
作为 OT 的替代方案,我们也可以为文本编辑设计 CRDT。其关键思路是不使用易变的索引来定位字符,而是为每个字符分配一个全局唯一的、不可变的位置标识符(例如一个有理数)。
文档被视为一个从 0(文档开始)到 1(文档结束)的数轴。插入字符时,在其前驱和后继字符的位置标识符之间选择一个新值(如中点)。所有字符按其位置标识符排序即构成文档内容。由于位置标识符固定,插入和删除操作天然可交换。
以下是该算法的简化伪代码:
# 字符表示为 (position_id, node_id, character)
# 初始化时包含开始和结束标记
characters = { (0, ‘start’, ‘|’), (1, ‘end’, ‘|’) }
# 在索引 `index` 处插入字符 `V`
def insert(index, V):
# 找到索引处的前驱和后继字符及其位置ID
(p1, n1, v1) = element_at(index)
(p2, n2, v2) = element_at(index + 1)
# 计算新位置ID(例如中点)
new_pos = (p1 + p2) / 2
# 构造新字符并因果广播
new_char = (new_pos, self.node_id, V)
broadcast_causal( (‘insert’, new_char) )
# 处理插入消息
def on_receive_insert((pos, node_id, char)):
characters.add( (pos, node_id, char) )
# 删除索引 `index` 处的字符
def delete(index):
(pos, node_id, char) = element_at(index + 1) # 跳过开始标记
broadcast_causal( (‘delete’, pos, node_id) )
# 处理删除消息
def on_receive_delete(pos, node_id):
characters.remove( (pos, node_id, _) ) # 移除匹配的字符
该算法使用因果广播(而非可靠广播)来确保删除操作总是在对应的插入操作之后被传递,从而避免误删。它实现了强最终一致性,且无需复杂的操作转换。
总结

本节课中我们一起学习了协作软件背后的分布式算法。我们首先了解了协作软件面临的并发更新挑战。接着,我们深入探讨了 CRDT 的两种实现方式:基于操作和基于状态,它们通过确保操作的可交换性来实现最终一致性。然后,我们介绍了用于协同文本编辑的 操作转换 算法,它通过转换操作来解决索引冲突。最后,我们看到了另一种为文本编辑设计的 CRDT,它通过为字符分配唯一位置标识符来避免索引问题。这些算法是构建现代实时协作应用(如在线文档和日历)的基石。
023:Google Spanner 🗄️
在本节课中,我们将学习Google开发的一个非常有趣的大型分布式数据库系统——Spanner。我们将探讨它如何在全球范围内管理海量数据,同时实现强一致性、可序列化的事务隔离和线性化读写。

从最终一致性到强一致性 🔄
上一节我们讨论了最终一致性系统。现在,让我们切换回强一致性系统。本节将介绍Google开发的Spanner数据库系统。
Spanner的设计目标 🎯
这是一个超大规模的数据库系统,旨在处理分布在全球数百万节点上的海量数据。尽管如此,Spanner仍致力于实现非常强的一致性属性。
具体来说,它追求我们见过的经典属性:
- 可序列化的事务隔离:这是我们能获得的最强隔离级别。
- 读写的线性化:这意味着我们总是能看到任何写入值的最新值。
此外,它需要支持分片。由于数据量太大,无法存储在单个节点上,必须将数据分割成子集。每个节点都拥有一个数据子集的副本,从而将数据分布到所有不同的节点上。
但这意味着一个事务可能需要读取和写入多个节点上的数据。对于这样的分布式事务,我们需要原子提交,以确保事务所做的更改要么在所有节点上提交,要么在所有节点上中止。
实现经典属性的经典技术 ⚙️
Spanner使用了许多经典的标准算法来实现这些属性。

为了实现分片内的节点复制,它使用了状态机复制。它使用Paxos共识算法(而非Raft),但两者原理非常相似。
为了实现可序列化的事务隔离,它使用了经典的两阶段锁。这意味着对于任何读取操作,我们都需要在要读取的数据上获取共享锁;对于任何写入操作,我们都需要在要写入的数据上获取排他锁。并且,我们需要持有这些锁直到事务提交。
最后,为了实现跨多个分片的原子性,它采用了经典的两阶段提交协议,正如我们在上一讲中看到的那样。
到目前为止,虽然很好地实现所有这些功能仍然是一项重大的工程挑战,但在概念上并没有太多新内容。
Spanner的亮点:无锁只读事务 🔓
Spanner变得有趣的地方在于,它支持无需加锁的只读事务。这很特别,因为如果你还记得两阶段锁的含义:如果你想读取任何数据,首先必须在该数据上获取共享锁,而共享锁会阻止其他事务更新该数据。
然而,在实际系统中,经常会出现非常大的只读事务。例如,进行数据库备份就是一个非常大的只读事务,它需要读取几乎整个数据库。备份可能需要很长时间。如果在进行数据库备份的长时间内,你必须在整个数据库上持有共享锁,那么用户会非常不满,因为这意味着在整个备份期间无法对数据库进行任何写入。这在实践中是行不通的,我们必须有某种方式来进行不需要任何锁的只读事务。Spanner的有趣之处就在于它如何实现这类只读事务。
一致快照与多版本并发控制 📸
其工作原理是,只读事务可以从所谓的一致快照中读取数据。一致快照本质上是在某个时间点查看整个数据库的一种方式,它通过时间戳来实现。
这个一致快照的“一致”性关键在于它意味着与因果关系保持一致。具体来说,如果我们有两个事务T1和T2,并且T1发生在T2之前,那么如果一个快照包含了T2所做的写入,则该快照也必须反映T1所做的写入。这意味着我们不会在快照中遗漏某些因果依赖。同样,如果快照不包含T1的写入,那么它也不会包含T2的写入。这就是快照与因果关系保持一致的含义:我们不会得到在因果意义上不合理的数据库快照片段。
我们希望确保这个一致快照,即使只读事务运行很长时间且不加任何锁。实现这一点的方法是通过一种称为多版本并发控制 或 MVCC 的方法。

MVCC实际上是一种非常常见的技术,被许多数据库使用,例如Postgres和MySQL。它的工作方式是为每个事务附加一个时间戳。假设一个写入事务有一个时间戳 T_w,该时间戳在该事务提交时分配。任何由此事务 T_w 写入的数据都会被标记上该写入事务的提交时间戳。
现在,如果一个对象被一个事务更新,我们不会简单地就地覆盖该对象,而是会创建该对象的一个新副本。这个新版本的对象将被标记上写入该版本的事务的时间戳 T_w。但我们会保留对象的旧版本,以防有只读事务需要读取旧版本。
现在,我们为每个只读事务也关联一个时间戳 T_r,该时间戳标识了该快照观察数据库的时间点。如果只读事务想要读取一个特定对象,它会查看该对象的不同版本(每个版本都标记有一个时间戳),忽略任何时间戳大于快照时间戳 T_r 的版本。然后,在那些时间戳小于或等于 T_r 的版本中,它选择时间戳最高的那个,这就是该事务将看到的对象版本。
这允许只读事务简单地忽略任何并发进行的写入。只读事务将看到特定时间 T_r 的整个数据库状态,无论其他写入如何发生,且无需获取任何锁。
Spanner的独特之处:时间戳生成 ⏱️
这是非常标准的MVCC。Spanner实现方式的有趣之处在于这些时间戳的生成方式。
为了确保我们的快照是因果一致的,我们需要:如果事务T1发生在T2之前,那么T1的时间戳必须小于T2的时间戳,因为MVCC中的排序属性依赖于此。
回顾我们讨论物理时钟的讲座,物理时钟实际上并不能保证这一点。使用物理时钟,你可能会遇到这样的情况:事务T1发生在事务T2之前,但T2的时间戳却低于T1,我们不希望这样。因此,我们必须采取其他措施来确保我们的时间戳与因果关系一致。
显而易见的答案是:为什么我们不使用逻辑时钟呢?因为这正是逻辑时钟的设计目的。不幸的是,像Lamport时钟这样的逻辑时钟在这种情况下实际上也不够用。

原因可以通过这个特定例子来说明:假设我们有两个副本A和B。用户在副本A上执行某个事务T1,然后查看该事务的结果(例如,结果显示在屏幕上)。接着,用户选择执行某个操作,该操作导致在副本B上执行某个事务T2。
这里非常明显,T1发生在T2之前,因为存在这种用户交互,确保了T2的操作依赖于T1的结果。因此,在这种情况下,我们肯定希望T1的时间戳低于T2。
但是,如果我们使用Lamport时钟,请记住它的工作原理:它通过为网络上发送的每条消息附加一个时间戳来工作。当你收到其中一条消息时,你会将自己的本地时钟增加到本地时间戳和收到的时间戳中的最大值。
然而,在这个例子中,副本A和B之间可能没有任何通信。因为通信是通过用户进行的,所以没有消息可以将时间戳从A传播到B。我们不能依赖用户手动输入时间戳之类的东西。因此,副本B可能实际上并未意识到它需要为T2分配一个高于T1时间戳的时间戳,因为没有传递Lamport时钟所需的时间戳信息。
所以Lamport时钟也不起作用。那么在这种情况下我们能做什么呢?我们可以回到物理时钟,但必须调整物理时钟并采取一些额外措施,以确保满足这里的因果排序属性。
TrueTime:管理不确定性的时钟系统 🕰️
Spanner实现这一点的方式是使用一个名为 TrueTime 的系统。TrueTime是一个物理时钟系统,它明确地捕获时间戳中的不确定性。这是Spanner真正有趣的部分。
其工作方式是:假设副本A想要在某个时间提交事务T1。当它准备提交该事务时,副本会向TrueTime请求一个时间戳。TrueTime不会简单地回复一个单一的时间戳,而是返回一个范围,即两个时间戳:最早可能的时间和最晚可能的时间。
由于我们存在不确定性(例如,系统中的时钟无法完美同步),我们永远无法完全确定当前真实的物理时间戳是什么。但我们可以跟踪系统中的所有误差和不确定性。如果我们正确地考虑了不确定性,那么我们就可以非常有把握地确定,真实的物理时间戳将位于这个最早可能和最晚可能的时间之间。
这意味着我们必须跟踪诸如到时钟服务器的往返时间、时钟漂移以及任何可能导致误差的因素。汇总所有这些潜在的误差来源,并将其纳入一个单一的不确定性区间中,这样我们就知道真实的时间戳位于这个最早和最晚时间之间的某个地方。
现在,Spanner要做的是:它从TrueTime获取这对最早和最晚时间戳,然后它将等待。等待的时间正好是两个时间戳之间的差值,这个不确定性时间区间的长度称为 δ1。事务将简单地等待那段时间,在此期间它不会做任何事情,但会继续持有所有的锁。事务已准备好提交,只是还没有实际提交。它将等待这个 δ1 时间段。一旦时间过去,它就会提交,释放所有锁,然后继续。

等待如何确保因果一致性 ⏳
这个额外的等待是关键所在。现在,假设副本B想要执行事务T2。当它准备提交时,它做同样的事情:向TrueTime请求时间戳。它同样会得到一个最早可能和最晚可能的时间戳,并再次等待这个不确定性区间 δ2(两个时间戳之间的差值)。
在这种情况下,我们存在这种实时依赖关系:T2在T1结束后才开始。这种等待的效果是:如果我们有两个存在实时依赖关系的时间戳,那么它们从TrueTime得到的不确定性区间将不会重叠。
因为不确定性区间不重叠,这意味着现在T1的提交时间戳肯定会低于T2的提交时间戳。通过使这些不确定性区间不重叠,我们消除了时间戳被重新排序从而导致与因果关系不一致的可能性。
这里的核心就是这种等待。当然,在实际系统中,我们希望等待时间尽可能短。因此,我们需要做两件事:首先,我们需要精确量化不确定性,以便知道需要等待多长时间;其次,我们必须尽可能减少不确定性,以使等待时间尽可能短。
如何最小化不确定性:原子钟与GPS 📡
TrueTime实现这一点的方式是,实际上在每个数据中心部署原子钟和GPS接收器。这当然需要成本,但Google发现它们实际上是负担得起的,可以在每个数据中心部署。
这为每个数据中心提供了一个可靠的时钟源。现在,数据中心中未直接连接到这些可靠时钟的每个其他节点(普通节点)只有通常的石英钟,它将定期将其本地石英钟与连接了原子钟或GPS接收器的时间服务器同步。
TrueTime实际上每30秒执行一次这种时钟同步。因此,每30秒,数据中心中的每个节点都会向其本地时间服务器发送一次ping。结果是,每次执行时钟同步时,不确定性都会下降,因为此时的不确定性基本上只是到服务器的往返时间加上服务器自身时间的不确定性。由于我们假设时间服务器位于本地数据中心,到时钟服务器的往返时间通常小于1毫秒,因为这里没有大的地理距离需要覆盖。因此,每当我们与时钟服务器同步时,本地时钟的不确定性就会下降到大约1毫秒。
然后,在这30秒的同步间隔之间,不确定性取决于本地时钟相对于真实时间的漂移速率。为此,Google进行了一些测量,确定了我们必须假设的最坏情况时钟不确定性,以确保所有时钟的漂移都低于这个界限。他们发现,假设最坏情况漂移为200 ppm(百万分之二百)是一个安全的假设。
这意味着,在这30秒的时钟同步间隔之间,每个本地时钟的不确定性会以200 ppm的速率不断漂移增加。这意味着在30秒的过程中,我们会累积大约6毫秒的时钟不确定性。TrueTime会精确跟踪自上次时钟同步以来经过了多长时间。因此,它拥有这些不断变宽的不确定性区间,直到下一次时钟同步,然后不确定性又回落到1毫秒,接着再次开始变宽,如此循环,形成一种锯齿状模式。
总结与课程结束语 🎓
本节课中,我们一起学习了Google Spanner分布式数据库系统。我们了解到,Spanner通过结合状态机复制、两阶段锁和两阶段提交等经典算法,实现了强一致性、可序列化隔离和原子提交。其核心创新在于利用TrueTime系统管理物理时钟的不确定性,通过等待不确定性区间来确保时间戳的因果顺序,从而支持无需加锁的、基于MVCC的一致快照只读事务。这使得进行大规模数据库备份等操作成为可能,而不会干扰正在进行的写入事务。
Spanner展示了在真实系统中,各种分布式系统概念(如共识、锁、提交协议、时钟同步)如何结合成一个具体、广泛部署的系统,这是一个非常令人兴奋的例子。

至此,分布式系统课程结束。希望你喜欢这门课程,并从这些思想中有所收获。如果你有任何问题,请随时给我发邮件。对于剑桥的学生们,别忘了填写讲座评估。希望你们喜欢这门课,下次再见。谢谢,再见。

浙公网安备 33010602011771号