简介

一直在断断续续的学习mit 6.824 分布式系统这门课程。分布式系统是现在计算机软件系统中不可避免的一种架构,了解分布式系统对于构建任何大型分布式应用,对于理解分布式程序的运行,对于优化分布式程序的运行环境都有一定的帮助。

mit6.824 这门课程可以说是明星课程了,主讲老师是Robert Morris,这个看起来平易近人的小老头,是个传奇人物【1】。能够听这样的传奇人物叨叨十几个小时,本身就是一种享受,更何况Robert教授能够一种理论联系实际的方式,将主流的分布式系统软件讲的浅显易懂。

美中不足的是,这门课程是全英文,甚至英文字幕都没有。对于国内的同学来说,如果英文没有足够好,很难较好的理解这门课程。因此我计划将这门课程翻译成中文文字版。我将在语句通顺的前提下,尽量还原课程的内容,希望可以帮助大家学习到这门课程。如果你的英语不是那么好,建议阅读完文字再去看视频相关的课程。

这门课程总共有20节课,4个实验,实验都是基于golang完成,课程配套了实验相关的测试用例,动手完成实验可以加深对于相关知识的理解。所有课程内容可以在【2】找到。

每一节课都在80分钟左右,大概会有6-9个知识点,我会按照独立的知识点将每节课拆分成6-9个小节。

对于文中出现的错别字,错误的描述,恳请大家发现后指出,我将改正。

【1】https://zh.wikipedia.org/wiki/%E7%BD%97%E4%BC%AF%E7%89%B9%C2%B7%E6%B3%B0%E6%BD%98%C2%B7%E8%8E%AB%E9%87%8C%E6%96%AF

【2】https://pdos.csail.mit.edu/6.824/schedule.html

如果

  • 你发现了翻译的错误,或者想把剩下几节课程的翻译补上,可以向关联的github提交PR
  • 你觉得我做的还不错,可以关注我的知乎,并给我一个点赞。
  • 你正在找一份工作,并且想与和我一样的工程师共事,请联系我:honghui_xiao@yeah.net
  • 还想学习其他MIT课程,我还做了一些其他的翻译:

声明

此次翻译纯属个人爱好,如果涉及到任何版权行为,请联系我,我将删除内容。文中所有内容,与本人现在,之前或者将来的雇佣公司无关,本人保留自省的权利,也就是说你看到的内容也不一定代表本人最新的认知和观点。

Lecture 01 - Introduction

在开始之前,强烈建议阅读MapReduce论文。

【1】https://pdos.csail.mit.edu/6.824/papers/mapreduce.pdf

1.1 分布式系统的驱动力和挑战(Drivens and Challenges)

本课程是 6.824 分布式系统。我会先简单的介绍我理解的分布式系统。

大家都知道分布式系统的核心是通过网络来协调,共同完成一致任务的一些计算机。我们在本课程中将会重点介绍一些案例,包括:大型网站的储存系统、大数据运算,如 MapReduce、以及一些更为奇妙的技术,比如点对点的文件共享。这是我们学习过程中的一些例子。分布式计算之所以如此重要的原因是,许多重要的基础设施都是在它之上建立的,它们需要多台计算机或者说本质上需要多台物理隔离的计算机。

在我先介绍分布式系统之前,也是提醒大家,在你设计一个系统时或者面对一个你需要解决的问题时,如果你可以在一台计算机上解决,而不需要分布式系统,那你就应该用一台计算机解决问题。有很多的工作都可以在一台计算机上完成,并且通常比分布式系统简单很多。所以,在选择使用分布式系统解决问题前,你应该要充分尝试别的思路,因为分布式系统会让问题解决变得复杂。

人们使用大量的相互协作的计算机驱动力是:

  • 人们需要获得更高的计算性能。可以这么理解这一点,(大量的计算机意味着)大量的并行运算,大量CPU、大量内存、以及大量磁盘在并行的运行。
  • 另一个人们构建分布式系统的原因是,它可以提供容错(tolerate faults)。比如两台计算机运行完全相同的任务,其中一台发生故障,可以切换到另一台。
  • 第三个原因是,一些问题天然在空间上是分布的。例如银行转账,我们假设银行A在纽约有一台服务器,银行B在伦敦有一台服务器,这就需要一种两者之间协调的方法。所以,有一些天然的原因导致系统是物理分布的。
  • 最后一个原因是,人们构建分布式系统来达成一些安全的目标。比如有一些代码并不被信任,但是你又需要和它进行交互,这些代码不会立即表现的恶意或者出现bug。你不会想要信任这些代码,所以你或许想要将代码分散在多处运行,这样你的代码在另一台计算机运行,我的代码在我的计算机上运行,我们通过一些特定的网络协议通信。所以,我们可能会担心安全问题,我们把系统分成多个的计算机,这样可以限制出错域。

这门课程中,我们主要会讨论前两点:性能和容错。剩下两点我们会通过对某些案例的研究来学习。

所有的这些分布式系统的问题(挑战)在于:

  • 因为系统中存在很多部分,这些部分又在并发执行,你会遇到并发编程和各种复杂交互所带来的问题,以及时间依赖的问题(比如同步,异步)。这让分布式系统变得很难。
  • 另一个导致分布式系统很难的原因是,分布式系统有多个组成部分,再加上计算机网络,你会会遇到一些意想不到的故障。如果你只有一台计算机,那么它通常要么是工作,要么是故障或者没电,总的来说,要么是在工作,要么是没有工作。而由多台计算机组成的分布式系统,可能会有一部分组件在工作,而另一部分组件停止运行,或者这些计算机都在正常运行,但是网络中断了或者不稳定。所以,局部错误也是分布式系统很难的原因。
  • 最后一个导致分布式系统很难的原因是,人们设计分布式系统的根本原因通常是为了获得更高的性能,比如说一千台计算机或者一千个磁盘臂达到的性能。但是实际上一千台机器到底有多少性能是一个棘手的问题,这里有很多难点。所以通常需要倍加小心地设计才能让系统实际达到你期望的性能。

本门课程就是为了解决这些问题。通常来说,问题和解决方案在技术上都很有趣。对于这些问题,有些有很好的解决方案,有些就没有那么好的解决方案。

分布式系统应用在很多现实生活中系统,例如大型网站通常是由大量的计算机构成的分布式系统来运行。当我刚开始教这门课的时候,分布式系统还是一种学术上的好奇尝试。人们只是发现有时需要一些小规模的系统,并且预感在未来这(大规模分布式系统)可能很重要。但是现在,随着大型网站的兴起和推动,出现了大量的数据和大型数据中心。在过去的二十年中,分布式系统已经是计算架构中很重要的一部分。这意味着大量的精力投入到解决相关问题的工作中,但是同样有少数问题还没有被解决。如果你是个研究生,并且对这方面研究感兴趣,还有很多关于分布式系统的问题等着你去解决,去进行相关研究。最后 如果你是一位热衷动手的同学,这会是一门不错的课程,因为它有一系列实验,你会编写出贴近现实,并且关注性能和容错的分布式系统。所以你会有很多机会去构建一个分布式系统并且让他们正常工作。

1.2 课程结构(Course Structure)

在讨论技术内容之前,我先介绍一下课程结构。你们应该可以通过网络搜索到这门课程的网站(最开始的简介里也有)。网站上有一些实验作业,课程时间表和一个Piazza(论坛)页面链接,你可以在那里发布问题并获得解答。课程主要的教学人员有:我Robert Morris会进行课堂授课,和四个助教。助教会重点解决实验问题,在工作时间,他们也会在办公室解答有关实验的问题。所以如果你有关于实验的问题,你应该在办公时间过去找他们,或者你可以将问题发到Piazza上。

这门课有几个重要组成部分:

  • 课堂授课
  • 几乎每节课都有论文阅读
  • 两次考试
  • 编程实验
  • 可选的项目(与Lab4二选一)

授课内容会围绕分布式系统的两个方面(性能和容错)。有几节课会介绍一些关于编程实验的内容。许多课程我们将会以案例分析为主要形式。我会在课前提供一些关于分布式系统的论文,这些论文有些是学术研究,也有一些是工业界关于现实问题的解决方案。授课内容会被录像并被上传到网络,这样不在课堂的人也可以在别的地方观看视频,同时你们也可以回顾课程视频。

这里的论文每周需要读一篇,论文主要是研究论文,也有一些经典论文,比如今天我希望你们阅读的论文是MapReduce的论文。这篇论文很老,但是这篇论文不论在学术界还是工业界都激发了巨大的关于分布式系统的兴趣。所以,论文有一些是经典论文,也有一些最近发布的论文,用来讨论最近人们关心的最新研究成果。我希望通过这些论文可以让你们弄清楚,什么是基本的问题,研究者们有哪些想法,这些想法可能会,也可能不会对解决分布式系统的问题有用。我们有时会讨论这些论文中的一些实施细节,因为这些细节与实际构建软件系统有很多关联。我们同样会花一些时间去看对人们对系统的评估。人们是如何通过系统容错性和性能来评估一个分布式系统。我希望你们在每次讲课前,都可以完成相关论文的阅读。如果没有提前阅读,光是课程本身的内容或许没有那么有意义,因为我们没有足够的时间来解释论文中的所有内容,同时来反思论文中一些有意思的地方。所以,我真的希望大家来课堂前先阅读论文。我也希望快速高效的读论文会是这堂课的一个收获,比如跳过一些并不太重要的部分,而关注作者重要的想法。我们课程网站上每一个日程的链接都有一些思考问题,你应该在读完每篇论文后回答这个问题。我们也需要你在网站上提出关于论文的一些问题,可以让我思考一下我对课程的准备。如果我有时间我会至少通过电子邮件回答一部分问题。这些问题和回答都需要课程前一天的零点前提交。

有两次考试,一次是随堂期中,大概在春假前最后一节课;并且会在学期期末周迎来期末考试。考试内容主要为论文和实验中的内容。我建议最好的准备方式当然参加课堂授课,并且阅读论文。另一个好的准备考试的方式就是查看我们过去20年所有的考试,这在网站上都有链接。这样你就知道,我会在考试中问哪些问题?因为我们(相比往年)会涉及到一些重复的论文,所以不可避免的,我会问一些与历年题目类似的问题。

有四次编程实验。第一次实验需要在下周五前完成,这是一个简单的MapReduce实验。你们要根据你们在论文中读到的来实现你们版本的MapReduce。我们过一会就会讨论这个论文。第二个实验实现Raft算法,这是一个理论上通过复制来让系统容错的算法,具体是通过复制和出现故障时自动切换来实现。第三个实验,你需要使用你的Raft算法实现来建立一个可以容错的KV服务。第四个实验,你需要把你写的KV服务器分发到一系列的独立集群中,这样你会切分你的KV服务,并通过运行这些独立的副本集群进行加速。同时,你也要负责将不同的数据块在不同的服务器之间搬迁,并确保数据完整。这里我们通常称之为分片式KV服务。分片是指我们将数据在多个服务器上做了分区,来实现并行的加速。

如果你不想做实验四,你也可以选择你自己的项目。如果你对分布式系统有一些自己的想法,比如我们课堂上讨论到的某个类型的分布式系统,或者说你有一些自己的追求并且想对这个想法进行评估,看他们能不能正确运行,你可以选择做这个项目。这个项目中你需要联系一些你的同学,因为我们需要以2-3人的小组形式完成。你需要把想法发给我,我来确定下是否合适或者是给你一些建议。如果我觉得合适,你也想做这个项目,你就可以用它在本学期末代替实验四。你需要做一些系统设计,并构建一个真实的系统并在最后一节课前演示。同时需要交一个简短的关于如何构建它的书面报告。我在网站上也提出一些或许对你们构建这个项目有帮助的大胆的想法。当然最好的项目应该是,你自己有一个很好的想法。你需要选择一个和课程讨论内容相关的系统作为你的项目。

回到实验部分,实验成绩会由一系列针对你代码的测试构成,所以你的成绩就是我们所有测试的结果。我们会公开全部的测试数据,并没有隐藏的测试,所以如果你完成了实验并且可靠的通过了全部测试,除非出现一些愚蠢的问题,一般来说就会得到满分。希望你们不会有任何关于实验评分的问题。我需要提醒你的是,debug这些代码可能很耗时间,因为它们是分布式系统,它们有很多并发和通信,可能发生一些奇怪且困难的错误。所以,你们应该尽早开始实验 ,不要在提交实验的最后时刻还要处理很多麻烦。如果有对实验有问题,可以在工作时间来到助教办公室,你也可以在Piazza上自由提问。当然我也希望,如果你知道一个问题的答案,你可以在Piazza回答别人的提问。

还有什么关于课程的问题吗?

学生提问:这些部分在总成绩的占比是多少?

Robert教授:我其实不记得了,不过你在课程网站上应该能找到答案。我想实验应该是占比最大的。

1.3 分布式系统的抽象和实现工具(Abstraction and Implementation)

这门课程是有关应用的基础架构的。所以,贯穿整个课程,我会以分离的方式介绍:第三方的应用程序,和这些应用程序所基于的,我们课程中主要介绍的一些基础架构。基础架构的类型主要是存储,通信(网络)和计算。

我们会讨论包含所有这三个部分的基础设施,但实际上我们最关注的是存储,因为这是一个定义明确且有用的抽象概念,并且通常比较直观。人们知道如何构建和使用储存系统,知道如何去构建一种多副本,容错的,高性能分布式存储实现。

我们还会讨论一些计算系统,比如今天会介绍的MapReduce。我们也会说一些关于通信的问题,但是主要的出发点是通信是我们建立分布式系统所用的工具。比如计算机可能需要通过网络相互通信,但是可能需要保证一定的可靠性,所以我们会提到一些通信。实际上我们更多是使用已有的通信方式,如果你想了解更多关于通信系统的问题,在6.829这门课程有更多的介绍。

对于存储和计算,我们的目标是为了能够设计一些简单接口,让第三方应用能够使用这些分布式的存储和计算,这样才能简单的在这些基础架构之上,构建第三方应用程序。这里的意思是,我们希望通过这种抽象的接口,将分布式特性隐藏在整个系统内。尽管这几乎是无法实现的梦想,但是我们确实希望建立这样的接口,这样从应用程序的角度来看,整个系统是一个非分布式的系统,就像一个文件系统或者一个大家知道如何编程的普通系统,并且有一个非常简单的模型语句。我们希望构建一个接口,它看起来就像一个非分布式存储和计算系统一样,但是实际上又是一个有极高的性能和容错性的分布式系统。

随着课程的进行,我们会知道,很难能找到一个抽象来描述分布式的存储或者计算,使得它们能够像非分布式系统一样有简单易懂的接口。但是,人们在这方面的做的越来越好,我们会尝试学习人们在构建这样的抽象时的一些收获。

当我们在考虑这些抽象的时候,第一个出现的话题就是实现。人们在构建分布系统时,使用了很多的工具,例如:

  • RPC(Remote Procedure Call)。RPC的目标就是掩盖我们正在不可靠网络上通信的事实。
  • 另一个我们会经常看到的实现相关的内容就是线程。这是一种编程技术,使得我们可以利用多核心计算机。对于本课程而言,更重要的是,线程提供了一种结构化的并发操作方式,这样,从程序员角度来说可以简化并发操作。
  • 因为我们会经常用到线程,我们需要在实现的层面上,花费一定的时间来考虑并发控制,比如锁。

关于这些实现思想会在课程中出现,我们也会在许多论文中看到。对于你来说,你将会在实验中面对这些问题。你需要编程实现分布式系统,而这些工具不仅是普通的编程工具,同时也是非常重要的用来构建分布式系统的工具。

1.4 可扩展性(Scalability)

另一个在很多论文中都出现过重要的话题,就是性能。

通常来说,构建分布式系统的目的是为了获取人们常常提到的可扩展的加速。所以,我们这里追求的是可扩展性(Scalability)。而我这里说的可扩展或者可扩展性指的是,如果我用一台计算机解决了一些问题,当我买了第二台计算机,我只需要一半的时间就可以解决这些问题,或者说每分钟可以解决两倍数量的问题。两台计算机构成的系统如果有两倍性能或者吞吐,就是我说的可扩展性。

这是一个很强大的特性。如果你构建了一个系统,并且只要增加计算机的数量,系统就能相应提高性能或者吞吐量,这将会是一个巨大的成果,因为计算机只需要花钱就可以买到。如果不增加计算机,就需要花钱雇程序员来重构这些系统,进而使这些系统有更高的性能,更高的运行效率,或者应用一个更好的算法之类的。花钱请程序员来修补这些代码,使它们运行的更快,通常会是一个昂贵的方法。我们还是希望能够通过从十台计算机提升到一千台计算机,就能扛住一百倍的流量。

所以,当人们使用一整个机房的计算机来构建大型网站的时候,为了获取对应的性能,必须要时刻考虑可扩展性。你需要仔细设计系统,才能获得与计算机数量匹配的性能。

我在课程中可能经常会画图来说明,比如我们来看这样一个图。假设我们建立了一个常规网站,一般来说一个网站有一个 HTTP服务器,还有一些用户和浏览器,用户与一个基于Python或者PHP的web服务器通信,web服务器进而跟一些数据库进行交互。

当你只有1-2个用户时,一台计算机就可以运行web服务器和数据,或者一台计算机运行web服务器,一台计算机运行数据库。但是有可能你的网站一夜之间就火了起来,你发现可能有一亿人要登录你的网站。你该怎么修改你的网站,使它能够在一台计算机上支持一亿个用户?你可以花费大量时间极致优化你的网站,但是很显然你没有那个时间。所以,为了提升性能,你要做的第一件事情就是购买更多的web服务器,然后把不同用户分到不同服务器上。这样,一部分用户可以去访问第一台web服务器,另一部分去访问第二台web服务器。因为你正在构建的是类似于Reddit的网站,所有的用户最终都需要看到相同的数据。所以,所有的web服务器都与后端数据库通信。这样,很长一段时间你都可以通过添加web服务器来并行的提高web服务器的代码效率。

只要单台web服务器没有给数据库带来太多的压力,你可以在出现问题前添加很多web服务器,但是这种可扩展性并不是无限的。很可能在某个时间点你有了10台,20台,甚至100台web服务器,它们都在和同一个数据库通信。现在,数据库突然成为了瓶颈,并且增加更多的web服务器都无济于事了。所以很少有可以通过无限增加计算机来获取完整的可扩展性的场景。因为在某个临界点,你在系统中添加计算机的位置将不再是瓶颈了。在我们的例子中,如果你有了很多的web服务器,那么瓶颈就会转移到了别的地方,这里是从web服务器移到了数据库。

这时,你几乎是必然要做一些重构工作。但是只有一个数据库时,很难重构它。而虽然可以将一个数据库拆分成多个数据库(进而提升性能),但是这需要大量的工作。

我们在本课程中,会看到很多有关分布式存储系统的例子,因为相关论文或者系统的作者都在运行大型网站,而单个数据库或者存储服务器不能支撑这样规模的网站(所以才需要分布式存储)。

所以,有关扩展性是这样:我们希望可以通过增加机器的方式来实现扩展,但是现实中这很难实现,需要一些架构设计来将这个可扩展性无限推进下去。

1.5 可用性(Availability)

另一个重要的话题是容错。

如果你只使用一台计算机构建你的系统,那么你的系统大概率是可靠的。因为一台计算机通常可以很好的运行很多年,比如我办公室的服务器已经运行很多年而没有故障,计算机是可靠的,操作系统是可靠的,明显我办公室的电源也是可靠的。所以,一台计算机正常工作很长时间并不少见。然而如果你通过数千台计算机构建你的系统,那么即使每台计算机可以稳定运行一年,对于1000台计算机也意味着平均每天会有3台计算机故障。

所以,大型分布式系统中有一个大问题,那就是一些很罕见的问题会被放大。例如在我们的1000台计算机的集群中,总是有故障,要么是机器故障,要么是运行出错,要么是运行缓慢,要么是执行错误的任务。一个更常见的问题是网络,在一个有1000台计算机的网络中,会有大量的网络电缆和网络交换机,所以总是会有人踩着网线导致网线从接口掉出,或者交换机风扇故障导致交换机过热而不工作。在一个大规模分布式系统中,各个地方总是有一些小问题出现。所以大规模系统会将一些几乎不可能并且你不需要考虑的问题,变成一个持续不断的问题。

所以,因为错误总会发生,必须要在设计时就考虑,系统能够屏蔽错误,或者说能够在出错时继续运行。同时,因为我们需要为第三方应用开发人员提供方便的抽象接口,我们的确也需要构建这样一种基础架构,它能够尽可能多的对应用开发人员屏蔽和掩盖错误。这样,应用开发人员就不需要处理各种各样的可能发生的错误。

对于容错,有很多不同的概念可以表述。这些表述中,有一个共同的思想就是可用性(Availability)。某些系统经过精心的设计,这样在特定的错误类型下,系统仍然能够正常运行,仍然可以像没有出现错误一样,为你提供完整的服务。

某些系统通过这种方式提供可用性。比如,你构建了一个有两个拷贝的多副本系统,其中一个故障了,另一个还能运行。当然如果两个副本都故障了,你的系统就不再有可用性。所以,可用系统通常是指,在特定的故障范围内,系统仍然能够提供服务,系统仍然是可用的。如果出现了更多的故障,系统将不再可用。

除了可用性之外,另一种容错特性是自我可恢复性(recoverability)。这里的意思是,如果出现了问题,服务会停止工作,不再响应请求,之后有人来修复,并且在修复之后系统仍然可以正常运行,就像没有出现过问题一样。这是一个比可用性更弱的需求,因为在出现故障到故障组件被修复期间,系统将会完全停止工作。但是修复之后,系统又可以完全正确的重新运行,所以可恢复性是一个重要的需求。

对于一个可恢复的系统,通常需要做一些操作,例如将最新的数据存放在磁盘中,这样在供电恢复之后(假设故障就是断电),才能将这些数据取回来。甚至说对于一个具备可用性的系统,为了让系统在实际中具备应用意义,也需要具备可恢复性。因为可用的系统仅仅是在一定的故障范围内才可用,如果故障太多,可用系统也会停止工作,停止一切响应。但是当足够的故障被修复之后,系统还是需要能继续工作。所以,一个好的可用的系统,某种程度上应该也是可恢复的。当出现太多故障时,系统会停止响应,但是修复之后依然能正确运行。这是我们期望看到的。

为了实现这些特性,有很多工具。其中最重要的有两个:

  • 一个是非易失存储(non-volatile storage,类似于硬盘)。这样当出现类似电源故障,甚至整个机房的电源都故障时,我们可以使用非易失存储,比如硬盘,闪存,SSD之类的。我们可以存放一些checkpoint或者系统状态的log在这些存储中,这样当备用电源恢复或者某人修好了电力供给,我们还是可以从硬盘中读出系统最新的状态,并从那个状态继续运行。所以,这里的一个工具是非易失存储。因为更新非易失存储是代价很高的操作,所以相应的出现了很多非易失存储的管理工具。同时构建一个高性能,容错的系统,聪明的做法是避免频繁的写入非易失存储。在过去,甚至对于今天的一个3GHZ的处理器,写入一个非易失存储意味着移动磁盘臂并等待磁碟旋转,这两个过程都非常缓慢。有了闪存会好很多,但是为了获取好的性能,仍然需要许多思考。
  • 对于容错的另一个重要工具是复制(replication),不过,管理复制的多副本系统会有些棘手。任何一个多副本系统中,都会有一个关键的问题,比如说,我们有两台服务器,它们本来应该是有着相同的系统状态,现在的关键问题在于,这两个副本总是会意外的偏离同步的状态,而不再互为副本。对于任何一种使用复制实现容错的系统,我们都面临这个问题。lab2和lab3都是通过管理多副本来实现容错的系统,你将会看到这里究竟有多复杂。

1.6 一致性(Consistency)

最后一个很重要的话题是一致性(Consistency)。

要理解一致性,这里有个例子,假设我们在构建一个分布式存储系统,并且这是一个KV服务。这个KV服务只支持两种操作,其中一个是put操作会将一个value存入一个key;另一个是get操作会取出key对应的value。整体表现就像是一个大的key-value表单。当我需要对一个分布式系统举例时,我总是会想到KV服务,因为它们也很基础,可以算是某种基础简单版本的存储系统。

现在,如果你是程序员,如果这两个操作有特定的意义(或者说操作满足一致性),那么对于你是有帮助的。你可以去查看手册,手册会向你解释,如果你调用get你会获取到什么,如果你调用put会有什么效果。如果有这样的手册,那是极好的。否则,如果你不知道put/get的实际行为,你又该如何写你的应用程序呢?

一致性就是用来定义操作行为的概念。之所以一致性是分布式系统中一个有趣的话题,是因为,从性能和容错的角度来说,我们通常会有多个副本。在一个非分布式系统中,你通常只有一个服务器,一个表单。虽然不是绝对,但是通常来说对于put/get的行为不会有歧义。直观上来说,put就是更新这个表单,get就是从表单中获取当前表单中存储的数据。但是在一个分布式系统中,由于复制或者缓存,数据可能存在于多个副本当中,于是就有了多个不同版本的key-value对。假设服务器有两个副本,那么他们都有一个key-value表单,两个表单中key 1对应的值都是20。

现在某个客户端发送了一个put请求,并希望将key 1改成值21。这里或许是KV服务里面的一个计数器。这个put请求发送给了第一台服务器,

之后会发送给第二台服务器,因为相同的put请求需要发送给两个副本,这样这两个副本才能保持同步。但是就在客户端准备给第二台服务器发送相同请求时,这个客户端故障了,可能是电源故障或者操作系统的bug之类的。所以,现在我们处于一个不好的状态,我们发送了一个put请求,更新了一个副本的值是21,但是另一个副本的值仍然是20。

如果现在某人通过get读取key为1的值,那么他可能获得21,也可能获得20,取决于get请求发送到了哪个服务器。即使规定了总是把请求先发送给第一个服务器,那么我们在构建容错系统时,如果第一台服务器故障了,请求也会发给第二台服务器。所以不管怎么样,总有一天你会面临暴露旧数据的风险。很可能是这样,最开始许多get请求都得到了21,之后过了一周突然一些get请求得到了一周之前的旧数据(20)。所以,这里不是很一致。并且,如果我们不小心的话,这个场景是可能发生的。所以,我们需要确定put/get操作的一些规则。

实际上,对于一致性有很多不同的定义。有一些非常直观,比如说get请求可以得到最近一次完成的put请求写入的值。这种一般也被称为强一致(Strong Consistency)。但是,事实上,构建一个弱一致的系统也是非常有用的。弱一致是指,不保证get请求可以得到最近一次完成的put请求写入的值。尽管有很多细节的工作要处理,强一致可以保证get得到的是put写入的最新的数据;而很多的弱一致系统不会做出类似的保证。所以在一个弱一致系统中,某人通过put请求写入了一个数据,但是你通过get看到的可能仍然是一个旧数据,而这个旧数据可能是很久之前写入的。

人们对于弱一致感兴趣的原因是,虽然强一致可以确保get获取的是最新的数据,但是实现这一点的代价非常高。几乎可以确定的是,分布式系统的各个组件需要做大量的通信,才能实现强一致性。如果你有多个副本,那么不管get还是put都需要询问每一个副本。在之前的例子中,客户端在更新的过程中故障了,导致一个副本更新了,而另一个副本没有更新。如果我们要实现强一致,简单的方法就是同时读两个副本,如果有多个副本就读取所有的副本,并使用最近一次写入的数据。但是这样的代价很高,因为需要大量的通信才能得到一个数据。所以,为了尽可能的避免通信,尤其当副本相隔的很远的时候,人们会构建弱一致系统,并允许读取出旧的数据。当然,为了让弱一致更有实际意义,人们还会定义更多的规则。

强一致带来的昂贵的通信问题,会把你带入这样的困境:当我们使用多副本来完成容错时,我们的确需要每个副本都有独立的出错概率,这样故障才不会关联。例如,将两个副本放在一个机房的一个机架上,是一个非常糟糕的主意。如果有谁踢到了机架的电源线,那我们数据的两个副本都没了,因为它们都连在同一个机架的同一根电线上。所以,为了使副本的错误域尽可能独立,为了获得良好的容错特性,人们希望将不同的副本放置在尽可能远的位置,例如在不同的城市或者在大陆的两端。这样,如果地震摧毁了一个数据中心,另一个数据中心中的副本有很大可能还能保留。我们期望这样的效果。但是如果我们这么做了,另一个副本可能在数千英里之外,按照光速来算,也需要花费几毫秒到几十毫秒才能完成横跨洲际的数据通信,而这只是为了更新数据的另一个副本。所以,为了保持强一致的通信,代价可能会非常高。因为每次你执行put或者get请求,你都需要等待几十毫秒来与数据的两个副本通信,以确保它们都被更新了或者都被检查了以获得最新的数据。现在的处理器每秒可以执行数十亿条指令,等待几十毫秒会大大影响系统的处理速度。

所以,人们常常会使用弱一致系统,你只需要更新最近的数据副本,并且只需要从最近的副本获取数据。在学术界和现实世界(工业界),有大量关于构建弱一致性保证的研究。所以,弱一致对于应用程序来说很有用,并且它可以用来获取高的性能。

以上就是本课程中一些技术思想的快速预览。

1.7 MapReduce基本工作方式

接下来介绍MapReduce。这是一个详细的案例研究,它会展示之前讲过的大部分的思想。

MapReduce是由Google设计,开发和使用的一个系统,相关的论文在2004年发表。Google当时面临的问题是,他们需要在TB级别的数据上进行大量的计算。比如说,为所有的网页创建索引,分析整个互联网的链接路径并得出最重要或者最权威的网页。如你所知,在当时,整个互联网的数据也有数十TB。构建索引基本上等同于对整个数据做排序,而排序比较费时。如果用一台计算机对整个互联网数据进行排序,要花费多长时间呢?可能要几周,几个月,甚至几年。所以,当时Google非常希望能将对大量数据的大量运算并行跑在几千台计算机上,这样才能快速完成计算。对Google来说,购买大量的计算机是没问题的,这样Google的工程师就不用花大量时间来看报纸来等他们的大型计算任务完成。所以,有段时间,Google买了大量的计算机,并让它的聪明的工程师在这些计算机上编写分布式软件,这样工程师们可以将手头的问题分包到大量计算机上去完成,管理这些运算,并将数据取回。

如果你只雇佣熟练的分布式系统专家作为工程师,尽管可能会有些浪费,也是可以的。但是Google想雇用的是各方面有特长的人,不一定是想把所有时间都花在编写分布式软件上的工程师。所以Google需要一种框架,可以让它的工程师能够进行任意的数据分析,例如排序,网络索引器,链接分析器以及任何的运算。工程师只需要实现应用程序的核心,就能将应用程序运行在数千台计算机上,而不用考虑如何将运算工作分发到数千台计算机,如何组织这些计算机,如何移动数据,如何处理故障等等这些细节。所以,当时Google需要一种框架,使得普通工程师也可以很容易的完成并运行大规模的分布式运算。这就是MapReduce出现的背景。

MapReduce的思想是,应用程序设计人员和分布式运算的使用者,只需要写简单的Map函数和Reduce函数,而不需要知道任何有关分布式的事情,MapReduce框架会处理剩下的事情。

抽象来看,MapReduce假设有一些输入,这些输入被分割成大量的不同的文件或者数据块。所以,我们假设现在有输入文件1,输入文件2和输入文件3,这些输入可能是从网上抓取的网页,更可能是包含了大量网页的文件。

MapReduce启动时,会查找Map函数。之后,MapReduce框架会为每个输入文件运行Map函数。这里很明显有一些可以并行运算的地方,比如说可以并行运行多个只关注输入和输出的Map函数。

Map函数以文件作为输入,文件又是整个输入数据的一部分。Map函数的输出是一个key-value对的列表。假设我们在实现一个最简单的MapReduce Job:单词计数器。它会统计每个单词出现的次数。在这个例子中,Map函数会输出key-value对,其中key是单词,而value是1。Map函数会将输入中的每个单词拆分,并输出一个key-value对,key是该单词,value是1。最后需要对所有的key-value进行计数,以获得最终的输出。所以,假设输入文件1包含了单词a和单词b,Map函数的输出将会是key=a,value=1和key=b,value=1。第二个Map函数只从输入文件2看到了b,那么输出将会是key=b,value=1。第三个输入文件有一个a和一个c。

我们对所有的输入文件都运行了Map函数,并得到了论文中称之为中间输出(intermediate output),也就是每个Map函数输出的key-value对。

运算的第二阶段是运行Reduce函数。MapReduce框架会收集所有Map函数输出的每一个单词的统计。比如说,MapReduce框架会先收集每一个Map函数输出的key为a的key-value对。收集了之后,会将它们提交给Reduce函数。

之后会收集所有的b。这里的收集是真正意义上的收集,因为b是由不同计算机上的不同Map函数生成,所以不仅仅是数据从一台计算机移动到另一台(如果Map只在一台计算机的一个实例里,可以直接通过一个RPC将数据从Map移到Reduce)。我们收集所有的b,并将它们提交给另一个Reduce函数。这个Reduce函数的入参是所有的key为b的key-value对。对c也是一样。所以,MapReduce框架会为所有Map函数输出的每一个key,调用一次Reduce函数。

在我们这个简单的单词计数器的例子中,Reduce函数只需要统计传入参数的长度,甚至都不用查看传入参数的具体内容,因为每一个传入参数代表对单词加1,而我们只需要统计个数。最后,每个Reduce都输出与其关联的单词和这个单词的数量。所以第一个Reduce输出a=2,第二个Reduce输出b=2,第三个Reduce输出c=1。

这就是一个典型的MapReduce Job。从整体来看,为了保证完整性,有一些术语要介绍一下:

  • Job。整个MapReduce计算称为Job。
  • Task。每一次MapReduce调用称为Task。

所以,对于一个完整的MapReduce Job,它由一些Map Task和一些Reduce Task组成。所以这是一个单词计数器的例子,它解释了MapReduce的基本工作方式。

1.8 Map函数和Reduce函数

Map函数使用一个key和一个value作为参数。我们这里说的函数是由普通编程语言编写,例如C++,Java等,所以这里的函数任何人都可以写出来。入参中,key是输入文件的名字,通常会被忽略,因为我们不太关心文件名是什么,value是输入文件的内容。所以,对于一个单词计数器来说,value包含了要统计的文本,我们会将这个文本拆分成单词。之后对于每一个单词,我们都会调用emit。emit由MapReduce框架提供,并且这里的emit属于Map函数。emit会接收两个参数,其中一个是key,另一个是value。在单词计数器的例子中,emit入参的key是单词,value是字符串“1”。这就是一个Map函数。在一个单词计数器的MapReduce Job中,Map函数实际就可以这么简单。而这个Map函数不需要知道任何分布式相关的信息,不需要知道有多台计算机,不需要知道实际会通过网络来移动数据。这里非常直观。

Reduce函数的入参是某个特定key的所有实例(Map输出中的key-value对中,出现了一次特定的key就可以算作一个实例)。所以Reduce函数也是使用一个key和一个value作为参数,其中value是一个数组,里面每一个元素是Map函数输出的key的一个实例的value。对于单词计数器来说,key就是单词,value就是由字符串“1”组成的数组,所以,我们不需要关心value的内容是什么,我们只需要关心value数组的长度。Reduce函数也有一个属于自己的emit函数。这里的emit函数只会接受一个参数value,这个value会作为Reduce函数入参的key的最终输出。所以,对于单词计数器,我们会给emit传入数组的长度。这就是一个最简单的Reduce函数。并且Reduce也不需要知道任何有关容错或者其他有关分布式相关的信息。

对于MapReduce的基本框架有什么问题吗?

学生提问:可以将Reduce函数的输出再传递给Map函数吗?

Robert教授:在现实中,这是很常见的。MapReduce用户定义了一个MapReduce Job,接收一些输入,生成一些输出。之后可能会有第二个MapReduce Job来消费前一个Job的输出。对于一些非常复杂的多阶段分析或者迭代算法,比如说Google用来评价网页的重要性和影响力的PageRank算法,这些算法是逐渐向答案收敛的。我认为Google最初就是这么使用MapReduce的,他们运行MapReduce Job多次,每一次的输出都是一个网页的列表,其中包含了网页的价值,权重或者重要性。所以将MapReduce的输出作为另一个MapReduce Job的输入这很正常。

学生提问:如果可以将Reduce的输出作为Map的输入,在生成Reduce函数的输出时需要有什么注意吗?

Robert教授:是的,你需要设置一些内容。比如你需要这么写Reduce函数,使其在某种程度上知道应该按照下一个MapReduce Job需要的格式生成数据。这里实际上带出了一些MapReduce框架的缺点。如果你的算法可以很简单的由Map函数、Map函数的中间输出以及Reduce函数来表达,那是极好的。MapReduce对于能够套用这种形式的算法是极好的。并且,Map函数必须是完全独立的,它们是一些只关心入参的函数。这里就有一些限制了。事实上,很多人想要的更长的运算流程,这涉及到不同的处理。使用MapReduce的话,你不得不将多个MapReduce Job拼装在一起。而在本课程后面会介绍的一些更高级的系统中,会让你指定完整的计算流程,然后这些系统会做优化。这些系统会发现所有你想完成的工作,然后有效的组织更复杂的计算。

学生提问:MapReduce框架更重要还是Map/Reduce函数更重要?

Robert教授:从程序员的角度来看,只需要关心Map函数和Reduce函数。从我们的角度来看,我们需要关心的是worker进程和worker服务器。这些是MapReduce框架的一部分,它们与其它很多组件一起调用了Map函数和Reduce函数。所以是的,从我们的角度来看,我们更关心框架是如何组成的。从程序员的角度来看,所有的分布式的内容都被剥离了。

学生提问:当你调用emit时,数据会发生什么变化?emit函数在哪运行?

Robert教授:首先看,这些函数在哪运行。这里可以看MapReduce论文的图1。现实中,MapReduce运行在大量的服务器之上,我们称之为worker服务器或者worker。同时,也会有一个Master节点来组织整个计算过程。这里实际发生的是,Master服务器知道有多少输入文件,例如5000个输入文件,之后它将Map函数分发到不同的worker。所以,它会向worker服务器发送一条消息说,请对这个输入文件执行Map函数吧。之后,MapReduce框架中的worker进程会读取文件的内容,调用Map函数并将文件名和文件内容作为参数传给Map函数。worker进程还需要实现emit,这样,每次Map函数调用emit,worker进程就会将数据写入到本地磁盘的文件中。所以,Map函数中调用emit的效果是在worker的本地磁盘上创建文件,这些文件包含了当前worker的Map函数生成的所有的key和value。

所以,Map阶段结束时,我们看到的就是Map函数在worker上生成的一些文件。之后,MapReduce的worker会将这些数据移动到Reduce所需要的位置。对于一个典型的大型运算,Reduce的入参包含了所有Map函数对于特定key的输出。通常来说,每个Map函数都可能生成大量key。所以通常来说,在运行Reduce函数之前。运行在MapReduce的worker服务器上的进程需要与集群中每一个其他服务器交互来询问说,看,我需要对key=a运行Reduce,请看一下你本地磁盘中存储的Map函数的中间输出,找出所有key=a,并通过网络将它们发给我。所以,Reduce worker需要从每一个worker获取特定key的实例。这是通过由Master通知到Reduce worker的一条指令来触发。一旦worker收集完所有的数据,它会调用Reduce函数,Reduce函数运算完了会调用自己的emit,这个emit与Map函数中的emit不一样,它会将输出写入到一个Google使用的共享文件服务中。

有关输入和输出文件的存放位置,这是我之前没有提到的,它们都存放在文件中,但是因为我们想要灵活的在任意的worker上读取任意的数据,这意味着我们需要某种网络文件系统(network file system)来存放输入数据。所以实际上,MapReduce论文谈到了GFS(Google File System)。GFS是一个共享文件服务,并且它也运行在MapReduce的worker集群的物理服务器上。GFS会自动拆分你存储的任何大文件,并且以64MB的块存储在多个服务器之上。所以,如果你有了10TB的网页数据,你只需要将它们写入到GFS,甚至你写入的时候是作为一个大文件写入的,GFS会自动将这个大文件拆分成64MB的块,并将这些块平均的分布在所有的GFS服务器之上,而这是极好的,这正是我们所需要的。如果我们接下来想要对刚刚那10TB的网页数据运行MapReduce Job,数据已经均匀的分割存储在所有的服务器上了。如果我们有1000台服务器,我们会启动1000个Map worker,每个Map worker会读取1/1000输入数据。这些Map worker可以并行的从1000个GFS文件服务器读取数据,并获取巨大的读取吞吐量,也就是1000台服务器能提供的吞吐量。

学生提问:这里的箭头代表什么意思?

Robert教授:随着Google这些年对MapReduce系统的改进,答案也略有不同。通常情况下,如果我们在一个例如GFS的文件系统中存储大的文件,你的数据分散在大量服务器之上,你需要通过网络与这些服务器通信以获取你的数据。在这种情况下,这个箭头表示MapReduce的worker需要通过网络与存储了输入文件的GFS服务器通信,并通过网络将数据读取到MapReduce的worker节点,进而将数据传递给Map函数。这是最常见的情况。并且这是MapReduce论文中介绍的工作方式。但是如果你这么做了,这里就有很多网络通信。 如果数据总共是10TB,那么相应的就需要在数据中心网络上移动10TB的数据。而数据中心网络通常是GB级别的带宽,所以移动10TB的数据需要大量的时间。在论文发表的2004年,MapReduce系统最大的限制瓶颈是网络吞吐。如果你读到了论文的评估部分,你会发现,当时运行在一个有数千台机器的网络上,每台计算机都接入到一个机架,机架上有以太网交换机,机架之间通过root交换机连接(最上面那个交换机)。

如果随机的选择MapReduce的worker服务器和GFS服务器,那么至少有一半的机会,它们之间的通信需要经过root交换机,而这个root交换机的吞吐量总是固定的。如果做一个除法,root交换机的总吞吐除以2000,那么每台机器只能分到50Mb/S的网络容量。这个网络容量相比磁盘或者CPU的速度来说,要小得多。所以,50Mb/S是一个巨大的限制。

在MapReduce论文中,讨论了大量的避免使用网络的技巧。其中一个是将GFS和MapReduce混合运行在一组服务器上。所以如果有1000台服务器,那么GFS和MapReduce都运行在那1000台服务器之上。当MapReduce的Master节点拆分Map任务并分包到不同的worker服务器上时,Master节点会找出输入文件具体存在哪台GFS服务器上,并把对应于那个输入文件的Map Task调度到同一台服务器上。所以,默认情况下,这里的箭头是指读取本地文件,而不会涉及网络。虽然由于故障,负载或者其他原因,不能总是让Map函数都读取本地文件,但是几乎所有的Map函数都会运行在存储了数据的相同机器上,并因此节省了大量的时间,否则通过网络来读取输入数据将会耗费大量的时间。

我之前提过,Map函数会将输出存储到机器的本地磁盘,所以存储Map函数的输出不需要网络通信,至少不需要实时的网络通信。但是,我们可以确定的是,为了收集所有特定key的输出,并将它们传递给某个机器的Reduce函数,还是需要网络通信。假设现在我们想要读取所有的相关数据,并通过网络将这些数据传递给单台机器,数据最开始在运行Map Task的机器上按照行存储(例如第一行代表第一个Map函数输出a=1,b=1),

而我们最终需要这些数据在运行Reduce函数的机器上按照列存储(例如,Reduce函数需要的是第一个Map函数的a=1和第三个Map函数的a=1)。

论文里称这种数据转换之为洗牌(shuffle)。所以,这里确实需要将每一份数据都通过网络从创建它的Map节点传输到需要它的Reduce节点。所以,这也是MapReduce中代价较大的一部分。

学生提问:是否可以通过Streaming的方式加速Reduce的读取?

Robert教授:你是对的。你可以设想一个不同的定义,其中Reduce通过streaming方式读取数据。我没有仔细想过这个方法,我也不知道这是否可行。作为一个程序接口,MapReduce的第一目标就是让人们能够简单的编程,人们不需要知道MapReduce里面发生了什么。对于一个streaming方式的Reduce函数,或许就没有之前的定义那么简单了。

不过或许可以这么做。实际上,很多现代的系统中,会按照streaming的方式处理数据,而不是像MapReduce那样通过批量的方式处理Reduce函数。在MapReduce中,需要一直要等到所有的数据都获取到了才会进行Reduce处理,所以这是一种批量处理。现代系统通常会使用streaming并且效率会高一些。

所以这里的shuffle的重点是,这里实际上可能会有大量的网络通信。假设你在进行排序,排序的输入输出会有相同的大小。这样,如果你的输入是10TB,为了能排序,你需要将10TB的数据在网络上移动,并且输出也会是10TB,所以这里有大量的数据。这可能发生在任何MapReduce job中,尽管有一些MapReduce job在不同阶段的数据没有那么大。

之前有人提过,想将Reduce的输出传给另一个MapReduce job,而这也是人们常做的事情。在一些场景中,Reduce的输出可能会非常巨大,比如排序,比如网页索引器。10TB的输入对应的是10TB的输出。所以,Reduce的输出也会存储在GFS上。但是Reduce只会生成key-value对,MapReduce框架会收集这些数据,并将它们写入到GFS的大文件中。所以,这里有需要一大轮的网络通信,将每个Reduce的输出传输到相应的GFS服务器上。你或许会认为,这里会使用相同的技巧,就将Reduce的输出存储在运行了Reduce Task的同一个GFS服务器上(因为是混部的)。或许Google这么做了,但是因为GFS会将数据做拆分,并且为了提高性能并保留容错性,数据会有2-3份副本。这意味着,不论你写什么,你总是需要通过网络将一份数据拷贝写到2-3台服务器上。所以,这里会有大量的网络通信。这里的网络通信,是2004年限制MapReduce的瓶颈。在2020年,因为之前的网络架构成为了人们想在数据中心中做的很多事情的限制因素,现代数据中心中,root交换机比过去快了很多。并且,你或许已经见过,一个典型的现代数据中心网络,会有很多的root交换机而不是一个交换机(spine-leaf架构)。每个机架交换机都与每个root交换机相连,网络流量在多个root交换机之间做负载分担。所以,现代数据中心网络的吞吐大多了。

我认为Google几年前就不再使用MapReduce了,不过在那之前,现代的MapReduce已经不再尝试在GFS数据存储的服务器上运行Map函数了,它乐意从任何地方加载数据,因为网络已经足够快了。

好的,我们没有时间聊MapReduce了,下周有一个lab,你会在lab中实现一个你自己的简单版本的MapReduce。

Lecture 03 - GFS

在开始之前,强烈建议阅读GFS论文。

【1】https://pdos.csail.mit.edu/6.824/papers/gfs.pdf

3.1分布式存储系统的难点(Why Hard)

今天我们讨论一下GFS,也就是The Google File System这篇论文。这门课程的主要内容是“大型存储”,而GFS这是这门课里有关如何构建大型存储系统的众多案例学习的第一篇。

之所以要说存储,原因是,存储是一种关键的抽象。你可以想象,在分布式系统中,可能有各种各样重要的抽象可以应用在分布式系统中,但是实际上,简单的存储接口往往非常有用且极其通用。所以,构建分布式系统大多都是关于如何设计存储系统,或是设计其它基于大型分布式存储的系统。所以我们会更加关注如何为大型分布式存储系统设计一个优秀的接口,以及如何设计存储系统的内部结构,这样系统才能良好运行。通过阅读GFS论文,我们可以开始了解到这是怎么做到的。

同时,GFS论文也涉及到很多本课程常出现的话题,例如并行性能、容错、复制和一致性。论文的内容也较为直观,容易理解。论文本身也是一篇优秀的系统论文,它从硬件到使用了GFS的软件都有讨论,并且它也是一个成功的现实世界的设计。尽管这是在学术会议上发表的学术论文,但是文章里介绍的东西(GFS)也相当成功,并且在现实世界中使用了相当长的时间。所以,我们知道,我们今天讨论的是一个非常好的且有用的设计。

在讨论GFS之前,我想聊一聊关于分布式存储系统,做一些背景介绍。

首先 为什么分布式存储系统会如此之难,以至于你需要做大量的工作才能让它正确工作?我们从一个特殊的角度来理解,在这门课程的后面,这种理解角度也会出现。

人们设计大型分布式系统或大型存储系统出发点通常是,他们想获取巨大的性能加成,进而利用数百台计算机的资源来同时完成大量工作。因此,性能问题就成为了最初的诉求。 之后,很自然的想法就是将数据分割放到大量的服务器上,这样就可以并行的从多台服务器读取数据。我们将这种方式称之为分片(Sharding)。

如果你在成百上千台服务器进行分片,你将会看见常态的故障。如果你有数千台服务器,那么总是会有一台服务器宕机,每天甚至每个小时都可能会发生错误。所以,我们需要自动化的方法而不是人工介入来修复错误。我们需要一个自动的容错系统,这就引出了容错这个话题(fault tolerance)。

实现容错最有用的一种方法是使用复制,只需要维护2-3个数据的副本,当其中一个故障了,你就可以使用另一个。所以,如果想要容错能力,就得有复制(replication)。

如果有复制,那就有了两份数据的副本。可以确定的是,如果你不小心,它们就会不一致。所以,你本来设想的是,有了两个数据副本,你可以任意使用其中一个副本来容错。但是如果你不够小心,两个数据的副本就不是完全一致,严格来说,它们就不再互为副本了。而你获取到的数据内容也将取决于你向哪个副本请求数据。这对于应用程序来说就有些麻烦了。所以,如果我们有了复制,我们就有不一致的问题(inconsistency)。

通过聪明的设计,你可以避免不一致的问题,并且让数据看起来也表现的符合预期。但是为了达到这样的效果,你总是需要额外的工作,需要不同服务器之间通过网络额外的交互,而这样的交互会降低性能。所以如果你想要一致性,你的代价就是低性能。但这明显不是我们最开始所希望的。

当然,这里并不是绝对的。你可以构建性能很高的系统,但是不可避免的,都会陷入到这里的循环来。现实中,如果你想要好的一致性,你就要付出相应的代价。如果你不想付出代价,那就要忍受一些不确定的行为。我们之后会在很多系统中看到这里介绍的循环。通常,人们很少会乐意为好的一致性付出相应的性能代价。

3.2 错误的设计(Bad Design)

这里说到了一致性,我在后面的课程会对“好”的一致性做更多的介绍(lec6-7)。对于具备强一致或者好的一致性的系统,从应用程序或者客户端看起来就像是和一台服务器在通信。尽管我们会通过数百台计算机构建一个系统,但是对于一个理想的强一致模型,你看到的就像是只有一台服务器,一份数据,并且系统一次只做一件事情。这是一种直观的理解强一致的方式。你可以认为只有一台服务器,甚至这个服务器只运行单线程,它同一时间只处理来自客户端的一个请求。这很重要,因为可能会有大量的客户端并发的发送请求到服务器上。这里要求服务器从请求中挑选一个出来先执行,执行完成之后再执行下一个。

对于存储服务器来说,它上面会有一块磁盘。执行一个写请求或许意味着向磁盘写入一个数据或者对数据做一次自增。如果这是一次修改操作,并且我们有一个以key-value为索引的数据表单,那么我们会修改这个表单。如果是一次读取操作,我们只需要将之前写入的数据,从表单中取出即可。

为了让这里的简单服务有可预期的行为,需要定义一条规则:一个时间只执行一条请求。这样每个请求都可以看到之前所有请求按照顺序执行生成的数据。所以,如果有一些写请求,并且服务器以某种顺序一次一个的处理了它们,当你从服务器读数据时,你可以看到期望的数据。这里的解释不是很直观,你可以通过下面的例子去理解。

例如,我们有一些客户端,客户端C1发起写请求将X设置成1。在同一时刻,客户端C2发起写请求将X设置成2。

过了一会,在C1和C2的写请求都执行完毕之后,客户端C3会发送读取X的请求,并得到了一个结果。客户端C4也会发送读取X的请求,也得到了一个结果。现在的问题是,这两个客户端看到的结果是什么?

学生提问:为什么一定要一次只处理一个请求?

Robert教授:这是个好问题。在这里,我假设C1和C2在同一时间发起请求。所以,如果我们在监控网络的话,我们可以看到两个请求同时发往服务器。之后在某个时刻,服务器会响应它们。但是这里没有足够的信息来判断,服务器会以哪种顺序执行这两个写请求。如果服务器先处理了写X为1的请求,那么就意味着它接下来会处理写X为2的请求,所以接下来的读X请求可以看到2。然而,如果服务器先处理了写X为2的请求,再处理写X为1的请求,那么接下来的读X请求看到的就是1。

我这里提出这个场景的目的是为了展示,即使在一个非常简单的系统中,仍然会出现一些模糊的场景使得你不知道系统的执行过程以及输出结果。你能做的只是从产生的结果来判断系统的输出是一致性还是非一致性。

如果C3读X得到2,那么C4最好也是读X得到2,因为在我们的例子中,C3读X得到2意味着,写X为2的请求必然是第二个执行的写请求。当C4读X时,写X为2应该仍然是第二个写请求。希望这里完全直观的介绍清楚了有关强一致的一个模型。

当然,这里的问题是,因为只有单个服务器,所以容错能力很差。如果服务器故障了,磁盘坏了,系统整个就不可用了。所以,在现实世界中,我们会构建多副本的分布式系统,但这却又是所有问题的开始。

这里有一个几乎是最糟糕的多副本设计,我提出它是为了让你们知道问题所在,并且同样的问题在GFS中也存在。这个设计是这样,我们有两台服务器,每个服务器都有数据的一份完整拷贝。它们在磁盘上都存储了一个key-value表单。当然,直观上我们希望这两个表单是完全一致的,这样,一台服务器故障了,我们可以切换到另一台服务器去做读写。

两个表单完全一致意味着,每一个写请求都必须在两台服务器上执行,而读请求只需要在一台服务器上执行,否则就没有容错性了。因为如果读请求也需要从两台服务器读数据,那么一台服务器故障我们就没法提供服务了。现在问题来了,假设客户端C1和C2都想执行写请求,其中一个要写X为1,另一个写X为2。C1会将写X为1的请求发送个两个服务器,因为我们想要更新两台服务器上的数据。C2也会将写X为2的请求发送给两个服务器。

这里会出现什么错误呢?是的,我们没有做任何事情来保障两台服务器以相同的顺序处理这2个请求。这个设计真不咋样。如果服务器1(S1)先处理C1的请求,那么在它的表单里面,X先是1,之后S1看到了来自C2的请求,会将自己表单中的X覆盖成2。但是,如果S2恰好以不同的顺序收到客户端请求,那么S2会先执行C2的请求,将X设置为2,之后收到C1的请求,将X设置为1。

之后,如果另外一些客户端,假设C3从S1读数据,C4从S2读数据,我们就会面临一个可怕的场景:这两个客户端读取的数据不一样。但是从前一个例子中的简单模型可以看出,相连的读请求应该读出相同的数据。

这里的问题可以以另一种方式暴露出来。假设我们尝试修复上面的问题,我们让客户端在S1还在线的时候,只从S1读取数据,S1不在线了再从S2读取数据。这样最开始所有的客户端读X都能得到2。但是突然,如果S1故障了,尽管没有写请求将X改成1,客户端读X得到的数据将会从2变成1。因为S1故障之后,所有的客户端都会切换到S2去读数据。这种数据的神奇变化与任何写操作都没有关联,并且也不可能在前一个例子的简单模型中发生。

当然,这里的问题是可以修复的,修复需要服务器之间更多的通信,并且复杂度也会提升。由于获取强一致会带来不可避免的复杂性的提升,有大量的方法可以在好的一致性和一些小瑕疵行为之间追求一个平衡。

3.3 GFS的设计目标

让我们来讨论GFS吧。GFS做了很多工作来解决前面提到的问题,虽然不够完美,但是GFS已经做的很好了。

GFS在2003年提出,距今已经有很长一段时间了。实际上,在当时,互联网的规模已经很大了,人们也在构建大型网站,在那之前,人们也对分布式系统做了数十年的研究。所以,在当时,至少在学术领域,人们知道如何构建高度并行且具备容错的分布式系统。不过在当时,很少有在工业界能有应用这些学术界思想的例子。大概从GFS论文发表的时间起,像Google这样的大型网站开始构建严格意义上的分布式系统。这让像我(Robert教授)这样的学术界分子感到非常兴奋,因为终于看到像Google这样的拥有大量数据的公司开始实际使用分布式系统。Google拥有远超过单个磁盘容量的数据,例如从整个互联网爬出来的网页,YouTube视频,用来构建搜索索引的中间文件,Web服务器中的大量日志文件等。所以,Google有大量的数据,需要大量的磁盘来存储这些数据,并且需要能借助MapReduce这样的工具来快速处理这些数据。所以,Google需要能够快速的并行访问这些海量数据。

Google的目标是构建一个大型的,快速的文件系统。并且这个文件系统是全局有效的,这样各种不同的应用程序都可以从中读取数据。一种构建大型存储系统的方法是针对某个特定的应用程序构建特定的裁剪的存储系统。但是如果另一个应用程序也想要一个大型存储系统,那么又需要重新构建一个存储系统。如果有一个全局通用的存储系统,那就意味着如果我存储了大量从互联网抓取的数据,你也可以通过申请权限来查看这些数据,因为我们都使用了同一个存储系统。这样,任何在Google内部的人员都可以根据名字读取这个文件系统(GFS)中可被共享的内容。

为了获得大容量和高速的特性,每个包含了数据的文件会被GFS自动的分割并存放在多个服务器之上,这样读写操作自然就会变得很快。因为可以从多个服务器上同时读取同一个文件,进而获得更高的聚合吞吐量。将文件分割存储还可以在存储系统中保存比单个磁盘还要大的文件。

因为我们现在在数百台服务器之上构建存储系统,我们希望有自动的故障修复。我们不希望每次服务器出了故障,派人到机房去修复服务器或者迁移数据。我们希望系统能自动修复自己。

还有一些特征并非是设计目标。比如GFS被设计成只在一个数据中心运行,所以这里并没有将副本保存在世界各地,单个GFS只存在于单个数据中心的单个机房里。理论上来说,数据的多个副本应该彼此之间隔的远一些,但是实现起来挺难的,所以GFS局限在一个数据中心内。

其次,GFS并不面向普通的用户,这是一个Google内部使用的系统,供Google的工程师写应用程序使用。所以Google并没有售卖GFS,它或许售卖了基于GFS的服务,但是GFS并不直接面向普通用户。

第三,GFS在各个方面对大型的顺序文件读写做了定制。在存储系统中有一个完全不同的领域,这个领域只对小份数据进行优化。例如一个银行账户系统就需要一个能够读写100字节的数据库,因为100字节就可以表示人们的银行账户。但是GFS不是这样的系统,GFS是为TB级别的文件而生。并且GFS只会顺序处理,不支持随机访问。某种程度上来说,它有点像批处理的风格。GFS并没有花费过多的精力来降低延迟,它的关注点在于巨大的吞吐量上,所以单次操作都涉及到MB级别的数据。

GFS论文发表在2003年的SOSP会议上,这是一个有关系统的顶级学术会议。通常来说,这种会议的论文标准是需要有大量的创新研究,但是GFS的论文不属于这一类标准。论文中的一些思想在当时都不是特别新颖,比如分布式,分片,容错这些在当时已经知道如何实现了。这篇论文的特点是,它描述了一个真正运行在成百上千台计算机上的系统,这个规模远远超过了学术界建立的系统。并且由于GFS被用于工业界,它反映了现实世界的经验,例如对于一个系统来说,怎样才能正常工作,怎样才能节省成本,这些内容也极其有价值。同时,论文也提出了一个当时非常异类的观点:存储系统具有弱一致性也是可以的。当时,学术界的观念认为,存储系统就应该有良好的行为,如果构建了一个会返回错误数据的系统,就像前面(详见3.2)介绍的糟糕的多副本系统一样,那还有什么意义?为什么不直接构建一个能返回正确数据的系统?GFS并不保证返回正确的数据,借助于这一点,GFS的目标是提供更好的性能。

最后,这篇论文还有一个有意思的事情。在一些学术论文中,你或许可以看到一些容错的,多副本,自动修复的多个Master节点共同分担工作,但是GFS却宣称使用单个Master节点并能够很好的工作。

学生提问:如果GFS返回错误的数据,会不会影响应用程序?

Robert教授:讽刺的是。有谁关心网上的投票数量是否正确呢,如果你通过搜索引擎做搜索,20000个搜索结果中丢失了一条或者搜索结果排序是错误的,没有人会注意到这些。这类系统对于错误的接受能力好过类似于银行这样的系统。当然并不意味着所有的网站数据都可以是错误的。如果你通过广告向别人收费,你最好还是保证相应的数字是对的。

另外,尽管GFS可能会返回错误的数据,但是可以在应用程序中做一些补偿。例如论文中提到,应用程序应当对数据做校验,并明确标记数据的边界,这样应用程序在GFS返回不正确数据时可以恢复。

3.4 GFS Master 节点

接下来看看GFS的大致架构,这在论文的图1中也有介绍。

假设我们有上百个客户端和一个Master节点。尽管实际中可以拿多台机器作为Master节点,但是GFS中Master是Active-Standby模式,所以只有一个Master节点在工作。Master节点保存了文件名和存储位置的对应关系。除此之外,还有大量的Chunk服务器,可能会有数百个,每一个Chunk服务器上都有1-2块磁盘。

在这里,Master节点用来管理文件和Chunk的信息,而Chunk服务器用来存储实际的数据。这是GFS设计中比较好的一面,它将这两类数据的管理问题几乎完全隔离开了,这样这两个问题可以使用独立设计来解决。Master节点知道每一个文件对应的所有的Chunk的ID,这些Chunk每个是64MB大小,它们共同构成了一个文件。如果我有一个1GB的文件,那么Master节点就知道文件的第一个Chunk存储在哪,第二个Chunk存储在哪,等等。当我想读取这个文件中的任意一个部分时,我需要向Master节点查询对应的Chunk在哪个服务器上,之后我可以直接从Chunk服务器读取对应的Chunk数据。

更进一步,我们看一下GFS的一致性以及GFS是如何处理故障。为了了解这些,我们需要知道Master节点内保存的数据内容,这里我们关心的主要是两个表单:

  • 第一个是文件名到Chunk ID或者Chunk Handle数组的对应。这个表单告诉你,文件对应了哪些Chunk。但是只有Chunk ID是做不了太多事情的,所以有了第二个表单。
  • 第二个表单记录了Chunk ID到Chunk数据的对应关系。这里的数据又包括了:
    • 每个Chunk存储在哪些服务器上,所以这部分是Chunk服务器的列表
    • 每个Chunk当前的版本号,所以Master节点必须记住每个Chunk对应的版本号。
    • 所有对于Chunk的写操作都必须在主Chunk(Primary Chunk)上顺序处理,主Chunk是Chunk的多个副本之一。所以,Master节点必须记住哪个Chunk服务器持有主Chunk。
    • 并且,主Chunk只能在特定的租约时间内担任主Chunk,所以,Master节点要记住主Chunk的租约过期时间。

以上数据都存储在内存中,如果Master故障了,这些数据就都丢失了。为了能让Master重启而不丢失数据,Master节点会同时将数据存储在磁盘上。所以Master节点读数据只会从内存读,但是写数据的时候,至少有一部分数据会接入到磁盘中。更具体来说,Master会在磁盘上存储log,每次有数据变更时,Master会在磁盘的log中追加一条记录,并生成CheckPoint(类似于备份点)。

有些数据需要存在磁盘上,而有些不用。它们分别是:

  • Chunk Handle的数组(第一个表单)要保存在磁盘上。我给它标记成NV(non-volatile, 非易失),这个标记表示对应的数据会写入到磁盘上。
  • Chunk服务器列表不用保存到磁盘上。因为Master节点重启之后可以与所有的Chunk服务器通信,并查询每个Chunk服务器存储了哪些Chunk,所以我认为它不用写入磁盘。所以这里标记成V(volatile),
  • 版本号要不要写入磁盘取决于GFS是如何工作的,我认为它需要写入磁盘。我们之后在讨论系统是如何工作的时候再详细讨论这个问题。这里先标记成NV。
  • 主Chunk的ID,几乎可以确定不用写入磁盘,因为Master节点重启之后会忘记谁是主Chunk,它只需要等待60秒租约到期,那么它知道对于这个Chunk来说没有主Chunk,这个时候,Master节点可以安全指定一个新的主Chunk。所以这里标记成V。
  • 类似的,租约过期时间也不用写入磁盘,所以这里标记成V。

任何时候,如果文件扩展到达了一个新的64MB,需要新增一个Chunk或者由于指定了新的主Chunk而导致版本号更新了,Master节点需要向磁盘中的Log追加一条记录说,我刚刚向这个文件添加了一个新的Chunk或者我刚刚修改了Chunk的版本号。所以每次有这样的更新,都需要写磁盘。GFS论文并没有讨论这么多细节,但是因为写磁盘的速度是有限的,写磁盘会导致Master节点的更新速度也是有限的,所以要尽可能少的写入数据到磁盘。

这里在磁盘中维护log而不是数据库的原因是,数据库本质上来说是某种B树(b-tree)或者hash table,相比之下,追加log会非常的高效,因为你可以将最近的多个log记录一次性的写入磁盘。因为这些数据都是向同一个地址追加,这样只需要等待磁盘的磁碟旋转一次。而对于B树来说,每一份数据都需要在磁盘中随机找个位置写入。所以使用Log可以使得磁盘写入更快一些。

当Master节点故障重启,并重建它的状态,你不会想要从log的最开始重建状态,因为log的最开始可能是几年之前,所以Master节点会在磁盘中创建一些checkpoint点,这可能要花费几秒甚至一分钟。这样Master节点重启时,会从log中的最近一个checkpoint开始恢复,再逐条执行从Checkpoint开始的log,最后恢复自己的状态。

3.5 GFS读文件(Read File)

有了之前的基础,我接下来会列出GFS读和写的步骤,最后,我会介绍出现故障之后,系统是如何保持正确的行为。

对于读请求来说,意味着应用程序或者GFS客户端有一个文件名和它想从文件的某个位置读取的偏移量(offset),应用程序会将这些信息发送给Master节点。Master节点会从自己的file表单中查询文件名,得到Chunk ID的数组。因为每个Chunk是64MB,所以偏移量除以64MB就可以从数组中得到对应的Chunk ID。之后Master再从Chunk表单中找到存有Chunk的服务器列表,并将列表返回给客户端。所以,第一步是客户端(或者应用程序)将文件名和偏移量发送给Master。第二步,Master节点将Chunk Handle(也就是ID,记为H)和服务器列表发送给客户端。

现在客户端可以从这些Chunk服务器中挑选一个来读取数据。GFS论文说,客户端会选择一个网络上最近的服务器(Google的数据中心中,IP地址是连续的,所以可以从IP地址的差异判断网络位置的远近),并将读请求发送到那个服务器。因为客户端每次可能只读取1MB或者64KB数据,所以,客户端可能会连续多次读取同一个Chunk的不同位置。所以,客户端会缓存Chunk和服务器的对应关系,这样,当再次读取相同Chunk数据时,就不用一次次的去向Master请求相同的信息。

接下来,客户端会与选出的Chunk服务器通信,将Chunk Handle和偏移量发送给那个Chunk服务器。Chunk服务器会在本地的硬盘上,将每个Chunk存储成独立的Linux文件,并通过普通的Linux文件系统管理。并且可以推测,Chunk文件会按照Handle(也就是ID)命名。所以,Chunk服务器需要做的就是根据文件名找到对应的Chunk文件,之后从文件中读取对应的数据段,并将数据返回给客户端。

学生提问:可以再讲一下第一步吗?

Robert教授:第一步是,应用程序想读取某个特定文件的某个特定的偏移位置上的某段特定长度的数据,比如说第1000到第2000个字节的数据。所以,应用程序将文件名,长度和起始位置发送给Master节点。Master节点会从其file表单中查询文件名并找到包含这个数据段的Chunk,这样可以吗?

学生提问:如果读取的数据超过了一个Chunk怎么办?

Robert教授:我不知道详细的细节。我的印象是,如果应用程序想要读取超过64MB的数据,或者就是2个字节,但是却跨越了Chunk的边界,应用程序会通过一个库来向GFS发送RPC,而这个库会注意到这次读请求会跨越Chunk边界,因此会将一个读请求拆分成两个读请求再发送到Master节点。所以,这里可能是向Master节点发送两次读请求,得到了两个结果,之后再向两个不同的Chunk服务器读取数据。

学生提问:如果客户端有偏移量信息,那可以直接算出来是第几个Chunk吧?

Robert教授:客户端可以算出来是哪个Chunk,但是客户端不知道Chunk在哪个服务器上。为了获取服务器信息,客户端需要与Master交互。我不能保证究竟是在哪里决定的要读取的是第几个Chunk。但是可以确定的是,Master节点找到了Chunk对应的ID,并确定了Chunk存储在哪个服务器上。

学生提问:能再介绍一下读数据跨越了Chunk边界的情况吗?

Robert教授:客户端本身依赖了一个GFS的库,这个库会注意到读请求跨越了Chunk的边界 ,并会将读请求拆分,之后再将它们合并起来。所以这个库会与Master节点交互,Master节点会告诉这个库说Chunk7在这个服务器,Chunk8在那个服务器。之后这个库会说,我需要Chunk7的最后两个字节,Chunk8的头两个字节。GFS库获取到这些数据之后,会将它们放在一个buffer中,再返回给调用库的应用程序。Master节点会告诉库有关Chunk的信息,而GFS库可以根据这个信息找到应用程序想要的数据。应用程序只需要确定文件名和数据在整个文件中的偏移量,GFS库和Master节点共同协商将这些信息转换成Chunk。

学生提问:从哪个Chunk服务器读取数据重要吗?

Robert教授:是也不是。概念上讲,它们都是副本。实际上,你可能已经注意到,或者我们之前也说过,不同Chunk服务器上的数据并不一定完全相同。应用程序应该要能够容忍这种情况。所以,实际上,如果从不同的Chunk服务器读取数据,可能会略微不同。GFS论文提到,客户端会尝试从同一个机架或者同一个交换机上的服务器读取数据。

3.6 GFS写文件(Write File)(1)

GFS写文件的过程会更加复杂且有趣。从应用程序的角度来看,写文件和读文件的接口是非常类似的,它们都是调用GFS的库。写文件是,应用程序会告诉库函数说,我想对这个文件名的文件在这个数据段写入当前存在buffer中的数据。让我(Robert教授)稍微收敛一下,我只想讨论数据的追加。所以我会限制这里的客户端接口,客户端(也就是应用程序)只能说,我想把buffer中的数据,追加到这个文件名对应的文件中。这就是GFS论文中讨论的记录追加(Record Append)。所以,再次描述一下,对于写文件,客户端会向Master节点发送请求说:我想向这个文件名对应的文件追加数据,请告诉我文件中最后一个Chunk的位置。

当有多个客户端同时写同一个文件时,一个客户端并不能知道文件究竟有多长。因为如果只有一个客户端在写文件,客户端自己可以记录文件长度,而多个客户端时,一个客户端没法知道其他客户端写了多少。例如,不同客户端写同一份日志文件,没有一个客户端会知道文件究竟有多长,因此也就不知道该往什么样的偏移量,或者说向哪个Chunk去追加数据。这个时候,客户端可以向Master节点查询哪个Chunk服务器保存了文件的最后一个Chunk。

对于读文件来说,可以从任何最新的Chunk副本读取数据,但是对于写文件来说,必须要通过Chunk的主副本(Primary Chunk)来写入。对于某个特定的Chunk来说,在某一个时间点,Master不一定指定了Chunk的主副本。所以,写文件的时候,需要考虑Chunk的主副本不存在的情况。

对于Master节点来说,如果发现Chunk的主副本不存在,Master会找出所有存有Chunk最新副本的Chunk服务器。如果你的一个系统已经运行了很长时间,那么有可能某一个Chunk服务器保存的Chunk副本是旧的,比如说还是昨天或者上周的。导致这个现象的原因可能是服务器因为宕机而没有收到任何的更新。所以,Master节点需要能够在Chunk的多个副本中识别出,哪些副本是新的,哪些是旧的。所以第一步是,找出新的Chunk副本。这一切都是在Master节点发生,因为,现在是客户端告诉Master节点说要追加某个文件,Master节点需要告诉客户端向哪个Chunk服务器(也就是Primary Chunk所在的服务器)去做追加操作。所以,Master节点的部分工作就是弄清楚在追加文件时,客户端应该与哪个Chunk服务器通信。

每个Chunk可能同时有多个副本,最新的副本是指,副本中保存的版本号与Master中记录的Chunk的版本号一致。Chunk副本中的版本号是由Master节点下发的,所以Master节点知道,对于一个特定的Chunk,哪个版本号是最新的。这就是为什么Chunk的版本号在Master节点上需要保存在磁盘这种非易失的存储中的原因(见3.4),因为如果版本号在故障重启中丢失,且部分Chunk服务器持有旧的Chunk副本,这时,Master是没有办法区分哪个Chunk服务器的数据是旧的,哪个Chunk服务器的数据是最新的。

学生提问:为什么不将所有Chunk服务器上保存的最大版本号作为Chunk的最新版本号?

Robert教授:当Master重启时,无论如何都需要与所有的Chunk服务器进行通信,因为Master需要确定哪个Chunk服务器存了哪个Chunk。你可能会想到,Master可以将所有Chunk服务器上的Chunk版本号汇总,找出里面的最大值作为最新的版本号。如果所有持有Chunk的服务器都响应了,那么这种方法是没有问题的。但是存在一种风险,当Master节点重启时,可能部分Chunk服务器离线或者失联或者自己也在重启,从而不能响应Master节点的请求。所以,Master节点可能只能获取到持有旧副本的Chunk服务器的响应,而持有最新副本的Chunk服务器还没有完成重启,或者还是离线状态(这个时候Master能找到的Chunk最大版本明显不对)。

当Master找不到持有最新Chunk的服务器时该怎么办?Master节点会定期与Chunk服务器交互来查询它们持有什么样版本的Chunk。假设Master保存的Chunk版本是17,但是又没有找到存储了版本号是17的Chunk服务器,那么有两种可能:要么Master会等待,并不响应客户端的请求;要么会返回给客户端说,我现在还不知道Chunk在哪,过会再重试吧。比如说机房电源故障了,所有的服务器都崩溃了,我们正在缓慢的重启。Master节点和一些Chunk服务器可能可以先启动起来,一些Chunk服务器可能要5分钟以后才能重启,这种场景下,我们需要等待,甚至可能是永远等待,因为你不会想使用Chunk的旧数据。

所以,总的来说,在重启时,因为Master从磁盘存储的数据知道Chunk对应的最新版本,Master节点会整合具有最新版本Chunk的服务器。每个Chunk服务器会记住本地存储Chunk对应的版本号,当Chunk服务器向Master汇报时,就可以说,我有这个Chunk的这个版本。而Master节点就可以忽略哪些版本号与已知版本不匹配的Chunk服务器。

回到之前的话题,当客户端想要对文件进行追加,但是又不知道文件尾的Chunk对应的Primary在哪时,Master会等所有存储了最新Chunk版本的服务器集合完成,然后挑选一个作为Primary,其他的作为Secondary。

之后,Master会增加版本号,并将版本号写入磁盘,这样就算故障了也不会丢失这个数据。

接下来,Master节点会向Primary和Secondary副本对应的服务器发送消息并告诉它们,谁是Primary,谁是Secondary,Chunk的新版本是什么。Primary和Secondary服务器都会将版本号存储在本地的磁盘中。这样,当它们因为电源故障或者其他原因重启时,它们可以向Master报告本地保存的Chunk的实际版本号。

学生提问:如果Chunk服务器上报的版本号高于Master存储的版本号会怎么样?

Robert教授:这个问题很好。我不知道答案,不过论文中有一些线索。其实刚刚我的介绍有一些错误,我认为你的问题让我明白了一些事情。GFS论文说,如果Master节点重启,并且与Chunk服务器交互,同时一个Chunk服务器重启,并上报了一个比Master记住的版本更高的版本。Master会认为它在分配新的Primary服务器时出现了错误,并且会使用这个更高的版本号来作为Chunk的最新版本号。

当Master向Primary和Secondary发送完消息之后就崩溃了,可能会出现上面这种情况。为了让Master能够处理这种情况,Master在发送完消息之后,需要将Chunk的最新版本写入到磁盘中。这里的写入或许需要等到Primary和Secondary返回确认消息之后。

学生提问:听不清(但是应该与这一节的第一个问题一样)。

Robert教授:我不认为这行得通。因为存在这种可能性,当Master节点重启时,存储了Chunk最新版本号的Chunk服务器是离线状态。这种情况下,我们不希望Master在重启时不知道当前的版本号,因为那样的话,Master就会认为当前发现的最高版本号是当前版本号,但是(由于有最新版本号的Chunk服务器还是离线状态)发现的最高版本号可能是个旧版本号。

我(Robert教授)之前没太关注这块,所以我也不太确定Master究竟是先写本地磁盘中的版本号,然后再通知Primary和Secondary,还是反过来。但是不管怎么样,Master会更新自己的版本号,并通知Primary和Secondary说,你们现在是Primary和Secondary,并且版本号更新了。

所以,现在我们有了一个Primary,它可以接收来自客户端的写请求,并将写请求应用在多个Chunk服务器中。之所以要管理Chunk的版本号,是因为这样Master可以将实际更新Chunk的能力转移给Primary服务器。并且在将版本号更新到Primary和Secondary服务器之后,如果Master节点故障重启,还是可以在相同的Primary和Secondary服务器上继续更新Chunk。

现在,Master节点通知Primary和Secondary服务器,你们可以修改这个Chunk。它还给Primary一个租约,这个租约告诉Primary说,在接下来的60秒中,你将是Primary,60秒之后你必须停止成为Primary。这种机制可以确保我们不会同时有两个Primary,我们之后会再做讨论(3.7的问答中有一个专门的问题讨论)。

我们现在来看GFS论文的图2。假设现在Master节点告诉客户端谁是Primary,谁是Secondary,GFS提出了一种聪明的方法来实现写请求的执行序列。客户端会将要追加的数据发送给Primary和Secondary服务器,这些服务器会将数据写入到一个临时位置。所以最开始,这些数据不会追加到文件中。当所有的服务器都返回确认消息说,已经有了要追加的数据,客户端会向Primary服务器发送一条消息说,你和所有的Secondary服务器都有了要追加的数据,现在我想将这个数据追加到这个文件中。Primary服务器或许会从大量客户端收到大量的并发请求,Primary服务器会以某种顺序,一次只执行一个请求。对于每个客户端的追加数据请求(也就是写请求),Primary会查看当前文件结尾的Chunk,并确保Chunk中有足够的剩余空间,然后将客户端要追加的数据写入Chunk的末尾。并且,Primary会通知所有的Secondary服务器也将客户端要追加的数据写入在它们自己存储的Chunk末尾。这样,包括Primary在内的所有副本,都会收到通知将数据追加在Chunk的末尾。

但是对于Secondary服务器来说,它们可能可以执行成功,也可能会执行失败,比如说磁盘空间不足,比如说故障了,比如说Primary发出的消息网络丢包了。如果Secondary实际真的将数据写入到了本地磁盘存储的Chunk中,它会回复“yes”给Primary。如果所有的Secondary服务器都成功将数据写入,并将“yes”回复给了Primary,并且Primary也收到了这些回复。Primary会向客户端返回写入成功。如果至少一个Secondary服务器没有回复Primary,或者回复了,但是内容却是:抱歉,一些不好的事情发生了,比如说磁盘空间不够,或者磁盘故障了,Primary会向客户端返回写入失败。

GFS论文说,如果客户端从Primary得到写入失败,那么客户端应该重新发起整个追加过程。客户端首先会重新与Master交互,找到文件末尾的Chunk;之后,客户端需要重新发起对于Primary和Secondary的数据追加操作。

3.7 GFS写文件(Write File)(2)

这一部分主要是对写文件操作的问答。

学生提问:写文件失败之后Primary和Secondary服务器上的状态如何恢复?

Robert教授:你的问题是,Primary告诉所有的副本去执行数据追加操作,某些成功了,某些没成功。如果某些副本没有成功执行,Primary会回复客户端说执行失败。之后客户端会认为数据没有追加成功。但是实际上,部分副本还是成功将数据追加了。所以现在,一个Chunk的部分副本成功完成了数据追加,而另一部分没有成功,这种状态是可接受的,没有什么需要恢复,这就是GFS的工作方式。

学生提问:写文件失败之后,读Chunk数据会有什么不同?

Robert教授:如果写文件失败之后,一个客户端读取相同的Chunk,客户端可能可以读到追加的数据,也可能读不到,取决于客户端读的是Chunk的哪个副本。

如果一个客户端发送写文件的请求,并且得到了表示成功的回复,那意味着所有的副本都在相同的位置追加了数据。如果客户端收到了表示失败的回复,那么意味着0到多个副本实际追加了数据,其他的副本没有追加上数据。所以这时,有些副本会有追加的数据,有些副本没有。这时,取决于你从哪个副本读数据,有可能读到追加的新数据,也有可能读不到。

学生提问:可不可以通过版本号来判断副本是否有之前追加的数据?

Robert教授:所有的Secondary都有相同的版本号。版本号只会在Master指定一个新Primary时才会改变。通常只有在原Primary发生故障了,才会指定一个新的Primary。所以,副本(参与写操作的Primary和Secondary)都有相同的版本号,你没法通过版本号来判断它们是否一样,或许它们就是不一样的(取决于数据追加成功与否)。

这么做的理由是,当Primary回复“no”给客户端时,客户端知道写入失败了,之后客户端的GFS库会重新发起追加数据的请求,直到最后成功追加数据。成功了之后,追加的数据会在所有的副本中相同位置存在。在那之前,追加的数据只会在部分副本中存在。

学生提问:客户端将数据拷贝给多个副本会不会造成瓶颈?

Robert教授:这是一个好问题。考虑到底层网络,写入文件数据的具体传输路径可能会非常重要。当论文第一次提到这一点时,它说客户端会将数据发送给每个副本。实际上,之后,论文又改变了说法,说客户端只会将数据发送给离它最近的副本,之后那个副本会将数据转发到另一个副本,以此类推形成一条链,直到所有的副本都有了数据。这样一条数据传输链可以在数据中心内减少跨交换机传输(否则,所有的数据吞吐都在客户端所在的交换机上)。

学生提问:什么时候版本号会增加?

Robert教授:版本号只在Master节点认为Chunk没有Primary时才会增加。在一个正常的流程中,如果对于一个Chunk来说,已经存在了Primary,那么Master节点会记住已经有一个Primary和一些Secondary,Master不会重新选择Primary,也不会增加版本号。它只会告诉客户端说这是Primary,并不会变更版本号。

学生提问:如果写入数据失败了,不是应该先找到问题在哪再重试吗?

Robert教授:我认为这是个有趣的问题。当Primary向客户端返回写入失败时,你或许会认为一定是哪里出错了,在修复之前不应该重试。实际上,就我所知,论文里面在重试追加数据之前没有任何中间操作。因为,错误可能就是网络数据的丢失,这时就没什么好修复的,网络数据丢失了,我们应该重传这条网络数据。客户端重新尝试追加数据可以看做是一种复杂的重传数据的方法。或许对于大多数的错误来说,我们不需要修改任何东西,同样的Primary,同样的Secondary,客户端重试一次或许就能正常工作,因为这次网络没有丢包。

但是如果是某一个Secondary服务器出现严重的故障,那问题变得有意思了。我们希望的是,Master节点能够重新生成Chunk对应的服务器列表,将不工作的Secondary服务器剔除,再选择一个新的Primary,并增加版本号。如果这样的话,我们就有了一组新的Primary,Secondary和版本号,同时,我们还有一个不太健康的Secondary,它包含的是旧的副本和旧的版本号,正是因为版本号是旧的,Master永远也不会认为它拥有新的数据。但是,论文中没有证据证明这些会立即发生。论文里只是说,客户端重试,并且期望之后能正常工作。最终,Master节点会ping所有的Chunk服务器,如果Secondary服务器挂了,Master节点可以发现并更新Primary和Secondary的集合,之后再增加版本号。但是这些都是之后才会发生(而不是立即发生)。

学生提问:如果Master节点发现Primary挂了会怎么办?

Robert教授:可以这么回答这个问题。在某个时间点,Master指定了一个Primary,之后Master会一直通过定期的ping来检查它是否还存活。因为如果它挂了,Master需要选择一个新的Primary。Master发送了一些ping给Primary,并且Primary没有回应,你可能会认为Master会在那个时间立刻指定一个新的Primary。但事实是,这是一个错误的想法。为什么是一个错误的想法呢?因为可能是网络的原因导致ping没有成功,所以有可能Primary还活着,但是网络的原因导致ping失败了。但同时,Primary还可以与客户端交互,如果Master为Chunk指定了一个新的Primary,那么就会同时有两个Primary处理写请求,这两个Primary不知道彼此的存在,会分别处理不同的写请求,最终会导致有两个不同的数据拷贝。这被称为脑裂(split-brain)。

脑裂是一种非常重要的概念,我们会在之后的课程中再次介绍它(详见6.1),它通常是由网络分区引起的。比如说,Master无法与Primary通信,但是Primary又可以与客户端通信,这就是一种网络分区问题。网络故障是这类分布式存储系统中最难处理的问题之一。

所以,我们想要避免错误的为同一个Chunk指定两个Primary的可能性。Master采取的方式是,当指定一个Primary时,为它分配一个租约,Primary只在租约内有效。Master和Primary都会知道并记住租约有多长,当租约过期了,Primary会停止响应客户端请求,它会忽略或者拒绝客户端请求。因此,如果Master不能与Primary通信,并且想要指定一个新的Primary时,Master会等到前一个Primary的租约到期。这意味着,Master什么也不会做,只是等待租约到期。租约到期之后,可以确保旧的Primary停止了它的角色,这时Master可以安全的指定一个新的Primary而不用担心出现这种可怕的脑裂的情况。

学生提问:为什么立即指定一个新的Primary是坏的设计?既然客户端总是先询问Master节点,Master指定完Primary之后,将新的Primary返回给客户端不行吗?

Robert教授:因为客户端会通过缓存提高效率,客户端会在短时间缓存Primary的身份信息(这样,客户端就不用每次都会向Master请求Primary信息)。即使没有缓存,也可能出现这种情况,客户端向Master节点查询Primary信息,Master会将Primary信息返回,这条消息在网络中传播。之后Master如果发现Primary出现故障,并且立刻指定一个新的Primary,同时向新的Primary发消息说,你是Primary。Master节点之后会向其他查询Primary的客户端返回这个新的Primary。而前一个Primary的查询还在传递过程中,前一个客户端收到的还是旧的Primary的信息。如果没有其他的更聪明的一些机制,前一个客户端是没办法知道收到的Primary已经过时了。如果前一个客户端执行写文件,那么就会与后来的客户端产生两个冲突的副本。

学生提问:如果是对一个新的文件进行追加,那这个新的文件没有副本,会怎样?

Robert教授:你会按照黑板上的路径(见3.6)再执行一遍。Master会从客户端收到一个请求说,我想向这个文件追加数据。我猜,Master节点会发现,该文件没有关联的Chunk。Master节点或许会通过随机数生成器创造一个新的Chunk ID。之后,Master节点通过查看自己的Chunk表单发现,自己其实也没有Chunk ID对应的任何信息。之后,Master节点会创建一条新的Chunk记录说,我要创建一个新的版本号为1,再随机选择一个Primary和一组Secondary并告诉它们,你们将对这个空的Chunk负责,请开始工作。论文里说,每个Chunk默认会有三个副本,所以,通常来说是一个Primary和两个Secondary。

3.8 GFS的一致性

或许这里最重要的部分就是重复我们刚刚(3.7的问答中)讨论过的内容。

当我们追加数据时,面对Chunk的三个副本,当客户端发送了一个追加数据的请求,要将数据A追加到文件末尾,所有的三个副本,包括一个Primary和两个Secondary,都成功的将数据追加到了Chunk,所以Chunk中的第一个记录是A。

假设第二个客户端加入进来,想要追加数据B,但是由于网络问题发送给某个副本的消息丢失了。所以,追加数据B的消息只被两个副本收到,一个是Primary,一个是Secondary。这两个副本都在文件中追加了数据B,所以,现在我们有两个副本有数据B,另一个没有。

之后,第三个客户端想要追加数据C,并且第三个客户端记得下图中左边第一个副本是Primary。Primary选择了偏移量,并将偏移量告诉Secondary,将数据C写在Chunk的这个位置。三个副本都将数据C写在这个位置。

对于数据B来说,客户端会收到写入失败的回复,客户端会重发写入数据B的请求。所以,第二个客户端会再次请求追加数据B,或许这次数据没有在网络中丢包,并且所有的三个副本都成功追加了数据B。现在三个副本都在线,并且都有最新的版本号。

之后,如果一个客户端读文件,读到的内容取决于读取的是Chunk的哪个副本。客户端总共可以看到三条数据,但是取决于不同的副本,读取数据的顺序是不一样的。如果读取的是第一个副本,那么客户端可以读到A、B、C,然后是一个重复的B。如果读取的是第三个副本,那么客户端可以读到A,一个空白数据,然后是C、B。所以,如果读取前两个副本,B和C的顺序是先B后C,如果读的是第三个副本,B和C的顺序是先C后B。所以,不同的读请求可能得到不同的结果。

或许最坏的情况是,一些客户端写文件时,因为其中一个Secondary未能成功执行数据追加操作,客户端从Primary收到写入失败的回复。在客户端重新发送写文件请求之前,客户端就故障了。所以,你有可能进入这种情形:数据D出现在某些副本中,而其他副本则完全没有。

在GFS的这种工作方式下,如果Primary返回写入成功,那么一切都还好,如果Primary返回写入失败,就不是那么好了。Primary返回写入失败会导致不同的副本有完全不同的数据。

学生提问:客户端重新发起写入的请求时从哪一步开始重新执行的?

Robert教授:根据我从论文中读到的内容,(当写入失败,客户端重新发起写入数据请求时)客户端会从整个流程的最开始重发。客户端会再次向Master询问文件最后一个Chunk是什么,因为文件可能因为其他客户端的数据追加而发生了改变。

学生提问:为什么GFS要设计成多个副本不一致?

Robert教授:我不明白GFS设计者为什么要这么做。GFS可以设计成多个副本是完全精确同步的,你们在lab2和lab3会设计一个系统,其中的副本是同步的。并且你们也会知道,为了保持同步,你们要使用各种各样的技术。如果你们想要让副本保持同步,其中一条规则就是你们不能允许这种只更新部分服务器的不完整操作。这意味着,你必须要有某种机制,即使客户端挂了,系统仍然会完成请求。如果这样的话,GFS中的Primary就必须确保每一个副本都得到每一条消息。

学生提问:如果第一次写B失败了,C应该在B的位置吧?

Robert教授:实际上并没有。实际上,Primary将C添加到了Chunk的末尾,在B第一次写入的位置之后。我认为这么做的原因是,当写C的请求发送过来时,Primary实际上可能不知道B的命运是什么。因为我们面对的是多个客户端并发提交追加数据的请求,为了获得高性能,你会希望Primary先执行追加数据B的请求,一旦获取了下一个偏移量,再通知所有的副本执行追加数据C的请求,这样所有的事情就可以并行的发生。

也可以减慢速度,Primary也可以判断B已经写入失败了,然后再发一轮消息让所有副本撤销数据B的写操作,但是这样更复杂也更慢。

GFS这样设计的理由是足够的简单,但是同时也给应用程序暴露了一些奇怪的数据。这里希望为应用程序提供一个相对简单的写入接口,但应用程序需要容忍读取数据的乱序。如果应用程序不能容忍乱序,应用程序要么可以通过在文件中写入序列号,这样读取的时候能自己识别顺序,要么如果应用程序对顺序真的非常敏感那么对于特定的文件不要并发写入。例如,对于电影文件,你不会想要将数据弄乱,当你将电影写入文件时,你可以只用一个客户端连续顺序而不是并发的将数据追加到文件中。

有人会问,如何将这里的设计转变成强一致的系统,从而与我们前面介绍的单服务器模型更接近,也不会产生一些给人“惊喜”的结果。实际上我不知道怎么做,因为这需要完全全新的设计。目前还不清楚如何将GFS转变成强一致的设计。但是,如果你想要将GFS升级成强一致系统,我可以为你列举一些你需要考虑的事情:

  • 你可能需要让Primary来探测重复的请求,这样第二个写入数据B的请求到达时,Primary就知道,我们之前看到过这个请求,可能执行了也可能没执行成功。Primay要尝试确保B不会在文件中出现两次。所以首先需要的是探测重复的能力。
  • 对于Secondary来说,如果Primay要求Secondary执行一个操作,Secondary必须要执行而不是只返回一个错误给Primary。对于一个严格一致的系统来说,是不允许Secondary忽略Primary的请求而没有任何补偿措施的。所以我认为,Secondary需要接受请求并执行它们。如果Secondary有一些永久性故障,例如磁盘被错误的拔出了,你需要有一种机制将Secondary从系统中移除,这样Primary可以与剩下的Secondary继续工作。但是GFS没有做到这一点,或者说至少没有做对。
  • 当Primary要求Secondary追加数据时,直到Primary确信所有的Secondary都能执行数据追加之前,Secondary必须小心不要将数据暴露给读请求。所以对于写请求,你或许需要多个阶段。在第一个阶段,Primary向Secondary发请求,要求其执行某个操作,并等待Secondary回复说能否完成该操作,这时Secondary并不实际执行操作。在第二个阶段,如果所有Secondary都回复说可以执行该操作,这时Primary才会说,好的,所有Secondary执行刚刚你们回复可以执行的那个操作。这是现实世界中很多强一致系统的工作方式,这被称为两阶段提交(Two-phase commit)。
  • 另一个问题是,当Primary崩溃时,可能有一组操作由Primary发送给Secondary,Primary在确认所有的Secondary收到了请求之前就崩溃了。当一个Primary崩溃了,一个Secondary会接任成为新的Primary,但是这时,新Primary和剩下的Secondary会在最后几个操作有分歧,因为部分副本并没有收到前一个Primary崩溃前发出的请求。所以,新的Primary上任时,需要显式的与Secondary进行同步,以确保操作历史的结尾是相同的。
  • 最后,时不时的,Secondary之间可能会有差异,或者客户端从Master节点获取的是稍微过时的Secondary。系统要么需要将所有的读请求都发送给Primary,因为只有Primary知道哪些操作实际发生了,要么对于Secondary需要一个租约系统,就像Primary一样,这样就知道Secondary在哪些时间可以合法的响应客户端。

为了实现强一致,以上就是我认为的需要在系统中修复的东西,它们增加了系统的复杂度,增加了系统内部组件的交互。我也是通过思考课程的实验,得到上面的列表的,你们会在lab2,3中建立一个强一致系统,并完成所有我刚刚说所有的东西。

最后,让我花一分钟来介绍GFS在它生涯的前5-10年在Google的出色表现,总的来说,它取得了巨大的成功,许多许多Google的应用都使用了它,许多Google的基础架构,例如BigTable和MapReduce是构建在GFS之上,所以GFS在Google内部广泛被应用。它最严重的局限可能在于,它只有一个Master节点,会带来以下问题:

  • Master节点必须为每个文件,每个Chunk维护表单,随着GFS的应用越来越多,这意味着涉及的文件也越来越多,最终Master会耗尽内存来存储文件表单。你可以增加内存,但是单台计算机的内存也是有上限的。所以,这是人们遇到的最早的问题。
  • 除此之外,单个Master节点要承载数千个客户端的请求,而Master节点的CPU每秒只能处理数百个请求,尤其Master还需要将部分数据写入磁盘,很快,客户端数量超过了单个Master的能力。
  • 另一个问题是,应用程序发现很难处理GFS奇怪的语义(本节最开始介绍的GFS的副本数据的同步,或者可以说不同步)。
  • 最后一个问题是,从我们读到的GFS论文中,Master节点的故障切换不是自动的。GFS需要人工干预来处理已经永久故障的Master节点,并更换新的服务器,这可能需要几十分钟甚至更长的而时间来处理。对于某些应用程序来说,这个时间太长了。

(所以我们才需要多副本,多活,高可用,故障自修复的分布式系统啊)

Lecture 04 - VMware FT

在开始之前,强烈建议阅读VMware FT论文。

【1】https://pdos.csail.mit.edu/6.824/papers/vm-ft.pdf

4.1 复制(Replication)

这一节课(Lecture 4),我想更多地讨论一些关于容错(Fault-Tolerance)和复制(Replication)的问题,然后,深入的看一下今天的论文,VMware FT。

容错本身是为了提供高可用性。例如,当你想构建一个服务时,尽管计算机硬件总是有可能故障,但是我们还是希望能稳定的提供服务,甚至,即使出现了网络问题我们还是想能够提供服务。我们所使用到的工具就是复制,至少在本课程的这一部分是这样。所以,一个很有意思的问题是:复制能处理什么样的故障呢?因为复制也不可能是万能的工具(可以用来解决所有的问题)。

用最简单的方法来描述复制能处理的故障,那就是,单台计算机的fail-stop故障。Fail-stop是一种容错领域的通用术语。它是指,如果某些东西出了故障,比如说计算机,那么它会单纯的停止运行。当任何地方出现故障时,就停止运行,而不是运算出错误结果。例如,某人将你服务器的电源线踢掉了,那就会产生一个fail-stop故障。类似的,如果某人拔了你的服务器的网线,即使你的服务器还在运行,那也算是一个fail-stop故障。服务器彻底从网络上隔离的场景有点有趣,因为从外界来看,服务器和停止运行没有两样。所以,这些是我们可以通过复制处理的一些故障。复制也能处理一些硬件问题,比如,服务器的风扇坏了,进而会使CPU过热,而CPU会自我关闭,并停止运行。

但是复制不能处理软件中的bug和硬件设计中的缺陷。以MapReduce的Master节点为例,如果我们复制并将其运行在两台计算机上,但是在Master程序里面有一个bug,那么复制对我们没有任何帮助,因为我们在两台计算机上的MapReduce Master都会计算出相同的错误结果,其他组件都会接受这个错误的结果。所以我们不能通过复制软件(为软件构建多副本)来抵御软件的bug,我们不能通过任何的复制的方案来抵御软件的bug。类似的,如我之前所说的,我们也不能期望复制可以处理硬件的漏洞,当硬件有漏洞的时候会计算出错误的结果,这时我们就无能为力了,至少基于复制这种技术,我们就无能为力了。

当然,如果你足够幸运的话,肯定也有一些硬件和软件的bug是可以被复制处理掉的。比如说,如果有一些不相关的软件运行在你的服务器上,并且它们导致了服务器崩溃,例如kernel panic或者服务器重启,虽然这些软件与你服务的副本无关,但是这种问题对于你的服务来说,也算是一种fail-stop。kernel panic之后,当前服务器上的服务副本会停止运行,备份副本会取而代之。一些硬件错误也可以转换成fail-stop错误,例如,当你通过网络发送了一个包,但是网络传输过程中,由于网络设备故障,导致数据包中的一个bit被翻转了,这可以通过数据包中的校验和检测出来,这样整个数据包会被丢弃。对于磁盘也可以做类似的事情,如果你往磁盘写了一些数据,过了一个月又读出来,但是磁盘的磁面或许不是很完美,导致最重要的几个数据bit读出来是错误的。通过纠错代码,在一定程度上可以修复磁盘中的错误,如果你足够幸运,随机的硬件错误可以被转换成正确的数据,如果没有那么幸运,那么至少可以检测出这里的错误,并将随机的错误转换成检测到的错误,这样,软件就知道发生了错误,并且会将错误转换成一个fail-stop错误,进而停止软件的运行,或者采取一些补救措施。总的来说,我们还是只能期望复制能够处理fail-stop错误。

对于复制,还有一些其他的限制。如果我们有两个副本,一个Primay和一个Backup节点,我们总是假设两个副本中的错误是相互独立的。但是如果它们之间的错误是有关联的,那么复制对我们就没有帮助。例如,我们要构建一个大型的系统,我们从同一个厂商买了数千台完全一样的计算机,我们将我们的副本运行在这些同一时间,同一地点购买的计算机上,这还是有一点风险的。因为如果其中一台计算机有制造缺陷,那么极有可能其他的计算机也有相同的缺陷。例如,由于制造商没有提供足够的散热系统,其中一台计算机总是过热,那么很有可能这一批计算机都有相同的问题。所以,如果其中一台因为过热导致宕机,那么其他计算机也很有可能会有相同的问题。这是一种关联错误。

你要小心的是另一种情况。比如,数据中心所在的城市发生了地震,摧毁了整个数据中心,无论我们在那个数据中心里有多少副本,都无济于事。因为这种由地震,停电,建筑失火引起的问题,如果多个副本在同一个建筑中,那么这类问题是副本之间关联的错误。所以,如果我们想处理类似地震引起的问题,我们需要将我们的副本放在不同的城市,或者至少物理上把它们分开,这样它们会有独立的供电,不会被同样的自然灾害影响。

以上是有关复制的一些背景知识。

另一个有关复制的问题是,你或许也会问自己,这种复制的方案是否值得?因为它使用了我们实际需要的2-3倍的计算机资源。GFS对于每个数据块都有3份拷贝,所以我们需要购买实际容量3倍的磁盘。今天的论文(VMware FT)复制了一份,但这也意味着我们需要两倍的计算机,CPU,内存。这些东西都不便宜,所以自然会有这个问题,这里的额外支出真的值得吗?

这不是一个可以从技术上来回答的问题,这是一个经济上的问题,它取决于一个可用服务的价值。如果你在运行一个银行系统,并且计算机宕机的后果是你不能再为你的用户提供服务,你将不能再有任何收入,你的用户也会讨厌你,那么多花1000-2000美金再买一台计算机或许是值得的。这种情况下,你可以有一个额外的副本。但是另一方面,如果是这个课程的网站,我不认为它值得拥有一个热备份,因为这个课程网站宕机的后果非常小。所以,对于系统做复制是否值得,该复制多少份,你愿意为复制花费多少,都取决于失败会给你带来多大的损失和不便。

4.2 状态转移和复制状态机(State Transfer and Replicated State Machine)

在VMware FT论文的开始,介绍了两种复制的方法,一种是状态转移(State Transfer),另一种是复制状态机(Replicated State Machine)。这两种我们都会介绍,但是在这门课程中,我们主要还是介绍后者。

如果我们有一个服务器的两个副本,我们需要让它们保持同步,在实际上互为副本,这样一旦Primary出现故障,因为Backup有所有的信息,就可以接管服务。状态转移背后的思想是,Primary将自己完整状态,比如说内存中的内容,拷贝并发送给Backup。Backup会保存收到的最近一次状态,所以Backup会有所有的数据。当Primary故障了,Backup就可以从它所保存的最新状态开始运行。所以,状态转移就是发送Primary的状态。虽然VMware FT没有采用这种复制的方法,但是假设采用了的话,那么转移的状态就是Primary内存里面的内容。这种情况下,每过一会,Primary就会对自身的内存做一大份拷贝,并通过网络将其发送到Backup。为了提升效率,你可以想到每次同步只发送上次同步之后变更了的内存。

复制状态机基于这个事实:我们想复制的大部分的服务或者计算机软件都有一些确定的内部操作,不确定的部分是外部的输入。通常情况下,如果一台计算机没有外部影响,它只是一个接一个的执行指令,每条指令执行的是计算机中内存和寄存器上确定的函数,只有当外部事件干预时,才会发生一些预期外的事。例如,某个随机时间收到了一个网络数据包,导致服务器做一些不同的事情。所以,复制状态机不会在不同的副本之间发送状态,相应的,它只会从Primary将这些外部事件,例如外部的输入,发送给Backup。通常来说,如果有两台计算机,如果它们从相同的状态开始,并且它们以相同的顺序,在相同的时间,看到了相同的输入,那么它们会一直互为副本,并且一直保持一致。

所以,状态转移传输的是可能是内存,而复制状态机会将来自客户端的操作或者其他外部事件,从Primary传输到Backup。

人们倾向于使用复制状态机的原因是,通常来说,外部操作或者事件比服务的状态要小。如果是一个数据库的话,它的状态可能是整个数据库,可能到达GB这个级别,而操作只是一些客户端发起的请求,例如读key27的数据。所以操作通常来说比较小,而状态通常比较大。所以复制状态机通常来说更吸引人一些。复制状态机的缺点是,它会更复杂一些,并且对于计算机的运行做了更多的假设。而状态转移就比较简单粗暴,我就是将我整个状态发送给你,你不需要再考虑别的东西。

有关这些方法有什么问题吗?

学生提问:如果这里的方法出现了问题,导致Primary和Backup并不完全一样,会有什么问题?

Robert教授:假设我们对GFS的Master节点做了多副本,其中的Primary对Chunk服务器1分发了一个租约。但是因为我们这里可能会出现多副本不一致,所以Backup并没有向任何人发出租约,它甚至都不知道任何人请求了租约,现在Primary认为Chunk服务器1对于某些Chunk有租约,而Backup不这么认为。当Primary挂了,Backup接手,Chunk服务器1会认为它对某些Chunk有租约,而当前的Primary(也就是之前的Backup)却不这么认为。当前的Primary会将租约分发给其他的Chunk服务器。现在我们就有两个Chunk服务器有着相同的租约。这只是一个非常现实的例子,基于不同的副本不一致,你可以构造出任何坏的场景和任何服务器运算出错误结果的情形。我之后会介绍VMware的方案是如何避免这一点的。

学生提问:随机操作在复制状态机会怎么处理?

Robert教授:我待会会再说这个问题,但是这是个好问题。只有当没有外部的事件时,Primary和Backup都执行相同的指令,得到相同的结果,复制状态机才有意义。对于ADD这样的指令来说,这是正确的。如果寄存器和内存都是相同的,那么两个副本执行一条ADD指令,这条指令有相同的输入,也必然会有相同的输出。但是,如你指出的一样,有一些指令,或许是获取当前的时间,因为执行时间的略微不同,会产生不同的结果。又或者是获取当前CPU的唯一ID和序列号,也会产生不同的结果。对于这一类问题的统一答案是,Primary会执行这些指令,并将结果发送给Backup。Backup不会执行这些指令,而是在应该执行指令的地方,等着Primary告诉它,正确的答案是什么,并将监听到的答案返回给软件。

有趣的是,或许你已经注意到了,VMware FT论文讨论的都是复制状态机,并且只涉及了单核CPU,目前还不确定论文中的方案如何扩展到多核处理器的机器中。在多核的机器中,两个核交互处理指令的行为是不确定的,所以就算Primary和Backup执行相同的指令,在多核的机器中,它们也不一定产生相同的结果。VMware在之后推出了一个新的可能完全不同的复制系统,并且可以在多核上工作。这个新系统从我看来使用了状态转移,而不是复制状态机。因为面对多核和并行计算,状态转移更加健壮。如果你使用了一台机器,并且将其内存发送过来了,那么那个内存镜像就是机器的状态,并且不受并行计算的影响,但是复制状态机确实会受并行计算的影响。但是另一方面,我认为这种新的多核方案代价会更高一些。

如果我们要构建一个复制状态机的方案,我们有很多问题要回答,我们需要决定要在什么级别上复制状态,我们对状态的定义是什么,我们还需要担心Primary和Backup之间同步的频率。因为很有可能Primary会比Backup的指令执行更超前一些,毕竟是Primary接收了外部的输入,Backup几乎必然是要滞后的。这意味着,有可能Primary出现了故障,而Backup没有完全同步上。但是,让Backup与Primary完全同步执行又是代价很高的操作,因为这需要大量的交互。所以,很多设计中,都关注同步的频率有多高。

如果Primary发生了故障,必须要有一些切换的方案,并且客户端必须要知道,现在不能与服务器1上的旧Primary通信,而应该与服务器2上的新Primary通信。所有的客户端都必须以某种方式完成这里的切换。几乎不可能设计一个不出现异常现象的切换系统。在理想的环境中,如果Primary故障了,系统会切换到Backup,同时没有人,没有一个客户端会注意到这里的切换。这在实际上基本不可能实现。所以,在切换过程中,必然会有异常,我们必须找到一种应对它们的方法。

如果我们的众多副本中有一个故障了,我们需要重新添加一个新的副本。如果我们只有两个副本,其中一个故障了,那我们的服务就命悬一线了,因为第二个副本随时也可能故障。所以我们绝对需要尽快将一个新的副本上线。但是这可能是一个代价很高的行为,因为副本的状态会非常大。我们喜欢复制状态机的原因是,我们认为状态转移的代价太高了。但是对于复制状态机来说,其中的两个副本仍然需要有完整的状态,我们只是有一种成本更低的方式来保持它们的同步。如果我们要创建一个新的副本,我们别无选择,只能使用状态转移,因为新的副本需要有完整状态的拷贝。所以创建一个新的副本,代价会很高。

以上就是人们主要担心的问题。我们在讨论其他复制状态机方案时,会再次看到这些问题。

让我们回到什么样的状态需要被复制这个话题。VMware FT论文对这个问题有一个非常有趣的回答。它会复制机器的完整状态,这包括了所有的内存,所有的寄存器。这是一个非常非常详细的复制方案,Primary和Backup,即使在最底层也是完全一样的。对于复制方案来说,这种类型是非常少见的。总的来说,大部分复制方案都跟GFS更像。GFS也有复制,但是它绝对没有在Primary和Backup之间复制内存中的每一个bit,它复制的更多是应用程序级别的Chunk。应用程序将数据抽象成Chunk和Chunk ID,GFS只是复制了这些,而没有复制任何其他的东西,所以也不会有复制其他东西的代价。对于应用程序来说,只要Chunk的副本的数据是一致的就可以了。基本上除了VMware FT和一些屈指可数的类似的系统,其他所有的复制方案都是采用的类似GFS的方案。也就是说基本上所有的方案使用的都是应用程序级别的状态复制,因为这更加高效,并且我们也不必陷入这样的困境,比如说需要确保中断在Primary和Backup的相同位置执行,GFS就完全不需要担心这种情况。但是VMware FT就需要担心这种情况,因为它从最底层就开始复制。所以,大多数人构建了高效的,应用程序级别的复制系统。这样做的后果是,复制这个行为,必须构建在应用程序内部。如果你收到了一系列应用程序级别的操作,你确实需要应用程序参与到复制中来,因为一些通用的复制系统,例如VMware FT,理解不了这些操作,以及需要复制的内容。总的来说,大部分场景都是应用程序级别的复制,就像GFS和其他这门课程中会学习的其他论文一样。

VMware FT的独特之处在于,它从机器级别实现复制,因此它不关心你在机器上运行什么样的软件,它就是复制底层的寄存器和内存。你可以在VMware FT管理的机器上运行任何软件,只要你的软件可以运行在VMware FT支持的微处理器上。这里说的软件可以是任何软件。所以,它的缺点是,它没有那么的高效,优点是,你可以将任何现有的软件,甚至你不需要有这些软件的源代码,你也不需要理解这些软件是如何运行的,在某些限制条件下,你就可以将这些软件运行在VMware FT的这套复制方案上。VMware FT就是那个可以让任何软件都具备容错性的魔法棒。

4.3 VMware FT 工作原理

让我来介绍一下VMware FT是如何工作的。

首先,VMware是一个虚拟机公司,它们的业务主要是售卖虚拟机技术。虚拟机的意思是,你买一台计算机,通常只能在硬件上启动一个操作系统。但是如果在硬件上运行一个虚拟机监控器(VMM,Virtual Machine Monitor)或者Hypervisor,Hypervisor会在同一个硬件上模拟出多个虚拟的计算机。所以通过VMM,可以在一个硬件上启动一到多个Linux虚机,一到多个Windows虚机。

这台计算机上的VMM可以运行一系列不同的操作系统,其中每一个都有自己的操作系统内核和应用程序。

这是VMware发家的技术,这里的硬件和操作系统之间的抽象,可以有很多很多的好处。首先是,我们只需要购买一台计算机,就可以在上面运行大量不同的操作系统,我们可以在每个操作系统里面运行一个小的服务,而不是购买大量的物理计算机,每个物理计算机只运行一个服务。所以,这是VMware的发家技术,并且它有大量围绕这个技术构建的复杂系统。

VMware FT需要两个物理服务器。将Primary和Backup运行在一台服务器的两个虚拟机里面毫无意义,因为容错本来就是为了能够抵御硬件故障。所以,你至少需要两个物理服务器运行VMM,Primary虚机在其中一个物理服务器上,Backup在另一个物理服务器上。在其中一个物理服务器上,我们有一个虚拟机,这个物理服务器或许运行了很多虚拟机,但是我们只关心其中一个。这个虚拟机跑了某个操作系统,和一种服务器应用程序,或许是个数据库,或许是MapReduce master或者其他的,我们将之指定为Primary。在第二个物理服务器上,运行了相同的VMM,和一个相同的虚拟机作为Backup。它与Primary有着一样的操作系统。

两个物理服务器上的VMM会为每个虚拟机分配一段内存,这两段内存的镜像需要完全一致,或者说我们的目标就是让Primary和Backup的内存镜像完全一致。所以现在,我们有两个物理服务器,它们每一个都运行了一个虚拟机,每个虚拟机里面都有我们关心的服务的一个拷贝。我们假设有一个网络连接了这两个物理服务器。

除此之外,在这个局域网(LAN,Local Area Network),还有一些客户端。实际上,它们不必是客户端,可以只是一些我们的多副本服务需要与之交互的其他计算机。其中一些客户端向我们的服务发送请求。在VMware FT里,多副本服务没有使用本地盘,而是使用了一些Disk Server(远程盘)。尽管从论文里很难发现,这里可以将远程盘服务器也看做是一个外部收发数据包的源,与客户端的区别不大。

所以,基本的工作流程是,我们假设这两个副本,或者说这两个虚拟机:Primary和Backup,互为副本。某些我们服务的客户端,向Primary发送了一个请求,这个请求以网络数据包的形式发出。

这个网络数据包产生一个中断,之后这个中断送到了VMM。VMM可以发现这是一个发给我们的多副本服务的一个输入,所以这里VMM会做两件事情:

  • 在虚拟机的guest操作系统中,模拟网络数据包到达的中断,以将相应的数据送给应用程序的Primary副本。
  • 除此之外,因为这是一个多副本虚拟机的输入,VMM会将网络数据包拷贝一份,并通过网络送给Backup虚机所在的VMM。

Backup虚机所在的VMM知道这是发送给Backup虚机的网络数据包,它也会在Backup虚机中模拟网络数据包到达的中断,以将数据发送给应用程序的Backup。所以现在,Primary和Backup都有了这个网络数据包,它们有了相同的输入,再加上许多细节,它们将会以相同的方式处理这个输入,并保持同步。

当然,虚机内的服务会回复客户端的请求。在Primary虚机里面,服务会生成一个回复报文,并通过VMM在虚机内模拟的虚拟网卡发出。之后VMM可以看到这个报文,它会实际的将这个报文发送给客户端。

说实话,这里画的挺乱的

另一方面,由于Backup虚机运行了相同顺序的指令,它也会生成一个回复报文给客户端,并将这个报文通过它的VMM模拟出来的虚拟网卡发出。但是它的VMM知道这是Backup虚机,会丢弃这里的回复报文。所以这里,Primary和Backup都看见了相同的输入,但是只有Primary虚机实际生成了回复报文给客户端。

这里有一个术语,VMware FT论文中将Primary到Backup之间同步的数据流的通道称之为Log Channel。虽然都运行在一个网络上,但是这些从Primary发往Backup的事件被称为Log Channel上的Log Event/Entry。

当Primary因为故障停止运行时,FT(Fault-Tolerance)就开始工作了。从Backup的角度来说,它将不再收到来自于Log Channel上的Log条目。实际中,Backup每秒可以收到很多条Log,其中一个来源就是来自于Primary的定时器中断。每个Primary的定时器中断都会生成一条Log条目并发送给Backup,这些定时器中断每秒大概会有100次。所以,如果Primary虚机还在运行,Backup必然可以期望从Log Channel收到很多消息。如果Primary虚机停止运行了,那么Backup的VMM就会说:天,我都有1秒没有从Log Channel收到任何消息了,Primary一定是挂了或者出什么问题了。当Backup不再从Primary收到消息,VMware FT论文的描述是,Backup虚机会上线(Go Alive)。这意味着,Backup不会再等待来自于Primary的Log Channel的事件,Backup的VMM会让Backup自由执行,而不是受来自于Primary的事件驱动。Backup的VMM会在网络中做一些处理(猜测是发GARP),让后续的客户端请求发往Backup虚机,而不是Primary虚机。同时,Backup的VMM不再会丢弃Backup虚机的输出。当然,它现在已经不再是Backup,而是Primary。所以现在,左边的虚机直接接收输入,直接产生输出。到此为止,Backup虚机接管了服务。

类似的一个场景,虽然没那么有趣,但是也需要能正确工作。如果Backup虚机停止运行,Primary也需要用一个类似的流程来抛弃Backup,停止向它发送事件,并且表现的就像是一个单点的服务,而不是一个多副本服务一样。所以,只要有一个因为故障停止运行,并且不再产生网络流量时,Primary和Backup中的另一个都可以上线继续工作。

学生提问:Backup怎么让其他客户端向自己发送请求?

Robert教授:魔法。。。取决于是哪种网络技术。从论文中看,一种可能是,所有这些都运行在以太网上。每个以太网的物理计算机,或者说网卡有一个48bit的唯一ID(MAC地址)。下面这些都是我(Robert教授)编的。每个虚拟机也有一个唯一的MAC地址,当Backup虚机接手时,它会宣称它有Primary的MAC地址,并向外通告说,我是那个MAC地址的主人。这样,以太网上的其他人就会向它发送网络数据包。不过这只是我(Robert教授)的解读。

学生提问:随机数生成器这种操作怎么在Primary和Backup做同步?

Robert教授:VMware FT的设计者认为他们找到了所有类似的操作,对于每一个操作,Primary执行随机数生成,或者某个时间点生成的中断(依赖于执行时间点的中断)。而Backup虚机不会执行这些操作,Backup的VMM会探测这些指令,拦截并且不执行它们。VMM会让Backup虚机等待来自Log Channel的有关这些指令的指示,比如随机数生成器这样的指令,之后VMM会将Primary生成的随机数发送给Backup。

论文有暗示说他们让Intel向处理器加了一些特性来支持这里的操作,但是论文没有具体说是什么特性。

4.4 非确定性事件(Non-Deterministic Events)

好的,目前为止,我们都假设只要Backup虚机也看到了来自客户端的请求,经过同样的执行过程,那么它就会与Primary保持一致,但是这背后其实有很多很重要的细节。就如其他同学之前指出的一样,其中一个问题是存在非确定性(Non-Deterministic)的事件。虽然通常情况下,代码执行都是直接明了的,但并不是说计算机中每一个指令都是由计算机内存的内容而确定的行为。这一节,我们来看一下不由当前内存直接决定的指令。如果我们不够小心,这些指令在Primary和Backup的运行结果可能会不一样。这些指令就是所谓的非确定性事件。所以,设计者们需要弄明白怎么让这一类事件能在Primary和Backup之间同步。

非确定性事件可以分成几类。

  • 客户端输入。假设有一个来自于客户端的输入,这个输入随时可能会送达,所以它是不可预期的。客户端请求何时送达,会有什么样的内容,并不取决于服务当前的状态。我们讨论的系统专注于通过网络来进行交互,所以这里的系统输入的唯一格式就是网络数据包。所以当我们说输入的时候,我们实际上是指接收到了一个网络数据包。而一个网络数据包对于我们来说有两部分,一个是数据包中的数据,另一个是提示数据包送达了的中断。当网络数据包送达时,通常网卡的DMA(Direct Memory Access)会将网络数据包的内容拷贝到内存,之后触发一个中断。操作系统会在处理指令的过程中消费这个中断。对于Primary和Backup来说,这里的步骤必须看起来是一样的,否则它们在执行指令的时候就会出现不一致。所以,这里的问题是,中断在什么时候,具体在指令流中的哪个位置触发?对于Primary和Backup,最好要在相同的时间,相同的位置触发,否则执行过程就是不一样的,进而会导致它们的状态产生偏差。所以,我们不仅关心网络数据包的内容,还关心中断的时间。

  • 另外,如其他同学指出的,有一些指令在不同的计算机上的行为是不一样的,这一类指令称为怪异指令,比如说:
    • 随机数生成器
    • 获取当前时间的指令,在不同时间调用会得到不同的结果
    • 获取计算机的唯一ID

  • 另外一个常见的非确定事件,在VMware FT论文中没有讨论,就是多CPU的并发。我们现在讨论的都是一个单进程系统,没有多CPU多核这种事情。之所以多核会导致非确定性事件,是因为当服务运行在多CPU上时,指令在不同的CPU上会交织在一起运行,进而产生的指令顺序是不可预期的。所以如果我们在Backup上运行相同的代码,并且代码并行运行在多核CPU上,硬件会使得指令以不同(于Primary)的方式交织在一起,而这会引起不同的运行结果。假设两个核同时向同一份数据请求锁,在Primary上,核1得到了锁;在Backup上,由于细微的时间差别核2得到了锁,那么执行结果极有可能完全不一样,这里其实说的就是(在两个副本上)不同的线程获得了锁。所以,多核是一个巨大的非确定性事件来源,VMware FT论文完全没有讨论它,并且它也不适用与我们这节课的讨论。

学生提问:如何确保VMware FT管理的服务只使用单核?

Robert教授:服务不能使用多核并行计算。硬件几乎可以肯定是多核并行的,但是这些硬件在VMM之下。在这篇论文中,VMM暴露给运行了Primary和Backup虚机操作系统的硬件是单核的。我猜他们也没有一种简单的方法可以将这里的内容应用到一个多核的虚拟机中。

所有的事件都需要通过Log Channel,从Primary同步到Backup。有关日志条目的格式在论文中没有怎么描述,但是我(Robert教授)猜日志条目中有三样东西:

  1. 事件发生时的指令序号。因为如果要同步中断或者客户端输入数据,最好是Primary和Backup在相同的指令位置看到数据,所以我们需要知道指令序号。这里的指令号是自机器启动以来指令的相对序号,而不是指令在内存中的地址。比如说,我们正在执行第40亿零79条指令。所以日志条目需要有指令序号。对于中断和输入来说,指令序号就是指令或者中断在Primary中执行的位置。对于怪异的指令(Weird instructions),比如说获取当前的时间来说,这个序号就是获取时间这条指令执行的序号。这样,Backup虚机就知道在哪个指令位置让相应的事件发生。
  2. 日志条目的类型,可能是普通的网络数据输入,也可能是怪异指令。
  3. 最后是数据。如果是一个网络数据包,那么数据就是网络数据包的内容。如果是一个怪异指令,数据将会是这些怪异指令在Primary上执行的结果。这样Backup虚机就可以伪造指令,并提供与Primary相同的结果。

举个例子,Primary和Backup两个虚机内部的guest操作系统需要在模拟的硬件里有一个定时器,能够每秒触发100次中断,这样操作系统才可以通过对这些中断进行计数来跟踪时间。因此,这里的定时器必须在Primary和Backup虚机的完全相同位置产生中断,否则这两个虚机不会以相同的顺序执行指令,进而可能会产生分歧。所以,在运行了Primary虚机的物理服务器上,有一个定时器,这个定时器会计时,生成定时器中断并发送给VMM。在适当的时候,VMM会停止Primary虚机的指令执行,并记下当前的指令序号,然后在指令序号的位置插入伪造的模拟定时器中断,并恢复Primary虚机的运行。之后,VMM将指令序号和定时器中断再发送给Backup虚机。虽然Backup虚机的VMM也可以从自己的物理定时器接收中断,但是它并没有将这些物理定时器中断传递给Backup虚机的guest操作系统,而是直接忽略它们。当来自于Primary虚机的Log条目到达时,Backup虚机的VMM配合特殊的CPU特性支持,会使得物理服务器在相同的指令序号处产生一个定时器中断,之后VMM获取到这个中断,并伪造一个假的定时器中断,并将其送入Backup虚机的guest操作系统,并且这个定时器中断会出现在与Primary相同的指令序号位置。

学生提问:这里的操作依赖硬件的定制吗?(实际上我听不清,猜的)

Robert教授:是的,这里依赖于CPU的一些特殊的定制,这样VMM就可以告诉CPU,执行1000条指令之后暂停一下,方便VMM将伪造的中断注入,这样Backup虚机就可以与Primary虚机在相同的指令位置触发相同的中断,执行相同的指令。之后,VMM会告诉CPU恢复执行。这里需要一些特殊的硬件,但是现在看起来所有的Intel芯片上都有这个功能,所以也不是那么的特殊。或许15年前,这个功能还是比较新鲜的,但是现在来说就比较正常了。现在这个功能还有很多其他用途,比如说做CPU时间性能分析,可以让处理器每1000条指令中断一次,这里用的是相同的硬件让微处理器每1000条指令产生一个中断。所以现在,这是CPU中非常常见的一个小工具。

学生提问:如果Backup领先了Primary会怎么样?

Robert教授: 场景可能是这样,Primary即将在第100万条指令处中断,但是Backup已经执行了100万零1条指令了。如果我们让这种场景发生,那么Primary的中断传输就太晚了。如果我们允许Backup执行领先Primary,就会使得中断在Backup中执行位置落后于Primary。所以我们不能允许这种情况发生,我们不能允许Backup在执行指令时领先于Primary。

VMware FT是这么做的。它会维护一个来自于Primary的Log条目的等待缓冲区,如果缓冲区为空,Backup是不允许执行指令的。如果缓冲区不为空,那么它可以根据Log的信息知道Primary对应的指令序号,并且会强制Backup虚机最多执行指令到这个位置。所以,Backup虚机的CPU总是会被通知执行到特定的位置就停止。Backup虚机只有在Log缓冲区中有数据才会执行,并且只会执行到Log条目对应的指令序号。在Primary产生的第一个Log,并且送达Backup之前,Backup甚至都不能执行指令,所以Backup总是落后于Primary至少一个Log。如果物理服务器的资源占用过多,导致Backup执行变慢,那么Backup可能落后于Primary多个Log条目。

网络数据包送达时,有一个细节会比较复杂。当网络数据包到达网卡时,如果我们没有运行虚拟机,网卡会将网络数据包通过DMA的方式送到计算机的关联内存中。现在我们有了虚拟机,并且这个网络数据包是发送给虚拟机的,在虚拟机内的操作系统可能会监听DMA并将数据拷贝到虚拟机的内存中。因为VMware的虚拟机设计成可以支持任何操作系统,我们并不知道网络数据包到达时操作系统会执行什么样的操作,有的操作系统或许会真的监听网络数据包拷贝到内存的操作。

我们不能允许这种情况发生。如果我们允许网卡直接将网络数据包DMA到Primary虚机中,我们就失去了对于Primary虚机的时序控制,因为我们也不知道什么时候Primary会收到网络数据包。所以,实际中,物理服务器的网卡会将网络数据包拷贝给VMM的内存,之后,网卡中断会送给VMM,并说,一个网络数据包送达了。这时,VMM会暂停Primary虚机,记住当前的指令序号,将整个网络数据包拷贝给Primary虚机的内存,之后模拟一个网卡中断发送给Primary虚机。同时,将网络数据包和指令序号发送给Backup。Backup虚机的VMM也会在对应的指令序号暂停Backup虚机,将网络数据包拷贝给Backup虚机,之后在相同的指令序号位置模拟一个网卡中断发送给Backup虚机。这就是论文中介绍的Bounce Buffer机制。

学生提问:怪异的指令(Weird instructions)会有多少呢?

Robert教授:怪异指令非常少。只有可能在Primary和Backup中产生不同结果的指令,才会被封装成怪异指令,比如获取当前时间,或者获取当前处理器序号,或者获取已经执行的的指令数,或者向硬件请求一个随机数用来加密,这种指令相对来说都很少见。大部分指令都是类似于ADD这样的指令,它们会在Primary和Backup中得到相同的结果。每个网络数据包未做修改直接被打包转发,然后被两边虚拟机的TCP/IP协议栈解析也会得到相同的结果。所以我预期99.99%的Log Channel中的数据都会是网络数据包,只有一小部分是怪异指令。

所以对于一个服务于客户端的服务来说,我们可以通过客户端流量判断Log Channel的流量大概是什么样子,因为它基本上就是客户端发送的网络数据包的拷贝。

4.5 输出控制(Output Rule)

对于VMware FT系统的输出,也是值得说一下的。在这个系统中,唯一的输出就是对于客户端请求的响应。客户端通过网络数据包将数据送入,服务器的回复也会以网络数据包的形式送出。我之前说过,Primary和Backup虚机都会生成回复报文,之后通过模拟的网卡送出,但是只有Primary虚机才会真正的将回复送出,而Backup虚机只是将回复简单的丢弃掉。

好吧,真实情况会复杂一些。假设我们正在跑一个简单的数据库服务器,这个服务器支持一个计数器自增操作,工作模式是这样,客户端发送了一个自增的请求,服务器端对计数器加1,并返回新的数值。假设最开始一切正常,在Primary和Backup中的计数器都存了10。

现在,局域网的一个客户端发送了一个自增的请求给Primary,

这个请求在Primary虚机的软件中执行,Primary会发现,现在的数据是10,我要将它变成11,并回复客户端说,现在的数值是11。

这个请求也会发送给Backup虚机,并将它的数值从10改到11。Backup也会产生一个回复,但是这个回复会被丢弃,这是我们期望发生的。

但是,你需要考虑,如果在一个不恰当的时间,出现了故障会怎样?在这门课程中,你需要始终考虑,故障的最坏场景是什么,故障会导致什么结果?在这个例子中,假设Primary确实生成了回复给客户端,但是之后立马崩溃了。更糟糕的是,现在网络不可靠,Primary发送给Backup的Log条目在Primary崩溃时也丢包了。那么现在的状态是,客户端收到了回复说现在的数据是11,但是Backup虚机因为没有看到客户端请求,所以它保存的数据还是10。

现在,因为察觉到Primary崩溃了,Backup接管服务。这时,客户端再次发送一个自增的请求,这个请求发送到了原来的Backup虚机,它会将自身的数值从10增加到11,并产生第二个数据是11的回复给客户端。

如果客户端比较前后两次的回复,会发现一个明显不可能的场景(两次自增的结果都是11)。

因为VMware FT的优势就是在不修改软件,甚至软件都不需要知道复制的存在的前提下,就能支持容错,所以我们也不能修改客户端让它知道因为容错导致的副本切换触发了一些奇怪的事情。在VMware FT场景里,我们没有修改客户端这个选项,因为整个系统只有在不修改服务软件的前提下才有意义。所以,前面的例子是个大问题,我们不能让它实际发生。有人还记得论文里面是如何防止它发生的吗?

论文里的解决方法就是控制输出(Output Rule)。直到Backup虚机确认收到了相应的Log条目,Primary虚机不允许生成任何输出。让我们回到Primary崩溃前,并且计数器的内容还是10,Primary上的正确的流程是这样的:

  1. 客户端输入到达Primary。
  2. Primary的VMM将输入的拷贝发送给Backup虚机的VMM。所以有关输入的Log条目在Primary虚机生成输出之前,就发往了Backup。之后,这条Log条目通过网络发往Backup,但是过程中有可能丢失。
  3. Primary的VMM将输入发送给Primary虚机,Primary虚机生成了输出。现在Primary虚机的里的数据已经变成了11,生成的输出也包含了11。但是VMM不会无条件转发这个输出给客户端。
  4. Primary的VMM会等到之前的Log条目都被Backup虚机确认收到了才将输出转发给客户端。所以,包含了客户端输入的Log条目,会从Primary的VMM送到Backup的VMM,Backup的VMM不用等到Backup虚机实际执行这个输入,就会发送一个表明收到了这条Log的ACK报文给Primary的VMM。当Primary的VMM收到了这个ACK,才会将Primary虚机生成的输出转发到网络中。

所以,这里的核心思想是,确保在客户端看到对于请求的响应时,Backup虚机一定也看到了对应的请求,或者说至少在Backup的VMM中缓存了这个请求。这样,我们就不会陷入到这个奇怪的场景:客户端已经收到了回复,但是因为有故障发生和副本切换,新接手的副本完全不知道客户端之前收到了对应的回复。

如果在上面的步骤2中,Log条目通过网络发送给Backup虚机时丢失了,然后Primary虚机崩溃了。因为Log条目丢失了, 所以Backup节点也不会发送ACK消息。所以,如果Log条目的丢失与Primary的崩溃同一时间发生,那么Primary必然在VMM将回复转发到网络之前就崩溃了,所以客户端也就不会收到任何回复,所以客户端就不会观察到任何异常。这就是输出控制(Output rule)。

学生提问:VMM这里是具体怎么实现的?

Robert教授:我不太清楚,论文也没有说VMM是如何实现的。我的意思是,这里涉及到非常底层的内容,因为包括了内存分配,页表(page table)分配,设备驱动交互,指令拦截,并理解guest操作系统正在执行的指令。这些都是底层的东西,它们通常用C或者C++实现,但是具体的内容我就不清楚了。

所以,Primary会等到Backup已经有了最新的数据,才会将回复返回给客户端。这几乎是所有的复制方案中对于性能产生伤害的地方。这里的同步等待使得Primary不能超前Backup太多,因为如果Primary超前了并且又故障了,对应的就是Backup的状态落后于客户端的状态。

所以,几乎每一个复制系统都有这个问题,在某个时间点,Primary必须要停下来等待Backup,这对于性能是实打实的限制。即使副本机器在相邻的机架上,Primary节点发送消息并收到回复仍然需要0.5毫秒的延时。如果我们想要能承受类似于地震或者城市范围内的断电等问题,Primary和Backup需要在不同的城市,之间可能有5毫秒的差距。如果我们将两个副本放置在不同的城市,每次生成一个输出时,都需要至少等待5毫秒,等Backup确认收到了前一个Log条目,然后VMM才能将输出发送到网络。对于一些低请求量的服务,这不是问题。但是如果我们的服务要能够每秒处理数百万个请求,那就会对我们的性能产生巨大的伤害。

所以如果条件允许,人们会更喜欢使用在更高层级做复制的系统(详见4.2 最后两段)。这样的复制系统可以理解操作的含义,这样的话Primary虚机就不必在每个网络数据包暂停同步一下,而是可以在一个更高层级的操作层面暂停来做同步,甚至可以对一些只读操作不做暂停。但是这就需要一些特殊的应用程序层面的复制机制。

学生提问:其实不用暂停Primary虚机的执行,只需要阻止Primary虚机的输出就行吧?

Robert教授:你是对的。所以,这里的同步等待或许没有那么糟糕。但是不管怎么样,在一个系统中,本来可以几微秒响应一个客户端请求,而现在我们需要先更新另一个城市的副本,这可能会将一个10微秒的操作变成10毫秒。

学生提问:这里虽然等待时间比较长,如果提高请求的并发度,是不是还是可以有高性能?

Robert教授:如果你有大量的客户端并发的发送请求,那么你或许还是可以在高延时的情况下获得高的吞吐量,但是就需要你有足够聪明的设计和足够的幸运。

学生提问:可以不可以将Log保留在Primary虚机对应的物理服务器内存中,这样就不用长时间的等待了。

Robert教授:这是一个很好的想法。但是如果你这么做的话,物理服务器宕机,Log就丢失了。通常,如果服务器故障,就认为服务器中的所有数据都没了,其中包括内存的内容。如果故障是某人不小心将服务器的电源拔了,即使Primary对应的物理服务器有电池供电的RAM,Backup也没办法从其获取Log。实际上,系统会在Backup的内存中记录Log。为了保证系统的可靠性,Primary必须等待Backup的ACK才真正输出。你这里的想法很好,但是我们还是不能使用Primary的内存来存Log。

学生提问:能不能输入送到Primary,输出从Backup送出?

Robert教授:这是个很聪明的想法。我之前完全没有想到过这点。它或许可以工作,我不确定,但是这很有意思。

4.6 重复输出(Duplicated Output)

还有一种可能的情况是,回复报文已经从VMM发往客户端了,所以客户端收到了回复,但是这时Primary虚机崩溃了。而在Backup侧,客户端请求还堆积在Backup对应的VMM的Log等待缓冲区(详见4.4倒数第二个学生提问),也就是说客户端请求还没有真正发送到Backup虚机中。当Primary崩溃之后,Backup接管服务,Backup首先需要消费所有在等待缓冲区中的Log,以保持与Primay在相同的状态,这样Backup才能以与Primary相同的状态接管服务。假设最后一条Log条目对应来自客户端的请求,那么Backup会在处理完客户端请求对应的中断之后,再上线接管服务。这意味着,Backup会将自己的计数器增加到11(原来是10,处理完客户端的自增请求变成11),并生成一个输出报文。因为这时,Backup已经上线接管服务,它生成的输出报文会被它的VMM发往客户端。这样客户端会收到两个内容是11的回复。如果这里的情况真的发生了,那么明显这也是一个异常行为,因为不可能在运行在单个服务器的服务上发生这种行为。

好消息是,几乎可以肯定,客户端通过TCP与服务进行交互,也就是说客户端请求和回复都通过TCP Channel收发。当Backup接管服务时,因为它的状态与Primary相同,所以它知道TCP连接的状态和TCP传输的序列号。当Backup生成回复报文时,这个报文的TCP序列号与之前Primary生成报文的TCP序列号是一样的,这样客户端的TCP栈会发现这是一个重复的报文,它会在TCP层面丢弃这个重复的报文,用户层的软件永远也看不到这里的重复。

这里可以认为是异常的场景,并且被意外的解决了。但是事实上,对于任何有主从切换的复制系统,基本上不可能将系统设计成不产生重复输出。为了避免重复输出,有一个选项是在两边都不生成输出,但这是一个非常糟糕的做法(因为对于客户端来说就是一次失败的请求)。当出现主从切换时,切换的两边都有可能生成重复的输出,这意味着,某种程度上来说,所有复制系统的客户端需要一种重复检测机制。这里我们使用的是TCP来完成重复检测,如果我们没有TCP,那就需要另一种其他机制,或许是应用程序级别的序列号。

在lab2和lab3中,基本上可以看到我们前面介绍的所有内容,例如输出控制,你会设计你的复制状态机。

学生提问:太长了,听不太清,直接看回答吧。

Robert教授:第一部分是对的。当Backup虚机消费了最后一条Log条目,这条Log包含了客户端的请求,并且Backup上线了。从这个时间点开始,我们不需要复制任何东西,因为Primary已经挂了,现在没有任何其他副本。

如果Primary向客户端发送了一个回复报文,之后,Primary或者客户端关闭了TCP连接,所以现在客户端侧是没有TCP连接的。Primary挂了之后,Backup虚机还是有TCP连接的信息。Backup执行最后一条Log,Backup会生成一个回复报文,但是这个报文送到客户端时,客户端并没有相应的TCP连接信息。客户端会直接丢弃报文,就像这个报文不存在一样。哦不!这里客户端实际会发送一个TCP Reset,这是一个类似于TCP error的东西给Backup虚机,Backup会处理这里的TCP Reset,但是没关系,因为现在只有一个副本,Backup可以任意处理,而不用担心与其他副本有差异。实际上,Backup会直接忽略这个报文。现在Backup上线了,在这个复制系统里面,它不受任何人任何事的限制。

学生提问:Backup接手服务之后,对于之前的TCP连接,还能用相同的TCP源端口来发送数据吗(因为源端口一般是随机的)?

Robert教授:你可以这么认为。因为Backup的内存镜像与Primary的完全一致,所以它们会以相同的TCP源端口来发送数据,它们在每一件事情上都是一样的。它们发送的报文每一bit都是一样的。

学生提问:甚至对于IP地址都会是一样的吗,毕竟这里涉及两个物理服务器?

Robert教授:在这个层面,物理服务器并没有IP地址。在我们的例子中,Primary虚机和Backup虚机都有IP地址,但是物理服务器和VMM在网络上基本是透明的。物理服务器上的VMM在网络上并没有自己的唯一标识。虚拟机有自己独立的操作系统和独立的TCP栈,但是对于IP地址和其他的关联数据,Primary和Backup是一样的(类似于HA VIP)。当虚机发送一个网络报文,它会以虚机的IP地址和MAC地址来发送,这些信息是直接透传到局域网的,而这正是我们想要的。所以Backup会生成与Primary完全一样的报文。这里有一些tricky,因为如果物理服务器都接在一个以太网交换机上,那么它们必然在交换机的不同端口上,在发生切换时,我们希望以太网交换机能够知道当前主节点在哪,这样才能正常的转发报文,这会有一些额外的有意思的事情。大部分时候,Primary和Backup都是生成相同的报文,并送出。

(注:早期的VMware虚机都是直接以VLAN或者Flat形式,通过DVS接入到物理网络,所以虚拟机的报文与物理机无关,可以直接在局域网发送。以太网交换机会维护MAC地址表,表明MAC地址与交换机端口的对应,因为Primary和Backup虚机的MAC地址一样,当主从切换时,这个表需要更新,这样同一个目的MAC地址,切换前是发往了Primary虚机所在的物理服务器对应的交换机端口,切换之后是发往了Backup虚机所在的物理服务器对应的交换机端口。交换机MAC地址表的切换通常通过虚机主动发起GARP来更新。)

4.7 Test-and-Set 服务

最后还有一个细节。我一直都假设Primary出现的是fail-stop故障(详见4.1最开始),但是这不是所有的情况。一个非常常见的场景就是,Primary和Backup都在运行,但是它们之间的网络出现了问题,同时它们各自又能够与一些客户端通信。这时,它们都会以为对方挂了,自己需要上线并接管服务。所以现在,我们对于同一个服务,有两个机器是在线的。因为现在它们都不向彼此发送Log条目,它们自然就出现了分歧。它们或许会因为接收了不同的客户端请求,而变得不一样。

因为涉及到了计算机网络,那就可能出现上面的问题,而不仅仅是机器故障。如果我们同时让Primary和Backup都在线,那么我们现在就有了脑裂(Split Brain)。这篇论文解决这个问题的方法是,向一个外部的第三方权威机构求证,来决定Primary还是Backup允许上线。这里的第三方就是Test-and-Set服务。

Test-and-Set服务不运行在Primary和Backup的物理服务器上,VMware FT需要通过网络支持Test-and-Set服务。这个服务会在内存中保留一些标志位,当你向它发送一个Test-and-Set请求,它会设置标志位,并且返回旧的值。Primary和Backup都需要获取Test-and-Set标志位,这有点像一个锁。为了能够上线,它们或许会同时发送一个Test-and-Set请求,给Test-and-Set服务。当第一个请求送达时,Test-and-Set服务会说,这个标志位之前是0,现在是1。第二个请求送达时,Test-and-Set服务会说,标志位已经是1了,你不允许成为Primary。对于这个Test-and-Set服务,我们可以认为运行在单台服务器。当网络出现故障,并且两个副本都认为对方已经挂了时,Test-and-Set服务就是一个仲裁官,决定了两个副本中哪一个应该上线。

对于这种机制有什么问题吗?

学生提问:只有在网络故障的时候才需要询问Test-and-Set服务吗?

Robert教授:即使没有网络分区,在所有情况下,两个副本中任意一个觉得对方挂了,哪怕对方真的挂了,想要上线的那个副本仍然需要获得Test-and-Set服务的锁。在6.824这门课程中,有个核心的规则就是,你无法判断另一个计算机是否真的挂了,你所知道的就是,你无法从那台计算机收到网络报文,你无法判断是因为那台计算机挂了,还是因为网络出问题了导致的。所以,Backup看到的是,我收不到来自Primary的网络报文,或许Primary挂了,或许还活着。Primary或许也同时看不到Backup的报文。所以,如果存在网络分区,那么必然要询问Test-and-Set服务。但是实际上没人知道现在是不是网络分区,所以每次涉及到主从切换,都需要向Test-and-Set服务进行查询。所以,当副本想要上线的时候,Test-and-Set服务必须要在线,因为副本需要获取这里的Test-and-Set锁。现在Test-and-Set看起来像是个单点故障(Single-Point-of-Failure)。虽然VMware FT尝试构建一个复制的容错的系统,但是最后,主从切换还是依赖于Test-and-Set服务在线,这有点让人失望。我强烈的认为,Test-and-Set服务本身也是个复制的服务,并且是容错的。几乎可以肯定的是,VMware非常乐意向你售卖价值百万的高可用存储系统,系统内使用大量的复制服务。因为这里用到了Test-and-Set服务,我猜它也是复制的。

你们将要在Lab2和Lab3构建的系统,会帮助你们构建容错的Test-and-Set服务,所以这个问题可以轻易被解决。

Lecture 06 - Raft1

接下来两节课的内容,将会是Raft。这是因为Raft可以帮助同学们完成相应的实验(Lab2)。同时,Raft也是一个正确实现了状态机复制(state machine replication)的例子。

为了更好的理解本节课,强烈建议先阅读Raft论文的前5节。

Raft论文:https://pdos.csail.mit.edu/6.824/papers/raft-extended.pdf

6.1 脑裂(Split Brain)

在之前的课程中,我们介绍了几个具备容错特性(fault-tolerant)的系统。如果你有留心的话,你会发现,它们有一个共同的特点。

  • MapReduce复制了计算,但是复制这个动作,或者说整个MapReduce被一个单主节点控制。
  • GFS以主备(primary-backup)的方式复制数据。它会实际的复制文件内容。但是它也依赖一个单主节点,来确定每一份数据的主拷贝的位置。
  • VMware FT,它在一个Primary虚机和一个Backup虚机之间复制计算相关的指令。但是,当其中一个虚机出现故障时,为了能够正确的恢复。需要一个Test-and-Set服务来确认,Primary虚机和Backup虚机只有一个能接管计算任务。

这三个例子中,它们都是一个多副本系统(replication system),但是在背后,它们存在一个共性:它们需要一个单节点来决定,在多个副本中,谁是主(Primary)。

使用一个单节点的好处是,它不可能否认自己。因为只有一个节点,它的决策就是整体的决策。但是使用单节点的缺点是,它本身又是一个单点故障(Single Point of Failure)。

所以,你可以认为我们前面介绍的这些系统,它们将系统容错的关键点,转移到了这个单点上。这个单点,会在系统出现局部故障时,选择数据的主拷贝来继续工作。使用单点的原因是,我们需要避免脑裂(Split-Brain)。当出现故障时,我们之所以要极其小心的决定数据的主拷贝,是因为,如果不这么做的话,我们可能需要面临脑裂的场景。

为了让同学们更深入的了解脑裂,我接下来会说明脑裂带来的问题,以及为什么这是个严重的问题。现在,假设我们将VMware FT中的Test-and-Set服务构建成多副本的。之前这是一个单点服务,而VMware FT依赖这个Test-and-Set服务来确定Primary虚机,所以,为了提高系统的容错性,我们来构建一个多副本的Test-and-Set服务。我们来看一下,为什么出现故障时,很难避免脑裂。

现在,我们来假设我们有一个网络,这个网络里面有两个服务器(S1,S2),这两个服务器都是我们Test-and-Set服务的拷贝。这个网络里面还有两个客户端(C1,C2),它们需要通过Test-and-Set服务确定主节点是谁。在这个例子中,这两个客户端本身就是VMware FT中的Primary和Backup虚拟机。

如果这是一个Test-and-Set服务,那么你知道这两个服务器中的数据记录将从0开始。任意一个客户端发送Test-and-Set指令,这个指令会将服务器中的状态设置成1。所以在这个图里面,两个服务器都应该设置成1,然后将旧的值0,返回给客户端。本质上来说,这是一种简化了的锁服务。

当一个客户端可以与其中一个服务器通信,但是不能与另一个通信时,有可能出现脑裂的问题。我们假设,客户端发送请求时,它会将请求同时发送给两个服务器。这样,我们就需要考虑,当某个服务器不响应时,客户端该怎么做?或者说,某个服务器不响应时,整个系统该如何响应?更具体点,我们假设C1可以访问S1但是不能访问S2,系统该如何响应?

一种情况是,我们必然不想让C1只与S1通信。因为,如果我们只将C1的请求设置给S1,而不设置给S2,会导致S2的数据不一致。所以,我们或许应该规定,对于任何操作,客户端必须总是与两个服务器交互,而不是只与其中一个服务器交互。但是这是一个错误的想法,为什么呢?因为这里根本就没有容错。这里甚至比只使用一个服务器更糟。因为当两个服务器中的一个故障了或者失联了,我们的系统就不能工作了。对于一个单点的服务,我们只依赖一个服务器。现在我们有两个服务器,并且两个服务器都必须一致在线,这里的难度比单个服务器更大。如果这种方式不是容错的,我们需要一种行之有效的方法。

另一个明显的答案是,如果客户端不能同时与两个服务器交互,那它就与它能连通的那个服务器交互,同时认为另一个服务器已经关机了。为什么这也是一个错误的答案呢?因为,我们的故障场景是,另一个服务器其实还开机着。我们假设我们经历的实际问题并不是这个服务器关机了,因为如果关机了对我们来说其实更好。实际情况可能更糟糕,实际可能是网络线路出现了故障,从而导致C1可以与S1交互,但是不能与S2交互。同时,C2可以与S2交互,但是不能与S1交互。现在我们规定,如果一个客户端连接了两个服务器,为了达到一定的容错性,客户端只与其中一个服务器交互也应该可以正常工作。但是这样就不可避免的出现了这种情况:假设这根线缆中断了,将网络分为两个部分。

C1发送Test-and-Set请求给S1,S1将自己的状态设置为1,并返回之前的状态0给C1。

这就意味着,C1会认为自己持有锁。如果这是一个VMware FT,C1对应的虚拟机会认为自己可以成为主节点。

但是同时,S2里面的状态仍然是0。所以如果现在C2也发送了一个Test-and-Set请求,本来应该发送给两个服务器,但是现在从C2看来,S1不能访问,根据之前定义的规则,那就发送给S2吧。同样的C2也会认为自己持有了锁。如果这个Test-and-Set服务被VMware FT使用,那么这两个VMware 虚机都会认为自己成为了主虚拟机而不需要与另一个虚拟机协商,所以这是一个错误的场景。

所以,在这种有两个拷贝副本的配置中,看起来我们只有两种选择:要么等待两个服务器响应,那么这个时候就没有容错能力;要么只等待一个服务器响应,那么就会进入错误的场景,而这种错误的场景,通常被称为脑裂。

这基本是上世纪80年代之前要面临的挑战。但是,当时又的确有多副本系统的要求。例如,控制电话交换机的计算机系统,或者是运行银行系统的计算机系统。当时的人们在构建多副本系统时,需要排除脑裂的可能。这里有两种技术:

  • 第一种是构建一个不可能出现故障的网络。实际上,不可能出现故障的网络一直在我们的身边。你们电脑中,连接了CPU和内存的线路就是不可能出现故障的网络。所以,带着合理的假设和大量的资金,同时小心的控制物理环境,比如不要将一根网线拖在地上,让谁都可能踩上去。如果网络不会出现故障,这样就排除了脑裂的可能。这里做了一些假设,但是如果有足够的资金,人们可以足够接近这个假设。当网络不出现故障时,那就意味着,如果客户端不能与一个服务器交互,那么这个服务器肯定是关机了。
  • 另一种就是人工解决问题,不要引入任何自动完成的操作。默认情况下,客户端总是要等待两个服务器响应,如果只有一个服务器响应,永远不要执行任何操作。相应的,给运维人员打电话,让运维人员去机房检查两个服务器。要么将一台服务器直接关机,要么确认一下其中一台服务器真的关机了,而另一个台还在工作。所以本质上,这里把人作为了一个决策器。而如果把人看成一台电脑的话,那么这个人他也是个单点。

所以,很长一段时间内,人们都使用以上两种方式中的一种来构建多副本系统。这虽然不太完美,因为人工响应不能很及时,而不出现故障的网络又很贵,但是这些方法至少是可行的。

6.2 过半票决(Majority Vote)

尽管存在脑裂的可能,但是随着技术的发展,人们发现哪怕网络可能出现故障,可能出现分区,实际上是可以正确的实现能够自动完成故障切换的系统。当网络出现故障,将网络分割成两半,网络的两边独自运行,且不能访问对方,这通常被称为网络分区。

在构建能自动恢复,同时又避免脑裂的多副本系统时,人们发现,关键点在于过半票决(Majority Vote)。这是Raft论文中出现的,用来构建Raft的一个基本概念。过半票决系统的第一步在于,服务器的数量要是奇数,而不是偶数。例如在上图中(只有两个服务器),中间出现故障,那两边就太过对称了。这里被网络故障分隔的两边,它们看起来完全是一样的,它们运行了同样的软件,所以它们也会做相同的事情,这样不太好(会导致脑裂)。

但是,如果服务器的数量是奇数的,那么当出现一个网络分割时,两个网络分区将不再对称。假设出现了一个网络分割,那么一个分区会有两个服务器,另一个分区只会有一个服务器,这样就不再是对称的了。这是过半票决吸引人的地方。所以,首先你要有奇数个服务器。然后为了完成任何操作,例如Raft的Leader选举,例如提交一个Log条目,在任何时候为了完成任何操作,你必须凑够过半的服务器来批准相应的操作。这里的过半是指超过服务器总数的一半。直观来看,如果有3个服务器,那么需要2个服务器批准才能完成任何的操作。

这里背后的逻辑是,如果网络存在分区,那么必然不可能有超过一个分区拥有过半数量的服务器。例如,假设总共有三个服务器,如果一个网络分区有一个服务器,那么它不是一个过半的分区。如果一个网络分区有两个服务器,那么另一个分区必然只有一个服务器。因此另一个分区必然不能凑齐过半的服务器,也必然不能完成任何操作。

这里有一点需要明确,当我们在说过半的时候,我们是在说所有服务器数量的一半,而不是当前开机服务器数量的一半。这个点困扰了我(Robert教授)很长时间。如果你有一个系统有3个服务器,其中某些已经故障了,如果你要凑齐过半的服务器,你总是需要从3个服务器中凑出2个,即便你知道1个服务器已经因为故障关机了。过半总是相对于服务器的总数来说。

对于过半票决,可以用一个更通用的方程式来描述。在一个过半票决的系统中,如果有3台服务器,那么需要至少2台服务器来完成任意的操作。换个角度来看,这个系统可以接受1个服务器的故障,任意2个服务器都足以完成操作。如果你需要构建一个更加可靠的系统,那么你可以为系统加入更多的服务器。所以,更通用的方程是:

如果系统有 2 * F + 1 个服务器,那么系统最多可以接受F个服务器出现故障,仍然可以正常工作。

通常这也被称为多数投票(quorum)系统,因为3个服务器中的2个,就可以完成多数投票。

前面已经提过,有关过半票决系统的一个特性就是,最多只有一个网络分区会有过半的服务器,所以我们不可能有两个分区可以同时完成操作。这里背后更微妙的点在于,如果你总是需要过半的服务器才能完成任何操作,同时你有一系列的操作需要完成,其中的每一个操作都需要过半的服务器来批准,例如选举Raft的Leader,那么每一个操作对应的过半服务器,必然至少包含一个服务器存在于上一个操作的过半服务器中。也就是说,任意两组过半服务器,至少有一个服务器是重叠的。实际上,相比其他特性,Raft更依赖这个特性来避免脑裂。例如,当一个Raft Leader竞选成功,那么这个Leader必然凑够了过半服务器的选票,而这组过半服务器中,必然与旧Leader的过半服务器有重叠。所以,新的Leader必然知道旧Leader使用的任期号(term number),因为新Leader的过半服务器必然与旧Leader的过半服务器有重叠,而旧Leader的过半服务器中的每一个必然都知道旧Leader的任期号。类似的,任何旧Leader提交的操作,必然存在于过半的Raft服务器中,而任何新Leader的过半服务器中,必然有至少一个服务器包含了旧Leader的所有操作。这是Raft能正确运行的一个重要因素。

学生提问:可以为Raft添加服务器吗?

Rober教授:Raft的服务器是可以添加或者修改的,Raft的论文有介绍,可能在Section 6。如果是一个长期运行的系统,例如运行5年或者10年,你可能需要定期更换或者升级一些服务器,因为某些服务器可能会出现永久的故障,又或者你可能需要将服务器搬到另一个机房去。所以,肯定需要支持修改Raft服务器的集合。虽然这不是每天都发生,但是这是一个长期运行系统的重要维护工作。Raft的作者提出了方法来处理这种场景,但是比较复杂。

所以,在过半票决这种思想的支持下,大概1990年的时候,有两个系统基本同时被提出。这两个系统指出,你可以使用这种过半票决系统,从某种程度上来解决之前明显不可能避免的脑裂问题,例如,通过使用3个服务器而不是2个,同时使用过半票决策略。两个系统中的一个叫做Paxos,Raft论文对这个系统做了很多的讨论;另一个叫做ViewStamped Replication(VSR)。尽管Paxos的知名度高得多,Raft从设计上来说,与VSR更接近。VSR是由MIT发明的。这两个系统有着数十年的历史,但是他们仅仅是在15年前,也就是他们发明的15年之后,才开始走到最前线,被大量的大规模分布式系统所使用。

6.3 Raft 初探

这一部分来初步看一下Raft。

Raft会以库(Library)的形式存在于服务中。如果你有一个基于Raft的多副本服务,那么每个服务的副本将会由两部分组成:应用程序代码和Raft库。应用程序代码接收RPC或者其他客户端请求;不同节点的Raft库之间相互合作,来维护多副本之间的操作同步。

从软件的角度来看一个Raft节点,我们可以认为在该节点的上层,是应用程序代码。例如对于Lab 3来说,这部分应用程序代码就是一个Key-Value数据库。应用程序通常都有状态,Raft层会帮助应用程序将其状态拷贝到其他副本节点。对于一个Key-Value数据库而言,对应的状态就是Key-Value Table。应用程序往下,就是Raft层。所以,Key-Value数据库需要对Raft层进行函数调用,来传递自己的状态和Raft反馈的信息。

同时,如Raft论文中的图2所示,Raft本身也会保持状态。对我们而言,Raft的状态中,最重要的就是Raft会记录操作的日志。

对于一个拥有三个副本的系统来说,很明显我们会有三个服务器,这三个服务器有完全一样的结构(上面是应用程序层,下面是Raft层)。理想情况下,也会有完全相同的数据分别存在于两层(应用程序层和Raft层)中。除此之外,还有一些客户端,假设我们有了客户端1(C1),客户端2(C2)等等。

客户端就是一些外部程序代码,它们想要使用服务,同时它们不知道,也没有必要知道,它们正在与一个多副本服务交互。从客户端的角度来看,这个服务与一个单点服务没有区别。

客户端会将请求发送给当前Raft集群中的Leader节点对应的应用程序。这里的请求就是应用程序级别的请求,例如一个访问Key-Value数据库的请求。这些请求可能是Put也可能是Get。Put请求带了一个Key和一个Value,将会更新Key-Value数据库中,Key对应的Value;而Get向当前服务请求某个Key对应的Value。

所以,看起来似乎没有Raft什么事,看起来就像是普通的客户端服务端交互。一旦一个Put请求从客户端发送到了服务端,对于一个单节点的服务来说,应用程序会直接执行这个请求,更新Key-Value表,之后返回对于这个Put请求的响应。但是对于一个基于Raft的多副本服务,就要复杂一些。

假设客户端将请求发送给Raft的Leader节点,在服务端程序的内部,应用程序只会将来自客户端的请求对应的操作向下发送到Raft层,并且告知Raft层,请把这个操作提交到多副本的日志(Log)中,并在完成时通知我。

之后,Raft节点之间相互交互,直到过半的Raft节点将这个新的操作加入到它们的日志中,也就是说这个操作被过半的Raft节点复制了。

当且仅当Raft的Leader节点知道了所有(课程里说的是所有,但是这里应该是过半节点)的副本都有了这个操作的拷贝之后。Raft的Leader节点中的Raft层,会向上发送一个通知到应用程序,也就是Key-Value数据库,来说明:刚刚你提交给我的操作,我已经提交给所有(注:同上一个说明)副本,并且已经成功拷贝给它们了,现在,你可以真正的执行这个操作了。

所以,客户端发送请求给Key-Value数据库,这个请求不会立即被执行,因为这个请求还没有被拷贝。当且仅当这个请求存在于过半的副本节点中时,Raft才会通知Leader节点,只有在这个时候,Leader才会实际的执行这个请求。对于Put请求来说,就是更新Value,对于Get请求来说,就是读取Value。最终,请求返回给客户端,这就是一个普通请求的处理过程。

学生提问:问题听不清。。。这里应该是学生在纠正前面对于所有节点和过半节点的混淆

Robert教授:这里只需要拷贝到过半服务器即可。为什么不需要拷贝到所有的节点?因为我们想构建一个容错系统,所以即使某些服务器故障了,我们依然期望服务能够继续工作。所以只要过半服务器有了相应的拷贝,那么请求就可以提交。

学生提问:除了Leader节点,其他节点的应用程序层会有什么样的动作?

Robert教授:哦对,抱歉。当一个操作最终在Leader节点被提交之后,每个副本节点的Raft层会将相同的操作提交到本地的应用程序层。在本地的应用程序层,会将这个操作更新到自己的状态。所以,理想情况是,所有的副本都将看到相同的操作序列,这些操作序列以相同的顺序出现在Raft到应用程序的upcall中,之后它们以相同的顺序被本地应用程序应用到本地的状态中。假设操作是确定的(比如一个随机数生成操作就不是确定的),所有副本节点的状态,最终将会是完全一样的。我们图中的Key-Value数据库,就是Raft论文中说的状态(也就是Key-Value数据库的多个副本最终会保持一致)。

6.4 Log 同步时序

这一部分我们从另一个角度来看Raft Log同步的一些交互,这种角度将会在这门课中出现很多次,那就是时序图。

接下来我将画一个时序图来描述Raft内部的消息是如何工作的。假设我们有一个客户端,服务器1是当前Raft集群的Leader。同时,我们还有服务器2,服务器3。这张图的纵坐标是时间,越往下时间越长。假设客户端将请求发送给服务器1,这里的客户端请求就是一个简单的请求,例如一个Put请求。

之后,服务器1的Raft层会发送一个添加日志(AppendEntries)的RPC到其他两个副本(S2,S3)。现在服务器1会一直等待其他副本节点的响应,一直等到过半节点的响应返回。这里的过半节点包括Leader自己。所以在一个只有3个副本节点的系统中,Leader只需要等待一个其他副本节点。

一旦过半的节点返回了响应,这里的过半节点包括了Leader自己,所以在一个只有3个副本的系统中,Leader只需要等待一个其他副本节点返回对于AppendEntries的正确响应。

当Leader收到了过半服务器的正确响应,Leader会执行(来自客户端的)请求,得到结果,并将结果返回给客户端。

与此同时,服务器3可能也会将它的响应返回给Leader,尽管这个响应是有用的,但是这里不需要等待这个响应。这一点对于理解Raft论文中的图2是有用的。

好了,大家明白了吗?这是系统在没有故障情况下,处理普通操作的流程。

学生提问:S2和S3的状态怎么保持与S1同步?

Robert教授:我的天,我忘了一些重要的步骤。现在Leader知道过半服务器已经添加了Log,可以执行客户端请求,并返回给客户端。但是服务器2还不知道这一点,服务器2只知道:我从Leader那收到了这个请求,但是我不知道这个请求是不是已经被Leader提交(committed)了,这取决于我的响应是否被Leader收到。服务器2只知道,它的响应提交给了网络,或许Leader没有收到这个响应,也就不会决定commit这个请求。所以这里还有一个阶段。一旦Leader发现请求被commit之后,它需要将这个消息通知给其他的副本。所以这里有一个额外的消息。

这条消息的具体内容依赖于整个系统的状态。至少在Raft中,没有明确的committed消息。相应的,committed消息被夹带在下一个AppendEntries消息中,由Leader下一次的AppendEntries对应的RPC发出。任何情况下,当有了committed消息时,这条消息会填在AppendEntries的RPC中。下一次Leader需要发送心跳,或者是收到了一个新的客户端请求,要将这个请求同步给其他副本时,Leader会将新的更大的commit号随着AppendEntries消息发出,当其他副本收到了这个消息,就知道之前的commit号已经被Leader提交,其他副本接下来也会执行相应的请求,更新本地的状态。

学生提问:这里的内部交互有点多吧?

Robert教授:是的,这是一个内部需要一些交互的协议,它不是特别的快。实际上,客户端发出请求,请求到达某个服务器,这个服务器至少需要与一个其他副本交互,在返回给客户端之前,需要等待多条消息。所以,一个客户端响应的背后有多条消息的交互。

学生提问:也就是说commit信息是随着普通的AppendEntries消息发出的?那其他副本的状态更新就不是很及时了。

Robert教授:是的,作为实现者,这取决于你在什么时候将新的commit号发出。如果客户端请求很稀疏,那么Leader或许要发送一个心跳或者发送一条特殊的AppendEntries消息。如果客户端请求很频繁,那就无所谓了。因为如果每秒有1000个请求,那么下一条AppendEntries很快就会发出,你可以在下一条消息中带上新的commit号,而不用生成一条额外的消息。额外的消息代价还是有点高的,反正你要发送别的消息,可以把新的commit号带在别的消息里。

实际上,我不认为其他副本(非Leader)执行客户端请求的时间很重要,因为没有人在等这个步骤。至少在不出错的时候,其他副本执行请求是个不太重要的步骤。例如说,客户端就没有等待其他副本执行请求,客户端只会等待Leader执行请求。所以,其他副本在什么时候执行请求,不会影响客户端感受的请求时延。

6.5 日志(Raft Log)

你们应该关心的一个问题是:为什么Raft系统这么关注Log,Log究竟起了什么作用?这个问题值得好好来回答一下。

Raft系统之所以对Log关注这么多的一个原因是,Log是Leader用来对操作排序的一种手段。这对于复制状态机(详见4.2)而言至关重要,对于这些复制状态机来说,所有副本不仅要执行相同的操作,还需要用相同的顺序执行这些操作。而Log与其他很多事物,共同构成了Leader对接收到的客户端操作分配顺序的机制。比如说,我有10个客户端同时向Leader发出请求,Leader必须对这些请求确定一个顺序,并确保所有其他的副本都遵从这个顺序。实际上,Log是一些按照数字编号的槽位(类似一个数组),槽位的数字表示了Leader选择的顺序。

Log的另一个用途是,在一个(非Leader,也就是Follower)副本收到了操作,但是还没有执行操作时。该副本需要将这个操作存放在某处,直到收到了Leader发送的新的commit号才执行。所以,对于Raft的Follower来说,Log是用来存放临时操作的地方。Follower收到了这些临时的操作,但是还不确定这些操作是否被commit了。我们将会看到,这些操作可能会被丢弃。

Log的另一个用途是用在Leader节点,我(Robert教授)很喜欢这个特性。Leader需要在它的Log中记录操作,因为这些操作可能需要重传给Follower。如果一些Follower由于网络原因或者其他原因短时间离线了或者丢了一些消息,Leader需要能够向Follower重传丢失的Log消息。所以,Leader也需要一个地方来存放客户端请求的拷贝。即使对那些已经commit的请求,为了能够向丢失了相应操作的副本重传,也需要存储在Leader的Log中。

所有节点都需要保存Log还有一个原因,就是它可以帮助重启的服务器恢复状态。你可能的确需要一个故障了的服务器在修复后,能重新加入到Raft集群,要不然你就永远少了一个服务器。比如对于一个3节点的集群来说,如果一个节点故障重启之后不能自动加入,那么当前系统只剩2个节点,那将不能再承受任何故障,所以我们需要能够重新并入故障重启了的服务器。对于一个重启的服务器来说,会使用存储在磁盘中的Log。每个Raft节点都需要将Log写入到它的磁盘中,这样它故障重启之后,Log还能保留。而这个Log会被Raft节点用来从头执行其中的操作进而重建故障前的状态,并继续以这个状态运行。所以,Log也会被用来持久化存储操作,服务器可以依赖这些操作来恢复状态。

学生提问:假设Leader每秒可以执行1000条操作,Follower只能每秒执行100条操作,并且这个状态一直持续下去,会怎样?

Robert(教授):这里有一点需要注意,Follower在实际执行操作前会确认操作。所以,它们会确认,并将操作堆积在Log中。而Log又是无限的,所以Follower或许可以每秒确认1000个操作。如果Follower一直这么做,它会生成无限大的Log,因为Follower的执行最终将无限落后于Log的堆积。 所以,当Follower堆积了10亿(不是具体的数字,指很多很多)Log未执行,最终这里会耗尽内存。之后Follower调用内存分配器为Log申请新的内存时,内存申请会失败。Raft并没有流控机制来处理这种情况。

所以我认为,在一个实际的系统中,你需要一个额外的消息,这个额外的消息可以夹带在其他消息中,也不必是实时的,但是你或许需要一些通信来(让Follower)告诉Leader,Follower目前执行到了哪一步。这样Leader就能知道自己在操作执行上领先太多。所以是的,我认为在一个生产环境中,如果你想使用系统的极限性能,你还是需要一条额外的消息来调节Leader的速度。

学生提问:如果其中一个服务器故障了,它的磁盘中会存有Log,因为这是Raft论文中图2要求的,所以服务器可以从磁盘中的Log恢复状态,但是这个服务器不知道它当前在Log中的执行位置。同时,当它第一次启动时,它也不知道那些Log被commit了。

Robert教授:所以,对于第一个问题的答案是,一个服务器故障重启之后,它会立即读取Log,但是接下来它不会根据Log做任何操作,因为它不知道当前的Raft系统对Log提交到了哪一步,或许有1000条未提交的Log。

学生补充问题:如果Leader出现了故障会怎样?

Robert教授:如果Leader也关机也没有区别。让我们来假设Leader和Follower同时故障了,那么根据Raft论文图2,它们只有non-volatile状态(也就是磁盘中存储的状态)。这里的状态包括了Log和最近一次任期号(Term Number)。如果大家都出现了故障然后大家都重启了,它们中没有一个在刚启动的时候就知道它们在故障前执行到了哪一步。所以这个时候,会先进行Leader选举,其中一个被选为Leader。如果你回顾一下Raft论文中的图2有关AppendEntries的描述,这个Leader会在发送第一次心跳时弄清楚,整个系统中目前执行到了哪一步。Leader会确认一个过半服务器认可的最近的Log执行点,这就是整个系统的执行位置。另一种方式来看这个问题,一旦你通过AppendEntries选择了一个Leader,这个Leader会迫使其他所有副本的Log与自己保持一致。这时,再配合Raft论文中介绍的一些其他内容,由于Leader知道它迫使其他所有的副本都拥有与自己一样的Log,那么它知道,这些Log必然已经commit,因为它们被过半的副本持有。这时,按照Raft论文的图2中对AppendEntries的描述,Leader会增加commit号。之后,所有节点可以从头开始执行整个Log,并从头构造自己的状态。但是这里的计算量或许会非常大。所以这是Raft论文的图2所描述的过程,很明显,这种从头开始执行的机制不是很好,但是这是Raft协议的工作流程。下一课我们会看一种更有效的,利用checkpoint的方式。

所以,这就是普通的,无故障操作的时序。

6.6 应用层接口

这一部分简单介绍一下应用层和Raft层之间的接口。你或许已经通过实验了解了一些,但是我们这里大概来看一下。假设我们的应用程序是一个key-value数据库,下面一层是Raft层。

在Raft集群中,每一个副本上,这两层之间主要有两个接口。

第一个接口是key-value层用来转发客户端请求的接口。如果客户端发送一个请求给key-value层,key-value层会将这个请求转发给Raft层,并说:请将这个请求存放在Log中的某处。

这个接口实际上是个函数调用,称之为Start函数。这个函数只接收一个参数,就是客户端请求。key-value层说:我接到了这个请求,请把它存在Log中,并在committed之后告诉我。

另一个接口是,随着时间的推移,Raft层会通知key-value层:哈,你刚刚在Start函数中传给我的请求已经commit了。Raft层通知的,不一定是最近一次Start函数传入的请求。例如在任何请求commit之前,可能会再有超过100个请求通过Start函数传给Raft层。

这个向上的接口以go channel中的一条消息的形式存在。Raft层会发出这个消息,key-value层要读取这个消息。所以这里有个叫做applyCh的channel,通过它你可以发送ApplyMsg消息。

当然,key-value层需要知道从applyCh中读取的消息,对应之前调用的哪个Start函数,所以Start函数的返回需要有足够的信息给key-value层,这样才能完成对应。Start函数的返回值包括,这个请求将会存放在Log中的位置(index)。这个请求不一定能commit成功,但是如果commit成功的话,会存放在这个Log位置。同时,它还会返回当前的任期号(term number)和一些其它我们现在还不太关心的内容。

在ApplyMsg中,将会包含请求(command)和对应的Log位置(index)。

所有的副本都会收到这个ApplyMsg消息,它们都知道自己应该执行这个请求,弄清楚这个请求的具体含义,并将它应用在本地的状态中。所有的副本节点还会拿到Log的位置信息(index),但是这个位置信息只在Leader有用,因为Leader需要知道ApplyMsg中的请求究竟对应哪个客户端请求(进而响应客户端请求)。

学生提问:为什么不在Start函数返回的时候就响应客户端请求呢?

Robert教授:我们假设客户端发送了任意的请求,我们假设这里是一个Put或者Get请求,是什么其实不重要,我们还是假设这里是个Get请求。客户端发送了一个Get请求,并且等待响应。当Leader知道这个请求被(Raft)commit之后,会返回响应给客户端。所以这里会是一个Get响应。所以,(在Leader返回响应之前)客户端看不到任何内容。

这意味着,在实际的软件中,客户端调用key-value的RPC,key-value层收到RPC之后,会调用Start函数,Start函数会立即返回,但是这时,key-value层不会返回消息给客户端,因为它还没有执行客户端请求,它也不知道这个请求是否会被(Raft)commit。一个不能commit的场景是,当key-value层调用了Start函数,Start函数返回之后,它就故障了,所以它必然没有发送Apply Entry消息或者其他任何消息,所以也不能执行commit。

所以实际上,Start函数返回了,随着时间的推移,对应于这个客户端请求的ApplyMsg从applyCh channel中出现在了key-value层。只有在那个时候,key-value层才会执行这个请求,并返回响应给客户端。

有一件事情你们需要熟悉,那就是,首先,对于Log来说有一件有意思的事情:不同副本的Log或许不完全一样。有很多场合都会不一样,至少不同副本节点的Log的末尾,会短暂的不同。例如,一个Leader开始发出一轮AppendEntries消息,但是在完全发完之前就故障了。这意味着某些副本收到了这个AppendEntries,并将这条新Log存在本地。而那些没有收到AppendEntries消息的副本,自然也不会将这条新Log存入本地。所以,这里很容易可以看出,不同副本中,Log有时会不一样。

不过对于Raft来说,Raft会最终强制不同副本的Log保持一致。或许会有短暂的不一致,但是长期来看,所有副本的Log会被Leader修改,直到Leader确认它们都是一致的。

接下来会有有关Raft的两个大的主题,一个是Lab2的内容:Leader Election是如何工作的;另一个是,Leader如何处理不同的副本日志的差异,尤其在出现故障之后。

6.7 Leader选举(Leader Election)

这一部分我们来看一下Leader选举。这里有个问题,为什么Raft系统会有个Leader,为什么我们需要一个Leader?

答案是,你可以不用Leader就构建一个类似的系统。实际上有可能不引入任何指定的Leader,通过一组服务器来共同认可Log的顺序,进而构建一个一致系统。实际上,Raft论文中引用的Paxos系统就没有Leader,所以这是有可能的。

有很多原因导致了Raft系统有一个Leader,其中一个最主要的是:通常情况下,如果服务器不出现故障,有一个Leader的存在,会使得整个系统更加高效。因为有了一个大家都知道的指定的Leader,对于一个请求,你可以只通过一轮消息就获得过半服务器的认可。对于一个无Leader的系统,通常需要一轮消息来确认一个临时的Leader,之后第二轮消息才能确认请求。所以,使用一个Leader可以提升系统性能至2倍。同时,有一个Leader可以更好的理解Raft系统是如何工作的。

Raft生命周期中可能会有不同的Leader,它使用任期号(term number)来区分不同的Leader。Followers(非Leader副本节点)不需要知道Leader的ID,它们只需要知道当前的任期号。每一个任期最多有一个Leader,这是一个很关键的特性。对于每个任期来说,或许没有Leader,或许有一个Leader,但是不可能有两个Leader出现在同一个任期中。每个任期必然最多只有一个Leader。

那Leader是如何创建出来的呢?每个Raft节点都有一个选举定时器(Election Timer),如果在这个定时器时间耗尽之前,当前节点没有收到任何当前Leader的消息,这个节点会认为Leader已经下线,并开始一次选举。所以我们这里有了这个选举定时器,当它的时间耗尽时,当前节点会开始一次选举。

开始一次选举的意思是,当前服务器会增加任期号(term number),因为它想成为一个新的Leader。而你知道的,一个任期内不能有超过一个Leader,所以为了成为一个新的Leader,这里需要开启一个新的任期。 之后,当前服务器会发出请求投票(RequestVote)RPC,这个消息会发给所有的Raft节点。其实只需要发送到N-1个节点,因为Raft规定了,Leader的候选人总是会在选举时投票给自己。

这里需要注意的一点是,并不是说如果Leader没有故障,就不会有选举。但是如果Leader的确出现了故障,那么一定会有新的选举。这个选举的前提是其他服务器还在运行,因为选举需要其他服务器的选举定时器超时了才会触发。另一方面,如果Leader没有故障,我们仍然有可能会有一次新的选举。比如,如果网络很慢,丢了几个心跳,或者其他原因,这时,尽管Leader还在健康运行,我们可能会有某个选举定时器超时了,进而开启一次新的选举。在考虑正确性的时候,我们需要记住这点。所以这意味着,如果有一场新的选举,有可能之前的Leader仍然在运行,并认为自己还是Leader。例如,当出现网络分区时,旧Leader始终在一个小的分区中运行,而较大的分区会进行新的选举,最终成功选出一个新的Leader。这一切,旧的Leader完全不知道。所以我们也需要关心,在不知道有新的选举时,旧的Leader会有什么样的行为?

(注:下面这一段实际在Lec 06的65-67分钟出现,与这一篇前后的内容在时间上不连续,但是因为内容相关就放到这里来了)

假设网线故障了,旧的Leader在一个网络分区中,这个网络分区中有一些客户端和少数(未过半)的服务器。在网络的另一个分区中,有着过半的服务器,这些服务器选出了一个新的Leader。旧的Leader会怎样,或者说为什么旧的Leader不会执行错误的操作?这里看起来有两个潜在的问题。第一个问题是,如果一个Leader在一个网络分区中,并且这个网络分区没有过半的服务器。那么下次客户端发送请求时,这个在少数分区的Leader,它会发出AppendEntries消息。但是因为它在少数分区,即使包括它自己,它也凑不齐过半服务器,所以它永远不会commit这个客户端请求,它永远不会执行这个请求,它也永远不会响应客户端,并告诉客户端它已经执行了这个请求。所以,如果一个旧的Leader在一个不同的网络分区中,客户端或许会发送一个请求给这个旧的Leader,但是客户端永远也不能从这个Leader获得响应。所以没有客户端会认为这个旧的Leader执行了任何操作。另一个更奇怪的问题是,有可能Leader在向一部分Followers发完AppendEntries消息之后就故障了,所以这个Leader还没决定commit这个请求。这是一个非常有趣的问题,我将会再花45分钟(下一节课)来讲。

学生提问:有没有可能出现极端的情况,导致单向的网络出现故障,进而使得Raft系统不能工作?

Robert教授:我认为是有可能的。例如,如果当前Leader的网络单边出现故障,Leader可以发出心跳,但是又不能收到任何客户端请求。它发出的心跳被送达了,因为它的出方向网络是正常的,那么它的心跳会抑制其他服务器开始一次新的选举。但是它的入方向网络是故障的,这会阻止它接收或者执行任何客户端请求。这个场景是Raft并没有考虑的众多极端的网络故障场景之一。

我认为这个问题是可修复的。我们可以通过一个双向的心跳来解决这里的问题。在这个双向的心跳中,Leader发出心跳,但是这时Followers需要以某种形式响应这个心跳。如果Leader一段时间没有收到自己发出心跳的响应,Leader会决定卸任,这样我认为可以解决这个特定的问题和一些其他的问题。

你是对的,网络中可能发生非常奇怪的事情,而Raft协议没有考虑到这些场景。

所以,我们这里有Leader选举,我们需要确保每个任期最多只有一个Leader。Raft是如何做到这一点的呢?

为了能够当选,Raft要求一个候选人从过半服务器中获得认可投票。每个Raft节点,只会在一个任期内投出一个认可选票。这意味着,在任意一个任期内,每一个节点只会对一个候选人投一次票。这样,就不可能有两个候选人同时获得过半的选票,因为每个节点只会投票一次。所以这里是过半原则导致了最多只能有一个胜出的候选人,这样我们在每个任期会有最多一个选举出的候选人。

同时,也是非常重要的一点,过半原则意味着,即使一些节点已经故障了,你仍然可以赢得选举。如果少数服务器故障了或者出现了网络问题,我们仍然可以选举出Leader。如果超过一半的节点故障了,不可用了,或者在另一个网络分区,那么系统会不断地额尝试选举Leader,并永远也不能选出一个Leader,因为没有过半的服务器在运行。

如果一次选举成功了,整个集群的节点是如何知道的呢?当一个服务器赢得了一次选举,这个服务器会收到过半的认可投票,这个服务器会直接知道自己是新的Leader,因为它收到了过半的投票。但是其他的服务器并不能直接知道谁赢得了选举,其他服务器甚至都不知道是否有人赢得了选举。这时,(赢得了选举的)候选人,会通过心跳通知其他服务器。Raft论文的图2规定了,如果你赢得了选举,你需要立刻发送一条AppendEntries消息给其他所有的服务器。这条代表心跳的AppendEntries并不会直接说:我赢得了选举,我就是任期23的Leader。这里的表达会更隐晦一些。Raft规定,除非是当前任期的Leader,没人可以发出AppendEntries消息。所以假设我是一个服务器,我发现对于任期19有一次选举,过了一会我收到了一条AppendEntries消息,这个消息的任期号就是19。那么这条消息告诉我,我不知道的某个节点赢得了任期19的选举。所以,其他服务器通过接收特定任期号的AppendEntries来知道,选举成功了。

6.8 选举定时器(Election Timer)

(选举定时器在上一篇有过一些介绍)

任何一条AppendEntries消息都会重置所有Raft节点的选举定时器。这样,只要Leader还在线,并且它还在以合理的速率(不能太慢)发出心跳或者其他的AppendEntries消息,Followers收到了AppendEntries消息,会重置自己的选举定时器,这样Leader就可以阻止任何其他节点成为一个候选人。所以只要所有环节都在正常工作,不断重复的心跳会阻止任何新的选举发生。当然,如果网络故障或者发生了丢包,不可避免的还是会有新的选举。但是如果一切都正常,我们不太可能会有一次新的选举。

如果一次选举选出了0个Leader,这次选举就失败了。有一些显而易见的场景会导致选举失败,例如太多的服务器关机或者不可用了,或者网络连接出现故障。这些场景会导致你不能凑齐过半的服务器,进而也不能赢得选举,这时什么事也不会发生。

一个导致选举失败的更有趣的场景是,所有环节都在正常工作,没有故障,没有丢包,但是候选人们几乎是同时参加竞选,它们分割了选票(Split Vote)。假设我们有一个3节点的多副本系统,3个节点的选举定时器几乎同超时,进而期触发选举。首先,每个节点都会为自己投票。之后,每个节点都会收到其他节点的RequestVote消息,因为该节点已经投票给自己了,所以它会返回反对投票。这意味着,3个节点中的每个节点都只能收到一张投票(来自于自己)。没有一个节点获得了过半投票,所以也就没有人能被选上。接下来它们的选举定时器会重新计时,因为选举定时器只会在收到了AppendEntries消息时重置,但是由于没有Leader,所有也就没有AppendEntries消息。所有的选举定时器重新开始计时,如果我们不够幸运的话,所有的定时器又会在同一时间到期,所有节点又会投票给自己,又没有人获得了过半投票,这个状态可能会一直持续下去。

Raft不能完全避免分割选票(Split Vote),但是可以使得这个场景出现的概率大大降低。Raft通过为选举定时器随机的选择超时时间来达到这一点。我们可以这样来看这种随机的方法。假设这里有个时间线,我会在上面画上事件。在某个时间,所有的节点收到了最后一条AppendEntries消息。之后,Leader就故障了。我们这里假设Leader在发出最后一次心跳之后就故障关机了。所有的Followers在同一时间重置了它们的选举定时器,因为它们大概率在同一时间收到了这条AppendEntries消息。

它们都重置了自己的选举定时器,这样在将来的某个时间会触发选举。但是这时,它们为选举定时器选择了不同的超时时间。

假设故障的旧的Leader是服务器1,那么服务器2(S2),服务器3(S3)会在这个点为它们的选举定时器设置随机的超时时间。

假设S2的选举定时器的超时时间在这,而S3的在这。

这个图里的关键点在于,因为不同的服务器都选取了随机的超时时间,总会有一个选举定时器先超时,而另一个后超时。假设S2和S3之间的差距足够大,先超时的那个节点(也就是S2)能够在另一个节点(也就是S3)超时之前,发起一轮选举,并获得过半的选票,那么那个节点(也就是S2)就可以成为新的Leader。大家都明白了随机化是如何去除节点之间的同步特性吗?

这里对于选举定时器的超时时间的设置,需要注意一些细节。一个明显的要求是,选举定时器的超时时间需要至少大于Leader的心跳间隔。这里非常明显,假设Leader每100毫秒发出一个心跳,你最好确认所有节点的选举定时器的超时时间不要小于100毫秒,否则该节点会在收到正常的心跳之前触发选举。所以,选举定时器的超时时间下限是一个心跳的间隔。实际上由于网络可能丢包,这里你或许希望将下限设置为多个心跳间隔。所以如果心跳间隔是100毫秒,你或许想要将选举定时器的最短超时时间设置为300毫秒,也就是3次心跳的间隔。所以,如果心跳间隔是这么多(两个AE之间),那么你会想要将选举定时器的超时时间下限设置成心跳间隔的几倍,在这里。

那超时时间的上限呢?因为随机的话都是在一个范围内随机,那我们应该在哪设置超时时间的上限呢?在一个实际系统中,有几点需要注意。

首先,这里的最大超时时间影响了系统能多快从故障中恢复。因为从旧的Leader故障开始,到新的选举开始这段时间,整个系统是瘫痪了。尽管还有一些其他服务器在运行,但是因为没有Leader,客户端请求会被丢弃。所以,这里的上限越大,系统的恢复时间也就越长。这里究竟有多重要,取决于我们需要达到多高的性能,以及故障出现的频率。如果一年才出一次故障,那就无所谓了。如果故障很频繁,那么我们或许就该关心恢复时间有多长。这是一个需要考虑的点。

另一个需要考虑的点是,不同节点的选举定时器的超时时间差(S2和S3之间)必须要足够长,使得第一个开始选举的节点能够完成一轮选举。这里至少需要大于发送一条RPC所需要的往返(Round-Trip)时间。

或许需要10毫秒来发送一条RPC,并从其他所有服务器获得响应。如果这样的话,我们需要设置超时时间的上限到足够大,从而使得两个随机数之间的时间差极有可能大于10毫秒。

在Lab2中,如果你的代码不能在几秒内从一个Leader故障的场景中恢复的话,测试代码会报错。所以这种场景下,你们需要调小选举定时器超时时间的上限。这样的话,你才可能在几秒内完成一次Leader选举。这并不是一个很严格的限制。

这里还有一个小点需要注意,每一次一个节点重置自己的选举定时器时,都需要重新选择一个随机的超时时间。也就是说,不要在服务器启动的时候选择一个随机的超时时间,然后反复使用同一个值。因为如果你不够幸运的话,两个服务器会以极小的概率选择相同的随机超时时间,那么你会永远处于分割选票的场景中。所以你需要每次都为选举定时器选择一个不同的随机超时时间。

6.9 可能的异常情况

一个旧Leader在各种奇怪的场景下故障之后,为了恢复系统的一致性,一个新任的Leader如何能整理在不同副本上可能已经不一致的Log?

这个话题只在Leader故障之后才有意义,如果Leader正常运行,Raft不太会出现问题。如果Leader正在运行,并且在其运行时,系统中有过半服务器。Leader只需要告诉Followers,Log该是什么样子。Raft要求Followers必须同意并接收Leader的Log,这在Raft论文的图2中有说明。只要Followers还能处理,它们就会全盘接收Leader在AppendEntries中发送给它们的内容,并加到本地的Log中。之后再收到来自Leader的commit消息,在本地执行请求。这里很难出错。

在Raft中,当Leader故障了才有可能出错。例如,旧的Leader在发送消息的过程中故障了,或者新Leader在刚刚当选之后,还没来得及做任何操作就故障了。所以这里有一件事情我们非常感兴趣,那就是在一系列故障之后,Log会是怎样?

这里有个例子,假设我们有3个服务器(S1,S2,S3),我将写出每个服务器的Log,每一列对齐之后就是Log的一个槽位。我这里写的值是Log条目对应的任期号,而不是Log记录的客户端请求。所以第一列是槽位1,第二列是槽位2。所有节点在任期3的时候记录了一个请求在槽位1,S2和S3在任期3的时候记录了一个请求在槽位2。在槽位2,S1没有任何记录。

所以,这里的问题是:这种情况可能发生吗?如果可能发生,是怎么发生的?

这种情况是可能发生的。假设S3是任期3的Leader,它收到了一个客户端请求,之后发送给其他服务器。其他服务器收到了相应的AppendEntries消息,并添加Log到本地,这是槽位1的情况。之后,S3从客户端收到了第二个请求,它还是需要将这个请求发送给其他服务器。但是这里有三种情况:

  • 发送给S1的消息丢了
  • S1当时已经关机了
  • S3在向S2发送完AppendEntries之后,在向S1发送AppendEntries之前故障了

现在,只有S2和S3有槽位2的Log。Leader在发送AppendEntries消息之前,总是会将新的请求加到自己的Log中(所以S3有Log),而现在AppendEntries RPC只送到了S2(所以S2有Log)。这是不同节点之间Log不一样的一种最简单的场景。我们现在知道了它是如何发生的。

如果现任Leader S3故障了,首先我们需要新的选举,之后某个节点会被选为新的Leader。接下来会发生两件事情:

  • 新的Leader需要认识到,槽位2的请求可能已经commit了,从而不能丢弃。
  • 新的Leader需要确保S1在槽位2记录与其他节点完全一样的请求。

这里还有另外一个例子需要考虑。还是3个服务器,这次我会给Log的槽位加上数字,这样更方便我们后面说明。我们这里有槽位10、11、12、13。槽位10和槽位11类似于前一个例子。在槽位12,S2有一个任期4的请求,而S3有一个任期5的请求。在我们分析之前,我们需要明白,发生了什么会导致这个场景?我们需要清楚这个场景是否真的存在,因为有些场景不可能存在我们也就没必要考虑它。所以现在的问题是,这种场景可能发生吗?

这种场景是可能发生的。我们假设S2在槽位12时,是任期4的新Leader,它收到了来自客户端的请求,将这个请求加到了自己的Log中,然后就故障了。

因为Leader故障了,我们需要一次新的选举。我们来看哪个服务器可以被选为新的Leader。这里S3可能被选上,因为它只需要从过半服务器获得认可投票,而在这个场景下,过半服务器就是S1和S3。所以S3可能被选为任期5的新Leader,之后收到了来自客户端的请求,将这个请求加到自己的Log中,然后故障了。之后就到了例子中的场景了。

因为可能发生,Raft必须能够处理这种场景。在我们讨论Raft会如何做之前,我们必须了解,怎样才是一种可接受的结果。大概看一眼这个图,我们知道在槽位10的Log,3个副本都有记录,它可能已经commit了,所以我们不能丢弃它。类似的在槽位11的Log,因为它被过半服务器记录了,它也可能commit了,所以我们也不能丢弃它。在槽位12记录的两个Log(分别是任期4和任期5),都没有被commit,所以Raft可以丢弃它们。这里没有要求必须都丢弃它们,但是至少需要丢弃一个Log,因为最终你还是要保持多个副本之间的Log一致。

学生提问:槽位10和11的请求必然执行成功了吗?

Robert教授:对于槽位11,甚至对于槽位10,我们不能从Log中看出来Leader在故障之前到底执行到了哪一步。有一种可能是Leader在发送完AppendEntries之后就立刻故障了,所以Leader没能收到其他副本的确认,相应的请求也就不会commit,进而也就不会执行这个请求,所以它也就不会发出增加了的commit值,其他副本也就可能也没有执行这个请求。所以完全可能槽位10和槽位11的请求没有被执行。如果Raft能知道这些,那么丢弃槽位10和槽位11的Log也是合法的,因为它们没有被commit。但是从Log上看,没有办法否认这些请求被commit了。换句话说,这些请求可能commit了。所以Raft必须认为它们已经被commit了,因为完全有可能,Leader是在对这些请求走完完整流程之后再故障。所以这里,我们不能排除Leader已经返回响应给客户端的可能性,只要这种可能性存在,我们就不能将槽位10和槽位11的Log丢弃,因为客户端可能已经知道了这个请求被执行了。所以我们必须假设这些请求被commit了。

我们会在下一节课继续这个话题。

Lecture 07 - Raft2

为了更好的理解本节课,强烈建议先阅读Raft论文的第7节至最后。

Raft论文:https://pdos.csail.mit.edu/6.824/papers/raft-extended.pdf

7.1 日志恢复(Log Backup)

(接6.9 的内容)

我们现在处于这样一个场景

我们假设下一个任期是6。尽管你无法从黑板上确认这一点,但是下一个任期号至少是6或者更大。我们同时假设S3在任期6被选为Leader。在某个时刻,新Leader S3会发送任期6的第一个AppendEntries RPC,来传输任期6的第一个Log,这个Log应该在槽位13。

这里的AppendEntries消息实际上有两条,因为要发给两个Followers。它们包含了客户端发送给Leader的请求。我们现在想将这个请求复制到所有的Followers上。这里的AppendEntries RPC还包含了prevLogIndex字段和prevLogTerm字段。所以Leader在发送AppendEntries消息时,会附带前一个槽位的信息。在我们的场景中,prevLogIndex是前一个槽位的位置,也就是12;prevLogTerm是S3上前一个槽位的任期号,也就是5。

这样的AppendEntries消息发送给了Followers。而Followers,它们在收到AppendEntries消息时,可以知道它们收到了一个带有若干Log条目的消息,并且是从槽位13开始。Followers在写入Log之前,会检查本地的前一个Log条目,是否与Leader发来的有关前一条Log的信息匹配。

所以对于S2 它显然是不匹配的。S2 在槽位12已经有一个条目,但是它来自任期4,而不是任期5。所以S2将拒绝这个AppendEntries,并返回False给Leader。S1在槽位12还没有任何Log,所以S1也将拒绝Leader的这个AppendEntries。到目前位置,一切都还好。为什么这么说呢?因为我们完全不想看到的是,S2 把这条新的Log添加在槽位13。因为这样会破坏Raft论文中图2所依赖的归纳特性,并且隐藏S2 实际上在槽位12有一条不同的Log的这一事实。

我们不想看到的场景

所以S1和S2都没有接受这条AppendEntries消息,所以,Leader看到了两个拒绝。

Leader为每个Follower维护了nextIndex。所以它有一个S2的nextIndex,还有一个S1的nextIndex。之前没有说明的是,如果Leader之前发送的是有关槽位13的Log,这意味着Leader对于其他两个服务器的nextIndex都是13。这种情况发生在Leader刚刚当选,因为Raft论文的图2规定了,nextIndex的初始值是从新任Leader的最后一条日志开始,而在我们的场景中,对应的就是槽位13.

为了响应Followers返回的拒绝,Leader会减小对应的nextIndex。所以它现在减小了两个Followers的nextIndex。这一次,Leader发送的AppendEntries消息中,prevLogIndex等于11,prevLogTerm等于3。同时,这次Leader发送的AppendEntries消息包含了prevLogIndex之后的所有条目,也就是S3上槽位12和槽位13的Log。

对于S2来说,这次收到的AppendEntries消息中,prevLogIndex等于11,prevLogTerm等于3,与自己本地的Log匹配,所以,S2会接受这个消息。Raft论文中的图2规定,如果接受一个AppendEntries消息,那么需要首先删除本地相应的Log(如果有的话),再用AppendEntries中的内容替代本地Log。所以,S2会这么做:它会删除本地槽位12的记录,再添加AppendEntries中的Log条目。这个时候,S2的Log与S3保持了一致。

但是,S1仍然有问题,因为它的槽位11是空的,所以它不能匹配这次的AppendEntries。它将再次返回False。而Leader会将S1对应的nextIndex变为11,并在AppendEntries消息中带上从槽位11开始之后的Log(也就是槽位11,12,13对应的Log)。并且带上相应的prevLogIndex(10)和prevLogTerm(3)。

这次的请求可以被S1接受,并得到肯定的返回。现在它们都有了一致的Log。

而Leader在收到了Followers对于AppendEntries的肯定的返回之后,它会增加相应的nextIndex到14。

在这里,Leader使用了一种备份机制来探测Followers的Log中,第一个与Leader的Log相同的位置。在获得位置之后,Leader会给Follower发送从这个位置开始的,剩余的全部Log。经过这个过程,所有节点的Log都可以和Leader保持一致。

重复一个我们之前讨论过的话题,或许我们还会再讨论。在刚刚的过程中,我们擦除了一些Log条目,比如我们刚刚删除了S2中的槽位12的Log。这个位置是任期4的Log。现在的问题是,为什么Raft系统可以安全的删除这条记录?毕竟我们在删除这条记录时,某个相关的客户端请求也随之被丢弃了。

我在上堂课说过这个问题,这里的原理是什么呢?是的,这条Log条目并没有存在于过半服务器中,因此无论之前的Leader是谁,发送了这条Log,它都没有得到过半服务器的认可。因此旧的Leader不可能commit了这条记录,也就不可能将它应用到应用程序的状态中,进而也就不可能回复给客户端说请求成功了。因为它没有存在于过半服务器中,发送这个请求的客户端没有理由认为这个请求被执行了,也不可能得到一个回复。因为这里有一条规则就是,Leader只会在commit之后回复给客户端。客户端甚至都没有理由相信这个请求被任意服务器收到了。并且,Raft论文中的图2说明,如果客户端发送请求之后一段时间没有收到回复,它应该重新发送请求。所以我们知道,不论这个被丢弃的请求是什么,我们都没有执行它,没有把它包含在任何状态中,并且客户端之后会重新发送这个请求。

学生提问:前面的过程中,为什么总是删除Followers的Log的结尾部分?

Robert教授:一个备选的答案是,Leader有完整的Log,所以当Leader收到有关AppendEntries的False返回时,它可以发送完整的日志给Follower。如果你刚刚启动系统,甚至在一开始就发生了非常反常的事情,某个Follower可能会从第一条Log 条目开始恢复,然后让Leader发送整个Log记录,因为Leader有这些记录。如果有必要的话,Leader拥有填充每个节点的日志所需的所有信息。

7.2 选举约束(Election Restriction)

在前面的例子中,我们选择S3作为Leader。现在有个问题是,哪些节点允许成为Leader?

如果你读了Raft论文,那么你就知道答案:为了保证系统的正确性,并非任意节点都可以成为Leader。不是说第一个选举定时器超时了并触发选举的节点,就一定是Leader。Raft对于谁可以成为Leader,谁不能成为Leader是有一些限制的。

为了证明并非任意节点都可以成为Leader,我们这里提出一个例子来证伪。在这个反例中,Raft会选择拥有最长Log记录的节点作为Leader,这个规则或许适用于其他系统,实际上在一些其他设计的系统中的确使用了这样的规则,但是在Raft中,这条规则不适用。所以,我们这里需要研究的问题是:为什么不选择拥有最长Log记录的节点作为Leader?如果我们这么做了的话,我们需要更改Raft中的投票规则,让选民只投票给拥有更长Log记录的节点。

很容易可以展示为什么这是一个错误的观点。我们还是假设我们有3个服务器,现在服务器1(S1)有任期5,6,7的Log,服务器2和服务器3(S2和S3)有任期5,8的Log。

为了避免我们在不可能出现的问题上浪费时间,这里的第一个问题是,这个场景可能出现吗?让我们回退一些时间,在这个时间点S1赢得了选举,现在它的任期号是6。它收到了一个客户端请求,在发出AppendEntries之前,它先将请求存放在自己的Log中,然后它就故障了,所以它没能发出任何AppendEntries消息。

之后它很快就故障重启了,因为它是之前的Leader,所以会有一场新的选举。这次,它又被选为Leader。然后它收到了一个任期7的客户端请求,将这个请求加在本地Log之后,它又故障了。

S1故障之后,我们又有了一次新的选举,这时S1已经关机了,不能再参加选举,这次S2被选为Leader。如果S2当选,而S1还在关机状态,S2会使用什么任期号呢?

明显我们的答案是8(因为之前画出来了),但是为什么任期号是8而不是6呢?尽管没有写在黑板上,但是S1在任期6,7能当选,它必然拥有了过半节点的投票,过半服务器至少包含了S2,S3中的一个节点。如果你去看处理RequestVote的代码和Raft论文的图2,当某个节点为候选人投票时,节点应该将候选人的任期号记录在持久化存储中。所里在这里,S2或者S3或者它们两者都知道任期6和任期7的存在。因此,当S1故障了,它们中至少一个知道当前的任期是8。这里,只有知道了任期8的节点才有可能当选,如果只有一个节点知道,那么这个节点会赢得选举,因为它拥有更高的任期号。如果S2和S3都知道当前任期是8,那么它们两者中的一个会赢得选举。所以,下一个任期必然为8这个事实,依赖于不同任期的过半服务器之间必然有重合这个特点。同时,也依赖任期号会通过RequestVote RPC更新给其他节点,并持久化存储,这样出现故障才不会丢失数据。所以下一个任期号将会是8,S2或者S3会赢得选举。不管是哪一个,新的Leader会继续将客户端请求转换成AppendEntries发给其他节点。所以我们现在有了这么一个场景。

现在我们回到对于这个场景的最初的问题,假设S1重新上线了,并且我们又有了一次新的选举,这时候可以选择S1作为Leader吗?或者说,可以选择拥有最长Log记录的节点作为Leader可以吗?明显,答案是不可以的。

如果S1是Leader,它会通过AppendEntries机制将自己的Log强加给2个Followers,这个我们刚刚(上一节)说过了。如果我们让S1作为Leader,它会发出AppendEntries消息来覆盖S2和S3在任期8的Log,并在S2和S3中写入S1中的任期6和任期7的Log,这样所有的节点的Log才能与S1保持一致。为什么我们不能认可这样的结果呢?

是的,因为S2和S3可以组成过半服务器,所以任期8的Log已经被commit了,对应的请求很可能已经执行了,应用层也很可能发送一个回复给客户端了。所以我们不能删除任期8的Log。因此,S1也就不能成为Leader并将自己的Log强制写入S2和S3。大家都明白了为什么这对于Raft来说是个坏的结果吗?正因为这个原因,我们不能在选举的时候直接选择拥有最长Log记录的节点。当然,最短Log记录的节点也不行。

在Raft论文的5.4.1,Raft有一个稍微复杂的选举限制(Election Restriction)。这个限制要求,在处理别节点发来的RequestVote RPC时,需要做一些检查才能投出赞成票。这里的限制是,节点只能向满足下面条件之一的候选人投出赞成票:

  1. 候选人最后一条Log条目的任期号大于本地最后一条Log条目的任期号;
  2. 或者,候选人最后一条Log条目的任期号等于本地最后一条Log条目的任期号,且候选人的Log记录长度大于等于本地Log记录的长度

回到我们的场景,如果S2收到了S1的RequestVote RPC,因为S1的最后一条Log条目的任期号是7,而S2的最后一条Log条目的任期号是8,两个限制都不满足,所以S2和S3都不会给S1投赞成票。即使S1的选举定时器的超时时间更短,并且先发出了RequestVote请求,除了它自己,没人会给它投票,所以它只能拿到一个选票,不能凑够过半选票。如果S2或者S3成为了候选人,它们中的另一个都会投出赞成票,因为它们最后的任期号一样,并且它们的Log长度大于等于彼此(满足限制2)。所以S2或者S3中的任意一个都会为另一个投票。S1会为它们投票吗?会的,因为S2或者S3最后一个Log条目对应的任期号更大(满足限制1)。

所以在这里,Raft更喜欢拥有更高任期号记录的候选人,或者说更喜欢拥有任期号更高的旧Leader记录的候选人。限制2说明,如果候选人都拥有任期号最高的旧Leader记录,那么Raft更喜欢拥有更多记录的候选人。

7.3 快速恢复(Fast Backup)

在前面(7.1)介绍的日志恢复机制中,如果Log有冲突,Leader每次会回退一条Log条目。 这在许多场景下都没有问题。但是在某些现实的场景中,至少在Lab2的测试用例中,每次只回退一条Log条目会花费很长很长的时间。所以,现实的场景中,可能一个Follower关机了很长时间,错过了大量的AppendEntries消息。这时,Leader重启了。按照Raft论文中的图2,如果一个Leader重启了,它会将所有Follower的nextIndex设置为Leader本地Log记录的下一个槽位(7.1有说明)。所以,如果一个Follower关机并错过了1000条Log条目,Leader重启之后,需要每次通过一条RPC来回退一条Log条目来遍历1000条Follower错过的Log记录。这种情况在现实中并非不可能发生。在一些不正常的场景中,假设我们有5个服务器,有1个Leader,这个Leader和另一个Follower困在一个网络分区。但是这个Leader并不知道它已经不再是Leader了。它还是会向它唯一的Follower发送AppendEntries,因为这里没有过半服务器,所以没有一条Log会commit。在另一个有多数服务器的网络分区中,系统选出了新的Leader并继续运行。旧的Leader和它的Follower可能会记录无限多的旧的任期的未commit的Log。当旧的Leader和它的Follower重新加入到集群中时,这些Log需要被删除并覆盖。可能在现实中,这不是那么容易发生,但是你会在Lab2的测试用例中发现这个场景。

所以,为了能够更快的恢复日志,Raft论文在论文的5.3结尾处,对一种方法有一些模糊的描述。原文有些晦涩,在这里我会以一种更好的方式尝试解释论文中有关快速恢复的方法。这里的大致思想是,让Follower返回足够的信息给Leader,这样Leader可以以任期(Term)为单位来回退,而不用每次只回退一条Log条目。所以现在,在恢复Follower的Log时,如果Leader和Follower的Log不匹配,Leader只需要对每个不同的任期发送一条AppendEntries,而不用对每个不同的Log条目发送一条AppendEntries。这只是一种加速策略,当然,或许你也可以想出许多其他不同的日志恢复加速策略。

我将可能出现的场景分成3类,为了简化,这里只画出一个Leader(S2)和一个Follower(S1),S2将要发送一条任期号为6的AppendEntries消息给Follower。

  • 场景1:S1没有任期6的任何Log,因此我们需要回退一整个任期的Log。

  • 场景2:S1收到了任期4的旧Leader的多条Log,但是作为新Leader,S2只收到了一条任期4的Log。所以这里,我们需要覆盖S1中有关旧Leader的一些Log。

  • 场景3:S1与S2的Log不冲突,但是S1缺失了部分S2中的Log。

可以让Follower在回复Leader的AppendEntries消息中,携带3个额外的信息,来加速日志的恢复。这里的回复是指,Follower因为Log信息不匹配,拒绝了Leader的AppendEntries之后的回复。这里的三个信息是指:

  • XTerm:这个是Follower中与Leader冲突的Log对应的任期号。在之前(7.1)有介绍Leader会在prevLogTerm中带上本地Log记录中,前一条Log的任期号。如果Follower在对应位置的任期号不匹配,它会拒绝Leader的AppendEntries消息,并将自己的任期号放在XTerm中。如果Follower在对应位置没有Log,那么这里会返回 -1。
  • XIndex:这个是Follower中,对应任期号为XTerm的第一条Log条目的槽位号。
  • XLen:如果Follower在对应位置没有Log,那么XTerm会返回-1,XLen表示空白的Log槽位数。

我们再来看这些信息是如何在上面3个场景中,帮助Leader快速回退到适当的Log条目位置。

  • 场景1。Follower(S1)会返回XTerm=5,XIndex=2。Leader(S2)发现自己没有任期5的日志,它会将自己本地记录的,S1的nextIndex设置到XIndex,也就是S1中,任期5的第一条Log对应的槽位号。所以,如果Leader完全没有XTerm的任何Log,那么它应该回退到XIndex对应的位置(这样,Leader发出的下一条AppendEntries就可以一次覆盖S1中所有XTerm对应的Log)。
  • 场景2。Follower(S1)会返回XTerm=4,XIndex=1。Leader(S2)发现自己其实有任期4的日志,它会将自己本地记录的S1的nextIndex设置到本地在XTerm位置的Log条目后面,也就是槽位2。下一次Leader发出下一条AppendEntries时,就可以一次覆盖S1中槽位2和槽位3对应的Log。
  • 场景3。Follower(S1)会返回XTerm=-1,XLen=2。这表示S1中日志太短了,以至于在冲突的位置没有Log条目,Leader应该回退到Follower最后一条Log条目的下一条,也就是槽位2,并从这开始发送AppendEntries消息。槽位2可以从XLen中的数值计算得到。

这些信息在Lab中会有用,如果你错过了我的描述,你可以再看看视频(Robert教授说的)。

对于这里的快速回退机制有什么问题吗?

学生提问:这里是线性查找,可以使用类似二分查找的方法进一步加速吗?

Robert教授:我认为这是对的,或许这里可以用二分查找法。我没有排除其他方法的可能,我的意思是,Raft论文中并没有详细说明是怎么做的,所以我这里加工了一下。或许有更好,更快的方式来完成。如果Follower返回了更多的信息,那是可以用一些更高级的方法,例如二分查找,来完成。

为了通过Lab2的测试,你肯定需要做一些优化工作。我们提供的Lab2的测试用例中,有一件不幸但是不可避免的事情是,它们需要一些实时特性。这些测试用例不会永远等待你的代码执行完成并生成结果。所以有可能你的方法技术上是对的,但是花了太多时间导致测试用例退出。这个时候,你是不能通过全部的测试用例的。因此你的确需要关注性能,从而使得你的方案即是正确的,又有足够的性能。不幸的是,性能与Log的复杂度相关,所以很容易就写出一个正确但是不够快的方法出来。

学生提问:能在解释一下这里的流程吗?

Robert教授:这里,Leader发现冲突的方法在于,Follower会返回它从冲突条目中看到的任期号(XTerm)。在场景1中,Follower会设置XTerm=5,因为这是有冲突的Log条目对应的任期号。Leader会发现,哦,我的Log中没有任期5的条目。因此,在场景1中,Leader会一次性回退到Follower在任期5的起始位置。因为Leader并没有任何任期5的Log,所以它要删掉Follower中所有任期5的Log,这通过回退到Follower在任期5的第一条Log条目的位置,也就是XIndex达到的。

7.4 持久化(Persistence)

下一个我想介绍的是持久化存储(persistence)。你可以从Raft论文的图2的左上角看到,有些数据被标记为持久化的(Persistent),有些信息被标记为非持久化的(Volatile)。持久化和非持久化的区别只在服务器重启时重要。当你更改了被标记为持久化的某个数据,服务器应该将更新写入到磁盘,或者其它的持久化存储中,例如一个电池供电的RAM。持久化的存储可以确保当服务器重启时,服务器可以找到相应的数据,并将其加载到内存中。这样可以使得服务器在故障并重启后,继续重启之前的状态。

你或许会认为,如果一个服务器故障了,那简单直接的方法就是将它从集群中摘除。我们需要具备从集群中摘除服务器,替换一个全新的空的服务器,并让该新服务器在集群内工作的能力。实际上,这是至关重要的,因为如果一些服务器遭受了不可恢复的故障,例如磁盘故障,你绝对需要替换这台服务器。同时,如果磁盘故障了,你也不能指望能从该服务器的磁盘中获得任何有用的信息。所以我们的确需要能够用全新的空的服务器替代现有服务器的能力。你或许认为,这就足以应对任何出问题的场景了,但实际上不是的。

实际上,一个常见的故障是断电。断电的时候,整个集群都同时停止运行,这种场景下,我们不能通过从Dell买一些新的服务器来替换现有服务器进而解决问题。这种场景下,如果我们希望我们的服务是容错的, 我们需要能够得到之前状态的拷贝,这样我们才能保持程序继续运行。因此,至少为了处理同时断电的场景,我们不得不让服务器能够将它们的状态存储在某处,这样当供电恢复了之后,还能再次获取这个状态。这里的状态是指,为了让服务器在断电或者整个集群断电后,能够继续运行所必不可少的内容。这是理解持久化存储的一种方式。

在Raft论文的图2中,有且仅有三个数据是需要持久化存储的。它们分别是Log、currentTerm、votedFor。Log是所有的Log条目。当某个服务器刚刚重启,在它加入到Raft集群之前,它必须要检查并确保这些数据有效的存储在它的磁盘上。服务器必须要有某种方式来发现,自己的确有一些持久化存储的状态,而不是一些无意义的数据。

Log需要被持久化存储的原因是,这是唯一记录了应用程序状态的地方。Raft论文图2并没有要求我们持久化存储应用程序状态。假如我们运行了一个数据库或者为VMware FT运行了一个Test-and-Set服务,根据Raft论文图2,实际的数据库或者实际的test-set值,并不会被持久化存储,只有Raft的Log被存储了。所以当服务器重启时,唯一能用来重建应用程序状态的信息就是存储在Log中的一系列操作,所以Log必须要被持久化存储。

那currentTerm呢?为什么currentTerm需要被持久化存储?是的,currentTerm和votedFor都是用来确保每个任期只有最多一个Leader。在一个故障的场景中,如果一个服务器收到了一个RequestVote请求,并且为服务器1投票了,之后它故障。如果它没有存储它为哪个服务器投过票,当它故障重启之后,收到了来自服务器2的同一个任期的另一个RequestVote请求,那么它还是会投票给服务器2,因为它发现自己的votedFor是空的,因此它认为自己还没投过票。现在这个服务器,在同一个任期内同时为服务器1和服务器2投了票。因为服务器1和服务器2都会为自己投票,它们都会认为自己有过半选票(3票中的2票),那它们都会成为Leader。现在同一个任期里面有了两个Leader。这就是为什么votedFor必须被持久化存储。

currentTerm的情况要更微妙一些,但是实际上还是为了实现一个任期内最多只有一个Leader,我们之前实际上介绍过这里的内容。如果(重启之后)我们不知道任期号是什么,很难确保一个任期内只有一个Leader。

在这里例子中,S1关机了,S2和S3会尝试选举一个新的Leader。它们需要证据证明,正确的任期号是8,而不是6。如果仅仅是S2和S3为彼此投票,它们不知道当前的任期号,它们只能查看自己的Log,它们或许会认为下一个任期是6(因为Log里的上一个任期是5)。如果它们这么做了,那么它们会从任期6开始添加Log。但是接下来,就会有问题了,因为我们有了两个不同的任期6(另一个在S1中)。这就是为什么currentTerm需要被持久化存储的原因,因为它需要用来保存已经被使用过的任期号。

这些数据需要在每次你修改它们的时候存储起来。所以可以确定的是,安全的做法是每次你添加一个Log条目,更新currentTerm或者更新votedFor,你或许都需要持久化存储这些数据。在一个真实的Raft服务器上,这意味着将数据写入磁盘,所以你需要一些文件来记录这些数据。如果你发现,直到服务器与外界通信时,才有可能持久化存储数据,那么你可以通过一些批量操作来提升性能。例如,只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。

之所以这很重要是因为,向磁盘写数据是一个代价很高的操作。如果是一个机械硬盘,我们通过写文件的方式来持久化存储,向磁盘写入任何数据都需要花费大概10毫秒时间。因为你要么需要等磁盘将你想写入的位置转到磁针下面, 而磁盘大概每10毫秒转一次。要么,就是另一种情况更糟糕,磁盘需要将磁针移到正确的轨道上。所以这里的持久化操作的代价可能会非常非常高。对于一些简单的设计,这些操作可能成为限制性能的因素,因为它们意味着在这些Raft服务器上执行任何操作,都需要10毫秒。而10毫秒相比发送RPC或者其他操作来说都太长了。如果你持久化存储在一个机械硬盘上,那么每个操作至少要10毫秒,这意味着你永远也不可能构建一个每秒能处理超过100个请求的Raft服务。这就是所谓的synchronous disk updates的代价。它存在于很多系统中,例如运行在你的笔记本上的文件系统。

设计人员花费了大量的时间来避开synchronous disk updates带来的性能问题。为了让磁盘的数据保证安全,同时为了能安全更新你的笔记本上的磁盘,文件系统对于写入操作十分小心,有时需要等待磁盘(前一个)写入完成。所以这(优化磁盘写入性能)是一个出现在所有系统中的常见的问题,也必然出现在Raft中。

如果你想构建一个能每秒处理超过100个请求的系统,这里有多个选择。其中一个就是,你可以使用SSD硬盘,或者某种闪存。SSD可以在0.1毫秒完成对于闪存的一次写操作,所以这里性能就提高了100倍。更高级一点的方法是,你可以构建一个电池供电的DRAM,然后在这个电池供电的DRAM中做持久化存储。这样,如果Server重启了,并且重启时间短于电池的可供电时间,这样你存储在RAM中的数据还能保存。如果资金充足,且不怕复杂的话,这种方式的优点是,你可以每秒写DRAM数百万次,那么持久化存储就不再会是一个性能瓶颈。所以,synchronous disk updates是为什么数据要区分持久化和非持久化(而非所有的都做持久化)的原因(越少数据持久化,越高的性能)。Raft论文图2考虑了很多性能,故障恢复,正确性的问题。

有任何有关持久化存储的问题吗?

学生提问:当你写你的Raft代码时,你实际上需要确认,当你持久化存储一个Log或者currentTerm,这些数据是否实时的存储在磁盘中,你该怎么做来确保它们在那呢?

Robert教授:在一个UNIX或者一个Linux或者一个Mac上,为了调用系统写磁盘的操作,你只需要调用write函数,在write函数返回时,并不能确保数据存在磁盘上,并且在重启之后还存在。几乎可以确定(write返回之后)数据不会在磁盘上。所以,如果在UNIX上,你调用了write,将一些数据写入之后,你需要调用fsync。在大部分系统上,fsync可以确保在返回时,所有之前写入的数据已经安全的存储在磁盘的介质上了。之后,如果机器重启了,这些信息还能在磁盘上找到。fsync是一个代价很高的调用,这就是为什么它是一个独立的函数,也是为什么write不负责将数据写入磁盘,fsync负责将数据写入磁盘。因为写入磁盘的代价很高,你永远也不会想要执行这个操作,除非你想要持久化存储一些数据。

所以你可以使用一些更贵的磁盘。另一个常见方法是,批量执行操作。如果有大量的客户端请求,或许你应该同时接收它们,但是先不返回。等大量的请求累积之后,一次性持久化存储(比如)100个Log,之后再发送AppendEntries。如果Leader收到了一个客户端请求,在发送AppendEntries RPC给Followers之前,必须要先持久化存储在本地。因为Leader必须要commit那个请求,并且不能忘记这个请求。实际上,在回复AppendEntries 消息之前,Followers也需要持久化存储这些Log条目到本地,因为它们最终也要commit这个请求,它们不能因为重启而忘记这个请求。

最后,有关持久化存储,还有一些细节。有些数据在Raft论文的图2中标记为非持久化的。所以,这里值得思考一下,为什么服务器重启时,commitIndex、lastApplied、nextIndex、matchIndex,可以被丢弃?例如,lastApplied表示当前服务器执行到哪一步,如果我们丢弃了它的话,我们需要重复执行Log条目两次(重启前执行过一次,重启后又要再执行一次),这是正确的吗?为什么可以安全的丢弃lastApplied?

这里综合考虑了Raft的简单性和安全性。之所以这些数据是非持久化存储的,是因为Leader可以通过检查自己的Log和发送给Followers的AppendEntries的结果,来发现哪些内容已经commit了。如果因为断电,所有节点都重启了。Leader并不知道哪些内容被commit了,哪些内容被执行了。但是当它发出AppendEntries,并从Followers搜集回信息。它会发现,Followers中有哪些Log与Leader的Log匹配,因此也就可以发现,在重启前,有哪些被commit了。

另外,Raft论文的图2假设,应用程序状态会随着重启而消失。所以图2认为,既然Log已经持久化存储了,那么应用程序状态就不必再持久化存储。因为在图2中,Log从系统运行的初始就被持久化存储下来。所以,当Leader重启时,Leader会从第一条Log开始,执行每一条Log条目,并提交给应用程序。所以,重启之后,应用程序可以通过重复执行每一条Log来完全从头构建自己的状态。这是一种简单且优雅的方法,但是很明显会很慢。这将会引出我们的下一个话题:Log compaction和Snapshot。

7.5 日志快照(Log Snapshot)

Log压缩和快照(Log compaction and snapshots)在Lab3b中出现的较多。在Raft中,Log压缩和快照解决的问题是:对于一个长期运行的系统,例如运行了几周,几个月甚至几年,如果我们按照Raft论文图2的规则,那么Log会持续增长。最后可能会有数百万条Log,从而需要大量的内存来存储。如果持久化存储在磁盘上,最终会消耗磁盘的大量空间。如果一个服务器重启了,它需要通过重新从头开始执行这数百万条Log来重建自己的状态。当故障重启之后,遍历并执行整个Log的内容可能要花费几个小时来完成。这在某种程度上来说是浪费,因为在重启之前,服务器已经有了一定的应用程序状态。

为了应对这种场景,Raft有了快照(Snapshots)的概念。快照背后的思想是,要求应用程序将其状态的拷贝作为一种特殊的Log条目存储下来。我们之前几乎都忽略了应用程序,但是事实是,假设我们基于Raft构建一个key-value数据库,Log将会包含一系列的Put/Get或者Read/Write请求。假设一条Log包含了一个Put请求,客户端想要将X设置成1,另一条Log想要将X设置成2,下一条将Y设置成7。

如果Raft一直执行没有故障,Raft之上的将会是应用程序,在这里,应用程序将会是key-value数据库。它将会维护一个表单,当Raft一个接一个的上传命令时,应用程序会更新它的表单。

所以第一个命令之后,应用程序会将表单中的X设置为1。

第二个命令之后,表单中的X会被设置为2。

第三个命令之后,表单中的Y会被设置为7。

这里有个有趣的事实,那就是:对于大多数的应用程序来说,应用程序的状态远小于Log的大小。某种程度上我们知道,在某些时间点,Log和应用程序的状态是可以互换的,它们是用来表示应用程序状态的不同事物。但是Log可能包含大量的重复的记录(例如对于X的重复赋值),这些记录使用了Log中的大量的空间,但是同时却压缩到了key-value表单中的一条记录。这在多副本系统中很常见。在这里,如果存储Log,可能尺寸会非常大,相应的,如果存储key-value表单,这可能比Log尺寸小得多。这就是快照的背后原理。

所以,当Raft认为它的Log将会过于庞大,例如大于1MB,10MB或者任意的限制,Raft会要求应用程序在Log的特定位置,对其状态做一个快照。所以,如果Raft要求应用程序做一个快照,Raft会从Log中选取一个与快照对应的点,然后要求应用程序在那个点的位置做一个快照。这里极其重要,因为我们接下来将会丢弃所有那个点之前的Log记录。如果我们有一个点的快照,那么我们可以安全的将那个点之前的Log丢弃。(在key-value数据库的例子中)快照本质上就是key-value表单。

我们还需要为快照标注Log的槽位号。在这个图里面,这个快照对应的正好是槽位3。

有了快照,并且Raft将它存放在磁盘中之后,Raft将不会再需要这部分Log。只要Raft持久化存储了快照,快照对应的Log槽位号,以及Log槽位号之后的所有Log,那么快照对应槽位号之前的这部分Log可以被丢弃,我们将不再需要这部分Log。

所以这就是Raft快照的工作原理,Raft要求应用程序做快照,得到快照之后将其存储在磁盘中,同时持久化存储快照之后的Log,并丢弃快照之前的Log。所以,Raft的持久化存储实际上是持久化应用程序快照,和快照之后的Log。大家都明白了吗?

学生提问:听不清。

Robert教授:或许可以这样看这些Log,快照之后的Log是实际存在的,而快照之前的Log可以认为是幽灵条目,我们可以认为它们还在那,只是说我们永远不会再去查看它们了, 因为我们现在有快照了。事实上,我们不再存储幽灵条目,但是效果上是等效于有完整的Log。

刚刚的回答可能有些草率。因为如果按照Raft论文的图2,你有时还是需要这些早期的Log(槽位1,2,3)。所以,在知道了有时候某些Log可能不存在的事实之后,你可能需要稍微重新理解一下图2。

所以,重启的时候会发生什么呢?现在,重启的场景比之前只有Log会更加复杂一点。重启的时候,必须让Raft有方法知道磁盘中最近的快照和Log的组合,并将快照传递给应用程序。因为现在我们不能重演所有的Log(部分被删掉了),所以必须要有一种方式来初始化应用程序。所以应用程序不仅需要有能力能生成一个快照,它还需要能够吸纳一个之前创建的快照,并通过它稳定的重建自己的内存。所以,尽管Raft在管理快照,快照的内容实际上是应用程序的属性。Raft并不理解快照中有什么,只有应用程序知道,因为快照里面都是应用程序相关的信息。所以重启之后,应用程序需要能够吸纳Raft能找到的最近的一次快照。到目前为止还算简单。

不幸的是,这里丢弃了快照之前的Log,引入了大量的复杂性。如果有的Follower的Log较短,在Leader的快照之前就结束,那么除非有一种新的机制,否则那个Follower永远也不可能恢复完整的Log。因为,如果一个Follower只有前两个槽位的Log,Leader不再有槽位3的Log可以通过AppendEntries RPC发给Follower,Follower的Log也就不可能补齐至Leader的Log。

我们可以通过这种方式来避免这个问题:如果Leader发现有任何一个Follower的Log落后于Leader要做快照的点,那么Leader就不丢弃快照之前的Log。Leader原则上是可以知道Follower的Log位置,然后Leader可以不丢弃所有Follower中最短Log之后的本地Log。

这或许是一个短暂的好方法,之所以这个方法不完美的原因在于,如果一个Follower关机了一周,它也就不能确认Log条目,同时也意味着Leader不能通过快照来减少自己的内存消耗(因为那个Follower的Log长度一直没有更新)。

所以,Raft选择的方法是,Leader可以丢弃Follower需要的Log。所以,我们需要某种机制让AppendEntries能处理某些Follower Log的结尾到Leader Log开始之间丢失的这一段Log。解决方法是(一个新的消息类型)InstallSnapshot RPC。

当Follower刚刚恢复,如果它的Log短于Leader通过 AppendEntries RPC发给它的内容,那么它首先会强制Leader回退自己的Log。在某个点,Leader将不能再回退,因为它已经到了自己Log的起点。这时,Leader会将自己的快照发给Follower,之后立即通过AppendEntries将后面的Log发给Follower。

不幸的是,这里明显的增加了的复杂度。因为这里需要Raft组件之间的协同,这里还有点违反模块性,因为这里需要组件之间有一些特殊的协商。例如,当Follower收到了InstallSnapshot,这个消息是被Raft收到的,但是Raft实际需要应用程序能吸纳这个快照。所以它们现在需要更多的交互了。

学生提问:快照的创建是否依赖应用程序?

Robert教授:肯定依赖。快照生成函数是应用程序的一部分,如果是一个key-value数据库,那么快照生成就是这个数据库的一部分。Raft会通过某种方式调用到应用程序,通知应用程序生成快照,因为只有应用程序自己才知道自己的状态(进而能生成快照)。而通过快照反向生成应用程序状态的函数,同样也是依赖应用程序的。但是这里又有点纠缠不清,因为每个快照又必须与某个Log槽位号对应。

学生提问:如果RPC消息乱序该怎么处理?

Robert教授:是在说Raft论文图13的规则6吗?这里的问题是,你们会在Lab3遇到这个问题,因为RPC系统不是完全的可靠和有序,RPC可以乱序的到达,甚至不到达。你或许发了一个RPC,但是收不到回复,并认为这个消息丢失了,但是消息实际上送达了,实际上是回复丢失了。所有这些都可能发生,包括发生在InstallSnapshot RPC中。Leader几乎肯定会并发发出大量RPC,其中包含了AppendEntries和InstallSnapshot,因此,Follower有可能受到一条很久以前的InstallSnapshot消息。因此,Follower必须要小心应对InstallSnapshot消息。我认为,你想知道的是,如果Follower收到了一条InstallSnapshot消息,但是这条消息看起来完全是冗余的,这条InstallSnapshot消息包含的信息比当前Follower的信息还要老,这时,Follower该如何做?

Raft论文图13的规则6有相应的说明。我认为正常的响应是,Follower可以忽略明显旧的快照。其实我(Robert教授)看不懂那条规则6。

7.6 线性一致(Linearizability)

接下来我们看一些更偏概念性的东西。目前为止,我们还没有尝试去确定正确意味着什么?当一个多副本服务或者任意其他服务正确运行意味着什么? 绝大多数时候,我都避免去考虑太多有关正确的精确定义。但事实是,当你尝试去优化一些东西,或者当你尝试去想明白一些奇怪的corner case,如果有个正式的方式定义什么是正确的行为,经常会比较方便。例如,当客户端通过RPC发送请求给我们的多副本服务时,可能是请求重发,可能是服务故障重启正在加载快照,或者客户端发送了请求并且得到了返回,但是这个返回是正确的吗?我们该如何区分哪个返回是正确的?所以,我们需要一个非常正式的定义来区分,什么是对的,什么是错的。

我们对于正确的定义就是线性一致(Linearizability)或者说强一致(Strong consistency)。通常来说,线性一致等价于强一致。一个服务是线性一致的,那么它表现的就像只有一个服务器,并且服务器没有故障,这个服务器每次执行一个客户端请求,并且没什么奇怪的是事情发生。

一个系统的执行历史是一系列的客户端请求,或许这是来自多个客户端的多个请求。如果执行历史整体可以按照一个顺序排列,且排列顺序与客户端请求的实际时间相符合,那么它是线性一致的。当一个客户端发出一个请求,得到一个响应,之后另一个客户端发出了一个请求,也得到了响应,那么这两个请求之间是有顺序的,因为一个在另一个完成之后才开始。一个线性一致的执行历史中的操作是非并发的,也就是时间上不重合的客户端请求与实际执行时间匹配。并且,每一个读操作都看到的是最近一次写入的值。(这里的定义可能比较晦涩,后面会再通过例子展开介绍,并重新回顾这里定义里面的两个限制条件。)

首先,执行历史是对于客户端请求的记录,你可以从系统的输入输出理解这个概念,而不用关心内部是如何实现的。如果一个系统正在工作,我们可以通过输入输出的消息来判断,系统的执行顺序是不是线性一致的。接下来,我们通过两个例子来看,什么是线性一致的,什么不是。

线性一致这个概念里面的操作,是从一个点开始,到另一个点结束。所以,这里前一个点对应了客户端发送请求,后一个点对应了收到回复的时间。我们假设,在某个特定的时间,客户端发送了请求,将X设置为1。

过了一会,在第二条竖线处,客户端收到了一个回复。客户端在第一条竖线发送请求,在第二条竖线收到回复。

过了一会,这个客户端或者其他客户端再发送一个请求,要将X设置为2,并收到了相应的回复。

同时,某个客户端发送了一个读X的请求,得到了2。在第一条竖线发送读请求,在这个点,也就是第二条竖线,收到了值是2的响应。

同时,还有一个读X的请求,得到值是1的响应。

如果我们观察到了这样的输入输出(执行历史),那么这样的执行历史是线性一致的吗?生成这样结果的系统,是一个线性一致的系统吗?或者系统在这种场景下,可以生成线性一致的执行历史吗?如果执行历史不是线性一致的,那么至少在Lab3,我们会有一些问题。所以,我们要分析并弄清楚,这里是不是线性一致的?

要达到线性一致,我们需要为这里的4个操作生成一个线性一致的顺序。所以我们现在要确定顺序,对于这个顺序,有两个限制条件:

  1. 如果一个操作在另一个操作开始前就结束了,那么这个操作必须在执行历史中出现在另一个操作前面。
  2. 执行历史中,读操作,必须在相应的key的写操作之后。

所以,这里我们要为4个操作创建一个顺序,两个读操作,两个写操作。我会通过箭头来标识刚刚两个限制条件,这样生成出来的顺序就能满足前面的限制条件。第一个写结束之后,第二个写才开始。所以一个限制条件是,在总的顺序中,第一个写操作必须在第二个写操作前面。

第一个读操作看到的是值2,那么在总的顺序中,这个读必然在第二个写操作后面,同时第二个写必须是离第一个读操作最近一次写。所以,这意味着,在总的顺序中,我们必须先看到对X写2,之后执行读X才能得到2。

第二个读X得到的是值1。我们假设X的值最开始不是1,那么会有下图的关系,因为读必须在写之后。

第二个读操作必须在第二个写操作之前执行,这样写X为1的操作才能成为第二个读操作最近一次写操作。

或许还有一些其他的限制,但是不管怎样,我们将这些箭头展平成一个线性一致顺序来看看真实的执行历史,我们可以发现总的执行历史是线性一致的。首先是将X写1,之后是读X得到1,之后将X写2,之后读X得到2。(这里可以这么理解,左边是一个多副本系统的输入输出,因为分布式程序或者程序的执行,产生了这样的时序,但是在一个线性一致的系统中,实际是按照右边的顺序执行的操作。左边是实际时钟,右边是逻辑时钟。)

所以这里有个顺序且符合前面两个限制条件,所以执行历史是线性一致的。如果我们关心一个系统是否是线性一致的,那么这个例子里面的输入输出至少与系统是线性一致的这个假设不冲突。

学生提问:听不清。

Robert教授:每个读操作,得到的值,都必须是顺序中的前一个写操作写入的值。在上面的例子中,这个顺序是没问题的,因为这里的读看到的值的确是前一个写操作。读操作不能获取旧的数据,如果我写了一些数据,然后读回来,那么我应该看到我写入的值。

让我再写一个不是线性一致的例子。我们假设有一个将X写1的请求,另一个将X写2的请求,还有一些读操作。

这里我们也通过箭头来表示限制,最后得到相应的执行顺序。因为第一个写操作在第二个写操作开始之前就结束,在我们生成的顺序中,它必须在第二个写操作之前。

第二个写操作,写的值是2,所以必须在返回2的读操作之前,所以我们有了这样一条箭头。

返回2的读操作,在返回1的读操作开始之前结束,所以我们有这样的箭头。

因为返回1的读操作必须在设置1的写操作之后,并且更重要的是,必须要在设置2的写操作之前。因为我们不能将X写了2之后再读出1来。所以我们有了这样的箭头。

因为这里的限制条件有个循环,所以没有一个线性一致的顺序能够满足前面的限制条件,因此这里的执行历史不是线性一致的,所以生成这样结果的系统不是线性一致的系统。但是只要去掉循环里面的任意一个请求,就可以打破循环,又可以是线性一致的了。

学生提问:听不清。

Robert教授:我不太确定。我不知道如何处理非常奇怪的场景,例如某个请求读到了27,但是之前又没有写27的操作。至少我写出来的规则没有对应的限制,或许你可以构建一些反依赖的规则。

好的,我们下节课继续这里的讨论。

Lecture 08 - Zookeeper

在开始之前,强烈建议阅读Zookeeper论文。

【1】https://pdos.csail.mit.edu/6.824/papers/zookeeper.pdf

8.1 线性一致(Linearizability)(1)

上一节课,我对线性一致这个概念开了个头,这一次我们来讲完它。

之所以我们要再进一步介绍这个概念,是因为这是我们对于存储系统中强一致的一种标准定义。例如,你们在Lab3中实现的系统必须是线性一致的。有时,当我们在讨论一个强一致的系统时,我们会想知道一个特定的行为是否是可接受的。其他时候,例如,当我们在讨论一个非线性一致的系统时,我们可能会想知道系统会以什么方式偏离线性一致。所以,首先,你需要能够查看某个系统的执行历史记录,并且回答这个问题:刚刚查看的操作的序列是否是线性一致的?接下来我会继续分析,并构建几个有趣的例子来帮助我们理解线性一致系统的响应。

线性一致是特定的操作历史记录的特性。所以,我们总是会提到,我们观察到了一系列不同时间的客户端请求和这些请求的响应,它们请求不同的数据,并且得到了各种各样的回复,我们需要回答,这样的一个历史记录是不是线性的?

下面会介绍一个历史记录的例子,它或许是线性的,或许不是。我们用一个图表示这个例子,在图里面,越靠右,时间越靠后。同时我们有一些客户端。这里的竖线表示客户端发送了一个请求,并且这是个写请求,它将key为X的数据的值写成0。所以这里有一个key,一个value,并且请求对应于将key为X的数据设置成0的PUT操作。

这是我们观察到的结果,客户端发出请求到我们的服务,某个时间点,服务响应了并说,好的,你的写操作完成了。

所以我们假设这里的服务具备通知请求完成的能力,否则我们很难判断线性一致。所以,我们有了某人发出的这个写请求。在这个例子中,我假设还有另一个请求。这根竖线意味着第二个请求在第一个请求结束之后开始。

这一点之所以重要的原因是,线性一致的历史记录必须与请求的实际时间匹配。这里的真实意思是在实际时间中,某个请求如果在另一个请求结束之后才开始,那么在我们构建用于证明线性一致的序列中,后来的请求都必须在先来的请求之后。

所以,在这里例子中,我假设有另一个写X的请求,将X写成1。

之后有个并发的写请求,或许比前一个请求开始的稍晚一点,将X写成2。

这里我们有两个客户端,在差不多的时间发送了两个不同的请求,想将X设置成两个不同的值。所以,当然,我们想知道最后X会是哪个值?之后,我们还有一些读操作。当你只有一些写操作时,很难判断线性一致,因为你没有任何证据证明系统实际做了哪些操作,或者存储了什么数据,所以(在判断线性一致时)我们必须要有一些读操作。

我们假设有一些读操作,其中一个读操作,在第一条竖线发起,在第二条竖线得到回复。这个操作读的是key X,得到的是2。

之后,有来自于同一个客户端的另一个读请求,但是这个请求在前一个读请求结束之后才开始,第二个读X的请求得到1。

所以,我们面前现在有个问题,这个历史记录是不是线性一致的?这里有两种可能。

要么我们能构建一个序列,同时满足

  1. 序列中的请求的顺序与实际时间匹配
  2. 每个读请求看到的都是序列中前一个写请求写入的值

如果我们能构造这么一个序列,那么可以证明,这里的请求历史记录是线性的。

另一种可能是,如果将上面的规则应用之后生成了一个带环的图,那么证明请求历史记录不是线性一致的。对于小规模的历史记录,我们可以遍历每个请求来做判断。

那么这里的请求历史记录是线性一致的吗?

学生回答1(Robert教授复述):这里的回答是,这里有点麻烦。我们看到读X得到2,之后读X得到1,或许这里会自相矛盾。

因为这里有两个写请求,一个写入1,另一个写入2。如果我们读X得到了3,那明显是个很糟糕的错误。但是现在有写X为1和2的请求,并且我们读到的X也是1和2。所以,这里的问题是,这里的读请求的顺序是否会与请求历史记录中的两个写请求矛盾?

学生回答2(Robert教授复述答案):在这里,我们或许有2个或者3个客户端,它们与某个服务交互,或许是个Raft服务。我们能看到的只有请求和响应。这里的意思是,我们看到了一个客户端请求写X为1。

我们在这里看到了响应。所以我们知道,在这个区间里的某处,服务实际上在内部将X的值改为1。

这里的意思是,在这个区间中的某处,服务在内部将X的值改为2。这里可能是区间里的任意一个时间点。这回答了你的问题吗?

学生回答3(Robert教授复述答案):所以这里的回答是,这里有实际的证据证明是线性一致的,也就是说有一个序列表明它是线性一致的。

所以是的,这里的请求历史记录是线性一致的。这里的序列是,首先是将X写0的请求,之后服务器收到了两个差不多时间的写操作,服务自己要为这两个写操作挑一个顺序。所以,我们可以假设,服务器先执行了将X写2的请求,之后执行读X返回2的请求,也就是第一个读X的请求。下一个请求是将X写1的请求,最后一个请求是读X返回1。

所以,这就是证明这里的请求历史是线性一致的证据,因为这个序列有所有请求,并且这个序列匹配请求的实际时间。

我们来再过一遍所有的请求。将X写0的请求在最开始,因为它在所有其他操作开始之前就结束了。我们将X写2的请求排第二。这里我会标记请求在实际时间中生效的位置,我用一个大X来标记这个请求实际发生的时间。所以,第二个请求的实际生效时间在这里。

下一个请求是读X得到2。这里并没有时间上的问题,因为读X得到2实际上与写X为2,这两个请求是并发的。这里并不是读X得到2结束之后,写X为2的请求才开始,这里它们是并发的。我们假设读X得到2的请求实际发生在这里。

我们并不关心第一个请求在什么时候发生。现在我们有了前3个请求的执行时间。

之后我们有个请求将X写为1,我们假设它在实际时间中发生在这里,因为它必须在序列中的前一个请求之后发生。所以,这是第4个请求。

之后,我们有读X得到1的请求,它可能在任何时间发生,但是让我们假设它发生在这里。

所以,这里展示了一个与实际时间匹配的序列,我们可以为每一个请求,在其开始和结束时间的区间里面挑选一个时间,来执行这个请求,挑选的时间可以匹配请求的实际时间。

所以这里的最后一个问题是,每个读操作是不是看到了前一个写请求写入的值?这里的读X得到2的请求,在写X为2的请求之后,这没问题。

读X得到1的请求,紧跟在在写X为1的请求之后。

所以这里的历史记录是线性一致的。

并不是所有的请求历史记录都能直接明了的判断是否是线性一致的。当看到这个例子里的历史记录时,很容易被误导。比如,写X为1的请求(比写X为2的请求)先开始,所以我们就假设X会先被写成1,但是在实际中不一定是这样的。

大家有什么问题吗?

学生提问:如果将写X为2的开始时间改在读X为2的结束时间之后会怎样?

Robert教授:如果写X为2的请求在读X得到2的请求结束之后才开始,那就不是线性一致了。因为在任何我们构建的序列中,都必须要遵守实际时间的顺序。同时,因为在上面的例子中,因为我们没有其他的写X为2的请求,这意味着这里的读请求只能得到0或者1,因为这里是其他两个可能在这个读请求之前的写请求。所以,修改之后,这里的例子就不再是线性一致的了。

学生提问:所以这里完全是根据客户端看到的响应来判断?

Robert教授:是的,所以这(线性一致)是一个非常以客户端为中心的定义,它表明客户端应该看到怎样的请求顺序。但是这背后发生了什么,或许服务有大量的副本,或许是一个复杂的网络,谁知道呢?这些基本与我们无关。这里的定义只关心客户端看到了什么。这里有一些灰度空间,我后面会介绍。例如,我们需要考虑,客户端可能需要重传一个请求。

(下面的内容在视频中时间不连续,是在讲解其他例子的时候,学生对这个例子的提问,因为内容相关,就放到这里)

学生提问:也就是收,系统可以在一个请求区间的任意时间点执行请求?

Robert教授:是的,如果请求的区间有重合,那么系统可以在区间的任何时间点执行请求,所以系统可能以任何的顺序执行这些请求。现在,你知道,如果不是这里的两个读请求,那么系统可以自由的以任何顺序执行这些写请求。但是因为现在我们看到了这两个读请求,我们知道了唯一的合法的顺序是先写X为2的请求,之后是写X为1的请求。所以是的,如果这里的两个读请求是重叠的,那么这两个读请求可以是任意的执行顺序。实际上,直到我们看见读请求返回了2和1,系统在commit之前可以以任意顺序返回读请求的数值。

学生提问:线性一致和强一致的区别是什么?

Robert教授:我将它们(线性一致和强一致)看成同义词。对于大部分的论文,尽管最近的论文可能不太一样,线性一致有明确的定义。人们对于线性一致的定义实际上没有相差太多,但是,对于强一致的具体定义来说,我认为共识会少一些。通常来说,它的定义与线性一致的定义非常接近。例如,强一致系统表现的也与系统中只有一份数据的拷贝一样,这与线性一致的定义非常接近。所以,可以合理的认为强一致与线性一致是一样的。

8.2 线性一致(Linearizability)(2)

这里还有一个例子,它与第一个例子前半部分是一样的。首先我们有一个写X为0的请求,之后有两个并发的写请求,还有与前一个例子相同的两个读请求。目前为止,与前一个例子都是一样的。所以,这里的请求历史记录必然是线性一致的。让我们假设,客户端C1发送了这里的两个读请求。客户端C1首先读X得到了2,然后读X得到了1。目前为止没有问题。

我们假设有另一个客户端C2(下图有误,第二个C1应为C2),读X得到了1,再次读X得到了2。

所以,这里的问题是,这个请求历史记录是线性一致的吗?我们要么需要构造一个序列(证明线性一致),要么需要构造一个带环的图(证明非线性一致)。

这里开始变得迷惑起来了。这里有两个并发写请求,在任何构造的序列中,要么一个写请求在前面,要么另一个写请求在前面。直观上来看,C1发现写X为2的请求在前面,之后才是写X为1的请求。它对应的两个读请求表明,在任何合法的序列中,写X为2的请求,必然要在写X为1的请求之前。这样我们才能看到这样的序列。

但是,C2的体验明显是相反的。C2发现,写X为1的请求在前面,之后才是写X为2的请求。

线性一致的一个条件是,对于整个请求历史记录,只存在一个序列,不允许不同的客户端看见不同的序列,或者说不允许一个存储在系统中的数据有不同的演进过程。这里只能有一个序列,所有的客户端必须感受到相同的序列。这里C1的读请求明显暗示了序列中先有写X为2,后有写X为1,所以不应该有其他的客户端能够观察到其他序列的证据。这里不应该有的证据就是C2现在观察到的读请求。这是直观上解释哪里出了问题。

顺便说一下,这里的请求历史记录可能出现的原因是,我们正在构建多副本的系统,要么是一个Raft系统,要么是带有缓存的系统,我们正在构建有多个拷贝的系统,所以或许有多个服务器都有X的拷贝,如果它们还没有获取到commit消息,多个服务器在不同的时间会有X的不同的值。某些副本可能有一种数值,其他可能有另一种数值。尽管这样,如果我们的系统是线性一致或者强一致,那么它必须表现的像只有一份数据的拷贝和一个线性的请求序列一样。这就是为什么这里是个有趣的例子,因为它可能出现在一些有问题的系统中。这个系统有两份数据的拷贝,一个拷贝以一种顺序执行这些写请求,另一个副本以另一种顺序执行这些写请求,这样我们就能看到这里的结果。所以这里不是线性一致,我们不能在一个正确的系统中看到这样的请求历史记录。

另一个证据证明这里不是线性一致的就是,可以构造一个带环的图。

写X为2的请求,必须在C1读X得到2的请求之前,所以这里有个这样的箭头。所以这个写请求必须在这个读请求之前。

C1读X得到2的请求必须在写X为1的请求之前,否则C1的第二个读请求不可能得到1。你可以假设写X为1的请求很早就发生了(在写X为2的实际执行时间就发生了),但那样的话,C1的第二个读请求不能看到1,只能看到2,因为第一个读请求看到的就是2(通俗解释就是,因为第一个读请求看到的是2,如果后面没有一个别写请求的话,那么后面的读请求应该看到相同的结果)。所以,读X得到2的请求必须在写X为1的请求之前。

写X为1的请求必须在任何读X得到1的请求之前,包括了C2读X得到1的请求。

但是,为了让C2先有读X得到1的请求,后有读X得到2的请求,C2的读X得到1的请求必须要在写X为2的请求之前(这样两次读才有可能是不同的值)。

这里就有了个环。所以不存在一个序列能满足线性一致的要求,因为我们构造了一个带环的图。

学生提问:所以说线性一致不是用来描述系统的,而是用来描述系统的请求记录的?

Robert教授:这是个好问题。线性一致的定义是有关历史记录的定义,而不是系统的定义。所以我们不能说一个系统设计是线性一致的,我们只能说请求的历史记录是线性一致的。如果我们不知道系统内部是如何运作的,我们唯一能做的就是在系统运行的时候观察它,那在观察到任何输出之前,我们并不知道系统是不是线性一致的,我们可以假设它是线性一致的。之后我们看到了越来越多的请求,我们发现,哈,这些请求都满足线性一致的要求,那么我们认为,或许这个系统是线性的。如果我们发现一个请求不满足线性一致的要求,那么这个系统就不是线性一致的。所以是的,线性一致不是有关系统设计的定义,这是有关系统行为的定义。

所以,当你在设计某个东西时,它不那么适用。在设计系统的时候,没有一个方法能将系统设计成线性一致。除非在一个非常简单的系统中,你只有一个服务器,一份数据拷贝,并且没有运行多线程,没有使用多核,在这样一个非常简单的系统中,要想违反线性一致还有点难。但是在任何分布式系统中,又是非常容易违反线性一致性。

所以这个例子的教训是,对于系统执行写请求,只能有一个顺序,所有客户端读到的数据的顺序,必须与系统执行写请求的顺序一致。

(下面的内容在视频中时间不连续,是在讲解其他例子的时候,学生对这个例子的提问,因为内容相关,就放到这里)

学生提问:可以再解释一下为什么写X为1的请求会在C1的读X得到2和读X得到1请求之间吗?

Robert教授:或许我这里偷懒了,这里实际发生的是,C1先有读X得到2,再有读X得到1。读X得到1在实际时间中的确在读X得到2之后,所以在这两个读请求中间,必然有一个写X为1的请求。在最终的序列中,在读X得到2的请求之后,在读X得到1的请求之前,必然会有一个写X为1的请求。这里只有一个写X为1的请求,如果有多个写X为1的请求,或许我们或许还能想想办法,但是这里只有一个请求,所以在最终的序列中,这个写X为1的请求必须位于这两个读请求中间。因此,我认为可以画这样一条箭头(从读X得到2到写X为1的箭头) 。这些箭头都表明了线性一致的规则。

学生提问:有没有可能有一个更简单的环?

Robert教授:可能会有一个更简单的环,这里4个请求的问题是,它们是出了问题的主要证据。这里例子值得好好思考一下,因为我我不能想到更好的解释方法。

8.3 线性一致(Linearizability)(3)

这里还有另一个简单的请求历史记录的例子。假设我们先写X为1,在这个请求完成之后,有另一个客户端发送了写X为2的请求,并收到了响应说,写入完成。之后,有第三个客户端,发送了一个读X的请求,得到了1。

这是一个很简单的例子,它明显不是线性一致的,因为线性一致要求生成的序列与实际时间匹配,这意味着,唯一可能的序列就是写X为1,之后写X为2,之后读X得到1。但是这个顺序明显违反了线性一致的第二个限制,因为读X得到1的前一个写请求是写X为2,这里读X应该返回2,所以这里明显不是线性一致的。

我提出这个例子的原因是,这是线性一致系统,或者强一致系统不可能提供旧的数据的证据。为什么一个系统有可能会提供旧的数据呢?或许你有大量的副本,每一个副本或许没有看到所有的写请求,或者所有的commit了的写请求。所以,或许所有的副本看到第一个写请求,也就是写X为1的请求,但是只有部分副本看到了第二个写请求,也就是写X为2的请求。所以,当你向一个已经“拖后腿”的副本请求数据时,它仍然只有X的值为1。然而客户端永远也不能在一个线性一致的系统中看到旧的数据(也就是X=1),因为一个线性一致的系统不允许读出旧的数据。

所以这里不是线性一致的,这里的教训是:对于读请求不允许返回旧的数据,只能返回最新的数据。或者说,对于读请求,线性一致系统只能返回最近一次完成的写请求写入的值。

好的,我最后还有一个小的例子。现在我们有两个客户端,其中一个提交了一个写X为3的请求,之后是一个写X为4的请求。同时,我们还有另一个客户端,在这个时间点,客户端发出了一个读X的请求,但是客户端没有收到回复。

在一个实际的系统实现中,可能有任何原因导致这个结果,例如:

  • Leader在某个时间故障了
  • 这个客户端发送了一个读请求,但是这个请求丢包了因此Leader没有收到这个请求
  • Leader收到了这个读请求并且执行了它,但是回复的报文被网络丢包了
  • Leader收到了请求并开始执行,在完成执行之前故障了
  • Leader执行了这个请求,但是在返回响应的时候故障了

不管是哪种原因,从客户端的角度来看,就是发送了一个请求,然后就没有回复了。在大多数系统的客户端内部实现机制中,客户端将会重发请求,或许发给一个不同的Leader,或许发送给同一个Leader。所以,客户端发送了第一个请求,之后没有收到回复并且超时之后,或许在这里发送了第二个请求。

之后,终于收到了一个回复。这将是Lab3的一个场景。

服务器处理重复请求的合理方式是,服务器会根据请求的唯一号或者其他的客户端信息来保存一个表。这样服务器可以记住,哦,我之前看过这个请求,并且执行过它,我会发送一个相同的回复给它,因为我不想执行相同的请求两次。例如,假设这是一个写请求,你不会想要执行这个请求两次。所以,服务器必须要有能力能够过滤出重复的请求。第一个请求的回复可能已经被网络丢包了。所以,服务器也必须要有能力能够将之前发给第一个请求的回复,再次发给第二个重复的请求。所以,服务器记住了最初的回复,并且在客户端重发请求的时候将这个回复返回给客户端。如果服务器这么做了,那么因为服务器或者Leader之前执行第一个读请求的时候,可能看到的是X=3,那么它对于重传的请求,可能还是会返回X=3。所以,我们必须要决定,这是否是一个合法的行为。

你可能会说,客户端在这里发送的(重传)请求,这在写X为4的请求之后,所以你这里应该返回4,而不是3。

这里取决于设计者,但是重传本身是一个底层的行为,或许在RPC的实现里面,或许在一些库里面实现。但是从客户端程序的角度来说,它只知道从第一条竖线的位置发送了一个请求,

并在第二条竖线的位置收到了一个回复,

这是从客户端角度看到的所有事情。所以,返回X为3是完全合法的,因为这个读请求花费了一个很长的时间,它与写X为4的请求是完全并发的,而不是串行的。

因此,对于这个读请求,返回3或者4都是合法的。取决于这个读请求实际上是在这里执行,

还是在这里执行。

所以,如果你的客户端有重传,并且你要从客户端的角度来定义线性一致,那么一个请求的区间从第一次传输开始,到最后应用程序实际收到响应为止,期间可能发生了很多次重传。

学生提问:如果客户端想要看到的是最新的数据而不是旧数据呢?

Robert教授:你在这里宁愿得到最新的数据而不是老旧的数据。假设这里的请求是查询当前时间,我向服务器发送个请求说,现在是几点,服务器返回给我一个响应。现在如果我发送了一个请求,2分钟过去了因为网络问题,我还没收到任何回复。或许应用程序更喜欢看到的回复是更近的时间,而不是很久之前开始发送请求的时间。现在,事实是,如果你使用一个线性一致的系统,你必须要实现能够容纳线性一致规则的程序。你必须写出正确的应用程序来容忍这样一个场景:应用程序发出了一个请求,过了一会才收到回复,比如在这里,如果我得到了一个值是3的回复,这对于应用程序来说可能不能接受这个值。因为这意味着,我在收到响应的时候,系统中X存储的值是3,这与事实不符(实际上X=4)。所以这里最终取决于应用程序本身。

你们在实验中会完成这样的机制,服务器发现了重复的请求,并将之前的回复重新发给客户端。这里的问题是,服务器最初在这里看到了请求,最后回复的数据是本应在之前一个时间点回复的数据,这样是否合理?我们使用线性一致的定义的一个原因是,它可以用来解释问题。例如,在这个场景里面,我们可以说,这样的行为符合线性一致的原则。

好的,这就是所有我想介绍的有关线性一致的东西。在期中测试我必然会问一个线性一致的问题。

8.4 Zookeeper

今天的论文是Zookeeper。我们选择这篇论文的部分原因是,Zookeeper是一个现实世界成功的系统,是一个很多人使用的开源服务,并且集成到了很多现实世界的软件中,所以它肯定有一些现实意义和成功。自然而然,Zookeeper的设计应该是一个合理的设计,这使得它变得吸引人。但是我对它感兴趣是因为一些更具体的技术。所以我们来看看我们为什么要研究这篇论文?

相比Raft来说,Raft实际上就是一个库。你可以在一些更大的多副本系统中使用Raft库。但是Raft不是一个你可以直接交互的独立的服务,你必须要设计你自己的应用程序来与Raft库交互。所以这里有一个有趣的问题:是否有一些有用的,独立的,通用的系统可以帮助人们构建分布式系统?是否有这样的服务可以包装成一个任何人都可以使用的独立服务,并且极大的减轻构建分布式应用的痛苦?所以,第一个问题是,对于一个通用的服务,API应该是怎样?我不太确定类似于Zookeeper这类软件的名字是什么,它们可以被认为是一个通用的协调服务(General-Purpose Coordination Service)。

第二个问题或者说第二个有关Zookeeper的有意思的特性是,作为一个多副本系统,Zookeeper是一个容错的,通用的协调服务,它与其他系统一样,通过多副本来完成容错。所以一个Zookeeper可能有3个、5个或者7个服务器,而这些服务器是要花钱的,例如7个服务器的Zookeeper集群比1个服务器的Zookeeper要贵7倍。所以很自然就会问,如果你买了7个服务器来运行你的多副本服务,你是否能通过这7台服务器得到7倍的性能?我们怎么能达到这一点呢?所以,现在问题是,如果我们有了n倍数量的服务器,是否可以为我们带来n倍的性能?

我会先说一下第二个问题。现在这里讨论的是性能,我接下来将会把Zookeeper看成一个类似于Raft的多副本系统。Zookeeper实际上运行在Zab之上,从我们的角度来看,Zab几乎与Raft是一样的。这里我只看多副本系统的性能,我并不关心Zookeeper的具体功能。

所以,现在全局来看,我们有大量的客户端,或许有数百个客户端,并且我们有一个Leader,这个Leader有两层,上面一层是与客户端交互的Zookeeper,下面是与Raft类似的管理多副本的Zab。Zab所做的工作是维护用来存放一系列操作的Log,这些操作是从客户端发送过来的,这与Raft非常相似。然后会有多个副本,每个副本都有自己的Log,并且会将新的请求加到Log中。这是一个很熟悉的配置(与Raft是一样的)。

当一个客户端发送了一个请求,Zab层会将这个请求的拷贝发送给其他的副本,其他副本会将请求追加在它们的内存中的Log或者是持久化存储在磁盘上,这样它们故障重启之后可以取回这些Log。

所以,现在的问题是,当我们增加更多的服务器,我们在这里可以有4个,5个,或者7个服务器,系统会随着我们我们增加更多的CPU,更多的算力,而变得更快吗?假设每一个副本都运行在独立的电脑上,这样你会有更多的CPU,那么当副本变多时,你的实验代码会变得更快吗?

是的,并没有这回事说,当你加入更多的服务器时,服务就会变得更快。这绝对是正确的,当我们加入更多的服务器时,Leader几乎可以确定是一个瓶颈,因为Leader需要处理每一个请求,它需要将每个请求的拷贝发送给每一个其他服务器。当你添加更多的服务器时,你只是为现在的瓶颈(Leader节点)添加了更多的工作负载。所以是的,你并不能通过添加服务器来达到提升性能的目的,因为新增的服务器并没有实际完成任何工作,它们只是愉快的完成Leader交代的工作,它们并没有减少Leader的工作。每一个操作都经过Leader。所以,在这里,随着服务器数量的增加,性能反而会降低,因为Leader需要做的工作更多了。所以,在这个系统中,我们现在有这个问题:更多的服务器使得系统更慢了。

这太糟糕了,这些服务器每台都花费了几千美元,你本来还期望通过它们达到更好的性能。

学生提问:如果请求是从不同的客户端发过来,或者从同一个客户端串行发过来,如果不同的请求交互的是数据的不同部分呢?比如,在一个key-value数据库中,或许一个请求更新X,另一个请求更新Y,它们两之间没有任何关系,我们可以利用这一点提升性能吗?

Robert教授:在这样(Zookeeper)一个系统中,要想利用这一点来提升性能是非常受限的。从一个全局角度来看,所有的请求还是发给了Leader,Leader还是要将请求发送给所有的副本,副本越多,Leader需要发送的消息也就越多。所以从一个全局的角度来看,这种交替的请求不太可能帮助这个系统。但是这是个很好的想法,因为它绝对可以用在其他系统中,人们可以在其他系统中利用这个想法。

所以这里有点让人失望,服务器的硬件并不能帮助提升性能。

或许最简单的可以用来利用这些服务器的方法,就是构建一个系统,让所有的写请求通过Leader下发。在现实世界中,大量的负载是读请求,也就是说,读请求(比写请求)多得多。比如,web页面,全是通过读请求来生成web页面,并且通常来说,写请求就相对少的多,对于很多系统都是这样的。所以,或许我们可以将写请求发给Leader,但是将读请求发给某一个副本,随便任意一个副本。

如果你有一个读请求,例如Lab3中的get请求,把它发给某一个副本而不是Leader。如果我们这么做了,对于写请求没有什么帮助,是我们将大量的读请求的负担从Leader移走了。现在对于读请求来说,有了很大的提升,因为现在,添加越多的服务器,我们可以支持越多的客户端读请求,因为我们将客户端的读请求分担到了不同的副本上。

所以,现在的问题是,如果我们直接将客户端的请求发送给副本,我们能得到预期的结果吗?

是的,实时性是这里需要考虑的问题。Zookeeper作为一个类似于Raft的系统,如果客户端将请求发送给一个随机的副本,那个副本中肯定有一份Log的拷贝,这个拷贝随着Leader的执行而变化。假设在Lab3中,这个副本有一个key-value表,当它收到一个读X的请求,在key-value表中会有X的某个数据,这个副本可以用这个数据返回给客户端。

所以,功能上来说,副本拥有可以响应来自客户端读请求的所有数据。这里的问题是,没有理由可以相信,除了Leader以外的任何一个副本的数据是最新(up to date)的。

这里有很多原因导致副本没有最新的数据,其中一个原因是,这个副本可能不在Leader所在的过半服务器中。对于Raft来说,Leader只会等待它所在的过半服务器中的其他follower对于Leader发送的AppendEntries消息的返回,之后Leader才会commit消息,并进行下一个操作。所以,如果这个副本不在过半服务器中,它或许永远也看不到写请求。又或许网络丢包了,这个副本永远没有收到这个写请求。所以,有可能Leader和过半服务器可以看见前三个请求,但是这个副本只能看见前两个请求,而错过了请求C。所以从这个副本读数据可能读到一个旧的数据。

即使这个副本看到了相应的Log条目,它可能收不到commit消息。Zookeeper的Zab与Raft非常相似,它先发出Log条目,之后,当Leader收到了过半服务器的回复,Leader会发送commit消息。然后这个副本可能没有收到这个commit消息。

最坏的情况是,我之前已经说过,这个副本可能与Leader不在一个网络分区,或者与Leader完全没有通信,作为follower,完全没有方法知道它与Leader已经失联了,并且不能收到任何消息了(心跳呢?)。

所以,如果这里不做任何改变,并且我们想构建一个线性一致的系统,尽管在性能上很有吸引力,我们不能将读请求发送给副本,并且你也不应该在Lab3这么做,因为Lab3也应该是线性一致的。这里是线性一致阻止了我们使用副本来服务客户端,大家有什么问题吗?

这里的证据就是之前介绍线性一致的简单例子(8.3中的第一个例子)。在一个线性一致系统中,不允许提供旧的数据。所以,Zookeeper这里是怎么办的?

如果你看Zookeeper论文的表2,Zookeeper的读性能随着服务器数量的增加而显著的增加。所以,很明显,Zookeeper这里有一些修改使得读请求可以由其他的服务器,其他的副本来处理。那么Zookeeper是如何确保这里的读请求是安全的(线性一致)?

对的,实际上,Zookeeper并不要求返回最新的写入数据。Zookeeper的方式是,放弃线性一致性。它对于这里问题的解决方法是,不提供线性一致的读。所以,因此,Zookeeper也不用为读请求提供最新的数据。它有自己有关一致性的定义,而这个定义不是线性一致的,因此允许为读请求返回旧的数据。所以,Zookeeper这里声明,自己最开始就不支持线性一致性,来解决这里的技术问题。如果不提供这个能力,那么(为读请求返回旧数据)就不是一个bug。这实际上是一种经典的解决性能和强一致之间矛盾的方法,也就是不提供强一致。

然而,我们必须考虑这个问题,如果系统不提供线性一致性,那么系统是否还可用?客户端发送了一个读请求,但是并没有得到当前的正确数据,也就是最新的数据,那我们为什么要相信这个系统是可用的?我们接下来看一下这个问题。

在这之前,还有问题吗?Zookeeper的确允许客户端将读请求发送给任意副本,并由副本根据自己的状态来响应读请求。副本的Log可能并没有拥有最新的条目,所以尽管系统中可能有一些更新的数据,这个副本可能还是会返回旧的数据。

8.5 一致保证(Consistency Guarantees)

Zookeeper的确有一些一致性的保证,用来帮助那些使用基于Zookeeper开发应用程序的人,来理解他们的应用程序,以及理解当他们运行程序时,会发生什么。与线性一致一样,这些保证与序列有关。Zookeeper有两个主要的保证,它们在论文的2.3有提及。

第一个是,写请求是线性一致的。

现在,你可以发现,它(Zookeeper)对于线性一致的定义与我的不太一样,因为Zookeeper只考虑写,不考虑读。这里的意思是,尽管客户端可以并发的发送写请求,然后Zookeeper表现的就像以某种顺序,一次只执行一个写请求,并且也符合写请求的实际时间。所以如果一个写请求在另一个写请求开始前就结束了,那么Zookeeper实际上也会先执行第一个写请求,再执行第二个写请求。所以,这里不包括读请求,单独看写请求是线性一致的。Zookeeper并不是一个严格的读写系统。写请求通常也会跟着读请求。对于这种混合的读写请求,任何更改状态的操作相比其他更改状态的操作,都是线性一致的。

Zookeeper的另一个保证是,任何一个客户端的请求,都会按照客户端指定的顺序来执行,论文里称之为FIFO(First In First Out)客户端序列。

这里的意思是,如果一个特定的客户端发送了一个写请求之后是一个读请求或者任意请求,那么首先,所有的写请求会以这个客户端发送的相对顺序,加入到所有客户端的写请求中(满足保证1)。所以,如果一个客户端说,先完成这个写操作,再完成另一个写操作,之后是第三个写操作,那么在最终整体的写请求的序列中,可以看到这个客户端的写请求以相同顺序出现(虽然可能不是相邻的)。所以,对于写请求,最终会以客户端确定的顺序执行。

这里实际上是服务端需要考虑的问题,因为客户端是可以发送异步的写请求,也就是说客户端可以发送多个写请求给Zookeeper Leader节点,而不用等任何一个请求完成。Zookeeper论文并没有明确说明,但是可以假设,为了让Leader可以实际的按照客户端确定的顺序执行写请求,我设想,客户端实际上会对它的写请求打上序号,表明它先执行这个,再执行这个,第三个是这个,而Zookeeper Leader节点会遵从这个顺序。这里由于有这些异步的写请求变得非常有意思。

对于读请求,这里会更加复杂一些。我之前说过,读请求不需要经过Leader,只有写请求经过Leader,读请求只会到达某个副本。所以,读请求只能看到那个副本的Log对应的状态。对于读请求,我们应该这么考虑FIFO客户端序列,客户端会以某种顺序读某个数据,之后读第二个数据,之后是第三个数据,对于那个副本上的Log来说,每一个读请求必然要在Log的某个特定的点执行,或者说每个读请求都可以在Log一个特定的点观察到对应的状态。

然后,后续的读请求,必须要在不早于当前读请求对应的Log点执行。也就是一个客户端发起了两个读请求,如果第一个读请求在Log中的一个位置执行,那么第二个读请求只允许在第一个读请求对应的位置或者更后的位置执行。

第二个读请求不允许看到之前的状态,第二个读请求至少要看到第一个读请求的状态。这是一个极其重要的事实,我们会用它来实现正确的Zookeeper应用程序。

这里特别有意思的是,如果一个客户端正在与一个副本交互,客户端发送了一些读请求给这个副本,之后这个副本故障了,客户端需要将读请求发送给另一个副本。这时,尽管客户端切换到了一个新的副本,FIFO客户端序列仍然有效。所以这意味着,如果你知道在故障前,客户端在一个副本执行了一个读请求并看到了对应于Log中这个点的状态,

当客户端切换到了一个新的副本并且发起了另一个读请求,假设之前的读请求在这里执行,

那么尽管客户端切换到了一个新的副本,客户端的在新的副本的读请求,必须在Log这个点或者之后的点执行。

这里工作的原理是,每个Log条目都会被Leader打上zxid的标签,这些标签就是Log对应的条目号。任何时候一个副本回复一个客户端的读请求,首先这个读请求是在Log的某个特定点执行的,其次回复里面会带上zxid,对应的就是Log中执行点的前一条Log条目号。客户端会记住最高的zxid,当客户端发出一个请求到一个相同或者不同的副本时,它会在它的请求中带上这个最高的zxid。这样,其他的副本就知道,应该至少在Log中这个点或者之后执行这个读请求。这里有个有趣的场景,如果第二个副本并没有最新的Log,当它从客户端收到一个请求,客户端说,上一次我的读请求在其他副本Log的这个位置执行,

那么在获取到对应这个位置的Log之前,这个副本不能响应客户端请求。

我不是很清楚这里具体怎么工作,但是要么副本阻塞了对于客户端的响应,要么副本拒绝了客户端的读请求并说:我并不了解这些信息,去问问其他的副本,或者过会再来问我。

最终,如果这个副本连上了Leader,它会更新上最新的Log,到那个时候,这个副本就可以响应读请求了。好的,所以读请求都是有序的,它们的顺序与时间正相关。

更进一步,FIFO客户端请求序列是对一个客户端的所有读请求,写请求生效。所以,如果我发送一个写请求给Leader,在Leader commit这个请求之前需要消耗一些时间,所以我现在给Leader发了一个写请求,而Leader还没有处理完它,或者commit它。之后,我发送了一个读请求给某个副本。这个读请求需要暂缓一下,以确保FIFO客户端请求序列。读请求需要暂缓,直到这个副本发现之前的写请求已经执行了。这是FIFO客户端请求序列的必然结果,(对于某个特定的客户端)读写请求是线性一致的。

最明显的理解这种行为的方式是,如果一个客户端写了一份数据,例如向Leader发送了一个写请求,之后立即读同一份数据,并将读请求发送给了某一个副本,那么客户端需要看到自己刚刚写入的值。如果我写了某个变量为17,那么我之后读这个变量,返回的不是17,这会很奇怪,这表明系统并没有执行我的请求。因为如果执行了的话,写请求应该在读请求之前执行。所以,副本必然有一些有意思的行为来暂缓客户端,比如当客户端发送一个读请求说,我上一次发送给Leader的写请求对应了zxid是多少,这个副本必须等到自己看到对应zxid的写请求再执行读请求。

学生提问:也就是说,从Zookeeper读到的数据不能保证是最新的?

Robert教授:完全正确。我认为你说的是,从一个副本读取的或许不是最新的数据,所以Leader或许已经向过半服务器发送了C,并commit了,过半服务器也执行了这个请求。但是这个副本并不在Leader的过半服务器中,所以或许这个副本没有最新的数据。这就是Zookeeper的工作方式,它并不保证我们可以看到最新的数据。Zookeeper可以保证读写有序,但是只针对一个客户端来说。所以,如果我发送了一个写请求,之后我读取相同的数据,Zookeeper系统可以保证读请求可以读到我之前写入的数据。但是,如果你发送了一个写请求,之后我读取相同的数据,并没有保证说我可以看到你写入的数据。这就是Zookeeper可以根据副本的数量加速读请求的基础。

学生提问:那么Zookeeper究竟是不是线性一致呢?

Robert教授:我认为Zookeeper不是线性一致的,但是又不是完全的非线性一致。首先,所有客户端发送的请求以一个特定的序列执行,所以,某种意义上来说,所有的写请求是线性一致的。同时,每一个客户端的所有请求或许也可以认为是线性一致的。尽管我不是很确定,Zookeeper的一致性保证的第二条可以理解为,单个客户端的请求是线性一致的。

学生提问:zxid必须要等到写请求执行完成才返回吗?

Robert教授:实际上,我不知道它具体怎么工作,但是这是个合理的假设。当我发送了异步的写请求,系统并没有执行这些请求,但是系统会回复我说,好的,我收到了你的写请求,如果它最后commit了,这将会是对应的zxid。所以这里是一个合理的假设,我实际上不知道这里怎么工作。之后如果客户端执行读请求,就可以告诉一个副本说,这个zxid是我之前发送的一个写请求。

学生提问:Log中的zxid怎么反应到key-value数据库的状态呢?

Robert教授:如果你向一个副本发送读请求,理论上,客户端会认为副本返回的实际上是Table中的值。所以,客户端说,我只想从这个Table读这一行,这个副本会将其当前状态中Table中对应的值和上次更新Table的zxid返回给客户端。

我不太确定,这里有两种可能,我认为任何一种都可以。第一个是,每个服务器可以跟踪修改每一行Table数值的写请求对应的zxid(这样可以读哪一行就返回相应的zxid);另一个是,服务器可以为所有的读请求返回Log中最近一次commit的zxid,不论最近一次请求是不是更新了当前读取的Table中的行。因为,我们只需要确认客户端请求在Log中的执行点是一直向前推进,所以对于读请求,我们只需要返回大于修改了Table中对应行的写请求对应的zxid即可。

好的,这些是Zookeeper的一致性保证。

8.6 同步操作(sync)

我们还有一个问题,是否可能基于这些保证实现合理的编程?总的来说,Zookeeper的一致性保证没有线性一致那么好。尽管它们有一些难以理解,并且需要一些额外共识,例如,读请求可能会返回旧数据,而这在一个线性一致系统不可能发生,但是,这些保证已经足够好了,好到可以用来直观解释很多基于Zookeeper的系统。接下来,我会尝试构建一些例子来解释,为什么Zookeeper不是一个坏的编程模型?

其中一个原因是,有一个弥补(非严格线性一致)的方法。

Zookeeper有一个操作类型是sync,它本质上就是一个写请求。假设我知道你最近写了一些数据,并且我想读出你写入的数据,所以现在的场景是,我想读出Zookeeper中最新的数据。这个时候,我可以发送一个sync请求,它的效果相当于一个写请求,

所以它最终会出现在所有副本的Log中,尽管我只关心与我交互的副本,因为我需要从那个副本读出数据。接下来,在发送读请求时,我(客户端)告诉副本,在看到我上一次sync请求之前,不要返回我的读请求。

如果这里把sync看成是一个写请求,这里实际上符合了FIFO客户端请求序列,因为读请求必须至少要看到同一个客户端前一个写请求对应的状态。所以,如果我发送了一个sync请求之后,又发送了一个读请求。Zookeeper必须要向我返回至少是我发送的sync请求对应的状态。

不管怎么样,如果我需要读最新的数据,我需要发送一个sync请求,之后再发送读请求。这个读请求可以保证看到sync对应的状态,所以可以合理的认为是最新的。但是同时也要认识到,这是一个代价很高的操作,因为我们现在将一个廉价的读操作转换成了一个耗费Leader时间的sync操作。所以,如果不是必须的,那还是不要这么做。

8.7 就绪文件(Ready file/znode)

在论文中有几个例子场景,通过Zookeeper的一致性保证可以很简答的解释它们。

首先我想介绍的是论文中2.3有关Ready file的一些设计(这里的file对应的就是论文里的znode,Zookeeper以文件目录的形式管理数据,所以每一个数据点也可以认为是一个file)。

我们假设有另外一个分布式系统,这个分布式有一个Master节点,而Master节点在Zookeeper中维护了一个配置,这个配置对应了一些file(也就是znode)。通过这个配置,描述了有关分布式系统的一些信息,例如所有worker的IP地址,或者当前谁是Master。所以,现在Master在更新这个配置,同时,或许有大量的客户端需要读取相应的配置,并且需要发现配置的每一次变化。所以,现在的问题是,尽管配置被分割成了多个file,我们还能有原子效果的更新吗?

为什么要有原子效果的更新呢?因为只有这样,其他的客户端才能读出完整更新的配置,而不是读出更新了一半的配置。这是人们使用Zookeeper管理配置文件时的一个经典场景。

我们这里直接拷贝论文中的2.3节的内容。假设Master做了一系列写请求来更新配置,那么我们的分布式系统中的Master会以这种顺序执行写请求。首先我们假设有一些Ready file,就是以Ready为名字的file。如果Ready file存在,那么允许读这个配置。如果Ready file不存在,那么说明配置正在更新过程中,我们不应该读取配置。所以,如果Master要更新配置,那么第一件事情是删除Ready file。之后它会更新各个保存了配置的Zookeeper file(也就是znode),这里或许有很多的file。当所有组成配置的file都更新完成之后,Master会再次创建Ready file。目前为止,这里的语句都很直观,这里只有写请求,没有读请求,而Zookeeper中写请求可以确保以线性顺序执行。

为了确保这里的执行顺序,Master以某种方式为这些请求打上了tag,表明了对于这些写请求期望的执行顺序。之后Zookeeper Leader需要按照这个顺序将这些写请求加到多副本的Log中。

接下来,所有的副本会履行自己的职责,按照这里的顺序一条条执行请求。它们也会删除(自己的)Ready file,之后执行这两个写请求,最后再次创建(自己的)Ready file。所以,这里是写请求,顺序还是很直观的。

对于读请求,需要更多的思考。假设我们有一些worker节点需要读取当前的配置。我们可以假设Worker节点首先会检查Ready file是否存在。如果不存在,那么Worker节点会过一会再重试。所以,我们假设Ready file存在,并且是经历过一次重新创建。

这里的意思是,左边的都是发送给Leader的写请求,右边是一个发送给某一个与客户端交互的副本的读请求。之后,如果文件存在,那么客户端会接下来读f1和f2。

这里,有关FIFO客户端序列中有意思的地方是,如果判断Ready file的确存在,那么也是从与客户端交互的那个副本得出的判断。所以,这里通过读请求发现Ready file存在,可以说明那个副本看到了Ready file的重新创建这个请求(由Leader同步过来的)。

同时,因为后续的读请求永远不会在更早的log条目号执行,必须在更晚的Log条目号执行,所以,对于与客户端交互的副本来说,如果它的log中包含了这条创建Ready file的log,那么意味着接下来客户端的读请求只会在log中更后面的位置执行(下图中横线位置)。

所以,如果客户端看见了Ready file,那么副本接下来执行的读请求,会在Ready file重新创建的位置之后执行。这意味着,Zookeeper可以保证这些读请求看到之前对于配置的全部更新。所以,尽管Zookeeper不是完全的线性一致,但是由于写请求是线性一致的,并且读请求是随着时间在Log中单调向前的,我们还是可以得到合理的结果。

学生提问:听不清

Robert教授:这是一个很好的问题,你的问题是,在一个实际场景中,会有更多的不确定因素。让我们来看一个更麻烦的场景,这个场景正好我也准备讲。

我们假设Master在完成配置更新之后创建了Ready file。之后Master又要更新配置,那么最开始,它要删除Ready file,之后再执行一些写请求。

这里可能有的问题是,需要读取配置的客户端,首先会在这个点,通过调用exist来判断Ready file是否存在。

在这个时间点,Ready file肯定是存在的。之后,随着时间的推移,客户端读取了组成配置的第一个file,但是,之后在读取第二个file时,Master可能正在更新配置。

所以现在客户端读到的是一个不正常的,由旧配置的f1和新配置的f2组成的配置。没有理由相信,这里获取的信息还是有用的。所以,前一个场景还是很美好的,但是这个场景就是个灾难。

所以,我们现在开始面对一个严重的挑战,而一个仔细设计的针对分布式系统中机器间的协调服务的API(就是说Zookeeper),或许可以帮助我们解决这个挑战。对于Lab3来说,你将会构建一个put/get系统,那样一个系统,也会遇到同样的问题,没有任何现有的工具可以解决这个问题。

Zookeeper的API实际上设计的非常巧妙,它可以处理这里的问题。之前说过,客户端会发送exists请求来查询,Ready file是否存在。但是实际上,客户端不仅会查询Ready file是否存在,还会建立一个针对这个Ready file的watch。

这意味着如果Ready file有任何变更,例如,被删除了,或者它之前不存在然后被创建了,副本会给客户端发送一个通知。在这个场景中,如果Ready file被删除了,副本会给客户端发送一个通知。

客户端在这里只与某个副本交互,所以这里的操作都是由副本完成。当Ready file有变化时,副本会确保,合适的时机返回对于Ready file变化的通知。这里什么意思呢?在这个场景中,这些写请求在实际时间中,出现在读f1和读f2之间。

而Zookeeper可以保证,如果客户端向某个副本watch了某个Ready file,之后又发送了一些读请求,当这个副本执行了一些会触发watch通知的请求,那么Zookeeper可以确保副本将watch对应的通知,先发给客户端,再处理触发watch通知请求(也就是删除Ready file的请求),在Log中位置之后才执行的读请求(有点绕,后面会有更多的解释)。

这里再来看看Log。FIFO客户端序列要求,每个客户端请求都存在于Log中的某个位置,所以,最后log的相对位置如下图所示:

我们之前已经设置好了watch,Zookeeper可以保证如果某个人删除了Ready file,相应的通知,会在任何后续的读请求之前,发送到客户端。客户端会先收到有关Ready file删除的通知,之后才收到其他在Log中位于删除Ready file之后的读请求的响应。这里意味着,删除Ready file会产生一个通知,而这个通知可以确保在读f2的请求响应之前发送给客户端。

这意味着,客户端在完成读所有的配置之前,如果对配置有了新的更改,Zookeeper可以保证客户端在收到删除Ready file的通知之前,看到的都是配置更新前的数据(也就是,客户端读取配置读了一半,如果收到了Ready file删除的通知,就可以放弃这次读,再重试读了)。

学生提问:谁出发了这里的watch?

Robert教授:假设这个客户端与这个副本在交互,它发送了一个exist请求,exist请求是个只读请求。相应的副本在一个table上生成一个watch的表单,表明哪些客户端watch了哪些file。

并且,watch是基于一个特定的zxid建立的,如果客户端在一个副本log的某个位置执行了读请求,并且返回了相对于这个位置的状态,那么watch也是相对于这个位置来进行。如果收到了一个删除Ready file的请求,副本会查看watch表单,并且发现针对这个Ready file有一个watch。watch表单或许是以file名的hash作为key,这样方便查找。

学生提问:这个副本必须要有一个watch表单,如果副本故障了,客户端需要连接到另外一个副本,那新连接的副本中的watch表单如何生成呢?

Robert教授:答案是,如果你的副本故障了,那么切换到的新的副本不会有watch表单。但是客户端在相应的位置会收到通知说,你正在交互的副本故障了,之后客户端就知道,应该重置所有数据,并与新的副本建立连接(包括watch)。

下一节课会继续介绍Zookeeper。

Lecture 09 - More Replication, CRAQ

这节课我想完成两件事情,第一个是结束关于Zookeeper的讨论,第二就是讨论CRAQ。

在开始之前,强烈建议阅读CRAQ论文。

【1】https://pdos.csail.mit.edu/6.824/papers/craq.pdf

9.1 Zookeeper API

Zookeeper里面我最感兴趣的事情是它的API设计。Zookeeper的API设计使得它可以成为一个通用的服务,从而分担一个分布式系统所需要的大量工作。那么为什么Zookeeper的API是一个好的设计?具体来看,因为它实现了一个值得去了解的概念:mini-transaction。

我们回忆一下Zookeeper的特点:

  • Zookeeper基于(类似于)Raft框架,所以我们可以认为它是,当然它的确是容错的,它在发生网络分区的时候,也能有正确的行为。
  • 当我们在分析各种Zookeeper的应用时,我们也需要记住Zookeeper有一些性能增强,使得读请求可以在任何副本被处理,因此,可能会返回旧数据。
  • 另一方面,Zookeeper可以确保一次只处理一个写请求,并且所有的副本都能看到一致的写请求顺序。这样,所有副本的状态才能保证是一致的(写请求会改变状态,一致的写请求顺序可以保证状态一致)。
  • 由一个客户端发出的所有读写请求会按照客户端发出的顺序执行。
  • 一个特定客户端的连续请求,后来的请求总是能看到相比较于前一个请求相同或者更晚的状态(详见8.5 FIFO客户端序列)。

在我深入探讨Zookeeper的API长什么样和为什么它是有用的之前,我们可以考虑一下,Zookeeper的目标是解决什么问题,或者期望用来解决什么问题?

  • 对于我来说,使用Zookeeper的一个主要原因是,它可以是一个VMware FT所需要的Test-and-Set服务(详见4.7)的实现。Test-and-Set服务在发生主备切换时是必须存在的,但是在VMware FT论文中对它的描述却又像个谜一样,论文里没有介绍:这个服务究竟是什么,它是容错的吗,它能容忍网络分区吗?Zookeeper实际的为我们提供工具来写一个容错的,完全满足VMware FT要求的Test-and-Set服务,并且可以在网络分区时,仍然有正确的行为。这是Zookeeper的核心功能之一。
  • 使用Zookeeper还可以做很多其他有用的事情。其中一件是,人们可以用它来发布其他服务器使用的配置信息。例如,向某些Worker节点发布当前Master的IP地址。
  • 另一个Zookeeper的经典应用是选举Master。当一个旧的Master节点故障时,哪怕说出现了网络分区,我们需要让所有的节点都认可同一个新的Master节点。
  • 如果新选举的Master需要将其状态保持到最新,比如说GFS的Master需要存储对于一个特定的Chunk的Primary节点在哪,现在GFS的Master节点可以将其存储在Zookeeper中,并且知道Zookeeper不会丢失这个信息。当旧的Master崩溃了,一个新的Master被选出来替代旧的Master,这个新的Master可以直接从Zookeeper中读出旧Master的状态。
  • 其他还有,对于一个类似于MapReduce的系统,Worker节点可以通过在Zookeeper中创建小文件来注册自己。
  • 同样还是类似于MapReduce这样的系统,你可以设想Master节点通过向Zookeeper写入具体的工作,之后Worker节点从Zookeeper中一个一个的取出工作,执行,完成之后再删除工作。

以上就是Zookeeper可以用来完成的工作。

学生提问:Zookeeper应该如何应用在这些场景中?

Robert教授:通常来说,如果你有一个大的数据中心,并且在数据中心内运行各种东西,比如说Web服务器,存储系统,MapReduce等等。你或许会想要再运行一个包含了5个或者7个副本的Zookeeper集群,因为它可以用在很多场景下。之后,你可以部署各种各样的服务,并且在设计中,让这些服务存储一些关键的状态到你的全局的Zookeeper集群中。

Zookeeper的API某种程度上来说像是一个文件系统。它有一个层级化的目录结构,有一个根目录(root),之后每个应用程序有自己的子目录。比如说应用程序1将自己的文件保存在APP1目录下,应用程序2将自己的文件保存在APP2目录下,这些目录又可以包含文件和其他的目录。

这么设计的一个原因刚刚也说过,Zookeeper被设计成要被许多可能完全不相关的服务共享使用。所以我们需要一个命名系统来区分不同服务的信息,这样这些信息才不会弄混。对于每个使用Zookeeper的服务,围绕着文件,有很多很方便的方法来使用Zookeeper。我们在接下来几个小节会看几个例子。

所以,Zookeeper的API看起来像是一个文件系统,但是又不是一个实际的文件系统,比如说你不能mount一个文件,你不能运行ls和cat这样的命令等等。这里只是在内部,以这种路径名的形式命名各种对象。假设应用程序2下面有X,Y,Z这些文件。当你通过RPC向Zookeeper请求数据时,你可以直接指定/APP2/X。这就是一种层级化的命名方式。

这里的文件和目录都被称为znodes。Zookeeper中包含了3种类型的znode,了解他们对于解决问题会有帮助。

  1. 第一种Regular znodes。这种znode一旦创建,就永久存在,除非你删除了它。
  2. 第二种是Ephemeral znodes。如果Zookeeper认为创建它的客户端挂了,它会删除这种类型的znodes。这种类型的znodes与客户端会话绑定在一起,所以客户端需要时不时的发送心跳给Zookeeper,告诉Zookeeper自己还活着,这样Zookeeper才不会删除客户端对应的ephemeral znodes。
  3. 最后一种类型是Sequential znodes。它的意思是,当你想要以特定的名字创建一个文件,Zookeeper实际上创建的文件名是你指定的文件名再加上一个数字。当有多个客户端同时创建Sequential文件时,Zookeeper会确保这里的数字不重合,同时也会确保这里的数字总是递增的。

这些在后面的例子中都会有介绍。

Zookeeper以RPC的方式暴露以下API。

  • CREATE(PATH,DATA,FLAG)。入参分别是文件的全路径名PATH,数据DATA,和表明znode类型的FLAG。这里有意思的是,CREATE的语义是排他的。也就是说,如果我向Zookeeper请求创建一个文件,如果我得到了yes的返回,那么说明这个文件之前不存在,我是第一个创建这个文件的客户端;如果我得到了no或者一个错误的返回,那么说明这个文件之前已经存在了。所以,客户端知道文件的创建是排他的。在后面有关锁的例子中,我们会看到,如果有多个客户端同时创建同一个文件,实际成功创建文件(获得了锁)的那个客户端是可以通过CREATE的返回知道的。
  • DELETE(PATH,VERSION)。入参分别是文件的全路径名PATH,和版本号VERSION。有一件事情我之前没有提到,每一个znode都有一个表示当前版本号的version,当znode有更新时,version也会随之增加。对于delete和一些其他的update操作,你可以增加一个version参数,表明当且仅当znode的当前版本号与传入的version相同,才执行操作。当存在多个客户端同时要做相同的操作时,这里的参数version会非常有帮助(并发操作不会被覆盖)。所以,对于delete,你可以传入一个version表明,只有当znode版本匹配时才删除。
  • EXIST(PATH,WATCH)。入参分别是文件的全路径名PATH,和一个有趣的额外参数WATCH。通过指定watch,你可以监听对应文件的变化。不论文件是否存在,你都可以设置watch为true,这样Zookeeper可以确保如果文件有任何变更,例如创建,删除,修改,都会通知到客户端。此外,判断文件是否存在和watch文件的变化,在Zookeeper内是原子操作。所以,当调用exist并传入watch为true时,不可能在Zookeeper实际判断文件是否存在,和建立watch通道之间,插入任何的创建文件的操作,这对于正确性来说非常重要。
  • GETDATA(PATH,WATCH)。入参分别是文件的全路径名PATH,和WATCH标志位。这里的watch监听的是文件的内容的变化。
  • SETDATA(PATH,DATA,VERSION)。入参分别是文件的全路径名PATH,数据DATA,和版本号VERSION。如果你传入了version,那么Zookeeper当且仅当文件的版本号与传入的version一致时,才会更新文件。
  • LIST(PATH)。入参是目录的路径名,返回的是路径下的所有文件。

9.2 使用Zookeeper实现计数器

我们来看看是如何使用这些Zookeeper API的。

第一个很简单的例子是计数器,假设我们在Zookeeper中有一个文件,我们想要在那个文件存储一个统计数字,例如,统计客户端的请求次数,当收到了一个来自客户端的请求时,我们需要增加存储的数字。

现在关键问题是,多个客户端会同时并发发送请求导致存储的数字增加。所以,第一个要解决的问题是,除了管理数据以外(类似于简单的SET和GET),我们是不是真的需要一个特殊的接口来支持多个客户端的并发。Zookeeper API看起来像是个文件系统,我们能不能只使用典型的存储系统的读写操作来解决并发的问题。

比如说,在Lab3中,你们会构建一个key-value数据库,它只支持两个操作,一个是PUT(K,V),另一个是GET(K)。对于所有我们想要通过Zookeeper来实现的操作,我们可以使用Lab3中的key-value数据库来完成吗?或许我们真的可以使用只有两个操作接口的Lab3来完成这里的计数器功能。你可以这样实现,首先通过GET读出当前的计数值,之后通过PUT写入X + 1。

为什么这是一个错误的答案?是的,这里不是原子操作,这是问题的根源。

如果有两个客户端想要同时增加计数器的值,它们首先都会先通过GET读出旧的计数器值,比如说10。之后,它们都会对10加1得到11,并调用PUT将11写入。所以现在我们只对计数器加了1,但是实际上有两个客户端执行了增加计数器的操作,而我们本应该对计数器增加2。所以,这就是什么Lab3甚至都不能用在这个最简单的例子中。

但是,Zookeeper自身也有问题,在Zookeeper的世界中,GET可能得到的是旧数据。而Lab3中,GET不允许返回旧的数据。因为Zookeeper读数据可能得到旧的数据,如果你得到了一个旧版本的计数器值,并对它加1,那么你实际会写入一个错误的数值。如果最新的数据是11,但是你通过Zookeeper的GET得到的是旧的数据10,然后你加了1,再将11写入到Zookeeper,这是一个错误的行为,因为我们实际上应该将12写入到Zookeeper中。所以,Zookeeper也有问题,我们必须要考虑GET得到的不是最新数据的情况。

所以,如何通过Zookeeper实现一个计数器呢?我会这样通过Zookeeper来实现计数器。你需要将这里的代码放在一个循环里面,因为代码不一定能在第一次执行的时候成功。我们对于循环加上while true,之后我们调用GETDATA来获取当前计数器的值,代码是X,V = GETDATA(“f”),我们并不关心文件名是什么,所以这里直接传入一个“f”。

现在,我们获得了一个数值X,和一个版本号V,可能不是最新的,也可能是新的。之后,我们对于SETDATA("f", X + 1, V)加一个IF判断。如果返回true,表明它的确写入了数据,那么我们会从循环中跳出 break,如果返回false,那我们会回到循环的最开始,重新执行。

WHILE TRUE:
    X, V = GETDATA("F")
    IF SETDATA("f", X + 1, V):
        BREAK

在代码的第2行,我们从某个副本读到了一个数据X和一个版本号V,或许是旧的或许是最新的。而第3行的SETDATA会在Zookeeper Leader节点执行,因为所有的写操作都要在Leader执行。第3行的意思是,只有当实际真实的版本号等于V的时候,才更新数据。如果系统没有其他的客户端在更新“f”对应的数据,那么我们可以直接读出最新的数据和最新的版本号,之后调用SETDATA时,我们对最新的数据加1,并且指定了最新的版本号,SETDATA最终会被Leader所接受并得到回复说写入成功,之后就可以通过BREAK跳出循环,因为此时,我们已经成功写入了数据。但是,如果我们在第2行得到的是旧的数据,或者得到的就是最新的数据,但是当我们的SETDATA送到Zookeeper Leader时,数据已经被其他的客户端修改了,这样我们的版本号就不再是最新的版本号。这时,SETDATA会失败,并且我们会得到一个错误的回复,这样我们的代码不会跳出循环,我们会回到循环的最开始,重头开始再执行,并且期望这次能执行成功。

学生提问:这里能确保循环一定退出吗?

Robert教授:不,我们这里并没有保证说循环一定会退出。例如在实际中,我们读取数据的副本与Leader失联了,并且永远返回给我们旧数据,那么这里永远都会陷在循环中。大部分情况下,Leader会使得所有的副本都趋向于拥有与Leader相同的数据。所以,如果我们第一次拿到的是旧的数据,在我们再次重试前,我们或许需要等待10ms。最终我们会看到最新的数据。

一种最坏的情况是,如果有1000个客户端并发请求要增加计数器,那么一次只有一个客户端可以成功。这1000个客户端中,第一个将SETDATA发到Leader的客户端可以成功增加计数,而其他的会失败,因为其他客户端持有的版本号已经过时了。之后,剩下的999个客户端会再次并发的发送请求,然后还是只有一个客户端能成功。所以,为了让所有的客户端都能成功计数,这里的复杂度是 $$O(n^2)$$ 。这不太好,但是最终所有的请求都能够完成。所以,如果你的场景中有大量的客户端,那么这里你或许要使用一个不同的策略。前面介绍的策略只适合低负载的场景。

学生提问:Zookeeper的数据都存在内存吗?

Robert教授:是的。如果数据小于内存容量那就没问题,如果数据大于内存容量,那就是个灾难。所以当你在使用Zookeeper时,你必须时刻记住Zookeeper对于100MB的数据很友好,但是对于100GB的数据或许就很糟糕了。这就是为什么人们用Zookeeper来存储配置,而不是大型网站的真实数据。

学生提问:对于高负载的场景该如何处理呢?

Robert教授:我们可以在SETDATA失败之后等待一会。我会这么做,首先,等待(sleep)是必须的,其次,等待的时间每次需要加倍再加上一些随机。这里实际上跟Raft的Leader Election里的Exceptional back-off是类似的。这是一种适应未知数量并发客户端请求的合理策略。

学生提问:提问过程比较长,听不太清,大概意思就是想使用WATCH机制来解决上面的 $$O(n^2)$$ 的问题。

Robert教授:首先,如果我们在GETDATA的时候,增加WATCH=true,那么在我们实际调用SETDATA时,如果有人修改了计数器的值,我们是可以收到通知的。

但是这里的时序并不是按照你设想的那样工作,上面代码的第2,3行之间的时间理论上是0。但是如果有一个其他客户端在我们GETDATA之后发送了增加计数的请求,我们收到通知的时间可能会比较长。首先那个客户端的请求要发送到Leader,之后Leader要将这个请求转发到Follower,Follower执行完之后Follower会查找自己的Watch表单,然后才能给我们发送一个通知。所以,就算我们在GETDATA的时候设置了WATCH,我们在SETDATA的时候,也不一定能收到其他客户端修改数据的通知。

在任何情况下,我认为WATCH不能帮助我们。因为1000个客户端都会做相同的事情,它们都会调用GETDATA,设置WATCH,它们都会同时获得通知,并作出相同的决定。又或许没有一个客户端可以得到WATCH结果,因为没有人成功的SETDATA了。所以,最坏的情况是,所有的客户端从一个位置开始执行,它们都调用GETDATA,得到了版本号为1,同时设置了WATCH。因为现在还没有变更,这一千个客户端都通过RPC发送了SETDATA给Leader。之后,第一个客户端更新了数据,然后其他的999个客户端才能得到通知,但是现在太晚了,因为它们已经发送了SETDATA。

WATCH或许可以在这里帮到我们。接下来的Lock的例子解决了这里的问题。所以,我们可以采用论文中的第二个有关Lock的例子,在有大量客户端想要增加计数器时,使得计数器一次只处理一个客户端。

还有其他问题吗?

这个例子,其实就是大家常说的mini-transaction。这里之所以是事务的,是因为一旦我们操作成功了,我们对计数器达成了_读-更改-写_的原子操作。对于我们在Lab3中实现的数据库来说,它的读写操作不是原子的。而我们上面那段代码,一旦完成了,就是原子的。因为一旦完成了,我们的读,更改,写操作就不受其他任何客户端的干扰。

之所以称之为mini-transaction,是因为这里并不是一个完整的数据库事务(transaction)。一个真正的数据库可以使用完整的通用的事务,你可以指定事务的开始,然后执行任意的数据读写,之后结束事务。数据库可以聪明的将所有的操作作为一个原子事务提交。一个真实的事务可能会非常复杂,而Zookeeper支持这种非常简单的事务,使得我们可以对于一份数据实现原子操作。这对于计数器或者其他的一些简单功能足够了。所以,这里的事务并不通用,但是的确也提供了原子性,所以它被称为mini-transaction。

通过计数器这个例子里的策略可以实现很多功能,比如VMware FT所需要的Test-and-Set服务就可以以非常相似的方式来实现。如果旧的数据是0,一个虚机尝试将其设置成1,设置的时候会带上旧数据的版本号,如果没有其他的虚机介入也想写这个数据,我们就可以成功的将数据设置成1,因为Zookeeper里数据的版本号没有改变。如果某个客户端在我们读取数据之后更改了数据,那么Leader会通知我们说数据写入失败了,所以我们可以用这种方式来实现Test-and-Set服务。你应该记住这里的策略。

9.3 使用Zookeeper实现非扩展锁

这一部分我想讨论的例子是非扩展锁。我讨论它的原因并不是因为我强烈的认为这种锁是有用的,而是因为它在Zookeeper论文中出现了。

对于锁来说,常见的操作是Aquire Lock,获得锁。获得锁可以用下面的伪代码实现:

WHILE TRUE:
    IF CREATE("f", data, ephemeral=TRUE): RETURN
    IF EXIST("f", watch=TRUE):
        WAIT

在代码的第2行,是尝试创建锁文件。除了指定文件名,还指定了ephemeral为TRUE(ephemeral的含义详见9.1)。如果锁文件创建成功了,表明我们获得了锁,直接RETURN。

如果锁文件创建失败了,我们需要等待锁释放。因为如果锁文件创建失败了,那表明锁已经被别人占住了,所以我们需要等待锁释放。最终锁会以删除文件的形式释放,所以我们这里通过EXIST函数加上watch=TRUE,来监测文件的删除。在代码的第3行,可以预期锁文件还存在,因为如果不存在的话,在代码的第2行就返回了。

在代码的第4行,等待文件删除对应的watch通知。收到通知之后,再回到循环的最开始,从代码的第2行开始执行。

所以,总的来说,先是通过CREATE创建锁文件,或许可以直接成功。如果失败了,我们需要等待持有锁的客户端释放锁。通过Zookeeper的watch机制,我们会在锁文件删除的时候得到一个watch通知。收到通知之后,我们回到最开始,尝试重新创建锁文件,如果运气足够好,那么这次是能创建成功的。

在这里,我们要问自己一个问题:如果多个客户端并发的请求锁会发生什么?

有一件事情可以确定,如果有两个客户端同时要创建锁文件,Zookeeper Leader会以某种顺序一次只执行一个请求。所以,要么是我的客户端先创建了锁文件,要么是另一个客户端创建了锁文件。如果我的客户端先创建了锁文件,我们的CREATE调用会返回TRUE,这表示我们获得了锁,然后我们直接RETURN返回,而另一个客户端调用CREATE必然会收到了FALSE。如果另一个客户端先创建了文件,那么我的客户端调用CREATE必然会得到FALSE。不管哪种情况,锁文件都会被创建。当有多个客户端同时请求锁时,因为Zookeeper一次只执行一个请求,所以还好。

如果我的客户端调用CREATE返回了FALSE,那么我接下来需要调用EXIST,如果锁在代码的第2行和第3行之间释放了会怎样呢?这就是为什么在代码的第3行,EXIST前面要加一个IF,因为锁文件有可能在调用EXIST之前就释放了。如果在代码的第3行,锁文件不存在,那么EXIST返回FALSE,代码又回到循环的最开始,重新尝试获得锁。

类似的,并且同时也更有意思的是,如果正好在我调用EXIST的时候,或者在与我交互的副本还在处理EXIST的过程中,锁释放了会怎样?不管我与哪个副本进行交互,在它的Log中,可以确保写请求会以某种顺序执行。所以,与我交互的副本,它的Log以某种方式向前增加。因为我的EXIST请求是个只读请求,所以它必然会在两个写请求之间执行。现在某个客户端的DELETE请求要在某个位置被处理,所以,在副本Log中的某处是来自其他客户端的DELETE请求。而我的EXIST请求有两种可能:要么完全的在DELETE请求之前处理,这样的话副本会认为,锁文件还存在,副本会在WATCH表单(详见8.7)中增加一条记录,之后才执行DELETE请求。

而当执行DELETE请求的时候,可以确保我的WATCH请求在副本的WATCH表单中,所以副本会给我发送一个通知,说锁文件被删除了。

要么我的EXIST请求在DELETE请求之后处理。这时,文件并不存在,EXIST返回FALSE,又回到了循环的最开始。

因为Zookeeper的写请求是序列化的,而读请求必然在副本Log的两个写请求之间确定的位置执行,所以这种情况也还好。

学生提问:如果EXIST返回FALSE,回到循环最开始,调用CREATE的时候,已经有其他人创建了锁会怎样呢?

Robert教授:那么CREATE会返回FALSE,我们又回到了EXIST,这次我们还是需要等待WATCH通知锁文件被删除了。

学生提问:为什么我们不关心锁的名字?

Robert教授:这只是一个名字,为了让不同的客户端可以使用同一个锁。所以,它只是个名字而已。当我获得锁之后,我可以对锁保护的数据做任何操作。比如,一次只有一个人可以在这个课堂里讲课,为了讲课,首先需要获得这个课堂的锁,那要先知道锁的名字,比如说34100(猜是教室名字)。这里讨论的锁本质上就是一个znode,但是没有人关心它的内容是什么。所以,我们需要对锁有一个统一的名字。所以,Zookeeper看起来像是一个文件系统,实际上它是一个命名系统(naming system)。

这里的锁设计并不是一个好的设计,因为它和前一个计数器的例子都受羊群效应(Herd Effect)的影响。所谓的羊群效应,对于计数器的例子来说,就是当有1000个客户端同时需要增加计数器时,我们的复杂度是 $$O(n^2)$$ ,这是处理完1000个客户端的请求所需要的总时间。对于这一节的锁来说,也存在羊群效应,如果有1000个客户端同时要获得锁文件,为1000个客户端分发锁所需要的时间也是 $$O(n^2)$$ 。因为每一次锁文件的释放,所有剩下的客户端都会收到WATCH的通知,并且回到循环的开始,再次尝试创建锁文件。所以CREATE对应的RPC总数与1000的平方成正比。所以这一节的例子也受羊群效应的影响,像羊群一样的客户端都阻塞在Zookeeper这。这一节实现的锁有另一个名字:非扩展锁(Non-Scalable Lock)。它对应的问题是真实存在的,我们会在其他系统中再次看到。

9.4 使用Zookeeper实现可扩展锁

在Zookeeper论文的结尾,讨论了如何使用Zookeeper解决非扩展锁的问题。有意思的是,因为Zookeeper的API足够灵活,可以用来设计另一个更复杂的锁,从而避免羊群效应。从而使得,即使有1000个客户端在等待锁释放,当锁释放时,另一个客户端获得锁的复杂度是$$O(1)$$ 而不是$$O(n)$$ 。这个设计有点复杂,下面是论文第6页中2.4部分的伪代码。在这个设计中,我们不再使用一个单独的锁文件,而是创建Sequential文件(详见9.1)。

CREATE("f", data, sequential=TRUE, ephemeral=TRUE)
WHILE TRUE:
    LIST("f*")
    IF NO LOWER #FILE: RETURN
    IF EXIST(NEXT LOWER #FILE, watch=TRUE):
        WAIT

在代码的第1行调用CREATE,并指定sequential=TRUE,我们创建了一个Sequential文件,如果这是以“f”开头的第27个Sequential文件,这里实际会创建类似以“f27”为名字的文件。这里有两点需要注意,第一是通过CREATE,我们获得了一个全局唯一序列号(比如27),第二Zookeeper生成的序号必然是递增的。

代码第3行,通过LIST列出了所有以“f”开头的文件,也就是所有的Sequential文件。

代码第4行,如果现存的Sequential文件的序列号都不小于我们在代码第1行得到的序列号,那么表明我们在并发竞争中赢了,我们获得了锁。所以当我们的Sequential文件对应的序列号在所有序列号中最小时,我们获得了锁,直接RETURN。序列号代表了不同客户端创建Sequential文件的顺序。在这种锁方案中,会使用这个顺序来向客户端分发锁。当存在更低序列号的Sequential文件时,我们要做的是等待拥有更低序列号的客户端释放锁。在这个方案中,释放锁的方式是删除文件。所以接下来,我们需要做的是等待序列号更低的锁文件删除,之后我们才能获得锁。

所以,在代码的第5行,我们调用EXIST,并设置WATCH,等待比自己序列号更小的下一个锁文件删除。如果等到了,我们回到循环的最开始。但是这次,我们不会再创建锁文件,代码从LIST开始执行。这是获得锁的过程,释放就是删除创建的锁文件。

学生提问:为什么重试的时候要在代码第3行再次LIST文件?

Robert教授:这是个好问题。问题是,我们在代码第3行得到了文件的列表,我们就知道了比自己序列号更小的下一个锁文件。Zookeeper可以确保,一旦一个序列号,比如说27,被使用了,那么之后创建的Sequential文件不会使用更小的序列号。所以,我们可以确定第一次LIST之后,不会有序列号低于27的锁文件被创建,那为什么在重试的时候要再次LIST文件?为什么不直接跳过?你们来猜猜答案。

答案是,持有更低序列号Sequential文件的客户端,可能在我们没有注意的时候就释放了锁,也可能已经挂了。比如说,我们是排在第27的客户端,但是排在第26的客户端在它获得锁之前就挂了。因为它挂了,Zookeeper会自动的删除它的锁文件(因为创建锁文件时,同时也指定了ephemeral=TRUE)。所以这时,我们要等待的是序列号25的锁文件释放。所以,尽管不可能再创建序列号更小的锁文件,但是排在前面的锁文件可能会有变化,所以我们需要在循环的最开始再次调用LIST,以防在等待锁的队列里排在我们前面的客户端挂了。

学生提问:如果不存在序列号更低的锁文件,那么当前客户端就获得了锁?

Robert教授:是的。

学生提问:为什么这种锁不会受羊群效应(Herd Effect)的影响?

Robert教授:假设我们有1000个客户端在等待获取锁,每个客户端都会在代码的第6行等待锁释放。但是每个客户端等待的锁文件都不一样,比如序列号为500的锁只会被序列号为501的客户端等待,而序列号500的客户端只会等待序列号499的锁文件。每个客户端只会等待一个锁文件,当一个锁文件被释放,只有下一个序列号对应的客户端才会收到通知,也只有这一个客户端会回到循环的开始,也就是代码的第3行,之后这个客户端会获得锁。所以,不管有多少个客户端在等待锁,每一次锁释放再被其他客户端获取的代价是一个常数。而在非扩展锁中,锁释放时,每个等待的客户端都会被通知到,之后,每个等待的客户端都会发送CREATE请求给Zookeeper,所以每一次锁释放再被其他客户端获取的代价与客户端数量成正比。

学生提问:那排在后面的客户端岂不是要等待很长的时间?

Robert教授:你可以去喝杯咖啡等一等。编程接口不是我们关心的内容,不过代码第6行的等待有两种可能,第一种是启动一个线程同步等待锁,在获得锁之前线程不会继续执行;第二种会更加复杂一些,你向Zookeeper发送请求,但是不等待其返回,同时有另外一个goroutine等待Zookeeper的返回,这跟前面介绍的AppCh(Apply Channel,详见6.6)一样,第二种方式更加常见。所以要么是多线程,要么是事件驱动,不管怎样,代码在等待的时候可以执行其他的动作。

学生提问:代码第5行EXIST返回TRUE意味着什么?

Robert教授:如果返回TRUE,意味着,要么对应的客户端还活着并持有着锁,要么还活着在等待其他的锁,我们不知道是哪种情况。如果EXIST返回FALSE,那么有两种可能:要么是序列号的前一个客户端释放了锁并删除了锁文件;要么是前一个客户端退出了,因为锁文件是ephemeral的,然后Zookeeper删除了锁文件。所以,不论EXIST返回什么,都有两种可能。所以我们重试的时候,要检查所有的信息,因为我们不知道EXIST完成之后是什么情况。

我第一次看到可扩展锁,是在一种完全不同的背景下,也就是在多线程代码中的可扩展锁。通常来说,这种锁称为可扩展锁(Scalable Lock)。我认为这是我见过的一种最有趣的结构,就像我很欣赏Zookeeper的API设计一样。

不得不说,我有点迷惑为什么Zookeeper论文要讨论锁。因为这里的锁并不像线程中的锁,在线程系统中,不存在线程随机的挂了然后下线。如果每个线程都正确使用了锁,你从线程锁中可以获得操作的原子性(Atomicity)。假如你获得了锁,并且执行了47个不同的读写操作,修改了一些变量,然后释放了锁。如果所有的线程都遵从这里的锁策略,没有人会看到一切奇怪的数据中间状态。这里的线程锁可以使得操作具备原子性。

而通过Zookeeper实现的锁就不太一样。如果持有锁的客户端挂了,它会释放锁,另一个客户端可以接着获得锁,所以它并不确保原子性。因为你在分布式系统中可能会有部分故障(Partial Failure),但是你在一个多线程代码中不会有部分故障。如果当前锁的持有者需要在锁释放前更新一系列被锁保护的数据,但是更新了一半就崩溃了,之后锁会被释放。然后你可以获得锁,然而当你查看数据的时候,只能看到垃圾数据,因为这些数据是只更新了一半的随机数据。所以,Zookeeper实现的锁,并没有提供类似于线程锁的原子性保证。

所以,读完了论文之后,我不禁陷入了沉思,为什么我们要用Zookeeper实现锁,为什么锁会是Zookeeper论文中的主要例子之一。

我认为,在一个分布式系统中,你可以这样使用Zookeeper实现的锁。每一个获得锁的客户端,需要做好准备清理之前锁持有者因为故障残留的数据。所以,当你获得锁时,你查看数据,你需要确认之前的客户端是否故障了,如果是的话,你该怎么修复数据。如果总是以确定的顺序来执行操作,假设前一个客户端崩溃了,你或许可以探测出前一个客户端是在操作序列中哪一步崩溃的。但是这里有点取巧,你需要好好设计一下。而对于线程锁,你就不需要考虑这些问题。

另外一个对于这些锁的合理的场景是:Soft Lock。Soft Lock用来保护一些不太重要的数据。举个例子,当你在运行MapReduce Job时,你可以用这样的锁来确保一个Task同时只被一个Work节点执行。例如,对于Task 37,执行它的Worker需要先获得相应的锁,再执行Task,并将Task标记成执行完成,之后释放锁。MapReduce本身可以容忍Worker节点崩溃,所以如果一个Worker节点获得了锁,然后执行了一半崩溃了,之后锁会被释放,下一个获得锁的Worker会发现任务并没有完成,并重新执行任务。这不会有问题,因为这就是MapReduce定义的工作方式。所以你可以将这里的锁用在Soft Lock的场景。

另一个值得考虑的问题是,我们可以用这里的代码来实现选举Master。

学生提问:有没有探测前一个锁持有者崩溃的方法?

Robert教授:还记录论文里说的吗?你可以先删除Ready file,之后做一些操作,最后再重建Ready file。这是一种非常好的探测并处理前一个Master或者锁持有者在半路崩溃的方法。因为可以通过Ready file是否存在来判断前一个锁持有者是否因为崩溃才退出。

学生提问:在Golang实现的多线程代码中,一个线程获得了锁,有没有可能在释放锁之前就崩溃了?

Robert教授:不幸的是,这个是可能的。对于单个线程来说有可能崩溃,或许在运算时除以0,或者一些其他的panic。我的建议是,现在程序已经故障了,最好把程序的进程杀掉。

在多线程的代码中,可以这么来看锁:当锁被持有时,数据是可变的,不稳定的。当锁的持有线程崩溃了,是没有安全的办法再继续执行代码的。因为不论锁保护的是什么数据,当锁没有释放时,数据都可以被认为是不稳定的。如果你足够聪明,你可以使用类似于Ready file的方法,但是在Golang里面实现这种方法超级难,因为内存模型决定了你不能依赖任何东西。如果你更新一些变量,之后设置一个类似于Ready file的Done标志位,这不意味任何事情,除非你释放了锁,其他人获得了锁。因为只有在那时线程的执行顺序是确定的,其他线程才能安全的读取Done标志位。所以在Golang里面,很难从一个持有了锁的线程的崩溃中恢复。但是在我们的锁里面,恢复或许会更加可能一些。

以上就是对于Zookeeper的一些介绍。有两点需要注意:第一是Zookeeper聪明的从多个副本读数据从而提升了性能,但同时又牺牲了一些一致性;另一个是Zookeeper的API设计,使得Zookeeper成为一个通用的协调服务,这是一个简单的put/get 服务所不能实现,这些API使你可以写出类似mini-transaction的代码,也可以帮你创建自己的锁。

9.5 链复制(Chain Replication)

这一部分,我们来讨论另一个论文CRAQ(Chain Replication with Apportioned Queries)。我们选择CRAQ论文有两个原因:第一个是它通过复制实现了容错;第二是它通过以链复制API请求这种有趣的方式,提供了与Raft相比不一样的属性。

CRAQ是对于一个叫链式复制(Chain Replication)的旧方案的改进。Chain Replication实际上用的还挺多的,有许多现实世界的系统使用了它,CRAQ是对它的改进。CRAQ采用的方式与Zookeeper非常相似,它通过将读请求分发到任意副本去执行,来提升读请求的吞吐量,所以副本的数量与读请求性能成正比。CRAQ有意思的地方在于,它在任意副本上执行读请求的前提下,还可以保证线性一致性(Linearizability)。这与Zookeeper不太一样,Zookeeper为了能够从任意副本执行读请求,不得不牺牲数据的实时性,因此也就不是线性一致的。CRAQ却可以从任意副本执行读请求,同时也保留线性一致性,这一点非常有趣。

首先,我想讨论旧的Chain Replication系统。Chain Replication是这样一种方案,你有多个副本,你想确保它们都看到相同顺序的写请求(这样副本的状态才能保持一致),这与Raft的思想是一致的,但是它却采用了与Raft不同的拓扑结构。

首先,在Chain Replication中,有一些服务器按照链排列。第一个服务器称为HEAD,最后一个被称为TAIL。

当客户端想要发送一个写请求,写请求总是发送给HEAD。

HEAD根据写请求更新本地数据,我们假设现在是一个支持PUT/GET的key-value数据库。所有的服务器本地数据都从A开始。

当HEAD收到了写请求,将本地数据更新成了B,之后会再将写请求通过链向下一个服务器传递。

下一个服务器执行完写请求之后,再将写请求向下一个服务器传递,以此类推,所有的服务器都可以看到写请求。

当写请求到达TAIL时,TAIL将回复发送给客户端,表明写请求已经完成了。这是处理写请求的过程。

对于读请求,如果一个客户端想要读数据,它将读请求发往TAIL,

TAIL直接根据自己的当前状态来回复读请求。所以,如果当前状态是B,那么TAIL直接返回B。读请求处理的非常的简单。

这里只是Chain Replication,并不是CRAQ。Chain Replication本身是线性一致的,在没有故障时,从一致性的角度来说,整个系统就像只有TAIL一台服务器一样,TAIL可以看到所有的写请求,也可以看到所有的读请求,它一次只处理一个请求,读请求可以看到最新写入的数据。如果没有出现故障的话,一致性是这么得到保证的,非常的简单。

从一个全局角度来看,除非写请求到达了TAIL,否则一个写请求是不会commit,也不会向客户端回复确认,也不能将数据通过读请求暴露出来。而为了让写请求到达TAIL,它需要经过并被链上的每一个服务器处理。所以我们知道,一旦我们commit一个写请求,一旦向客户端回复确认,一旦将写请求的数据通过读请求暴露出来,那意味着链上的每一个服务器都知道了这个写请求。

9.6 链复制的故障恢复(Fail Recover)

在Chain Replication中,出现故障后,你可以看到的状态是相对有限的。因为写请求的传播模式非常有规律,我们不会陷入到类似于Raft论文中图7和图8描述的那种令人毛骨悚然的复杂场景中。并且在出现故障之后,也不会出现不同的副本之间各种各样不同步的场景。

在Chain Replication中,因为写请求总是依次在链中处理,写请求要么可以达到TAIL并commit,要么只到达了链中的某一个服务器,之后这个服务器出现故障,在链中排在这个服务器后面的所有其他服务器不再能看到写请求。所以,只可能有两种情况:committed的写请求会被所有服务器看到;而如果一个写请求没有commit,那就意味着在导致系统出现故障之前,写请求已经执行到链中的某个服务器,所有在链里面这个服务器之前的服务器都看到了写请求,所有在这个服务器之后的服务器都没看到写请求。

总的来看,Chain Replication的故障恢复也相对的更简单。

如果HEAD出现故障,作为最接近的服务器,下一个节点可以接手成为新的HEAD,并不需要做任何其他的操作。对于还在处理中的请求,可以分为两种情况:

  • 对于任何已经发送到了第二个节点的写请求,不会因为HEAD故障而停止转发,它会持续转发直到commit。
  • 如果写请求发送到HEAD,在HEAD转发这个写请求之前HEAD就故障了,那么这个写请求必然没有commit,也必然没有人知道这个写请求,我们也必然没有向发送这个写请求的客户端确认这个请求,因为写请求必然没能送到TAIL。所以,对于只送到了HEAD,并且在HEAD将其转发前HEAD就故障了的写请求,我们不必做任何事情。或许客户端会重发这个写请求,但是这并不是我们需要担心的问题。

如果TAIL出现故障,处理流程也非常相似,TAIL的前一个节点可以接手成为新的TAIL。所有TAIL知道的信息,TAIL的前一个节点必然都知道,因为TAIL的所有信息都是其前一个节点告知的。

中间节点出现故障会稍微复杂一点,但是基本上来说,需要做的就是将故障节点从链中移除。或许有一些写请求被故障节点接收了,但是还没有被故障节点之后的节点接收,所以,当我们将其从链中移除时,故障节点的前一个节点或许需要重发最近的一些写请求给它的新后继节点。这是恢复中间节点流程的简单版本。

Chain Replication与Raft进行对比,有以下差别:

  • 从性能上看,对于Raft,如果我们有一个Leader和一些Follower。Leader需要直接将数据发送给所有的Follower。所以,当客户端发送了一个写请求给Leader,Leader需要自己将这个请求发送给所有的Follower。然而在Chain Replication中,HEAD只需要将写请求发送到一个其他节点。数据在网络中发送的代价较高,所以Raft Leader的负担会比Chain Replication中HEAD的负担更高。当客户端请求变多时,Raft Leader会到达一个瓶颈,而不能在单位时间内处理更多的请求。而同等条件以下,Chain Replication的HEAD可以在单位时间处理更多的请求,瓶颈会来的更晚一些。
  • 另一个与Raft相比的有趣的差别是,Raft中读请求同样也需要在Raft Leader中处理,所以Raft Leader可以看到所有的请求。而在Chain Replication中,每一个节点都可以看到写请求,但是只有TAIL可以看到读请求。所以负载在一定程度上,在HEAD和TAIL之间分担了,而不是集中在单个Leader节点。
  • 前面分析的故障恢复,Chain Replication也比Raft更加简单。这也是使用Chain Replication的一个主要动力。

学生提问:如果一个写请求还在传递的过程中,还没有到达TAIL,TAIL就故障了,会发生什么?

Robert教授:如果这个时候TAIL故障了,TAIL的前一个节点最终会看到这个写请求,但是TAIL并没有看到。因为TAIL的故障,TAIL的前一个节点会成为新的TAIL,这个写请求实际上会完成commit,因为写请求到达了新的TAIL。所以新的TAIL可以回复给客户端,但是它极有可能不会回复,因为当它收到写请求时,它可能还不是TAIL。这样的话,客户端或许会重发写请求,但是这就太糟糕了,因为同一个写请求会在系统中处理两遍,所以我们需要能够在HEAD抑制重复请求。不过基本上我们讨论的所有系统都需要能够抑制重复的请求。

学生提问:假设第二个节点不能与HEAD进行通信,第二个节点能不能直接接管成为新的HEAD,并通知客户端将请求发给自己,而不是之前的HEAD?

Robert教授:这是个非常好的问题。你认为呢?

你的方案听起来比较可行。假设HEAD和第二个节点之间的网络出问题了,

HEAD还在正常运行,同时HEAD认为第二个节点挂了。然而第二个节点实际上还活着,它认为HEAD挂了。所以现在他们都会认为,另一个服务器挂了,我应该接管服务并处理写请求。因为从HEAD看来,其他服务器都失联了,HEAD会认为自己现在是唯一的副本,那么它接下来既会是HEAD,又会是TAIL。第二个节点会有类似的判断,会认为自己是新的HEAD。所以现在有了脑裂的两组数据,最终,这两组数据会变得完全不一样。

(下一节继续分析怎么解决这里的问题)

9.7 链复制的配置管理器(Configuration Manager)

(接上一节最后)

所以,Chain Replication并不能抵御网络分区,也不能抵御脑裂。在实际场景中,这意味它不能单独使用。Chain Replication是一个有用的方案,但是它不是一个完整的复制方案。它在很多场景都有使用,但是会以一种特殊的方式来使用。总是会有一个外部的权威(External Authority)来决定谁是活的,谁挂了,并确保所有参与者都认可由哪些节点组成一条链,这样在链的组成上就不会有分歧。这个外部的权威通常称为Configuration Manager。

Configuration Manager的工作就是监测节点存活性,一旦Configuration Manager认为一个节点挂了,它会生成并送出一个新的配置,在这个新的配置中,描述了链的新的定义,包含了链中所有的节点,HEAD和TAIL。Configuration Manager认为挂了的节点,或许真的挂了也或许没有,但是我们并不关心。因为所有节点都会遵从新的配置内容,所以现在不存在分歧了。

现在只有一个角色(Configuration Manager)在做决定,它不可能否认自己,所以可以解决脑裂的问题。

当然,你是如何使得一个服务是容错的,不否认自己,同时当有网络分区时不会出现脑裂呢?答案是,Configuration Manager通常会基于Raft或者Paxos。在CRAQ的场景下,它会基于Zookeeper。而Zookeeper本身又是基于类似Raft的方案。

所以,你的数据中心内的设置通常是,你有一个基于Raft或者Paxos的Configuration Manager,它是容错的,也不会受脑裂的影响。之后,通过一系列的配置更新通知,Configuration Manager将数据中心内的服务器分成多个链。比如说,Configuration Manager决定链A由服务器S1,S2,S3组成,链B由服务器S4,S5,S6组成。

Configuration Manager通告给所有参与者整个链的信息,所以所有的客户端都知道HEAD在哪,TAIL在哪,所有的服务器也知道自己在链中的前一个节点和后一个节点是什么。现在,单个服务器对于其他服务器状态的判断,完全不重要。假如第二个节点真的挂了,在收到新的配置之前,HEAD需要不停的尝试重发请求。节点自己不允许决定谁是活着的,谁挂了。

这种架构极其常见,这是正确使用Chain Replication和CRAQ的方式。在这种架构下,像Chain Replication一样的系统不用担心网络分区和脑裂,进而可以使用类似于Chain Replication的方案来构建非常高速且有效的复制系统。比如在上图中,我们可以对数据分片(Sharding),每一个分片都是一个链。其中的每一个链都可以构建成极其高效的结构来存储你的数据,进而可以同时处理大量的读写请求。同时,我们也不用太担心网络分区的问题,因为它被一个可靠的,非脑裂的Configuration Manager所管理。

学生提问:为什么存储具体数据的时候用Chain Replication,而不是Raft?

Robert教授:这是一个非常合理的问题。其实数据用什么存并不重要。因为就算我们这里用了Raft,我们还是需要一个组件在产生冲突的时候来做决策。比如说数据如何在我们数百个复制系统中进行划分。如果我需要一个大的系统,我需要对数据进行分片,需要有个组件来决定数据是如何分配到不同的分区。随着时间推移,这里的划分可能会变化,因为硬件可能会有增减,数据可能会变多等等。Configuration Manager会决定以A或者B开头的key在第一个分区,以C或者D开头的key在第二个分区。至于在每一个分区,我们该使用什么样的复制方法,Chain Replication,Paxos,还是Raft,不同的人有不同的选择,有些人会使用Paxos,比如说Spanner,我们之后也会介绍。在这里,不使用Paxos或者Raft,是因为Chain Replication更加的高效,因为它减轻了Leader的负担,这或许是一个非常关键的问题。

某些场合可能更适合用Raft或者Paxos,因为它们不用等待一个慢的副本。而当有一个慢的副本时,Chain Replication会有性能的问题,因为每一个写请求需要经过每一个副本,只要有一个副本变慢了,就会使得所有的写请求处理变慢。这个可能非常严重,比如说你有1000个服务器,因为某人正在安装软件或者其他的原因,任意时间都有几个服务器响应比较慢。每个写请求都受限于当前最慢的服务器,这个影响还是挺大的。然而对于Raft,如果有一个副本响应速度较慢,Leader只需要等待过半服务器,而不用等待所有的副本。最终,所有的副本都能追上Leader的进度。所以,Raft在抵御短暂的慢响应方面表现的更好。一些基于Paxos的系统,也比较擅长处理副本相距较远的情况。对于Raft和Paxos,你只需要过半服务器确认,所以不用等待一个远距离数据中心的副本确认你的操作。这些原因也使得人们倾向于使用类似于Raft和Paxos这样的选举系统,而不是Chain Replication。这里的选择取决于系统的负担和系统要实现的目标。

不管怎样,配合一个外部的权威机构这种架构,我不确定是不是万能的,但的确是非常的通用。

学生提问:如果Configuration Manger认为两个服务器都活着,但是两个服务器之间的网络实际中断了会怎样?

Robert教授:对于没有网络故障的环境,总是可以假设计算机可以通过网络互通。对于出现网络故障的环境,可能是某人踢到了网线,一些路由器被错误配置了或者任何疯狂的事情都可能发生。所以,因为错误的配置你可能陷入到这样一个情况中,Chain Replication中的部分节点可以与Configuration Manager通信,并且Configuration Manager认为它们是活着的,但是它们彼此之间不能互相通信。

这是这种架构所不能处理的情况。如果你希望你的系统能抵御这样的故障。你的Configuration Manager需要更加小心的设计,它需要选出不仅是它能通信的服务器,同时这些服务器之间也能相互通信。在实际中,任意两个节点都有可能网络不通。

Lecture 10 - Cloud Replicated DB, Aurora

应该是受新冠的影响,从这节课开始,课堂人就少多了,并且基本也没人提问。

为了更好的理解本节课,强烈建议先阅读Aurora论文。

Aurora论文:https://pdos.csail.mit.edu/6.824/papers/aurora.pdf

10.1 Aurora 背景历史

今天的论文是Amazon的Aurora。Aurora是一个高性能,高可靠的数据库。Aurora本身作为云基础设施一个组成部分而存在,同时又构建在Amazon自己的基础设施之上。

我们之所以要看这篇论文,有以下几个原因:

  • 首先这是最近的来自于Amazon的一种非常成功的云服务,有很多Amazon的用户在使用它。Aurora以自己的方式展示了一个聪明设计所取得的巨大成果。从论文的表1显示的与一些其他数据库的性能比较可以看出,在处理事务的速度上,Aurora宣称比其他数据库快35倍。这个数字非常了不起了。
  • 这篇论文同时也探索了在使用容错的,通用(General-Purpose)存储前提下,性能可以提升的极限。Amazon首先使用的是自己的通用存储,但是后来发现性能不好,然后就构建了完全是应用定制(Application-Specific)的存储,并且几乎是抛弃了通用存储。
  • 论文中还有很多在云基础设施世界中重要的细节。

因为这是Amazon认为它的云产品用户应该在Amazon基础设施之上构建的数据库,所以在讨论Aurora之前,我想花一点时间来回顾一下历史,究竟是什么导致了Aurora的产生?

最早的时候,Amazon提供的云产品是EC2,它可以帮助用户在Amazon的机房里和Amazon的硬件上创建类似网站的应用。EC2的全称是Elastic Cloud 2。Amazon有装满了服务器的数据中心,并且会在每一个服务器上都运行VMM(Virtual Machine Monitor)。它会向它的用户出租虚拟机,而它的用户通常会租用多个虚拟机用来运行Web服务、数据库和任何其他需要运行的服务。所以,在一个服务器上,有一个VMM,还有一些EC2实例,其中每一个实例都出租给不同的云客户。每个EC2实例都会运行一个标准的操作系统,比如说Linux,在操作系统之上,运行的是应用程序,例如Web服务、数据库。这种方式相对来说成本较低,也比较容易配置,所以是一个成功的服务模式。

这里有一个对我们来说极其重要的细节。因为每一个服务器都有一块本地的硬盘,在最早的时候,如果你租用一个EC2实例,每一个EC2实例会从服务器的本地硬盘中分到一小片硬盘空间。所以,最早的时候EC2用的都是本地盘,每个EC2实例会分到本地盘的一小部分。但是从EC2实例的操作系统看起来就是一个硬盘,一个模拟的硬盘。

EC2对于无状态的Web服务器来说是完美的。客户端通过自己的Web浏览器连接到一些运行了Web服务的EC2实例上。如果突然新增了大量客户,你可以立刻向Amazon租用更多的EC2实例,并在上面启动Web服务。这样你就可以很简单的对你的Web服务进行扩容。

另一类人们主要运行在EC2实例的服务是数据库。通常来说一个网站包含了一些无状态的Web服务,任何时候这些Web服务需要一些持久化存储的数据时,它们会与一个后端数据库交互。

所以,现在的场景是,在Amazon基础设施之外有一些客户端浏览器(C1,C2,C3)。之后是一些EC2实例,上面运行了Web服务,这里你可以根据网站的规模想起多少实例就起多少。这些EC2实例在Amazon基础设施内。之后,还有一个EC2实例运行了数据库。Web服务所在的EC2实例会与数据库所在的EC2实例交互,完成数据库中记录的读写。

不幸的是,对于数据库来说,EC2就不像对于Web服务那样完美了,最直接的原因就是存储。对于运行了数据库的EC2实例,获取存储的最简单方法就是使用EC2实例所在服务器的本地硬盘。如果服务器宕机了,那么它本地硬盘也会无法访问。当Web服务所在的服务器宕机了,是完全没有问题的,因为Web服务本身没有状态,你只需要在一个新的EC2实例上启动一个新的Web服务就行。但是如果数据库所在的服务器宕机了,并且数据存储在服务器的本地硬盘中,那么就会有大问题,因为数据丢失了。

Amazon本身有实现了块存储的服务,叫做S3。你可以定期的对数据库做快照,并将快照存储在S3上,并基于快照来实现故障恢复,但是这种定期的快照意味着你可能会损失两次快照之间的数据。

所以,为了向用户提供EC2实例所需的硬盘,并且硬盘数据不会随着服务器故障而丢失,就出现了一个与Aurora相关的服务,并且同时也是容错的且支持持久化存储的服务,这个服务就是EBS。EBS全称是Elastic Block Store。从EC2实例来看,EBS就是一个硬盘,你可以像一个普通的硬盘一样去格式化它,就像一个类似于ext3格式的文件系统或者任何其他你喜欢的Linux文件系统。但是在实现上,EBS底层是一对互为副本的存储服务器。随着EBS的推出,你可以租用一个EBS volume。一个EBS volume看起来就像是一个普通的硬盘一样,但却是由一对互为副本EBS服务器实现,每个EBS服务器本地有一个硬盘。所以,现在你运行了一个数据库,相应的EC2实例将一个EBS volume挂载成自己的硬盘。当数据库执行写磁盘操作时,数据会通过网络送到EBS服务器。

这两个EBS服务器会使用Chain Replication(9.5)进行复制。所以写请求首先会写到第一个EBS服务器,之后写到第二个EBS服务器,然后从第二个EBS服务器,EC2实例可以得到回复。当读数据的时候,因为这是一个Chain Replication,EC2实例会从第二个EBS服务器读取数据。

所以现在,运行在EC2实例上的数据库有了可用性。因为现在有了一个存储系统可以在服务器宕机之后,仍然能持有数据。如果数据库所在的服务器挂了,你可以启动另一个EC2实例,并为其挂载同一个EBS volume,再启动数据库。新的数据库可以看到所有前一个数据库留下来的数据,就像你把硬盘从一个机器拔下来,再插入到另一个机器一样。所以EBS非常适合需要长期保存数据的场景,比如说数据库。

对于我们来说,有关EBS有一件很重要的事情:这不是用来共享的服务。任何时候,只有一个EC2实例,一个虚机可以挂载一个EBS volume。所以,尽管所有人的EBS volume都存储在一个大的服务器池子里,每个EBS volume只能被一个EC2实例所使用。

尽管EBS是一次很大的进步,但是它仍然有自己的问题。它有一些细节不是那么的完美。

  • 如果你在EBS上运行一个数据库,那么最终会有大量的数据通过网络来传递。论文的图2中,就有对在一个Network Storage System之上运行数据库所需要的大量写请求的抱怨。所以,如果在EBS上运行了一个数据库,会产生大量的网络流量。在论文中有暗示,除了网络的限制之外,还有CPU和存储空间的限制。在Aurora论文中,花费了大量的精力来降低数据库产生的网络负载,同时看起来相对来说不太关心CPU和存储空间的消耗。所以也可以理解成他们认为网络负载更加重要。
  • 另一个问题是,EBS的容错性不是很好。出于性能的考虑,Amazon总是将EBS volume的两个副本存放在同一个数据中心。所以,如果一个副本故障了,那没问题,因为可以切换到另一个副本,但是如果整个数据中心挂了,那就没辙了。很明显,大部分客户还是希望在数据中心故障之后,数据还是能保留的。数据中心故障有很多原因,或许网络连接断了,或许数据中心着火了,或许整个建筑断电了。用户总是希望至少有选择的权利,在一整个数据中心挂了的时候,可以选择花更多的钱,来保留住数据。 但是Amazon描述的却是,EC2实例和两个EBS副本都运行在一个AZ(Availability Zone)。

在Amazon的术语中,一个AZ就是一个数据中心。Amazon通常这样管理它们的数据中心,在一个城市范围内有多个独立的数据中心。大概2-3个相近的数据中心,通过冗余的高速网络连接在一起,我们之后会看一下为什么这是重要的。但是对于EBS来说,为了降低使用Chain Replication的代价,Amazon 将EBS的两个副本放在一个AZ中。

10.2 故障可恢复事务(Crash Recoverable Transaction)

为了能更好的理解Aurora的设计,在进一步介绍它是如何工作之前,我们必须要知道典型的数据库是如何设计的。因为Aurora使用的是与MySQL类似的机制实现,但是又以一种有趣的方式实现了加速,所以我们需要知道一个典型的数据库是如何设计实现的,这样我们才能知道Aurora是如何实现加速的。

所以这一部分是数据库教程,但是实际上主要关注的是,如何实现一个故障可恢复事务(Crash Recoverable Transaction)。所以这一部分我们主要看的是事务(Transaction)和故障可恢复(Crash Recovery)。数据库还涉及到很多其他的方面,但是对于Aurora来说,这两部分最重要。

首先,什么是事务?事务是指将多个操作打包成原子操作,并确保多个操作顺序执行。假设我们运行一个银行系统,我们想在不同的银行账户之间转账。你可以这样看待一个事务,首先需要定义想要原子打包的多个操作的开始;之后是操作的内容,现在我们想要从账户Y转10块钱到账户X,那么账户X需要增加10块,账户Y需要减少10块;最后表明事务结束。

我们希望数据库顺序执行这两个操作,并且不允许其他任何人看到执行的中间状态。同时,考虑到故障,如果在执行的任何时候出现故障,我们需要确保故障恢复之后,要么所有操作都已经执行完成,要么一个操作也没有执行。这是我们想要从事务中获得的效果。除此之外,数据库的用户期望数据库可以通知事务的状态,也就是事务是否真的完成并提交了。如果一个事务提交了,用户期望事务的效果是可以持久保存的,即使数据库故障重启了,数据也还能保存。

通常来说,事务是通过对涉及到的每一份数据加锁来实现。所以你可以认为,在整个事务的过程中,都对X,Y加了锁。并且只有当事务结束、提交并且持久化存储之后,锁才会被释放。所以,数据库实际上在事务的过程中,是通过对数据加锁来确保其他人不能访问。这一点很重要,理解了这一点,论文中有一些细节才变得有意义。

所以,这里具体是怎么实现的呢?对于一个简单的数据库模型,数据库运行在单个服务器上,并且使用本地硬盘。

在硬盘上存储了数据的记录,或许是以B-Tree方式构建的索引。所以有一些data page用来存放数据库的数据,其中一个存放了X的记录,另一个存放了Y的记录。每一个data page通常会存储大量的记录,而X和Y的记录是page中的一些bit位。

在硬盘中,除了有数据之外,还有一个预写式日志(Write-Ahead Log,简称为WAL)。预写式日志对于系统的容错性至关重要。

在服务器内部,有数据库软件,通常数据库会对最近从磁盘读取的page有缓存。

当你在执行一个事务内的各个操作时,例如执行 X=X+10 的操作时,数据库会从硬盘中读取持有X的记录,给数据加10。但是在事务提交之前,数据的修改还只在本地的缓存中,并没有写入到硬盘。我们现在还不想向硬盘写入数据,因为这样可能会暴露一个不完整的事务。

为了让数据库在故障恢复之后,还能够提供同样的数据,在允许数据库软件修改硬盘中真实的data page之前,数据库软件需要先在WAL中添加Log条目来描述事务。所以在提交事务之前,数据库需要先在WAL中写入完整的Log条目,来描述所有有关数据库的修改,并且这些Log是写入磁盘的。

让我们假设,X的初始值是500,Y的初始值是750。

在提交并写入硬盘的data page之前,数据库通常需要写入至少3条Log记录:

  1. 第一条表明,作为事务的一部分,我要修改X,它的旧数据是500,我要将它改成510。
  2. 第二条表明,我要修改Y,它的旧数据是750,我要将它改成740。
  3. 第三条记录是一个Commit日志,表明事务的结束。

通常来说,前两条Log记录会打上事务的ID作为标签,这样在故障恢复的时候,可以根据第三条commit日志找到对应的Log记录,进而知道哪些操作是已提交事务的,哪些是未完成事务的。

学生提问:为什么在WAL的log中,需要带上旧的数据值?

Robert教授:在这个简单的数据库中,在WAL中只记录新的数据就可以了。如果出现故障,只需要重新应用所有新的数据即可。但是大部分真实的数据库同时也会在WAL中存储旧的数值,这样对于一个非常长的事务,只要WAL保持更新,在事务结束之前,数据库可以提前将更新了的page写入硬盘,比如说将Y写入新的数据740。之后如果在事务提交之前故障了,恢复的软件可以发现,事务并没有完成,所以需要撤回之前的操作,这时,这些旧的数据,例如Y的750,需要被用来撤回之前写入到data page中的操作。对于Aurora来说,实际上也使用了undo/redo日志,用来撤回未完成事务的操作。

如果数据库成功的将事务对应的操作和commit日志写入到磁盘中,数据库可以回复给客户端说,事务已经提交了。而这时,客户端也可以确认事务是永久可见的。

接下来有两种情况。

如果数据库没有崩溃,那么在它的cache中,X,Y对应的数值分别是510和740。最终数据库会将cache中的数值写入到磁盘对应的位置。所以数据库写磁盘是一个lazy操作,它会对更新进行累积,每一次写磁盘可能包含了很多个更新操作。这种累积更新可以提升操作的速度。

如果数据库在将cache中的数值写入到磁盘之前就崩溃了,这样磁盘中的page仍然是旧的数值。当数据库重启时,恢复软件会扫描WAL日志,发现对应事务的Log,并发现事务的commit记录,那么恢复软件会将新的数值写入到磁盘中。这被称为redo,它会重新执行事务中的写操作。

这就是事务型数据库的工作原理的简单描述,同时这也是一个极度精简的MySQL数据库工作方式的介绍,MySQL基本以这种方式实现了故障可恢复事务。而Aurora就是基于这个开源软件MYSQL构建的。

10.3 关系型数据库(Amazon RDS)

在MySQL基础上,结合Amazon自己的基础设施,Amazon为其云用户开发了改进版的数据库,叫做RDS(Relational Database Service)。尽管论文不怎么讨论RDS,但是论文中的图2基本上是对RDS的描述。RDS是第一次尝试将数据库在多个AZ之间做复制,这样就算整个数据中心挂了,你还是可以从另一个AZ重新获得数据而不丢失任何写操作。

对于RDS来说,有且仅有一个EC2实例作为数据库。这个数据库将它的data page和WAL Log存储在EBS,而不是对应服务器的本地硬盘。当数据库执行了写Log或者写page操作时,这些写请求实际上通过网络发送到了EBS服务器。所有这些服务器都在一个AZ中。

每一次数据库软件执行一个写操作,Amazon会自动的,对数据库无感知的,将写操作拷贝发送到另一个数据中心的AZ中。从论文的图2来看,可以发现这是另一个EC2实例,它的工作就是执行与主数据库相同的操作。所以,AZ2的副数据库会将这些写操作拷贝AZ2对应的EBS服务器。

在RDS的架构中,也就是论文图2中,每一次写操作,例如数据库追加日志或者写磁盘的page,数据除了发送给AZ1的两个EBS副本之外,还需要通过网络发送到位于AZ2的副数据库。副数据库接下来会将数据再发送给AZ2的两个独立的EBS副本。之后,AZ2的副数据库会将写入成功的回复返回给AZ1的主数据库,主数据库看到这个回复之后,才会认为写操作完成了。

RDS这种架构提供了更好的容错性。因为现在在一个其他的AZ中,有了数据库的一份完整的实时的拷贝。这个拷贝可以看到所有最新的写请求。即使AZ1发生火灾都烧掉了,你可以在AZ2的一个新的实例中继续运行数据库,而不丢失任何数据。

学生提问:为什么EBS的两个副本不放在两个数据中心呢?这样就不用RDS也能保证跨数据中心的高可用了。

Robert教授:我不知道怎么回答这个问题。EBS不是这样工作的,我猜是因为,对于大部分的EBS用户,如果每一个写请求都需要跨数据中心传递,这就太慢了。我不太确定具体的实现,但我认为这是他们不这么做的主要原因。RDS可以看成是EBS工作方式的一种补救,所以使用的还是未经更改的EBS工作方式。

如论文中表1所示,RDS的写操作代价极高,就如你所预期的一样高,因为需要写大量的数据。即使如之前的例子,执行类似于 x+10,y-10,这样的操作,虽然看起来就是修改两个整数,每个整数或许只有8字节或者16字节,但是对于data page的读写,极有可能会比10多个字节大得多。因为每一个page会有8k字节,或者16k字节,或者是一些由文件系统或者磁盘块决定的相对较大的数字。这意味着,哪怕是只写入这两个数字,当需要更新data page时,需要向磁盘写入多得多的数据。如果使用本地的磁盘,明显会快得多。

我猜,当他们开始通过网络来传输8k字节的page数据时,他们发现使用了太多的网络容量,所以论文中图2的架构,也就是RDS的架构很明显太慢了。

学生提问:为什么会慢呢?(教室今天好空)

Robert教授:在这个架构中,对于数据库来说是无感知的,每一次数据库调用写操作,更新自己对应的EBS服务器,每一个写操作的拷贝穿过AZ也会写入到另一个AZ中的2个EBS服务器中,另一个AZ会返回确认说写入成功,只有这时,写操作看起来才是完成的。所以这里必须要等待4个服务器更新完成,并且等待数据在链路上传输。

如论文中表1描述的性能所担心的一样,这种Mirrored MySQL比Aurora慢得多的原因是,它通过网络传输了大量的数据。这就是性能低的原因,并且Amazon想要修复这里的问题。所以这种架构增强了容错性,因为我们在一个不同的AZ有了第二个副本拷贝,但是对于性能来说又太糟糕了。

10.4 Aurora 初探

这一部分开始介绍Aurora。整体上来看,我们还是有一个数据库服务器,但是这里运行的是Amazon提供的定制软件。所以,我可以向Amazon租用一个Aurora服务器,但是我不在上面运行我的软件,我租用了一个服务器运行Amazon的Aurora软件。这里只是一个实例,它运行在某个AZ中。

在Aurora的架构中,有两件有意思的事情:

第一个是,在替代EBS的位置,有6个数据的副本,位于3个AZ,每个AZ有2个副本。所以现在有了超级容错性,并且每个写请求都需要以某种方式发送给这6个副本。这有些复杂,我们之后会再介绍。

现在有了更多的副本,我的天,为什么Aurora不是更慢了,之前Mirrored MySQL中才有4个副本。答案是,这里通过网络传递的数据只有Log条目,这才是Aurora成功的关键。从之前的简单数据库模型可以看出,每一条Log条目只有几十个字节那么多,也就是存一下旧的数值,新的数值,所以Log条目非常小。然而,当一个数据库要写本地磁盘时,它更新的是data page,这里的数据是巨大的,虽然在论文里没有说,但是我认为至少是8k字节那么多。所以,对于每一次事务,需要通过网络发送多个8k字节的page数据。而Aurora只是向更多的副本发送了少量的Log条目。因为Log条目的大小比8K字节小得多,所以在网络性能上这里就胜出了。这是Aurora的第一个特点,只发送Log条目。

当然,这里的后果是,这里的存储系统不再是通用(General-Purpose)存储,这是一个可以理解MySQL Log条目的存储系统。EBS是一个非常通用的存储系统,它模拟了磁盘,只需要支持读写数据块。EBS不理解除了数据块以外的其他任何事物。而这里的存储系统理解使用它的数据库的Log。所以这里,Aurora将通用的存储去掉了,取而代之的是一个应用定制的(Application-Specific)存储系统。

另一件重要的事情是,Aurora并不需要6个副本都确认了写入才能继续执行操作。相应的,只要Quorum形成了,也就是任意4个副本确认写入了,数据库就可以继续执行操作。所以,当我们想要执行写入操作时,如果有一个AZ下线了,或者AZ的网络连接太慢了,或者只是服务器响应太慢了,Aurora可以忽略最慢的两个服务器,或者已经挂掉的两个服务器,它只需要6个服务器中的任意4个确认写入,就可以继续执行。所以这里的Quorum是Aurora使用的另一个聪明的方法。通过这种方法,Aurora可以有更多的副本,更多的AZ,但是又不用付出大的性能代价,因为它永远也不用等待所有的副本,只需要等待6个服务器中最快的4个服务器即可。

所以,这节课剩下的时间,我们会用来解释Quorum和Log条目。论文的表1总结了一些结果。Mirrored MySQL将大的page数据发送给4个副本,而Aurora只是将小的Log条目发送给6个副本,Aurora获得了35倍的性能提升。论文并没有介绍性能的提升中,有多少是Quorum的功劳,有多少是只发送Log条目的功劳,但是不管怎么样,35倍的性能提升是令人尊敬的结果,同时也是对用户来说非常有价值的结果。我相信对于许多Amazon的客户来说,这是具有革新意义的。

10.5 Aurora存储服务器的容错目标(Fault-Tolerant Goals)

从之前的描述可以看出,Aurora的Quorum系统管理了6个副本的容错系统。所以值得思考的是,Aurora的容错目标是什么?

  • 首先是对于写操作,当只有一个AZ彻底挂了之后,写操作不受影响。
  • 其次是对于读操作,当一个AZ和一个其他AZ的服务器挂了之后,读操作不受影响。这里的原因是,AZ的下线时间可能很长,比如说数据中心被水淹了。人们可能需要几天甚至几周的时间来修复洪水造成的故障,在AZ下线的这段时间,我们只能依赖其他AZ的服务器。如果其他AZ中的一个服务器挂了,我们不想让整个系统都瘫痪。所以当一个AZ彻底下线了之后,对于读操作,Aurora还能容忍一个额外服务器的故障,并且仍然可以返回正确的数据。至于为什么会定这样的目标,我们必须理所当然的认为Amazon知道他们自己的业务,并且认为这是实现容错的最佳目标。
  • 此外,我之前也提过,Aurora期望能够容忍暂时的慢副本。如果你向EBS读写数据,你并不能得到稳定的性能,有时可能会有一些卡顿,或许网络中一部分已经过载了,或许某些服务器在执行软件升级,任何类似的原因会导致暂时的慢副本。所以Aurora期望能够在出现短暂的慢副本时,仍然能够继续执行操作。
  • 最后一个需求是,如果一个副本挂了,在另一个副本挂之前,是争分夺秒的。统计数据或许没有你期望的那么好,因为通常来说服务器故障不是独立的。事实上,一个服务器挂了,通常意味着有很大的可能另一个服务器也会挂,因为它们有相同的硬件,或许从同一个公司购买,来自于同一个生产线。如果其中一个有缺陷,非常有可能会在另一个服务器中也会有相同的缺陷。所以,当出现一个故障时,人们总是非常紧张,因为第二个故障可能很快就会发生。对于Aurora的Quorum系统,有点类似于Raft,你只能从局部故障中恢复。所以这里需要快速生成新的副本(Fast Re-replication)。也就是说如果一个服务器看起来永久故障了,我们期望能够尽可能快的根据剩下的副本,生成一个新的副本。

所以,以上就是论文列出的Aurora的主要容错目标。顺便说一下,这里的讨论只针对存储服务器,所以这里讨论的是存储服务器的故障特性,以及如何从故障中恢复。如果数据库服务器本身挂了, 该如何恢复是一个完全不同的话题。Aurora有一个完全不同的机制,可以发现数据库服务器挂了之后,创建一个新的实例来运行新的数据库服务器。但是这不是我们现在讨论的话题,我们会在稍后再讨论。现在只是讨论存储服务器,以及存储服务器的容错。

10.6 Quorum 复制机制(Quorum Replication)

Aurora使用了Quorum这种思想。接下来,我将描述一下经典的Quorum思想,它最早可以追溯到1970年代。Aurora使用的是一种经典quorum思想的变种。Quorum系统背后的思想是通过复制构建容错的存储系统,并确保即使有一些副本故障了,读请求还是能看到最近的写请求的数据。通常来说,Quorum系统就是简单的读写系统,支持Put/Get操作。它们通常不直接支持更多更高级的操作。你有一个对象,你可以读这个对象,也可以通过写请求覆盖这个对象的数值。

假设有N个副本。为了能够执行写请求,必须要确保写操作被W个副本确认,W小于N。所以你需要将写请求发送到这W个副本。如果要执行读请求,那么至少需要从R个副本得到所读取的信息。这里的W对应的数字称为Write Quorum,R对应的数字称为Read Quorum。这是一个典型的Quorum配置。

这里的关键点在于,W、R、N之间的关联。Quorum系统要求,任意你要发送写请求的W个服务器,必须与任意接收读请求的R个服务器有重叠。这意味着,R加上W必须大于N( 至少满足R + W = N + 1 ),这样任意W个服务器至少与任意R个服务器有一个重合。

假设你有3个服务器,并且假设每个服务器只存了一个对象。

我们发送了一个写请求,想将我们的对象设置成23。为了能够执行写请求,我们需要至少将写请求发送到W个服务器。我们假设在这个系统中,R和W都是2,N是3。为了执行一个写请求,我们需要将新的数值23发送到至少2个服务器上。所以,或许我们的写请求发送到了S1和S3。所以,它们现在知道了我们对象的数值是23。

如果某人发起读请求,读请求会至少检查R个服务器。在这个配置中,R也是2。这里的R个服务器可能包含了并没有看到之前写请求的服务器(S2),但同时也至少还需要一个其他服务器来凑齐2个服务器。这意味着,任何读请求都至少会包含一个看到了之前写请求的服务器。

这是Quorum系统的要求,Read Quorum必须至少与Write Quorum有一个服务器是重合的。所以任何读请求可以从至少一个看见了之前写请求的服务器得到回复。

这里还有一个关键的点,客户端读请求可能会得到R个不同的结果,现在的问题是,客户端如何知道从R个服务器得到的R个结果中,哪一个是正确的呢?通过不同结果出现的次数来投票(Vote)在这是不起作用的,因为我们只能确保Read Quorum必须至少与Write Quorum有一个服务器是重合的,这意味着客户端向R个服务器发送读请求,可能只有一个服务器返回了正确的结果。对于一个有6个副本的系统,可能Read Quorum是4,那么你可能得到了4个回复,但是只有一个与之前写请求重合的服务器能将正确的结果返回,所以这里不能使用投票。在Quorum系统中使用的是版本号(Version)。所以,每一次执行写请求,你需要将新的数值与一个增加的版本号绑定。之后,客户端发送读请求,从Read Quorum得到了一些回复,客户端可以直接使用其中的最高版本号的数值。

假设刚刚的例子中,S2有一个旧的数值20。每一个服务器都有一个版本号,S1和S3是版本3,因为它们看到了相同的写请求,所以它们的版本号是相同的。同时我们假设没有看到前一个写请求的S2的版本号是2。

之后客户端从S2和S3读取数据,得到了两个不同结果,它们有着不同的版本号,客户端会挑选版本号最高的结果。

如果你不能与Quorum数量的服务器通信,不管是Read Quorum还是Write Quorum,那么你只能不停的重试了。这是Quorum系统的规则,你只能不停的重试,直到服务器重新上线,或者重新联网。

相比Chain Replication,这里的优势是可以轻易的剔除暂时故障、失联或者慢的服务器。实际上,这里是这样工作的,当你执行写请求时,你会将新的数值和对应的版本号给所有N个服务器,但是只会等待W个服务器确认。类似的,对于读请求,你可以将读请求发送给所有的服务器,但是只等待R个服务器返回结果。因为你只需要等待R个服务器,这意味着在最快的R个服务器返回了之后,你就可以不用再等待慢服务器或者故障服务器超时。这里忽略慢服务器或者挂了的服务器的机制完全是隐式的。在这里,我们不用决定哪个服务器是在线或者是离线的,只要Quorum能达到,系统就能继续工作,所以我们可以非常平滑的处理慢服务或者挂了的服务。

除此之外,Quorum系统可以调整读写的性能。通过调整Read Quorum和Write Quorum,可以使得系统更好的支持读请求或者写请求。对于前面的例子,我们可以假设Write Quorum是3,每一个写请求必须被所有的3个服务器所确认。这样的话,Read Quorum可以只是1。所以,如果你想要提升读请求的性能,在一个3个服务器的Quorum系统中,你可以设置R为1,W为3,这样读请求会快得多,因为它只需要等待一个服务器的结果,但是代价是写请求执行的比较慢。如果你想要提升写请求的性能,可以设置R为3,W为1,这意味着可能只有1个服务器有最新的数值,但是因为客户端会咨询3个服务器,3个服务器其中一个肯定包含了最新的数值。

当R为1,W为3时,写请求就不再是容错的了,同样,当R为3,W为1时,读请求不再是容错的,因为对于读请求,所有的服务器都必须在线才能执行成功。所以在实际场景中,你不会想要这么配置,你或许会与Aurora一样,使用更多的服务器,将N变大,然后再权衡Read Quorum和Write Quorum。

为了实现上一节描述的Aurora的容错目标,也就是在一个AZ完全下线时仍然能写,在一个AZ加一个其他AZ的服务器下线时仍然能读,Aurora的Quorum系统中,N=6,W=4,R=3。W等于4意味着,当一个AZ彻底下线时,剩下2个AZ中的4个服务器仍然能完成写请求。R等于3意味着,当一个AZ和一个其他AZ的服务器下线时,剩下的3个服务器仍然可以完成读请求。当3个服务器下线了,系统仍然支持读请求,仍然可以返回当前的状态,但是却不能支持写请求。所以,当3个服务器挂了,现在的Quorum系统有足够的服务器支持读请求,并据此重建更多的副本,但是在新的副本创建出来替代旧的副本之前,系统不能支持写请求。同时,如我之前解释的,Quorum系统可以剔除暂时的慢副本。

10.7 Aurora读写存储服务器

我之前也解释过,Aurora中的写请求并不是像一个经典的Quorum系统一样直接更新数据。对于Aurora来说,它的写请求从来不会覆盖任何数据,它的写请求只会在当前Log中追加条目(Append Entries)。所以,Aurora使用Quorum只是在数据库执行事务并发出新的Log记录时,确保Log记录至少出现在4个存储服务器上,之后才能提交事务。所以,Aurora的Write Quorum的实际意义是,每个新的Log记录必须至少追加在4个存储服务器中,之后才可以认为写请求完成了。当Aurora执行到事务的结束,并且在回复给客户端说事务已经提交之前,Aurora必须等待Write Quorum的确认,也就是4个存储服务器的确认,组成事务的每一条Log都成功写入了。

实际上,在一个故障恢复过程中,事务只能在之前所有的事务恢复了之后才能被恢复。所以,实际中,在Aurora确认一个事务之前,它必须等待Write Quorum确认之前所有已提交的事务,之后再确认当前的事务,最后才能回复给客户端。

这里的存储服务器接收Log条目,这是它们看到的写请求。它们并没有从数据库服务器获得到新的data page,它们得到的只是用来描述data page更新的Log条目。

但是存储服务器内存最终存储的还是数据库服务器磁盘中的page。在存储服务器的内存中,会有自身磁盘中page的cache,例如page1(P1),page2(P2),这些page其实就是数据库服务器对应磁盘的page。

当一个新的写请求到达时,这个写请求只是一个Log条目,Log条目中的内容需要应用到相关的page中。但是我们不必立即执行这个更新,可以等到数据库服务器或者恢复软件想要查看那个page时才执行。对于每一个存储服务器存储的page,如果它最近被一个Log条目修改过,那么存储服务器会在内存中缓存一个旧版本的page和一系列来自于数据库服务器有关修改这个page的Log条目。所以,对于一个新的Log条目,它会立即被追加到影响到的page的Log列表中。这里的Log列表从上次page更新过之后开始(相当于page是snapshot,snapshot后面再有一系列记录更新的Log)。如果没有其他事情发生,那么存储服务器会缓存旧的page和对应的一系列Log条目。

如果之后数据库服务器将自身缓存的page删除了,过了一会又需要为一个新的事务读取这个page,它会发出一个读请求。请求发送到存储服务器,会要求存储服务器返回当前最新的page数据。在这个时候,存储服务器才会将Log条目中的新数据更新到page,并将page写入到自己的磁盘中,之后再将更新了的page返回给数据库服务器。同时,存储服务器在自身cache中会删除page对应的Log列表,并更新cache中的page,虽然实际上可能会复杂的多。

如刚刚提到的,数据库服务器有时需要读取page。所以,可能你已经发现了,数据库服务器写入的是Log条目,但是读取的是page。这也是与Quorum系统不一样的地方。Quorum系统通常读写的数据都是相同的。除此之外,在一个普通的操作中,数据库服务器可以避免触发Quorum Read。数据库服务器会记录每一个存储服务器接收了多少Log。所以,首先,Log条目都有类似12345这样的编号,当数据库服务器发送一条新的Log条目给所有的存储服务器,存储服务器接收到它们会返回说,我收到了第79号和之前所有的Log。数据库服务器会记录这里的数字,或者说记录每个存储服务器收到的最高连续的Log条目号。这样的话,当一个数据库服务器需要执行读操作,它只会挑选拥有最新Log的存储服务器,然后只向那个服务器发送读取page的请求。所以,数据库服务器执行了Quorum Write,但是却没有执行Quorum Read。因为它知道哪些存储服务器有最新的数据,然后可以直接从其中一个读取数据。这样的代价小得多,因为这里只读了一个副本,而不用读取Quorum数量的副本。

但是,数据库服务器有时也会使用Quorum Read。假设数据库服务器运行在某个EC2实例,如果相应的硬件故障了,数据库服务器也会随之崩溃。在Amazon的基础设施有一些监控系统可以检测到Aurora数据库服务器崩溃,之后Amazon会自动的启动一个EC2实例,在这个实例上启动数据库软件,并告诉新启动的数据库:你的数据存放在那6个存储服务器中,请清除存储在这些副本中的任何未完成的事务,之后再继续工作。这时,Aurora会使用Quorum的逻辑来执行读请求。因为之前数据库服务器故障的时候,它极有可能处于执行某些事务的中间过程。所以当它故障了,它的状态极有可能是它完成并提交了一些事务,并且相应的Log条目存放于Quorum系统。同时,它还在执行某些其他事务的过程中,这些事务也有一部分Log条目存放在Quorum系统中,但是因为数据库服务器在执行这些事务的过程中崩溃了,这些事务永远也不可能完成。对于这些未完成的事务,我们可能会有这样一种场景,第一个副本有第101个Log条目,第二个副本有第102个Log条目,第三个副本有第104个Log条目,但是没有一个副本持有第103个Log条目。

所以故障之后,新的数据库服务器需要恢复,它会执行Quorum Read,找到第一个缺失的Log序号,在上面的例子中是103,并说,好吧,我们现在缺失了一个Log条目,我们不能执行这条Log之后的所有Log,因为我们缺失了一个Log对应的更新。

所以,这种场景下,数据库服务器执行了Quorum Read,从可以连接到的存储服务器中发现103是第一个缺失的Log条目。这时,数据库服务器会给所有的存储服务器发送消息说:请丢弃103及之后的所有Log条目。103及之后的Log条目必然不会包含已提交的事务,因为我们知道只有当一个事务的所有Log条目存在于Write Quorum时,这个事务才会被commit,所以对于已经commit的事务我们肯定可以看到相应的Log。这里我们只会丢弃未commit事务对应的Log条目。

所以,某种程度上,我们将Log在102位置做了切割,102及之前的Log会保留。但是这些会保留的Log中,可能也包含了未commit事务的Log,数据库服务器需要识别这些Log。这是可行的,可以通过Log条目中的事务ID和事务的commit Log条目来判断(10.3)哪些Log属于已经commit的事务,哪些属于未commit的事务。数据库服务器可以发现这些未完成的事务对应Log,并发送undo操作来撤回所有未commit事务做出的变更。这就是为什么Aurora在Log中同时也会记录旧的数值的原因。因为只有这样,数据库服务器在故障恢复的过程中,才可以回退之前只提交了一部分,但是没commit的事务。

10.8 数据分片(Protection Group)

这一部分讨论,Aurora如何处理大型数据库。目前为止,我们已经知道Aurora将自己的数据分布在6个副本上,每一个副本都是一个计算机,上面挂了1-2块磁盘。但是如果只是这样的话,我们不能拥有一个数据大小大于单个机器磁盘空间的数据库。因为虽然我们有6台机器,但是并没有为我们提供6倍的存储空间,每个机器存储的都是相同的数据。如果我使用的是SSD,我可以将数TB的数据存放于单台机器上,但是我不能将数百TB的数据存放于单台机器上。

为了能支持超过10TB数据的大型数据库。Amazon的做法是将数据库的数据,分割存储到多组存储服务器上,每一组都是6个副本,分割出来的每一份数据是10GB。所以,如果一个数据库需要20GB的数据,那么这个数据库会使用2个PG(Protection Group),其中一半的10GB数据在一个PG中,包含了6个存储服务器作为副本,另一半的10GB数据存储在另一个PG中,这个PG可能包含了不同的6个存储服务器作为副本。

因为Amazon运行了大量的存储服务器,这些服务器一起被所有的Aurora用户所使用。两组PG可能使用相同的6个存储服务器,但是通常来说是完全不同的两组存储服务器。随着数据库变大,我们可以有更多的Protection Group。

这里有一件有意思的事情,你可以将磁盘中的data page分割到多个独立的PG中,比如说奇数号的page存在PG1,偶数号的page存在PG2。如果可以根据data page做sharding,那是极好的。

Sharding之后,Log该如何处理就不是那么直观了。如果有多个Protection Group,该如何分割Log呢?答案是,当Aurora需要发送一个Log条目时,它会查看Log所修改的数据,并找到存储了这个数据的Protection Group,并把Log条目只发送给这个Protection Group对应的6个存储服务器。这意味着,每个Protection Group只存储了部分data page和所有与这些data page关联的Log条目。所以每个Protection Group存储了所有data page的一个子集,以及这些data page相关的Log条目。

如果其中一个存储服务器挂了,我们期望尽可能快的用一个新的副本替代它。因为如果4个副本挂了,我们将不再拥有Read Quorum,我们也因此不能创建一个新的副本。所以我们想要在一个副本挂了以后,尽可能快的生成一个新的副本。表面上看,每个存储服务器存放了某个数据库的某个某个Protection Group对应的10GB数据,但实际上每个存储服务器可能有1-2块几TB的磁盘,上面存储了属于数百个Aurora实例的10GB数据块。所以在存储服务器上,可能总共会有10TB的数据,当它故障时,它带走的不仅是一个数据库的10GB数据,同时也带走了其他数百个数据库的10GB数据。所以生成的新副本,不是仅仅要恢复一个数据库的10GB数据,而是要恢复存储在原来服务器上的整个10TB的数据。我们来做一个算术,如果网卡是10Gb/S,通过网络传输10TB的数据需要8000秒。这个时间太长了,我们不想只是坐在那里等着传输。所以我们不想要有这样一种重建副本的策略:找到另一台存储服务器,通过网络拷贝上面所有的内容到新的副本中。我们需要的是一种快的多的策略。

Aurora实际使用的策略是,对于一个特定的存储服务器,它存储了许多Protection Group对应的10GB的数据块。对于Protection Group A,它的其他副本是5个服务器。

或许这个存储服务器还为Protection Group B保存了数据,但是B的其他副本存在于与A没有交集的其他5个服务器中(虽然图中只画了4个)。

类似的,对于所有的Protection Group对应的数据块,都会有类似的副本。这种模式下,如果一个存储服务器挂了,假设上面有100个数据块,现在的替换策略是:找到100个不同的存储服务器,其中的每一个会被分配一个数据块,也就是说这100个存储服务器,每一个都会加入到一个新的Protection Group中。所以相当于,每一个存储服务器只需要负责恢复10GB的数据。所以在创建新副本的时候,我们有了100个存储服务器(下图中下面那5个空白的)。

对于每一个数据块,我们会从Protection Group中挑选一个副本,作为数据拷贝的源。这样,对于100个数据块,相当于有了100个数据拷贝的源。之后,就可以并行的通过网络将100个数据块从100个源拷贝到100个目的。

假设有足够多的服务器,这里的服务器大概率不会有重合,同时假设我们有足够的带宽,现在我们可以以100的并发,并行的拷贝1TB的数据,这只需要10秒左右。如果只在两个服务器之间拷贝,正常拷贝1TB数据需要1000秒左右。

这就是Aurora使用的副本恢复策略,它意味着,如果一个服务器挂了,它可以并行的,快速的在数百台服务器上恢复。如果大量的服务器挂了,可能不能正常工作,但是如果只有一个服务器挂了,Aurora可以非常快的重新生成副本。

10.9 只读数据库(Read-only Database)

如果你查看论文的图3,你可以发现,Aurora不仅有主数据库实例,同时多个数据库的副本。对于Aurora的许多客户来说,相比读写查询,他们会有多得多的只读请求。你可以设想一个Web服务器,如果你只是查看Web页面,那么后台的Web服务器需要读取大量的数据才能生成页面所需的内容,或许需要从数据库读取数百个条目。但是在浏览Web网页时,写请求就要少的多,或许一些统计数据要更新,或许需要更新历史记录,所以读写请求的比例可能是100:1。所以对于Aurora来说,通常会有非常大量的只读数据库查询。

对于写请求,可以只发送给一个数据库,因为对于后端的存储服务器来说,只能支持一个写入者。背后的原因是,Log需要按照数字编号,如果只在一个数据库处理写请求,非常容易对Log进行编号,但是如果有多个数据库以非协同的方式处理写请求,那么为Log编号将会非常非常难。

但是对于读请求,可以发送给多个数据库。Aurora的确有多个只读数据库,这些数据库可以从后端存储服务器读取数据。所以,图3中描述了,除了主数据库用来处理写请求,同时也有一组只读数据库。论文中宣称可以支持最多15个只读数据库。如果有大量的读请求,读请求可以分担到这些只读数据库上。

当客户端向只读数据库发送读请求,只读数据库需要弄清楚它需要哪些data page来处理这个读请求,之后直接从存储服务器读取这些data page,并不需要主数据库的介入。所以只读数据库向存储服务器直接发送读取page的请求,之后它会缓存读取到的page,这样对于将来的一些读请求,可以直接根据缓存中的数据返回。

当然,只读数据库也需要更新自身的缓存,所以,Aurora的主数据库也会将它的Log的拷贝发送给每一个只读数据库。这就是你从论文中图3看到的蓝色矩形中间的那些横线。主数据库会向这些只读数据库发送所有的Log条目,只读数据库用这些Log来更新它们缓存的page数据,进而获得数据库中最新的事务处理结果。

这的确意味着只读数据库会落后主数据库一点,但是对于大部分的只读请求来说,这没问题。因为如果你查看一个网页,如果数据落后了20毫秒,通常来说不会是一个大问题。

这里其实有一些问题,其中一个问题是,我们不想要这个只读数据库看到未commit的事务。所以,在主数据库发给只读数据库的Log流中,主数据库需要指出,哪些事务commit了,而只读数据库需要小心的不要应用未commit的事务到自己的缓存中,它们需要等到事务commit了再应用对应的Log。

另一个问题是,数据库背后的B-Tree结构非常复杂,可能会定期触发rebalance。而rebalance是一个非常复杂的操作,对应了大量修改树中的节点的操作,这些操作需要有原子性。因为当B-Tree在rebalance的过程中,中间状态的数据是不正确的,只有在rebalance结束了才可以从B-Tree读取数据。但是只读数据库直接从存储服务器读取数据库的page,它可能会看到在rebalance过程中的B-Tree。这时看到的数据是非法的,会导致只读数据库崩溃或者行为异常。

论文中讨论了微事务(Mini-Transaction)和VDL/VCL。这部分实际讨论的就是,数据库服务器可以通知存储服务器说,这部分复杂的Log序列只能以原子性向只读数据库展示,也就是要么全展示,要么不展示。这就是微事务(Mini-Transaction)和VDL。所以当一个只读数据库需要向存储服务器查看一个data page时,存储服务器会小心的,要么展示微事务之前的状态,要么展示微事务之后的状态,但是绝不会展示中间状态。

以上就是所有技术相关的内容,我们来总结一下论文中有意思的地方,以及我们可以从论文中学到的一些东西。

  • 一件可以学到的事情其实比较通用,并不局限于这篇论文。大家都应该知道事务型数据库是如何工作的,并且知道事务型数据库与后端存储之间交互带来的影响。这里涉及了性能,故障修复,以及运行一个数据库的复杂度,这些问题在系统设计中会反复出现。
  • 另一个件可以学到的事情是,Quorum思想。通过读写Quorum的重合,可以确保总是能看见最新的数据,但是又具备容错性。这种思想在Raft中也有体现,Raft可以认为是一种强Quorum的实现(读写操作都要过半服务器认可)。
  • 这个论文中另一个有趣的想法是,数据库和存储系统基本是一起开发出来的,数据库和存储系统以一种有趣的方式集成在了一起。通常我们设计系统时,需要有好的隔离解耦来区分上层服务和底层的基础架构。所以通常来说,存储系统是非常通用的,并不会为某个特定的应用程序定制。因为一个通用的设计可以被大量服务使用。但是在Aurora面临的问题中,性能问题是非常严重的,它不得不通过模糊服务和底层基础架构的边界来获得35倍的性能提升,这是个巨大的成功。
  • 最后一件有意思的事情是,论文中的一些有关云基础架构中什么更重要的隐含信息。例如:
    • 需要担心整个AZ会出现故障;
    • 需要担心短暂的慢副本,这是经常会出现的问题;
    • 网络是主要的瓶颈,毕竟Aurora通过网络发送的是极短的数据,但是相应的,存储服务器需要做更多的工作(应用Log),因为有6个副本,所以有6个CPU在复制执行这些redo Log条目,明显,从Amazon看来,网络容量相比CPU要重要的多。

Lecture 11 - Cache Consistency: Frangipani

为了更好的理解本节课,强烈建议先阅读Frangipani论文。

Frangipani论文:https://pdos.csail.mit.edu/6.824/papers/thekkath-frangipani.pdf

11.1 Frangipani 初探

今天讨论的论文是Frangipani,这是一篇很久之前有关分布式文件系统的论文。尽管如此,我们还要读这篇论文的原因是,它有大量有关缓存一致性,分布式事务和分布式故障恢复有关的有趣的并且优秀的设计,并且它还介绍了这几种功能之间的关联,论文里的这些内容是我们真正想要了解的。

Frangipani论文里面有大量缓存一致性的介绍(Cache Coherence)。缓存一致性是指,如果我缓存了一些数据,之后你修改了实际数据但是并没有考虑我缓存中的数据,必须有一些额外的工作的存在,这样我的缓存才能与实际数据保持一致。论文还介绍了分布式事务(Distributed Transaction),这对于向文件系统的数据结构执行复杂更新来说是必须的。因为文件本质上是分割散落在大量的服务器上,能够从这些服务器实现分布式故障恢复(Distributed Crash Recovery)也是至关重要的。

从整体架构上来说,Frangipani就是一个网络文件系统(NFS,Network File System)。它的目标是与已有的应用程序一起工作,比如说一个运行在工作站上的普通UNIX程序。它与Athena的AFS非常类似(没听过)。从一个全局视图来看,它包含了大量的用户(U1,U2,U3)。

每个用户坐在一个工作站前面,在论文那个年代,笔记本还不流行,大家使用的主要是工作站,不过工作站也就是一个带有键盘鼠标显示器和操作系统的计算机。三个用户对应的工作站(Workstation)分别是WS1,WS2,WS3。

每一个工作站运行了一个Frangipani服务。论文中大部分功能都是在Frangipani软件中实现。所以,用户坐在一个工作站前面,他可能在运行一些普通的应用程序,比如说一个普通的文本编辑(VI)或者说一个编译程序(CC)。当这些普通的应用程序执行文件系统调用时,在系统内核中,有一个Frangipani模块,它实现了文件系统。

在所有的工作站中,都有类似的结构。

文件系统的数据结构,例如文件内容、inode、目录、目录的文件列表、inode和块的空闲状态,所有这些数据都存在一个叫做Petal的共享虚拟磁盘服务中。Petal运行在一些不同的服务器上,有可能是机房里面的一些服务器,但是不会是人们桌子上的工作站。Petal会复制数据,所以你可以认为Petal服务器成对的出现,这样就算一个故障了,我们还是能取回我们的数据。当Frangipani需要读写文件时,它会向正确的Petal服务器发送RPC,并说,我需要这个块,请读取这个块,并将数据返回给我。在大部分时候,Petal表现的就像是一个磁盘,你可以把它看做是共享的磁盘,所有的Frangipani都会与之交互。

从我们的角度来看,大部分的讨论都会假设Petal就是一个被所有Frangipani使用的,基于网络的共享磁盘。你可以通过一个块号或者磁盘上的一个地址来读写数据,就像一个普通的硬盘一样。

论文作者期望使用Frangipani的目的,是驱动设计的一个重要因素。作者想通过Frangipani来支持他们自己的一些活动,作者们是一个研究所的成员,假设研究所有50个人,他们习惯于使用共享的基础设施,例如分时间段使用同一批服务器,工作站。他们还期望通过网络文件系统在相互协作的研究员之间共享文件。所以他们想要这样一个文件系统,它可以用来存放每一个研究员的home目录,同时也可以存放共享的项目文件。这意味着,如果我编辑了一个文件,我希望其他与我一起工作的人可以读到我刚刚编辑的文件。他们期望达成这样的共享的目的。

除此之外,如果我坐在任意一个工作站前面,我都能获取到所有的文件,包括了我的home目录,我环境中所需要的一切文件。所以他们需要的是一个在相对小的组织中,针对普通使用者的共享文件系统。相对小的组织的意思是,每个人在每台工作站前都被信任。本质上来说,Frangipani的设计并没有讨论安全。在一个类似Athena的系统中,是不能随意信任使用者和工作站。所以,Frangipani是针对作者自己环境的一个设计。

至于性能,在他们的环境中也非常重要。实际上,大部分时候,人们使用工作站时,他们基本上只会读写自己的文件。他们或许会读取一些共享文件,比如说项目文件,但是大部分时候,我只会读写我自己的文件,你在你的工作站上只会读写你自己的文件。用户之间频繁的分享文件反而很少见。所以,尽管数据的真实拷贝是在共享的磁盘中,但是如果在本地能有一些缓存,那将是极好的。因为这样的话,我登录之后,我使用了我的一些文件,之后它们在本地缓存了一份,这样它们接下来可以在微秒级别读出来,而不是毫秒级别的从文件服务器获取它们。

除了最基本的缓存之外,Frangipani还支持Write-Back缓存。所以,除了在每个工作站或者说每个Frangipani服务器上要持有缓存之外,我们还需要支持Write-Back缓存。这意味着,如果我想要修改某个数据,比如说我修改了一个文件,或者创建了一个文件,或者删除了一个文件,只要没有其他的工作站需要看到我的改动,Frangipani通过Write-Back缓存方式管理这些数据。这意味着,最开始的时候,我的修改只会在本地的缓存中。如果我创建了一个文件,至少在最开始,有关新创建文件的信息,比如说新创建的inode和初始化的内容,home目录文件列表的更新,文件名等等,所有的这些修改最初只会在本地缓存中存在,因此类似于创建文件的操作可以非常快的完成,因为只需要修改本地的内存中对于磁盘的缓存。而这些修改要过一会才会写回到Petal。所以最开始,我们可以为文件系统做各种各样的修改,至少对于我自己的目录,我自己的文件,这些修改完全是本地的。这对于性能来说有巨大的帮助,因为写本地内存的性能比通过RPC向一个远端服务器写要快1000倍。

在这样的架构下,一个非常重要的后果是,文件系统的逻辑需要存在于每个工作站上。为了让所有的工作站能够只通过操作内存就完成类似创建文件的事情,这意味着所有对于文件系统的逻辑和设计必须存在于工作站内部。

在Frangipani的设计中,Petal作为共享存储系统存在,它不知道文件系统,文件,目录,它只是一个很直观简单的存储系统,所有的复杂的逻辑都在工作站中的Frangipani模块中。所以这是一个非常去中心化的设计,这或许是实际需要的设计,也有可能是作者能想到的能让他们完成目标的设计。这种设计有好的影响。因为主要的复杂度,主要的CPU运算在每个工作站上,这意味着,随着你向系统增加更多的工作站,增加更多的用户,你自动的获得了更多的CPU算力来运行这些新的用户的文件系统操作。因为大部分的文件系统操作只在工作站本地发生,所以大部分CPU消耗的都是本地的,所以这个系统的天然自带扩展性。每个新工作站会接收来自一个新用户更多的负担,但是它同时也带来更多的CPU算力来运行那个用户的文件系统操作。

当然,在某个时间点,瓶颈会在Petal。因为这是一个中心化的存储系统,这时,你需要增加更多的存储服务器。

所以,我们现在有了一个系统,它在工作站里面做了大量的缓存,并且文件的修改可以在本地缓存完成。这几乎立刻引出了有关设计的几个非常严重的挑战。

Frangipani的设计基本上就是用来解决相应的挑战的,我们接下来看一下会有哪些挑战。

11.2 Frangipani的挑战(Challenges)

Frangipani的挑战主要来自于两方面,一个是缓存,另一个是这种去中心化的架构带来的大量的逻辑存在于客户端之中进而引起的问题。

第一个挑战是,假设工作站W1创建了一个文件 /A。最初,这个文件只会在本地缓存中创建。首先,Frangipani需要从Petal获得 / 目录下的内容,之后当创建文件时,工作站只是修改缓存的拷贝,并不会将修改立即返回给Petal。

这里有个直接的问题,假设工作站W2上的用户想要获取 / 目录下的文件列表,我们希望这个用户可以看到新创建的文件。这是一个用户期望的行为,否则用户会感到非常困惑。比如我在大厅里喊了一嘴说我把所有有意思的信息都放到了这个新创建的文件_/A_中,你们快去看一看啊。但是当你从W2上尝试读取这个文件,却找不相应的文件。所以这里我们想要非常强的一致性,这样当有人在大厅里说自己在文件系统里面做了修改,其他人应该能看到这个修改。另一个场景是,如果我在一个工作站修改了文件,之后在另一个计算机上编译它,我期望编译器能看到我刚刚做的修改。这意味着,文件系统必须要做一些事情来确保客户端可以读到最新的写入文件。我们之前讨论过这个话题,我们称之为强一致或者线性一致,在这里我们也想要这种特性。但是在一个缓存的环境中,现在说的一致性的问题不是指存储服务器的一致性,而是指工作站上的一些修改需要被其他工作站看到。因为历史的原因,这通常被称为缓存一致性(Cache Coherence)。这是缓存系统的一个属性。它表明,如果我缓存了一个数据,并且其他人在他的缓存中修改了这个数据,那么我的缓存需要自动的应用那个修改。所以我们想要有这种缓存一致性的属性。

另一个问题是,因为所有的文件和目录都是共享的,非常容易会有两个工作站在同一个时间修改同一个目录。假设用户U1在他的工作站W1上想要创建文件_/A_,这是一个在 / 目录下的新文件,同时,用户U2在他的工作站W2上想要创建文件 /B

这里他们在同一个目录下创建了不同名字的两个文件A和B,但是他们都需要修改根目录,为根目录增加一个新的文件名。所以这里的问题是,当他们同时操作时,系统能识别这些修改了相同目录的操作,并得到一些有意义的结果吗?这里的有意义的结果是指,A和B最后都要创建成功,我们不想只创建一个文件,因为第二个文件的创建有可能会覆盖并取代第一个文件。这里期望的行为有很多种叫法,但是这里我们称之为原子性(Atomicity)。我们希望类似于创建文件,删除文件这样的操作表现的就像即时生效的一样,同时不会与相同时间其他工作站的操作相互干扰。每一个操作就像在一个时间点发生,而不是一个时间段发生。即使对于复杂的操作,涉及到修改很多状态,我们也希望这些操作表现的好像就是即时生效的。

最后一个问题是,假设我的工作站修改了大量的内容,由于Write-Back缓存,可能会在本地的缓存中堆积了大量的修改。如果我的工作站崩溃了,但是这时这些修改只有部分同步到了Petal,还有部分仍然只存在于本地。同时,其他的工作站还在使用文件系统。那么,我的工作站在执行操作的过程中的崩溃,最好不要损坏其他人同样会使用的文件系统。这意味着,我们需要的是单个服务器的故障恢复,我希望我的工作站的崩溃不会影响其他使用同一个共享系统的工作站。哪怕说这些工作站正在查看我的目录,我的文件,它们应该看到一些合理的现象。它们可以漏掉我最后几个操作,但是它们应该看到一个一致的文件系统,而不是一个损坏了的文件系统数据。所以这里我们希望有故障恢复。一如既往的,在分布式系统中,这增加了更多的复杂度,我们可以很容易陷入到这样一个场景,一个工作站崩溃了,但是其他的工作站还在运行。

对于所有的这些内容,所有的3个挑战,在我们接下来的讨论中,我们会关注Frangipani是如何应对这些挑战。对于Petal虚拟磁盘,它也会有许多类似的关联问题,但是它不是今天关注的重点。Petal有完全不同的,可靠的容错机制。实际上,它与我们之前讨论过的Chain-Replication非常相似。

11.3 Frangipani的锁服务(Lock Server)

Frangipani的第一个挑战是缓存一致性。在这里我们想要的是线性一致性和缓存带来的好处。对于线性一致性来说,当我查看文件系统中任何内容时,我总是能看到最新的数据。对于缓存来说,我们想要缓存带来的性能提升。某种程度上,我们想要同时拥有这两种特性的优点。

人们通常使用缓存一致性协议(Cache Coherence Protocol)来实现缓存一致性。这些协议在很多不同的场景都有使用,不只在分布式文件系统,在多核处理器每个核的缓存的同步中也有使用。只是不同场景中,使用的协议是不一样的。

Frangipani的缓存一致性核心是由锁保证的,我们之后在原子性和故障恢复中将会再次看到锁。但是现在,我们只讨论用锁来保证缓存一致,用锁来帮助工作站确定当它们缓存了数据时,它们缓存的是最新的数据。

除了Frangipani服务器(也就是工作站),Petal存储服务器,在Frangipani系统中还有第三类服务器,锁服务器。尽管你可以通过分片将锁分布到多个服务器上,但是我接下来会假设只有一个锁服务器。逻辑上,锁服务器是独立的服务器,但是实际上我认为它与Petal服务器运行在一起。在锁服务器里面,有一个表单,就叫做locks。我们假设每一个锁以文件名来命名,所以对于每一个文件,我们都有一个锁,而这个锁,可能会被某个工作站所持有。

在这个例子中,我们假设锁是排他锁(Exclusive Lock),尽管实际上Frangipani中的锁更加复杂可以支持两种模式:要么允许一个写入者持有锁,要么允许多个读取者持有锁。

假设文件X最近被工作站WS1使用了,所以WS1对于文件X持有锁。同时文件Y最近被工作站WS2使用,所以WS2对于文件Y持有锁。锁服务器会记住每个文件的锁被谁所持有。当然一个文件的锁也有可能不被任何人持有。

在每个工作站,会记录跟踪它所持有的锁,和锁对应的文件内容。所以在每个工作站中,Frangipani模块也会有一个lock表单,表单会记录文件名、对应的锁的状态和文件的缓存内容。这里的文件内容可能是大量的数据块,也可能是目录的列表。

当一个Frangipani服务器决定要读取文件,比如读取目录 /、读取文件A、查看一个inode,首先,它会向一个锁服务器请求文件对应的锁,之后才会向Petal服务器请求文件或者目录的数据。收到数据之后,工作站会记住,本地有一个文件X的拷贝,对应的锁的状态,和相应的文件内容。

每一个工作站的锁至少有两种模式。工作站可以读或者写相应的文件或者目录的最新数据,可以在创建,删除,重命名文件的过程中,如果这样的话,我们认为锁在Busy状态。

在工作站完成了一些操作之后,比如创建文件,或者读取文件,它会随着相应的系统调用(例如rename,write,create,read)释放锁。只要系统调用结束了,工作站会在内部释放锁,现在工作站不再使用那个文件。但是从锁服务器的角度来看,工作站仍然持有锁。工作站内部会标明,这是锁时Idle状态,它不再使用这个锁。所以这个锁仍然被这个工作站持有,但是工作站并不再使用它。这在稍后的介绍中比较重要。

现在这里的配置是一致的,锁服务器知道文件X和Y的锁存在,并且都被WS1所持有。工作站WS1也有同等的信息,它内部的表单知道它持有了这两个锁,并且它记住了这两个锁对应的文件或者目录。

这里Frangipani应用了很多的规则,这些规则使得Frangipani以一种提供缓存一致性的方式来使用锁,并确保没有工作站会使用缓存中的旧数据。这些规则、锁、缓存数据需要配合使用。这里的规则包括了:

  • 工作站不允许持有缓存的数据,除非同时也持有了与数据相关的锁。所以基本上来说,不允许在没有锁保护的前提下缓存数据。从操作意义上来说,这意味着对于一个工作站来说,在它使用一个数据之前,它首先要从锁服务器获取数据的锁。只有当工作站持有锁了,工作站才会从Petal读取数据,并将数据放在缓存中。所以这里的顺序是,获得锁,之后再从Petal读取数据。所以,直到获取了锁,工作站是不能缓存数据的,要想缓存数据,工作站必须先持有锁,之后,才能从Petal读取数据。

  • 如果你在释放锁之前,修改了锁保护的数据,那你必须将修改了的数据写回到Petal,只有在Petal确认收到了数据,你才可以释放锁,也就是将锁归还给锁服务器。所以这里的顺序是,先向Petal存储系统写数据,之后再释放锁。

最后再从工作站的lock表单中删除关文件的锁的记录和缓存的数据。

11.4 缓存一致性(Cache Coherence)

工作站和锁服务器之间的缓存一致协议协议包含了4种不同的消息。本质上你可以认为它们就是一些单向的网络消息。

首先是Request消息,从工作站发给锁服务器。Request消息会说:hey锁服务器,我想获取这个锁。

如果从锁服务器的lock表单中发现锁已经被其他人持有了,那锁服务器不能立即交出锁。但是一旦锁被释放了,锁服务器会回复一个Grant消息给工作站。这里的Request和Grant是异步的。

如果你向锁服务器请求锁,而另一个工作站现在正持有锁,锁服务器需要持有锁的工作站先释放锁,因为一个锁不能同时被两个人持有。那我们怎么能让这个工作站获取到锁呢?

前面说过,如果一个工作站在使用锁,并在执行读写操作,那么它会将锁标记为Busy。但是通常来说,当工作站使用完锁之后,不会向锁服务器释放锁。所以,如果我创建了一个新文件,create函数返回时,这些新文件的锁仍然被我的工作站持有。只是说现在锁的状态会变成Idle而不是Busy。但是从锁服务器看来,我的工作站仍然持有锁。这里延迟将锁还给锁服务器的原因是,如果我在我的工作站上创建了文件Y。我接下来几乎肯定要将Y用于其他目的,或许我向它写一些数据,或许会从它读数据。所以,如果工作站能持有所有最近用过的文件的锁并不主动归还的话,会有非常大的优势。在一个常见的例子中,我使用了home目录下的一些文件,并且其他工作站没有人查看过这些文件。我的工作站最后会为我的文件持有数百个在Idle状态的锁。但是如果某人查看了我的文件,他需要先获取锁,而这时我就需要释放锁了。

所以这里的工作方式是,如果锁服务器收到了一个加锁的请求,它查看自己的lock表单可以发现,这个锁现在正被工作站WS1所持有,锁服务器会发送一个Revoke消息给当前持有锁的工作站WS1。并说,现在别人要使用这个文件,请释放锁吧。

当一个工作站收到了一个Revoke请求,如果锁时在Idle状态,并且缓存的数据脏了,工作站会首先将修改过的缓存写回到Petal存储服务器中,因为前面的规则要求在释放锁之前,要先将数据写入Petal。所以如果锁的状态是Idle,首先需要将修改了的缓存数据发回给Petal,只有在那个时候,工作站才会再向锁服务器发送一条消息说,好吧,我现在放弃这个锁。所以,对于一个Revoke请求的响应是,工作站会向锁服务器发送一条Release消息。

如果工作站收到Revoke消息时,它还在使用锁,比如说正在删除或者重命名文件的过程中,直到工作站使用完了锁为止,或者说直到它完成了相应的文件系统操作,它都不会放弃锁。完成了操作之后,工作站中的锁的状态才会从Busy变成Idle,之后工作站才能注意到Revoke请求,在向Petal写完数据之后最终释放锁。

所以,这就是Frangipani使用的一致性协议的一个简单版本的描述。如我之前所描述的,这里面没有考虑一个事实,那就是锁可以是为写入提供的排他锁(Exclusive Lock),也可以是为只读提供的共享锁(Shared Lock)。

就像Petal只是一个块存储服务,并不理解文件系统。锁服务器也不理解文件,目录,还有文件系统,它只是维护lock表单,表单中记录的是锁的名字和锁的持有者。Frangipani可以理解锁与某个文件相关联。实际上Frangipani在这里使用的是Unix风格的inode号来作为lock表单的key,而不是文件的名字。

接下来,我们看一下如何应用这里的缓存一致协议,并演示Petal操作和和锁服务器操作之间的关联。我会过一遍工作站修改文件系统数据,之后另一个工作站查看对应数据的流程。

所以,首先我们有了2个工作站(WS1,WS2),一个锁服务器(LS)。

按照协议,如果WS1想要读取并修改文件Z。在它从Petal读取文件之前,它需要先获取对于Z的锁,所以它向锁服务器发送Request消息(下图中ACQ Z)。

如果当前没有人持有对文件Z的锁,或者锁服务器没听过对于文件Z的锁(初始化状态),锁服务器会在lock表单中增加一条记录,并返回Grant消息给工作站说,你现在持有了对于Z文件的锁。

从这个时间点开始,工作站WS1持有了对文件Z的锁,并且被授权可以从Petal读取Z的数据。所以这个时间点,WS1会从Petal读取并缓存Z的内容。之后,WS1也可以在本地缓存中修改Z的内容。

过了一会,坐在工作站WS2前面的用户也想读取文件Z。但是一开始WS2并没有对于文件Z的锁,所以它要做的第一件事情就是向锁服务器发送Request消息,请求对于文件Z的锁。

但是,锁服务器知道不能给WS2回复Grant消息,因为WS1现在还持有锁。接下来锁服务器会向WS1发送Revoke消息。

而工作站WS1在向Petal写入修改数据之前,不允许释放锁。所以它现在会将任何修改的内容写回给Petal。

写入结束之后,WS1才可以向锁服务器发送Release消息。

锁服务器必然会有一个表单记录谁在等待文件Z的锁,一旦锁的当前持有者释放了锁,锁服务器需要通知等待者。所以当锁服务器收到了这条Release消息时,锁服务器会更新自己的表单,并最终将Grant消息发送给工作站WS2。

这个时候,WS2终于可以从Petal读取文件Z。

这就是缓存一致性协议的工作流程,它确保了,直到所有有可能私底下在缓存中修改了数据的工作站先将数据写回到Petal,其他工作站才能读取相应的文件。所以,这里的锁机制确保了读文件总是能看到最新写入文件的数据。

在这个缓存一致性的协议中,有许多可以优化的地方。实际上,我之前已经描述过一个优化点了,

每个工作站用完了锁之后,不是立即向锁服务器释放锁,而是将锁的状态标记为Idle就是一种优化。

另一个主要的优化是,Frangipani有共享的读锁(Shared Read Lock)和排他的写锁(Exclusive Write Lock)。如果有大量的工作站需要读取文件,但是没有人会修改这个文件,它们都可以同时持有对这个文件的读锁。如果某个工作站需要修改这个已经被大量工作站缓存的文件时,那么它首先需要Revoke所有工作站的读锁,这样所有的工作站都会放弃自己对于该文件的缓存,只有在那时,这个工作站才可以修改文件。因为没有人持有了这个文件的缓存,所以就算文件被修改了,也没有人会读到旧的数据。

这就是以锁为核心的缓存一致性。

学生提问:如果没有其他工作站读取文件,那缓存中的数据就永远不写入后端存储了吗?

Robert教授:这是一个好问题。实际上,在我刚刚描述的机制中是有风险的,如果我在我的工作站修改了一个文件,但是没有人读取它,这时,这个文件修改后的版本的唯一拷贝只存在于我的工作站的缓存或者RAM上。这些文件里面可能有一些非常珍贵的信息,如果我的工作站崩溃了,并且我们不做任何特殊的操作,数据的唯一拷贝会丢失。所以为了阻止这种情况,不管怎么样,工作站每隔30秒会将所有修改了的缓存写回到Petal中。所以,如果我的工作站突然崩溃了,我或许会丢失过去30秒的数据,但是不会丢更多,这实际上是模仿Linux或者Unix文件系统的普通工作模式。在一个分布式文件系统中,很多操作都是在模仿Unix风格的文件系统,这样使用者才不会觉得Frangipani的行为异常,因为它基本上与用户在使用的文件系统一样。

11.5 原子性(Atomicity)

下一个挑战是确保原子性。当我做了一个复杂的操作,比如说创建一个文件,这里涉及到标识一个新的inode、初始化一个inode(inode是用来描述文件的一小份数据)、为文件分配空间、在目录中为新文件增加一个新的名字,这里有很多步骤,很多数据都需要更新。我们不想任何人看到任何中间的状态,我们希望其他的工作站要么发现文件不存在,要么文件完全存在,但是我们绝不希望它看到中间状态。所以我们希望多个步骤的操作具备原子性。

为了实现原子性,为了让多步骤的操作,例如创建文件,重命名文件,删除文件具备原子性,Frangipani在内部实现了一个数据库风格的事务系统,并且是以锁为核心。并且,这是一个分布式事务系统,我们之后会在这门课看到更多有关分布式事务系统的内容,它在分布式系统中是一种非常常见的需求。

简单来说,Frangipani是这样实现分布式事务的:在我完全完成操作之前,Frangipani确保其他的工作站看不到我的修改。首先我的工作站需要获取所有我需要读写数据的锁,在完成操作之前,我的工作站不会释放任何一个锁。并且为了遵循一致性规则(11.3),将所有修改了的数据写回到Petal之后,我的工作站才会释放所有的锁。比如我将文件从一个目录移到另一个目录,这涉及到修改两个目录的内容,我不想让人看到两个目录都没有文件的状态。为了实现这样的结果,Frangipani首先会获取执行操作所需要的所有数据的锁,

之后完成所有的步骤,比如完成所有数据的更新,并将更新写入到Petal,最后释放锁。

因为我们有了锁服务器和缓存一致性协议,我们只需要确保我们在整个操作的过程中持有所有的锁,我们就可以无成本的获得这里的不可分割原子事务。

所以为了让操作具备原子性,Frangipani持有了所有的锁。对于锁来说,这里有一件有意思的事情,Frangipani使用锁实现了两个几乎相反的目标。对于缓存一致性,Frangipani使用锁来确保写操作的结果对于任何读操作都是立即可见的,所以对于缓存一致性,这里使用锁来确保写操作可以被看见。但是对于原子性来说,锁确保了人们在操作完成之前看不到任何写操作,因为在所有的写操作完成之前,工作站持有所有的锁。

11.6 Frangipani Log

下一个有意思的事情是故障恢复。

我们需要能正确应对这种场景:一个工作站持有锁,并且在一个复杂操作的过程中崩溃了。比如说一个工作站在创建文件,或者删除文件时,它首先获取了大量了锁,然后会更新大量的数据,在其向Petal回写数据的过程中,一部分数据写入到了Petal,还有一部分还没写入,这时工作站崩溃了,并且锁也没有释放(因为数据回写还没有完成)。这是故障恢复需要考虑的有趣的场景。

对于工作站故障恢复的一些直观处理方法,但是都不太好。

其中一种处理方法是,如果发现工作站崩溃了,就释放它所有的锁。假设工作站在创建新文件,它已经在Petal里将文件名更新到相应的目录下,但是它还没有将描述了文件的inode写入到Petal,Petal中的inode可能还是一些垃圾数据,这个时候是不能释放崩溃工作站持有的锁(因为其他工作站读取这个文件可能读出错误的数据)。

另一种处理方法是,不释放崩溃了的工作站所持有的锁。这至少是正确的。如果工作站在向Petal写入数据的过程中崩溃了,因为它还没有写完所有的数据,也就意味着它不能释放所有的锁。所以,简单的不释放锁是正确的行为,因为这可以将这里的未完成的更新向文件的读取者隐藏起来,这样没人会因为看到只更新了一半的数据而感到困惑了。但是另一方面,如果任何人想要使用这些文件,那么他需要永远等待锁,因为我们没有释放这些锁。

所以,我们绝对需要释放锁,这样其他的工作站才能使用这个系统,使用相同的文件和目录。但同时,我们也需要处理这种场景:崩溃了的工作站只写入了与操作相关的部分数据,而不是全部的数据。

Frangipani与其他的系统一样,需要通过预写式日志(Write-Ahead Log,WAL,见10.2)实现故障可恢复的事务(Crash Recoverable Transaction)。我们在上节课介绍Aurora时,也使用过WAL。

当一个工作站需要完成涉及到多个数据的复杂操作时,在工作站向Petal写入任何数据之前,工作站会在Petal中自己的Log列表中追加一个Log条目,这个Log条目会描述整个的需要完成的操作。只有当这个描述了完整操作的Log条目安全的存在于Petal之后,工作站才会开始向Petal发送数据。所以如果工作站可以向Petal写入哪怕是一个数据,那么描述了整个操作、整个更新的Log条目必然已经存在于Petal中。

这是一种非常标准的行为,它就是WAL的行为。但是Frangipani在实现WAL时,有一些不同的地方。

第一个是,在大部分的事务系统中,只有一个Log,系统中的所有事务都存在于这个Log中。当有故障时,如果有多个操作会影响同一份数据,我们在这一个Log里,就会保存这份数据的所有相关的操作。所以我们知道,对于一份数据,哪一个操作是最新的。但是Frangipani不是这么保存Log的,它对于每个工作站都保存了一份独立的Log。

另一个有关Frangipani的Log系统有意思的事情是,工作站的Log存储在Petal,而不是本地磁盘中。几乎在所有使用了Log的系统中,Log与运行了事务的计算机紧紧关联在一起,并且几乎总是保存在本地磁盘中。但是出于优化系统设计的目的,Frangipani的工作站将自己的Log保存在作为共享存储的Petal中。每个工作站都拥有自己的半私有的Log,但是却存在Petal存储服务器中。这样的话,如果工作站崩溃了,它的Log可以被其他工作站从Petal中获取到。所以Log存在于Petal中。

这里其实就是,每个工作站的独立的Log,存放在公共的共享存储中,这是一种非常有意思,并且反常的设计。

我们需要大概知道Log条目的内容是什么,但是Frangipani的论文对于Log条目的格式没有非常清晰的描述,论文说了每个工作站的Log存在于Petal已知的块中,并且,每个工作站以一种环形的方式使用它在Petal上的Log空间。Log从存储的起始位置开始写,当到达结尾时,工作站会回到最开始,并且重用最开始的Log空间。所以工作站需要能够清除它的Log,这样就可以确保,在空间被重复利用之前,空间上的Log条目不再被需要。

每个Log条目都包含了Log序列号,这个序列号是个自增的数字,每个工作站按照12345为自己的Log编号,这里直接且唯一的原因在论文里也有提到,如果工作站崩溃了,Frangipani会探测工作站Log的结尾,Frangipani会扫描位于Petal的Log直到Log序列号不再增加,这个时候Frangipani可以确定最后一个Log必然是拥有最高序列号的Log。所以Log条目带有序列号是因为Frangipani需要检测Log的结尾。

除此之外,每个Log条目还有一个用来描述一个特定操作中所涉及到的所有数据修改的数组。数组中的每一个元素会有一个Petal中的块号(Block Number),一个版本号和写入的数据。类似的数组元素会有多个,这样就可以用来描述涉及到修改多份文件系统数据的操作。

这里有一件事情需要注意,Log只包含了对于元数据的修改,比如说文件系统中的目录、inode、bitmap的分配。Log本身不会包含需要写入文件的数据,所以它并不包含用户的数据,它只包含了故障之后可以用来恢复文件系统结构的必要信息。例如,我在一个目录中创建了一个文件F,那会生成一个新的Log条目,里面的数组包含了两个修改的描述,一个描述了如何初始化新文件的inode,另一个描述了在目录中添加的新文件的名字。(这里我比较疑惑,如果Log只包含了元数据的修改,那么在故障恢复的时候,文件的内容都丢失了,也就是对于创建一个新文件的故障恢复只能得到一个空文件,这不太合理。)

当然,Log是由多个Log条目组成,

为了能够让操作尽快的完成,最初的时候,Frangipani工作站的Log只会存在工作站的内存中,并尽可能晚的写到Petal中。这是因为,向Petal写任何数据,包括Log,都需要花费较长的时间,所以我们要尽可能避免向Petal写入Log条目,就像我们要尽可能避免向Petal写入缓存数据一样。

所以,这里的完整的过程是。当工作站从锁服务器收到了一个Revoke消息,要自己释放某个锁,它需要执行好几个步骤。

  1. 首先,工作站需要将内存中还没有写入到Petal的Log条目写入到Petal中。
  2. 之后,再将被Revoke的Lock所保护的数据写入到Petal。
  3. 最后,向锁服务器发送Release消息。

这里采用这种流程的原因是,在第二步我们向Petal写入数据的时候,如果我们在中途故障退出了,我们需要确认其他组件有足够的信息能完成我们未完成修改。先写入Log将会使我们能够达成这个目标。这些Log记录是对将要做的修改的完整记录。所以我们需要先将完整的Log写入到Petal。之后工作站可以开始向Petal写入其修改了的块数据,这个过程中,可能会故障,也可能不会。如果工作站完成了向Petal写入块数据,它就能向锁服务发送Release消息。所以,如果我的工作站修改了一些文件,之后其他的工作站想要读取这些文件,上面的才是一个实际的工作流程。锁服务器要我释放锁,我的工作站会先向Petal写入Log,之后再向Petal写入脏的块数据,最后才向锁服务器发送Release消息。之后,其他的工作站才能获取锁,并读取相应的数据块。这是没有故障的时候对应的流程。

当然,只有当故障发生时,事情才变得有意思。

学生提问:Revoke的时候会将所有的Log都写入到Petal吗?

Robert教授:对于Log,你绝对是正确的,Frangipani工作站会将完整的Log写入Petal。所以,如果我们收到了一个针对特定文件Z的Revoke消息,工作站会将整个Log都写入Petal。但是因为工作站现在需要放弃对于Z的锁,它还需要向Petal写入Z相关的数据块。所以我们需要写入完整的Log,和我们需要释放的锁对应的文件内容,之后我们就可以释放锁。

或许写入完整的Log显得没那么必要,在这里可以稍作优化。如果Revoke要撤回的锁对应的文件Z只涉及第一个Log,并且工作站中的其他Log并没有修改文件Z,那么可以只向Petal写入一个Log,剩下的Log之后再写入,这样可以节省一些时间。

11.7 故障恢复(Crash Recovery)

接下来,我们讨论一下,当工作站持有锁,并且故障了会发生什么。

这里的场景是,当工作站需要重命名文件或者创建一个文件时,首先它会获得所有需要修改数据的锁,之后修改自身的缓存来体现改动。但是后来工作站在向Petal写入数据的过程中故障了。工作站可能在很多个位置发生故障,但是由于前面介绍过的工作流程,Frangipani总是会先将自身的Log先写入到Petal。这意味着如果发生了故障,那么发生故障时可能会有这几种场景:

  • 要么工作站正在向Petal写入Log,所以这个时候工作站必然还没有向Petal写入任何文件或者目录。
  • 要么工作站正在向Petal写入修改的文件,所以这个时候工作站必然已经写入了完整的Log。

因为有了前面的工作流程,我们需要担心的故障发生时间点是有限的。

当持有锁的工作站崩溃了之后,发生的第一件事情是锁服务器向工作站发送一个Revoke消息,但是锁服务器得不到任何响应,之后才会触发故障恢复。如果没有人需要用到崩溃工作站持有的锁,那么基本上没有人会注意到工作站崩溃了。假设一个其他的工作站需要崩溃了的工作站所持有的一个锁,锁服务器会发出Revoke消息,但是锁服务器永远也不会从崩溃了的工作站收到Release消息。Frangipani出于一些原因对锁使用了租约,当租约到期了,锁服务器会认定工作站已经崩溃了,之后它会初始化恢复过程。实际上,锁服务器会通知另一个还活着的工作站说:看,工作站1看起来崩溃了,请读取它的Log,重新执行它最近的操作并确保这些操作完成了,在你完成之后通知我。在收到这里的通知之后,锁服务器才会释放锁。这就是为什么日志存放在Petal是至关重要的,因为一个其他的工作站可能会要读取这个工作站在Petal中的日志。

发生故障的场景究竟有哪些呢?

  • 第一种场景是,工作站WS1在向Petal写入任何信息之前就故障了。这意味着,当其他工作站WS2执行恢复,查看崩溃了的工作站的Log时,发现里面没有任何信息,自然也就不会做任何操作。之后WS2会释放WS1所持有的锁。工作站WS1或许在自己的缓存中修改了各种各样的数据,但是如果它没有在自己的Log存储区写入任何信息,那么它也不可能在Petal中写入任何它修改的块数据。我们会丢失WS1的最后几个操作,但是文件系统会与WS1开始修改之前保持一致。因为很明显,工作站WS1没能走到向Petal写Log那一步,自然也不可能向Petal写入块数据。
  • 第二种场景是,工作站WS1向Petal写了部分Log条目。这样的话,执行恢复的工作站WS2会从Log的最开始向后扫描,直到Log的序列号不再增加,因为这必然是Log结束的位置。工作站WS2会检查Log条目的更新内容,并向Petal执行Log条目中的更新内容。比如Petal中的特定块需要写入特定的数据,这里对应的其实就是工作站WS1在自己本地缓存中做的一些修改。所以执行恢复的工作站WS2会检查每个Log条目,并重新向Petal执行WS1的每一条Log。当WS2执行完WS1存放在Petal中的Log,它会通知锁服务器,之后锁服务器会释放WS1持有的锁。这样的过程会使得Petal更新至故障工作站WS1在故障前的执行的部分操作。或许不能全部恢复WS1的操作,因为故障工作站可能只向Petal写了部分Log就崩溃了。同时,除非在Petal中找到了完整的Log条目,否则执行恢复的工作站WS2是不会执行这条Log条目的,所以,这里的隐含意思是需要有类似校验和的机制,这样执行恢复的工作站就可以知道,这个Log条目是完整的,而不是只有操作的一部分数据。这一点很重要,因为在恢复时,必须要在Petal的Log存储区中找到完整的操作。所以,对于一个操作的所有步骤都需要打包在一个Log条目的数组里面,这样执行恢复的工作站就可以,要么全执行操作的所有步骤,要么不执行任何有关操作的步骤,但是永远不会只执行部分步骤。这就是当在向Petal写入Log时,发生了故障的修复过程。
  • 另一个有趣的可能是,工作站WS1在写入Log之后,并且在写入块数据的过程中崩溃了。先不考虑一些极其重要的细节,执行恢复的工作站WS2并不知道WS1在哪个位置崩溃的,它只能看到一些Log条目,同样的,WS2会以相同的方式重新执行Log。尽管部分修改已经写入了Petal,WS2会重新执行修改。对于部分已经写入的数据,相当于在相同的位置写入相同的数据。对于部分未写入的数据,相当于更新了Petal中的这部分数据,并完成了操作。

上面的描述并没有涵盖所有的场景,下面的这个场景会更加复杂一些。如果一个工作站,完成了上面流程的步骤1,2,在释放锁的过程中崩溃了,进而导致崩溃的工作站不是最后修改特定数据的工作站。具体可以看下面这个例子,假设我们有一个工作站WS1,它执行了删除文件(d/f)的操作。

之后,有另一个工作站WS2,在删除文件之后,以相同的名字创建了文件,当然这是一个不同的文件。所以之后,工作站WS2创建了同名的文件(d/f)。

在创建完成之后,工作站WS1崩溃了,

所以,我们需要基于WS1的Log执行恢复,这时,可能有第三个工作站WS3来执行恢复的过程。

这里的时序表明,WS1删除了一个文件,WS2创建了一个文件,WS3做了恢复操作。有可能删除操作仍然在WS1的Log中,当WS1崩溃后,WS3需要读取WS1的Log,并重新执行WS1的Log中的更新。因为删除文件的Log条目仍然存在于WS1的Log中,如果不做任何额外的事情,WS3会删除这个文件(d/f)。但是实际上,WS3删除的会是WS2稍后创建的一个完全不同的文件。

这样的结果是完全错误的,因为需要被删除的是WS1指定的文件,而不是WS2创建的一个相同名字的文件。因为WS2的创建是在WS1的删除之后,所以我们不能只是不经思考的重新执行WS1的Log,WS1的Log在我们执行的时候可能已经过时了,其他的一些工作站可能已经以其他的方式修改了相同的数据,所以我们不能盲目的重新执行Log条目。

Frangipani是这样解决这个问题的,通过对每一份存储在Petal文件系统数据增加一个版本号,同时将版本号与Log中描述的更新关联起来。在Petal中,每一个元数据,每一个inode,每一个目录下的内容,都有一个版本号,当工作站需要修改Petal中的元数据时,它会向从Petal中读取元数据,并查看当前的版本号,之后在创建Log条目来描述更新时,它会在Log条目中对应的版本号填入元数据已有的版本号加1。

之后,如果工作站执行到了写数据到Petal的步骤,它也会将新的增加了的版本号写回到Petal。

所以,如果一个工作站没有故障,并且成功的将数据写回到了Petal。这样元数据的版本号会大于等于Log条目中的版本号。如果有其他的工作站之后修改了同一份元数据,版本号会更高。

所以,实际上WS3看到的WS1的删除操作对应的Log条目,会有一个特定的版本号,它表明,由这个Log条目影响的元数据对应版本号3(举例)。

WS2的修改在WS1崩溃之前,所以WS1必然已经释放了相关数据的锁。WS2获得了锁,它会读取当前的元数据可以发现当前的版本号是3,当WS2写入数据的时候,它会将版本号设置为4。

之后,当WS3执行恢复流程时,WS3会重新执行WS1的Log,它会首先检查版本号,通过查看Log条目中的版本号,并查看Petal中存储的版本号,如果Petal中存储的版本号大于等于Log条目中的版本号,那么WS3会忽略Log条目中的修改,因为很明显Petal中的数据已经被故障了的工作站所更新,甚至可能被后续的其他工作站修改了。所以在恢复的过程中,WS3会选择性的根据版本号执行Log,只有Log中的版本号高于Petal中存储的数据的版本时,Log才会被执行。

这里有个比较烦人的问题就是,WS3在执行恢复,但是其他的工作站还在频繁的读取文件系统,持有了一些锁并且在向Petal写数据。WS3在执行恢复的过程中,WS2是完全不知道的。WS2可能还持有目录 d的锁,而WS3在扫描故障工作站WS1的Log时,需要读写目录d,但是目录d的锁还被WS2所持有。我们该如何解决这里的问题?

一种不可行的方法是,让执行恢复的WS3先获取所有关联数据的锁,再重新执行Log。这种方法不可行的一个原因是,有可能故障恢复是在一个大范围电力故障之后,这样的话谁持有了什么锁的信息都丢失了,因此我们也就没有办法使用之前的缓存一致性协议,因为哪些数据加锁了,哪些数据没有加锁在断电的过程中丢失了。

但是幸运的是,执行恢复的工作站可以直接从Petal读取数据而不用关心锁。这里的原因是,执行恢复的工作站想要重新执行Log条目,并且有可能修改与目录d关联的数据,它就是需要读取Petal中目前存放的目录数据。接下来只有两种可能,要么故障了的工作站WS1释放了锁,要么没有。如果没有的话,那么没有其他人不可以拥有目录的锁,执行恢复的工作站可以放心的读取目录数据,没有问题。如果释放了锁,那么在它释放锁之前,它必然将有关目录的数据写回到了Petal。这意味着,Petal中存储的版本号,至少会和故障工作站的Log条目中的版本号一样大,因此,之后恢复软件对比Log条目的版本号和Petal中存储的版本号,它就可以发现Log条目中的版本号并没有大于存储数据的版本号,那么这条Log条目就会被忽略。所以这种情况下,执行恢复的工作站可以不持有锁直接读取块数据,但是它最终不会更新数据。因为如果锁被释放了,那么Petal中存储的数据版本号会足够高,表明在工作站故障之前,Log条目已经应用到了Petal。所以这里不需要关心锁的问题。

11.8 Frangipani总结

前面我介绍了这些主要的内容:

  • Petal是什么
  • 缓存一致性
  • 分布式事务
  • 分布式故障恢复

论文还讨论了一下性能,但是过了20年之后的今天,很难理解这些性能数据。因为作者在一个与今天非常不同的硬件,非常不同的环境测试的性能。笼统来说,作者展示的性能数据表明,随着越来越多的Frangipani工作站加入到系统中,系统并没有明显变慢。即使新加入的工作站在频繁的执行文件系统操作,并不会影响现有的工作站。这样的话,系统可以提供合理的扩展性,因为它可以在不减慢现有工作站的前提下增加更多的工作站。

回过头来看,尽管Frangipani有大量有意思且值得记住的技术,但是它对于存储系统的演进并没有什么影响。部分原因是,Frangipani的目标环境是一个小的工作组,人们坐在桌子上的工作站前共享文件。这样的环境现在还存在与某些地方,但是却不是分布式存储的主要应用场景。真正的应用场景是一些大型的数据中心、大型网站、大数据运算,在这些场景中,文件系统的接口相比数据库接口来说,就不是那么有用了。比如,在大型网站的环境中,人们非常喜欢事务,但是人们在非常小的数据下才需要事务,这些小的数据也就是你会存储在数据库中的数据,而不是你会存储在文件系统中的数据。所以这里的一些技术,你可以在一些现代的系统中看到类似的设计,但是通常出现在数据库中。

另一个大的场景是为大数据运算存储大的文件,例如MapReduce。实际上GFS某种程度上看起来就像是一个文件系统,但是实际上是为了MapReduce设计的存储系统。但是不论对于GFS也好,还是大数据运算也好,Frangipani关注在工作站的本地缓存和缓存一致性,反而不是很有用。如果你读取10TB的数据,缓存基本上没什么用,并且会适得其反。所以,随着时间的推移,Frangipani在一些场合还是有用的,但是并不符合在设计新系统时候的需求。

Lecture 12 - Distributed Transaction

为了更好的理解本节课,强烈建议先阅读下面书籍的9.1.5、9.1.6、9.5.2、9.5.3、9.6.3。

https://ocw.mit.edu/resources/res-6-004-principles-of-computer-system-design-an-introduction-spring-2009/online-textbook/

12.1 分布式事务初探(Distributed Transaction)

今天讨论的内容是分布式事务。

分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit)。

之所以提及分布式事务,是因为对于拥有大量数据的人来说,他们通常会将数据进行分割或者分片到许多不同的服务器上。假设你运行了一个银行,你一半用户的账户在一个服务器,另一半用户的账户在另一个服务器,这样的话可以同时满足负载分担和存储空间的要求。对于其他的场景也有类似的分片,比如说对网站上文章的投票,或许有上亿篇文章,那么可以在一个服务器上对一半的文章进行投票,在另一个服务器对另一半进行投票。

对于一些操作,可能会要求从多个服务器上修改或者读取数据。比如说我们从一个账户到另一个账户完成银行转账,这两个账户可能在不同的服务器上。因此,为了完成转账,我们必须要读取并修改两个服务器的数据。

一种构建系统的方式,我们在后面的课程也会看到,就是尝试向应用程序的开发人员,隐藏将数据分割在多个服务器上带来的复杂度。在过去的几十年间,这都是设计数据库需要考虑的问题,所以很多现在的材料的介绍都是基于数据库。但是这种方式(隐藏数据分片在多个服务器),现在在一些与传统数据库不相关的分布式系统也在广泛应用。

人们通常将并发控制和原子提交放在一起,当做事务。有关事务,我们之前介绍过。

可以这么理解事务:程序员有一些不同的操作,或许针对数据库不同的记录,他们希望所有这些操作作为一个整体,不会因为失败而被分割,也不会被其他活动看到中间状态。事务处理系统要求程序员对这些读操作、写操作标明起始和结束,这样才能知道事务的起始和结束。事务处理系统可以保证在事务的开始和结束之间的行为是可预期的。

例如,假设我们运行了一个银行,我们想从用户Y转账到用户X,这两个账户最开始都有10块钱,这里的X,Y都是数据库的记录。

这里有两个交易,第一个是从Y转账1块钱到X,另一个是对于所有的银行账户做审计,确保总的钱数不会改变,因为毕竟在账户间转钱不会改变所有账户的总钱数。我们假设这两个交易同时发生。为了用事务来描述这里的交易,我们需要有两个事务,第一个事务称为T1,程序员会标记它的开始,我们称之为BEGIN_X,之后是对于两个账户的操作,我们会对账户X加1,对账户Y加-1。之后我们需要标记事务的结束,我们称之为END_X。

同时,我们还有一个事务,会检查所有的账户,对所有账户进行审计,确保尽管可能存在转账,但是所有账户的金额加起来总数是不变的。所以,第二个事务是审计事务,我们称为T2。我们也需要为事务标记开始和结束。这一次我们只是读数据,所以这是一个只读事务。我们需要获取所有账户的当前余额,因为现在我们只有两个账户,所以我们使用两个临时的变量,第一个是用来读取并存放账户X的余额,第二个用来读取并存放账户Y的余额,之后我们将它们都打印出来,最后是事务的结束。

这里的问题是,这两个事务的合法结果是什么?这是我们首先想要确定的事情。最初的状态是,两个账户都是10块钱,但是在同时运行完两个事务之后,最终结果可能是什么?我们需要一个概念来定义什么是正确的结果。一旦我们知道了这个概念,我们需要构建能执行这些事务的机制,在可能存在并发和失败的前提下,仍然得到正确的结果。

所以,首先,什么是正确性?数据库通常对于正确性有一个概念称为ACID。分别代表:

  • Atomic,原子性。它意味着,事务可能有多个步骤,比如说写多个数据记录,尽管可能存在故障,但是要么所有的写数据都完成了,要么没有写数据能完成。不应该发生类似这种情况:在一个特定的时间发生了故障,导致事务中一半的写数据完成并可见,另一半的写数据没有完成,这里要么全有,要么全没有(All or Nothing)。
  • Consistent,一致性。我们实际上不会担心这一条,它通常是指数据库会强制某些应用程序定义的数据不变,这不是我们今天要考虑的点。
  • Isolated,隔离性。这一点还比较重要。这是一个属性,它表明两个同时运行的事务,在事务结束前,能不能看到彼此的更新,能不能看到另一个事务中间的临时的更新。目标是不能。隔离在技术上的具体体现是,事务需要串行执行,我之后会再解释这一条。但是总结起来,事务不能看到彼此之间的中间状态,只能看到完成的事务结果。
  • Durable,持久化的。这意味着,在事务提交之后,在客户端或者程序提交事务之后,并从数据库得到了回复说,yes,我们执行了你的事务,那么这时,在数据库中的修改是持久化的,它们不会因为一些错误而被擦除。在实际中,这意味着数据需要被写入到一些非易失的存储(Non-Volatile Storage),持久化的存储,例如磁盘。

今天的课程会讨论,在考虑到错误,考虑到多个并发行为的前提下,什么才是正确的行为,并确保数据在出现故障之后,仍然存在。这里对我们来说最有意思的部分是有关隔离性或者串行的具体定义。我会首先介绍这一点,之后再介绍如何执行上面例子中的两个事务。

通常来说,隔离性(Isolated)意味着可序列化(Serializable)。它的定义是如果在同一时间并行的执行一系列的事务,那么可以生成一系列的结果。这里的结果包括两个方面:由任何事务中的修改行为产生的数据库记录的修改;和任何事务生成的输出。所以前面例子中的两个事务,T1的结果是修改数据库记录,T2的结果是打印出数据。

我们说可序列化是指,并行的执行一些事物得到的结果,与按照某种串行的顺序来执行这些事务,可以得到相同的结果。实际的执行过程或许会有大量的并行处理,但是这里要求得到的结果与按照某种顺序一次一个事务的串行执行结果是一样的。所以,如果你要检查一个并发事务执行是否是可序列化的,你查看结果,并看看是否可以找到对于同一些事务,存在一次只执行一个事务的顺序,按照这个顺序执行可以生成相同的结果。

所以,我们刚刚例子中的事务,只有两种一次一个的串行顺序,要么是T1,T2,要么是T2,T1。我们可以看一下这两种串行执行生成的结果。

我们先执行T1,再执行T2,我们得到X=11,Y=9,因为T1先执行,T2中的打印,可以看到这两个更新过后的数据,所以这里会打印字符串“11,9”。

另一种可能的顺序是,先执行T2,再执行T1,这种情况下,T2可以看到更新之前的数据,但是更新仍然会在T1中发生,所以最后的结果是X=11,Y=9。但是这一次,T2打印的是字符串“10,10”。

所以,这是两种串行执行的合法结果。如果我们同时执行这两个事务,看到了这两种结果之外的结果,那么我们运行的数据库不能提供序列化执行的能力(也就是不具备隔离性 Isolated)。所以,实际上,我们在考虑问题的时候,可以认为这是唯二可能的结果,我们最好设计我们的系统,并让系统只输出两个结果中的一个。

如果你同时提交两个事务,你不知道是T1,T2的顺序,还是T2,T1的顺序,所以你需要预期可能会有超过一个合法的结果。当你同时运行了更多的事务,结果也会更加复杂,可能会有很多不同的正确的结果,这些结果都是可序列化的,因为这里对于事务存在许多顺序,可以被用来满足序列化的要求。

现在我们对于正确性有了一个定义,我们甚至知道了可能的结果是什么。我们可以提出几个有关执行顺序的假设。

例如,假设系统实际上这么执行,开始执行T2,并执行到读X,之后执行了T1。在T1结束之后,T2再继续执行。

如果不是T2这样的事务,最后的结果可能也是合法的。但是现在,我们想知道如果按照这种方式执行,我们得到的结果是否是之前的两种结果之一。在这里,T2事务中的变量t1可以看到10,t2会看到减Y之后的结果所以是9,最后的打印将会是字符串“10,9”。这不符合之前的两种结果,所以这里描述的执行方式不是可序列化的,它不合法。

另一个有趣的问题是,如果我们一开始执行事务T1,然后在执行完第一个add时,执行了整个事务T2。

这意味着,在T2执行的点,T2可以读到X为11,Y为10,之后打印字符串“11,10”。这也不是之前的两种合法结果之一。所以对于这两个事务,这里的执行过程也不合法。

可序列化是一个应用广泛且实用的定义,背后的原因是,它定义了事务执行过程的正确性。它是一个对于程序员来说是非常简单的编程模型,作为程序员你可以写非常复杂的事务而不用担心系统同时在运行什么,或许有许多其他的事务想要在相同的时间读写相同的数据,或许会发生错误,这些你都不需要关心。可序列化特性确保你可以安全的写你的事务,就像没有其他事情发生一样。因为系统最终的结果必须表现的就像,你的事务在这种一次一个的顺序中是独占运行的。这是一个非常简单,非常好的编程模型。

可序列化的另一方面优势是,只要事务不使用相同的数据,它可以允许真正的并行执行事务。我们之前的例子之所以有问题,是因为T1和T2都读取了数据X和Y。但是如果它们使用完全没有交集的数据库记录,那么这两个事务可以完全并行的执行。在一个分片的系统中,不同的数据在不同的机器上,你可以获得真正的并行速度提升,因为可能一个事务只会在第一个机器的第一个分片上执行,而另一个事务并行的在第二个机器上执行。所以,这里有可能可以获得更高的并发性能。

在我详细介绍可序列化的事务之前,我还想提出一个小点。有一件场景我们需要能够应付,事务可能会因为这样或那样的原因在执行的过程中失败或者决定失败,通常这被称为Abort。对于大部分的事务系统,我们需要能够处理,例如当一个事务尝试访问一个不存在的记录,或者除以0,又或者是,某些事务的实现中使用了锁,一些事务触发了死锁,而解除死锁的唯一方式就是干掉一个或者多个参与死锁的事务,类似这样的场景。所以在事务执行的过程中,如果事务突然决定不能继续执行,这时事务可能已经修改了部分数据库记录,我们需要能够回退这些事务,并撤回任何已经做了的修改。

实现事务的策略,我会划分成两块,在这门课程中我都会介绍它们,先来简单的看一下这两块。

第一个大的有关实现的话题是并发控制(Concurrency Control)。这是我们用来提供可序列化的主要工具。所以并发控制就是可序列化的别名。通过与其他尝试使用相同数据的并发事务进行隔离,可以实现可序列化。

另一个有关实现的大的话题是原子提交(Atomic Commit)。它帮助我们处理类似这样的可能场景:前面例子中的事务T1在执行过程中可能已经修改了X的值,突然事务涉及的一台服务器出现错误了,我们需要能从这种场景恢复。所以,哪怕事务涉及的机器只有部分还在运行,我们需要具备能够从部分故障中恢复的能力。这里我们使用的工具就是原子提交。我们后面会介绍。

12.2 并发控制(Concurrency Control)

第一个要介绍的是并发控制(Concurrency Control)。在并发控制中,主要有两种策略,在这门课程中我都会介绍。

第一种主要策略是悲观并发控制(Pessimistic Concurrency Control)。

这里通常涉及到锁,我们在实验中的Go语言里面已经用过锁了。实际上,数据库的事务处理系统也会使用锁。这里的想法或许你已经非常熟悉了,那就是在事务使用任何数据之前,它需要获得数据的锁。如果一些其他的事务已经在使用这里的数据,锁会被它们持有,当前事务必须等待这些事务结束,之后当前事务才能获取到锁。在悲观系统中,如果有锁冲突,比如其他事务持有了锁,就会造成延时等待。所以这里需要为正确性而牺牲性能。

第二种主要策略是乐观并发控制(Optimistic Concurrency Control)。

这里的基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。

实际,这两种策略哪个更好取决于不同的环境。如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗。今天我们只会介绍悲观并发控制。几周之后的论文,我们会讨论一种乐观并发控制的方法。

所以,今天讨论悲观并发控制,这里涉及到的基本上就是锁机制。这里的锁是两阶段锁(Two-Phase Locking),这是一种最常见的锁。

对于两阶段锁来说,当事务需要使用一些数据记录时,例如前面例子中的X,Y,第一个规则是在使用任何数据之前,在执行任何数据的读写之前,先获取锁。

第二个对于事务的规则是,事务必须持有任何已经获得的锁,直到事务提交或者Abort,你不允许在事务的中间过程释放锁。你必须要持有所有的锁,并不断的累积你持有的锁,直到你的事务完成了。所以,这里的规则是,持有锁直到事务结束。

所以,这就是两阶段锁的两个阶段,第一个阶段获取锁,第二个阶段是在事务结束前一直持有锁。

为什么两阶段锁能起作用呢?虽然有很多的变种,在一个典型的锁系统中,每一个数据库中的记录(每个Table中的每一行)都有一个独立的锁(虽然实际中粒度可能更大)。一个事务,例如前面例子中的T1,最开始的时候不持有任何锁,当它第一次使用X记录时,在它真正使用数据前,它需要获得对于X的锁,这里或许需要等待。当它第一次使用Y记录时,它需要获取另一个对于Y的锁,当它结束之后,它会释放这两个锁。如果我们同时运行之前例子中的两个事务,它们会同时竞争对于X的锁。任何一个事务先获取了X的锁,它会继续执行,最后结束并提交。同时,另一个没有获得X的锁,它会等待锁,在对X进行任何修改之前,它需要先获取锁。所以,如果T2先获取了锁,它会获取X,Y的数值,打印,结束事务,之后释放锁。只有在这时,事务T1才能获得对于X的锁。

如你所见的,这里基本上迫使事务串行执行,在刚刚的例子中,两阶段锁迫使执行顺序是T2,T1。所以这里显式的迫使事务的执行遵循可序列化的定义,因为实际上就是T2完成之后,再执行T1。所以我们可以获得正确的执行结果。

这里有一个问题是,为什么需要在事务结束前一直持有锁?你或许会认为,你可以只在使用数据的时候持有锁,这样也会更有效率。在刚刚的例子中,或许只在T2获取记录X的数值时持有对X的锁,或许只在T1执行对X加1操作的时候持有对于X的锁,之后立即释放锁,虽然这样违反了两阶段锁的规则,但是如果立刻释放对于数据的锁,另一个事务可以早一点执行,我们就可以有更多的并发度,进而获得更高的性能。所以,两阶段锁必然对于性能来说很糟糕,所以我们才需要确认,它对于正确性来说是必要的。

如果事务尽可能早的释放锁,会发生什么呢?假设T2读取了X,然后立刻释放了锁,那么在这个位置,T2不持有任何锁,因为它刚刚释放了对于X的锁。

因为T2不持有任何锁,这意味着T1可以完全在这个位置执行。从前面的反例我们已经知道,这样的执行是错误的(因为T2会打印“10,9”),因为它没能生成正确结果。

类似的,如果T1在执行完对X加1之后,就释放了对X的锁,这会使得整个T2有可能在这个位置执行。

我们之前也看到了,这会导致非法的结果。

如果在修改完数据之后就释放锁,还会有额外的问题。如果T1在执行完对X加1之后释放锁,它允许T2看到修改之后的X,之后T2会打印出这个结果。但是如果T1之后Abort了,或许因为银行账户Y并不存在,或许账户Y存在,但是余额为0,而我们不允许对于余额为0的账户再做减法,这样会造成透支。所以T1有可能会修改X,然后Abort。Abort的一部分工作就是要撤回对于X的修改,这样才能维持原子性。这意味着,如果T1释放了对于X的锁,事务T2会看到X的虚假数值11,这个数值最终不存在,因为T1中途Abort了,T2会看到一个永远不曾存在的数值。T2的结果最好是看起来就像是T2自己在运行,并没有T1的存在。但是这里,T2会看到X加1,然后打印出11,这与数据库的任何状态都对应不上。

所以,使用了两阶段锁可以避免这两种违反可序列化特性的场景。

对于这些规则,还有一些需要知道的事情。首先是,这里非常容易产生死锁。例如我们有两个事务,T1读取记录X,之后再读取记录Y,T2读取记录Y,之后再读取记录X。如果它们同时运行,这里就是个死锁。

每个事务都获取了第一个读取数据的锁,直到事务结束了,它们都不会释放这个锁。所以接下来,它们都会等待另一个事务持有的锁,除非数据库足够聪明,这里会永远死锁。实际上,事务有各种各样的策略,包括了判断循环,超时来判断它们是不是陷入到这样一个场景中。如果是的话,数据库会Abort其中一个事务,撤回它所有的操作,并表现的像这个事务从来没有发生一样。

所以这就是使用两阶段锁的并发控制。这是一个完全标准的数据库行为,在一个单主机的数据库中是这样,在一个分布式数据库也是这样,不过会更加的有趣。

12.3 两阶段提交(Two-Phase Commit)

我们下一个话题更具体一点:在一个分布式环境中,数据被分割在多台机器上,如何构建数据库或存储系统以支持事务。所以这个话题是,如何构建分布式事务(Distributed Transaction)。具体来说,如何应付错误,甚至是由多台机器中的一台引起的部分错误。这种部分错误在分布式系统中很常见。所以,在分布式事务之外,我们也要确保出现错误时,数据库仍然具有可序列化和某种程度的All-or-Nothing原子性。

一个场景是,我们或许有两个服务器,服务器S1保存了X的记录,服务器S2保存了Y的记录,它们的初始值都是10。

接下来我们要运行之前的两个事务。事务T1同时修改了X和Y,相应的我们需要向数据库发送消息说对X加1,对Y减1。但是如果我们不够小心,我们很容易就会陷入到这个场景中:我们告诉服务器S1去对X加1,

但是,之后出现了一些故障,或许持有Y记录的服务器S2故障了,使得我们没有办法完成更新的第二步。所以,这是一个问题:某个局部的故障会导致事务被分割成两半。如果我们不够小心,我们会导致一个事务中只有一半指令会生效。

甚至服务器没有崩溃都可能触发这里的场景。如果X完成了在事务中的工作,并且在服务器S2上,收到了对Y减1的请求,但是服务器S2发现Y记录并不存在。

或者存在,但是账户余额等于0。这时,不能对Y减1。

不管怎样,服务器2不能完成它在事务中应该做的那部分工作。但是服务器1又完成了它在事务中的那部分工作。所以这也是一种需要处理的问题。

这里我们想要的特性,我之前也提到过,就是,要么系统中的每一部分都完成它们在事务中的工作,要么系统中的所有部分都不完成它们在事务中的工作。在前面,我们违反的规则是,在故障时没有保证原子性。

原子性是指,事务的每一个部分都执行,或者任何一个部分都不执行。很多时候,我们看到的解决方案是原子提交协议(Atomic Commit Protocols)。通常来说,原子提交协议的风格是:假设你有一批计算机,每一台都执行一个大任务的不同部分,原子提交协议将会帮助计算机来决定,它是否能够执行它对应的工作,它是否执行了对应的工作,又或者,某些事情出错了,所有计算机都要同意,没有一个会执行自己的任务。

这里的挑战是,如何应对各种各样的故障,机器故障,消息缺失。同时,还要考虑性能。原子提交协议在今天的阅读内容中有介绍,其中一种是两阶段提交(Two-Phase Commit)。

两阶段提交不仅被分布式数据库所使用,同时也被各种看起来不像是传统数据库的分布式系统所使用。通常情况下,我们需要执行的任务会以某种方式分包在多个服务器上,每个服务器需要完成任务的不同部分。所以,在前一个例子中,实际上是数据被分割在不同的服务器上,所以相应的任务(为X加1,为Y减1)也被分包在不同的服务器上。我们将会假设,有一个计算机会用来管理事务,它被称为事务协调者(Transaction Coordinator)。事务协调者有很多种方法用来管理事务,我们这里就假设它是一个实际运行事务的计算机。在一个计算机上,事务协调者以某种形式运行事务的代码,例如Put/Get/Add,它向持有了不同数据的其他计算机发送消息,其他计算机再执行事务的不同部分。

所以,在我们的配置中,我们有一个计算机作为事务协调者(TC),然后还有服务器S1,S2,分别持有X,Y的记录。

事务协调者会向服务器S1发消息说,请对X加1,向服务器S2发消息说,请对Y减1。

之后会有更多消息来确认,要么两个服务器都执行了操作,要么两个服务器都没有执行操作。这就是两阶段提交的实现框架。

有些事情你需要记住,在一个完整的系统中,或许会有很多不同的并发运行事务,也会有许多个事务协调者在执行它们各自的事务。在这个架构里的各个组成部分,都需要知道消息对应的是哪个事务。它们都会记录状态。每个持有数据的服务器会维护一个锁的表单,用来记录锁被哪个事务所持有。所以对于事务,需要有事务ID(Transaction ID),简称为TID。

虽然不是很确定,这里假设系统中的每一个消息都被打上唯一的事务ID作为标记。这里的ID在事务开始的时候,由事务协调器来分配。这样事务协调器会发出消息说:这个消息是事务95的。同时事务协调器会在本地记录事务95的状态,对事务的参与者(例如服务器S1,S2)打上事务ID的标记。

这就是一些相关的术语,我们有事务协调者,我们还有其他的服务器执行部分的事务,这些服务器被称为参与者(Participants)。

接下来,让我画出两阶段提交协议的一个参考执行过程。我们将Two-Phase Commit简称为2PC。参与者有:事务协调者(TC),我们假设只有两个参与者(A,B),两个参与者就是持有数据的两个不同的服务器。

事务协调者运行了整个事务,它会向A,B发送Put和Get,告诉它们读取X,Y的数值,对X加1等等。所以,在事务的最开始,TC会向参与者A发送Get请求并得到回复,之后再向参与者B发送一个Put请求并得到回复。

这里只是举个例子,如果有一个复杂的事务,可能会有一个更长的请求序列。

之后,当事务协调者到达了事务的结束并想要提交事务,这样才能:

  • 释放所有的锁,
  • 并使得事务的结果对于外部是可见的,
  • 再向客户端回复。

我们假设有一个外部的客户端C,它在最最开始的时候会向TC发请求说,请运行这个事务。并且之后这个客户端会等待回复。

在开始执行事务时,TC需要确保,所有的事务参与者能够完成它们在事务中的那部分工作。更具体的,如果在事务中有任何Put请求,我们需要确保,执行Put的参与者仍然能执行Put。TC为了确保这一点,会向所有的参与者发送Prepare消息。

当A或者B收到了Prepare消息,它们就知道事务要执行但是还没执行的内容,它们会查看自身的状态并决定它们实际上能不能完成事务。或许它们需要Abort这个事务因为这个事务会引起死锁,或许它们在故障重启过程中并完全忘记了这个事务因此不能完成事务。所以,A和B会检查自己的状态并说,我有能力或者我没能力完成这个事务,它们会向TC回复Yes或者No。

事务协调者会等待来自于每一个参与者的这些Yes/No投票。如果所有的参与者都回复Yes,那么事务可以提交,不会发生错误。之后事务协调者会发出一个Commit消息,给每一个事务的参与者,

之后,事务参与者通常会回复ACK说,我们知道了要commit。

当事务协调者发出Prepare消息时,如果所有的参与者都回复Yes,那么事务可以commit。如果任何一个参与者回复了No,表明自己不能完成这个事务,或许是因为错误,或许有不一致性,或许丢失了记录,那么事务协调者不会发送commit消息,

它会发送一轮Abort消息给所有的参与者说,请撤回这个事务。

在事务Commit之后,会发生两件事情。首先,事务协调者会向客户端发送代表了事务输出的内容,表明事务结束了,事务没有被Abort并且被持久化保存起来了。另一个有意思的事情是,为了遵守前面的锁规则(两阶段锁),事务参与者会释放锁(这里不论Commit还是Abort都会释放锁)。

实际上,为了遵循两阶段锁规则,每个事务参与者在参与事务时,会对任何涉及到的数据加锁。所以我们可以想象,在每个参与者中都会有个表单,表单会记录数据当前是为哪个事务加的锁。当收到Commit或者Abort消息时,事务参与者会对数据解锁,之后其他的事务才可以使用相应的数据。这里的解锁操作会解除对于其他事务的阻塞。这实际上是可序列化机制的一部分。

目前来说,还没有问题,因为架构中的每一个成员都遵循了协议,没有错误,两个参与者只会一起Commit,如果其中一个需要Abort,那么它们两个都会Abort。所以,基于刚刚描述的协议,如果没有错误的话,我们得到了这种All-or-Noting的原子特性。

12.4 故障恢复(Crash Recovery)

现在,我们需要在脑中设想各种可能发生的错误,并确认这里的两阶段提交协议是否仍然可以提供All-or-Noting的原子特性。如果不能的话,我们该如何调整或者扩展协议?

第一个我想考虑的错误是故障重启。我的意思是类似于断电,服务器会突然中断执行,当电力恢复之后,作为事务处理系统的一部分,服务器会运行一些恢复软件。这里实际上有两个场景需要考虑。

第一个场景是,参与者B可能在回复事务协调者的Prepare消息之前的崩溃了,

所以,B在回复Yes之前就崩溃了。从TC的角度来看,B没有回复Yes,TC也就不能Commit,因为它需要等待所有的参与者回复Yes。

如果B发现自己不可能发送Yes,比如说在发送Yes之前自己就故障了,那么B被授权可以单方面的Abort事务。因为B知道自己没有发送Yes,那么它也知道事务协调者不可能Commit事务。这里有很多种方法可以实现,其中一种方法是,因为B故障重启了,内存中的数据都会清除,所以B中所有有关事务的信息都不能活过故障,所以,故障之后B不知道任何有关事务的信息,也不知道给谁回复过Yes。之后,如果事务协调者发送了一个Prepare消息过来,因为B不知道事务,B会回复No,并要求Abort事务。

当然,B也可能在回复了Yes给事务协调者的Prepare消息之后崩溃的。B可能开心的回复给事务协调者说好的,我将会commit。但是在B收到来自事务协调者的commit消息之前崩溃了。

现在我们有了一个完全不同的场景。现在B承诺可以commit,因为它回复了Yes。接下来极有可能发生的事情是,事务协调者从所有的参与者获得了Yes的回复,并将Commit消息发送给了A,所以A实际上会执行事务分包给它的那一部分,持久化存储结果,并释放锁。这样的话,为了确保All-or-Nothing原子性,我们需要确保B在故障恢复之后,仍然能完成事务分包给它的那一部分。在B故障的时候,不知道事务是否能Commit,因为它还没有收到Commit消息。但是B还是需要做好Commit的准备。这意味着,在故障重启的时候,B不能丢失对于事务的状态记录。

在B回复Prepare之前,它必须确保记住当前事务的中间状态,记住所有要做的修改,记住事务持有的所有的锁,这些信息必须在磁盘上持久化存储。通常来说,这些信息以Log的形式在磁盘上存储。所以在B回复Yes给Prepare消息之前,它首先要将相应的Log写入磁盘,并在Log中记录所有有关提交事务必须的信息。这包括了所有由Put创建的新的数值,和锁的完整列表。之后,B才会回复Yes。

之后,如果B在发送完Yes之后崩溃了,当它重启恢复时,通过查看自己的Log,它可以发现自己正在一个事务的中间,并且对一个事务的Prepare消息回复了Yes。Log里有Commit需要做的所有的修改,和事务持有的所有的锁。之后,当B最终收到了Commit而不是Abort,通过读取Log,B就知道如何完成它在事务中的那部分工作。

所以,这里是我之前在介绍协议的时候遗漏的一点。B在这个时间点(回复Yes给TC的Prepare消息之前),必须将Log写入到自己的磁盘中。这里会使得两阶段提交稍微有点慢,因为这里要持久化存储数据。

最后一个可能崩溃的地方是,B可能在收到Commit之后崩溃了。

B有可能在处理完Commit之后就崩溃了。但是这样的话,B就完成了修改,并将数据持久化存储在磁盘上了。这样的话,故障重启就不需要做任何事情,因为事务已经完成了。

因为没有收到ACK,事务协调者会再次发送Commit消息。当B重启之后,收到了Commit消息时,它可能已经将Log中的修改写入到自己的持久化存储中、释放了锁、并删除了有关事务的Log。所以我们需要关心,如果B收到了同一个Commit消息两次,该怎么办?这里B可以记住事务的信息,但是这会消耗内存,所以实际上B会完全忘记已经在磁盘上持久化存储的事务的信息。对于一个它不知道事务的Commit消息,B会简单的ACK这条消息。这一点在后面的一些介绍中非常重要。

上面是事务的参与者在各种奇怪的时间点崩溃的场景。那对于事务协调者呢?它只是一个计算机,如果它出现故障,也会是个问题。

同样的,这里的关键点在于,如果事务的任何一个参与者可能已经提交了,或者事务协调者可能已经回复给客户端了,那么我们不能忽略事务。比如,如果事务协调者已经向A发送了Commit消息,但是还没来得及向B发送Commit消息就崩溃了,那么事务协调者必须在重启的时候准备好向B重发Commit消息,以确保两个参与者都知道事务已经提交了。所以,事务协调者在哪个时间点崩溃了非常重要。

如果事务协调者在发送Commit消息之前就崩溃了,那就无所谓了,因为没有一个参与者会Commit事务。也就是说,如果事务协调者在崩溃前没有发送Commit消息,它可以直接Abort事务。因为参与者可以在自己的Log中看到事务,但是又从来没有收到Commit消息,事务的参与者会向事务协调者查询事务,事务协调者会发现自己不认识这个事务,它必然是之前崩溃的时候Abort的事务。所以这就是事务协调者在Commit之前就崩溃了的场景。

如果事务协调者在发送完一个或者多个Commit消息之后崩溃,

那么就不允许它忘记相关的事务。这意味着,在崩溃的时间点,也就是事务协调者决定要Commit而不是Abort事务,并且在发送任何Commit消息之前,它必须先将事务的信息写入到自己的Log,并存放在例如磁盘的持久化存储中,这样计算故障重启了,信息还会存在。

所以,事务协调者在收到所有对于Prepare消息的Yes/No投票后,会将结果和事务ID写入存在磁盘中的Log,之后才会开始发送Commit消息。之后,可能在发送完第一个Commit消息就崩溃了,也可能发送了所有的Commit消息才崩溃,不管在哪,当事务协调者故障重启时,恢复软件查看Log可以发现哪些事务执行了一半,哪些事务已经Commit了,哪些事务已经Abort了。作为恢复流程的一部分,对于执行了一半的事务,事务协调者会向所有的参与者重发Commit消息或者Abort消息,以防在崩溃前没有向参与者发送这些消息。这就是为什么参与者需要准备好接收重复的Commit消息的一个原因。

这些就是主要的服务器崩溃场景。我们还需要担心如果消息在网络传输的时候丢失了怎么办?或许你发送了一个消息,但是消息永远也没有送达。或许你发送了一个消息,并且在等待回复,或许回复发出来了,但是之后被丢包了。这里的任何一个消息都有可能丢包,我们必须想清楚在这样的场景下该怎么办?

举个例子,事务协调者发送了Prepare消息,但是并没有收到所有的Yes/No消息,事务协调者这时该怎么做呢?

其中一个选择是,事务协调者重新发送一轮Prepare消息,表明自己没有收到全部的Yes/No回复。事务协调者可以持续不断的重发Prepare消息。但是如果其中一个参与者要关机很长时间,我们将会在持有锁的状态下一直等待。假设A不响应了,但是B还在运行,因为我们还没有Commit或者Abort,B仍然为事务持有了锁,这会导致其他的事务等待。所以,如果可以避免的话,我们不想永远等待。

在事务协调者没有收到Yes/No回复一段时间之后,它可以单方面的Abort事务。因为它知道它没有得到完整的Yes/No消息,当然它也不可能发送Commit消息,所以没有一个参与者会Commit事务,所以总是可以Abort事务。事务的协调者在等待完整的Yes/No消息时,如果因为消息丢包或者某个参与者崩溃了,而超时了,它可以直接决定Abort这个事务,并发送一轮Abort消息。

之后,如果一个崩溃了的参与者重启了,向事务协调者发消息说,我并没有收到来自你的有关事务95的消息,事务协调者会发现自己并不知道到事务95的存在,因为它在之前就Abort了这个事务并删除了有关这个事务的记录。这时,事务协调者会告诉参与者说,你也应该Abort这个事务。

类似的,如果参与者等待Prepare消息超时了,那意味着它必然还没有回复Yes消息,进而意味着事务协调者必然还没有发送Commit消息。所以如果一个参与者在这个位置因为等待Prepare消息而超时,

那么它也可以决定Abort事务。在之后的时间里,如果事务协调者上线了,再次发送Prepare消息,B会说我不知道有关事务的任何事情并回复No。这也没问题,因为这个事务在这个时间也不可能在任何地方Commit了。所以,如果网络某个地方出现了问题,或者事务协调器挂了一会,事务参与者仍然在等待Prepare消息,总是可以允许事务参与者Abort事务,并释放锁,这样其他事务才可以继续。这在一个负载高的系统中可能会非常重要。

但是,假设B收到了Prepare消息,并回复了Yes。大概在下图的位置中,

这个时候参与者没有收到Commit消息,它接下来怎么也等不到Commit消息。或许网络出现问题了,或许事务协调器的网络连接中断了,或者事务协调器断电了,不管什么原因,B等了很长时间都没有收到Commit消息。这段时间里,B一直持有事务涉及到数据的锁,这意味着,其他事务可能也在等待这些锁的释放。所以,这里我们应该尽早的Abort事务,并释放锁。所以这里的问题是,如果B收到了Prepare消息,并回复了Yes,在等待了10秒钟或者10分钟之后还没有收到Commit消息,它能单方面的决定Abort事务吗?

很不幸的是,这里的答案不行。

在回复Yes给Prepare消息之后,并在收到Commit消息之前这个时间区间内,参与者会等待Commit消息。如果等待Commit消息超时了,参与者不允许Abort事务,它必须无限的等待Commit消息,这里通常称为Block。

这里的原因是,因为B对Prepare消息回复了Yes,这意味着事务协调者可能收到了来自于所有参与者的Yes,并且可能已经向部分参与者发送Commit消息。这意味着A可能已经看到了Commit消息,Commit事务,持久化存储事务的结果并释放锁。所以在上面的区间里,B不能单方面的决定Abort事务,它必须无限等待事务协调者的Commit消息。如果事务协调者故障了,最终会有人来修复它,它在恢复过程中会读取Log,并重发Commit消息。

就像不能单方面的决定Abort事务一样,这里B也不能单方面的决定Commit事务。因为A可能对Prepare消息回复了No,但是B没有收到相应的Abort消息。所以,在上面的区间中,B既不能Commit,也不能Abort事务。

这里的Block行为是两阶段提交里非常重要的一个特性,并且它不是一个好的属性。因为它意味着,在特定的故障中,你会很容易的陷入到一个需要等待很长时间的场景中,在等待过程中,你会一直持有锁,并阻塞其他的事务。所以,人们总是尝试在两阶段提交中,将这个区间尽可能快的完成,这样可能造成Block的时间窗口也会尽可能的小。所以人们尽量会确保协议中这部分尽可能轻量化,甚至对于一些变种的协议,对于一些特定的场景都不用等待。

这就是基本的协议。为什么这里的两阶段提交协议能构建一个A和B要么全Commit,要么全Abort的系统?其中一个原因是,决策是在一个单一的实例,也就是事务协调者完成的。A或者B不能决定Commit还是不Commit事务,A和B之间不会交互来达成一致并完成事务的Commit,相反的只有事务协调者可以做决定。事务协调者是一个单一的实例,它会通知其他的部分这是我的决定,请执行它。但是,使用一个单一实例的事务协调者的缺点是,在某个时间点你需要Block并等待事务协调者告诉你决策是什么。

一个进一步的问题是,我们知道事务协调者必然在它的Log中记住了事务的信息,那么它在什么时候可以删除Log中有关事务的信息?这里的答案是,如果事务协调者成功的得到了所有参与者的ACK,

那么它就知道所有的参与者知道了事务已经Commit或者Abort,所有参与者必然也完成了它们在事务中相应的工作,并且永远也不会需要知道事务相关的信息。所以当事务协调者得到了所有的ACK,它可以擦除所有有关事务的记忆。

类似的,当一个参与者收到了Commit或者Abort消息,完成了它们在事务中的相应工作,持久化存储事务结果并释放锁,那么在它发送完ACK之后,参与者也可以完全忘记相关的事务。

当然事务协调者或许不能收到ACK,这时它会假设丢包了并重发Commit消息。这时,如果一个参与者收到了一个Commit消息,但是它并不知道对应的事务,因为它在之前回复ACK之后就忘记了这个事务,那么参与者会再次回复一个ACK。因为如果参与者收到了一个自己不知道的事务的Commit消息,那么必然是因为它之前已经完成对这个事务的Commit或者Abort,然后选择忘记这个事务了。

12.5 总结

这就是两阶段提交,它实现了原子提交。两阶段提交在大量的将数据分割在多个服务器上的分片数据库或者存储系统中都有使用。两阶段提交可以支持读写多条记录,一些更特殊的存储系统不允许你在多条记录上支持事务。对于这些不支持事务中包含多条数据的系统,你就不需要两阶段提交。但是如果你需要在事务中支持多条数据,并且你将数据分片在多台服务器之上,那么你必须支持两阶段提交。

然而,两阶段提交有着极差的名声。其中一个原因是,因为有多轮消息的存在,它非常的慢。在上面的图中,各个组成部分之间着大量的交互。另一个原因是,这里有大量的写磁盘操作,比如说B在回复Yes给Prepare消息之后不仅要向磁盘写入数据,还需要等待磁盘写入结束,如果你使用一个机械硬盘,这会花费10毫秒来完成Log数据的写入,这决定了事务的参与者能够以多快的速度处理事务。10毫秒完成Log写磁盘,那么最快就是每秒处理100个事务,这是一个非常慢的结果。同时,事务协调者也需要写磁盘,在收到所有Prepare消息的Yes回复之后,它也需要将Log写入磁盘,并等待磁盘写入结束。之后它才能发送Commit消息,这里又有了10毫秒。在这两个10毫秒内,锁都被参与者持有者,其他使用相关数据的事务都会被阻塞。

这里我持续的在介绍性能,但是它的确非常重要,因为在一个繁忙的事务处理系统中,存在大量的事务,许多事务都会等待相同的数据,我们希望不要在一个长时间内持有锁。但是两阶段提交迫使我们在各个阶段都做等待。

进一步的问题是,如果任何地方出错了,消息丢了,某台机器崩溃了,如果你不够幸运进入到Block区间,参与者需要在持有锁的状态下等待一段长时间。

因此,你只会在一个小的环境中看到两阶段提交,比如说在一个组织的一个机房里面。你不会在不同的银行之间转账看到它,你或许可以在银行内部的系统中看见两阶段提交,但是你永远也不会在物理分隔的不同组织之间看见两阶段提交,因为它可能会陷入到Block区间中。你不会想将你的数据库的命运寄托在其他的数据库不在错误的时间崩溃,从而使得你的数据库被迫在很长一段时间持有锁。

因为两阶段提交很慢,有很多很多的研究都是关于如何让它变得更快,比如以各种方式放松这里的规则进而使得它变得更快,又比如对于一些特定的场景做一些定制化从而避免一些消息,我们在这门课中会看到很多这种定制。

两阶段提交的架构中,本质上是有一个Leader(事务协调者),将消息发送给Follower(事务参与者),Leader只能在收到了足够多Follower的回复之后才能继续执行。这与Raft非常像,但是,这里协议的属性与Raft又非常的不一样。这两个协议解决的是完全不同的问题。

使用Raft可以通过将数据复制到多个参与者得到高可用。Raft的意义在于,即使部分参与的服务器故障了或者不可达,系统仍然能工作。Raft能做到这一点是因为所有的服务器都在做相同的事情,所以我们不需要所有的服务器都参与,我们只需要过半服务器参与。然而两阶段提交,参与者完全没有在做相同的事情,每个参与者都在做事务中的不同部分,比如A可能在对X加1,B可能在对Y减1。所以在两阶段提交中,所有的参与者都在做不同的事情。所有的参与者都必须完成自己那部分工作,这样事务才能结束,所以这里需要等待所有的参与者。

所以,Raft通过复制可以不用每一个参与者都在线,而两阶段提交每个参与者都做了不同的工作,并且每个参与者的工作都必须完成,所以两阶段提交对于可用性没有任何帮助。Raft完全就是可用性,而两阶段提交完全不是高可用的,系统中的任何一个部分出错了,系统都有可能等待直到这个部分修复。比如事务协调者在错误的时间崩溃了,我们需要等待它上线并读取它的Log再重发Commit消息。如果一个参与者在错误的时间崩溃了,如果我们足够幸运,我们只需要Abort事务。所以实际上,两阶段提交的可用性非常低,因为任何一个部分崩溃都有可能阻止整个系统的运行。Raft并不需要确保所有的参与者执行操作,它只需要过半服务器执行操作,或许少数的服务器完全没有执行操作也没关系。这里的原因是Raft系统中,所有的参与者都在做相同的事情,我们不必等待所有的参与者。这就是为什么Raft有更高的可用性。所以这是两个完全不同的协议。

然而,是有可能结合这两种协议的。两阶段提交对于故障来说是非常脆弱的,在故障时它可以有正确的结果,但是不具备可用性。所以,这里的问题是,是否可以构建一个合并的系统,同时具备Raft的高可用性,但同时又有两阶段提交的能力将事务分包给不同的参与者。这里的结构实际上是,通过Raft或者Paxos或者其他协议,来复制两阶段提交协议里的每一个组成部分。

所以,在前面的例子中,我们会有三个不同的集群,事务协调器会是一个复制的服务,包含了三个服务器,我们在这3个服务器上运行Raft,

其中一个服务器会被选为Leader,它们会有复制的状态,它们有Log来帮助它们复制,我们只需要等待过半服务器响应就可以执行事务协调器的指令。事务协调器还是会执行两阶段提交里面的各个步骤,并将这些步骤记录在自己的Raft集群的Log中。

每个事务参与者也同样是一个Raft集群。

最终,消息会在这些集群之间传递。

不得不承认,这里很复杂,但是它展示了你可以结合两种思想来同时获得高可用和原子提交。在Lab4,我们会构建一个类似的系统,实际上就是个分片的数据库,每个分片以这种形式进行复制,同时还有一个配置管理器,来允许将分片的数据从一个Raft集群移到另一个Raft集群。除此之外,我们还会读一篇论文叫做Spanner,它描述了Google使用的一种数据库,Spanner也使用了这里的结构来实现事务写。