[译]谷歌文件系统(The Google File System)(1~5章)

2,302 阅读57分钟

论文后半部分:[译]谷歌文件系统(The Google File System)(6~9章)(完)

摘要

我们设计并实现了一个面向大规模数据密集型应用的可扩展的分布式文件系统,即谷歌文件系统。当运行在廉价硬件上时,它能提供一种容错机制,在大量客户端连接时提供了高内聚的性能

尽管与之前的分布式文件系统有着相同的目标,但我们的设计是考量于我们应用的当前和未来的工作负载以及技术环境,这反映了它与一些早期的文件系统预设有着明显的不同,也让我们重新审视传统的设计选择,并探索有根本性差别的设计观点

这个文件系统已经成功满足我们的存储需要,已经作为存储平台在谷歌内部广泛部署,用于生成并处理我们用于需要大量数据集的搜索和研发服务的数据。迄今为止最大的集群,在超过一千台机器上的的数千个磁盘上提供了数百TB的存储服务,同时能够响应数百台客户端的并发访问

在本篇论文中,我们将展示用于支持分布式应用的文件系统的接口扩展,讨论我们的许多设计切面,以及微观标准下和现实世界中的一些测量报告

分类和主题描述

D [4]: 3— 分布式文件系统

通用词汇

设计,可靠性,性能,测量

关键词

容错,可扩展性,数据存储,集群存储

1. 介绍

我们设计并实现了谷歌文件系统(GFS)来满足快速增长的谷歌数据处理需求的需要。GFS和之前的分布式文件系统有着许多相同的目标,比如性能,可扩展性,可靠性,以及可用性。然而,谷歌文件系统的设计是受到当前乃至未来我们应用的工作负载和技术环境的观察而推动的,反映了与早期的一些文件系统的设计理念有着根本性的不同。我们重新审视了传统的设计选择,并在设计空间中探索出了一些根本不同的观点

第一,组件失效是一种常态而不是异常。文件系统由数百乃至数千个通过廉价商品零件组装起来的存储服务器组成,能够被相当数量的客户端访问。这些组件的数量和质量在本质上决定了一部分机器在任何给定的时间均无法使用,一部分机器将不会从故障中恢复。我们观察到了很多故障,包括由于应用程序的bug,操作系统的bug,人为的错误,磁盘、内存、连接器、网络,乃至电力系统的故障。因此,常态化的监控,故障发现,容错机制,以及自动恢复机制必须集成到系统中

第二,以传统的标准来看,文件是庞大的,数GB的文件是很常见的。每一个文件一般都由许多类似网页文档等应用程序对象组成,当我们经常处理快速增长的包含数十亿个对象的数TB大小的数据集时,即使文件系统可以支持,管理数十亿个KB左右大小的文件也是不可取的。所以,不得不重新考量设计中的假设和参数,比如I/O操作和块大小的设计

第三,大多数文件是通过追加新数据发生内容改变的,而不是覆写已存在的数据,文件内容的随机写几乎是不存在的。一旦写入,文件就是只读的,而且都是顺序读取的。大量的数据都有这种特点,有些可能是构成数据分析程序扫描的大型存储库,有些可能是运行中的应用持续生成的数据流,有些可能是档案数据,有些可能是在一台机器上生成的用于另一台机器处理的中间结果,不管是同时还是稍后。给定大文件的访问模式, 当缓存数据块在客户端丢失引用时,追加操作就成了性能优化和保证原子性的关键

第四,共同设计应用程序和文件系统的服务接口可以提高灵活性,这对于整个系统是有利的。比如,我们放宽了GFS的一致性模型从而大大简化了文件系统,而没有给应用程序带来繁重的负担。我们还引入了一个原子性的追加操作,使得多个客户端可以同时对文件执行追加操作而不需要在它们之间执行额外的同步操作。这些都将会在接下来进行更详细的讨论。

目前我们部署了多个出于不同目的的GFS集群,其中最大的存储节点拥有超过1000个存储节点,以及超过300TB的磁盘存储,并且被不同机器上的数百个客户端频繁大量地访问

2. 设计概述

2.1 假设

在设计一个满足我们自己需要的文件系统时,我们以充满着机遇与挑战的假设为导向。之前曾提到了一些关键的观察结果,现在我们把我们的假设在这里更详细地罗列出来

  • 系统是由许多经常会故障的廉价商品组件组成,所以必须要经常对它进行监控,以及在例行基础上进行检测、容错,以及故障组件的恢复
  • 系统存储了一定量的大文件。我们预计有数百万个文件,每个一般在100MB或更大。数GB的文件是很常见的情况,应该被有效的管理。小文件必须得到支持,但我们并不需要为它们进行优化
  • 工作负载主要包括两种读取情况:大型流式读取和小型随机读取。在大型流式读取中,单个操作一般会读取数百KB的内容,更常见的是1MB或更多。同一个客户端连续的操作通常会读取文件中的相邻区域。小型随机读取则一般会在随机偏移位置读取几KB的内容。注重性能的应用程序通常会对小量读取进行批处理和排序,以便稳步地向前读取文件而不是来回移动
  • 工作负载还有很多追加数据到文件的大型的顺序写入。一般操作的大小和读取的大小是相似的。一旦写入结束,文件就几乎不再进行改变。文件随机位置的少量写入是被支持的,但是效率不一定高
  • 系统必须为同时对同一文件执行追加操作的客户端有效地实现定义良好的语义。我们的文件通常用作生产者消费者队列或是多方合并。每台机器运行数百个生产者同时为文件执行追加操作,具有最小同步开销的原子操作是必不可少的。文件可以被稍后阅读,而消费者可以同时阅读文件
  • 持续的高带宽比低延迟更重要。我们大多数的应用程序都非常重视以高速率批量处理数据,而几乎不会对单次读写有着严格的响应时间要求

2.2 接口

GFS提供了我们熟悉的文件系统的接口,虽然没有实现例如POSIX的标准API。文件在目录中按层次结构组织,并通过路径名标识。我们也支持诸如创建、删除、打开、关闭、读取,以及写入文件等常规操作

除此之外,GFS还提供了快照和记录追加操作。快照可以以较低的成本创建文件或目录树的一个副本,记录追加操作允许多个客户端同时追加数据到同一个文件中,同时保证每个客户端追加操作的原子性。许多客户端在不需要额外加锁的条件下同时进行追加操作,这一点对于实现多路合并和生产者消费者队列是很有用的,我们还发现这些文件的类型在构建大规模分布式应用中是很有价值的。快照和记录追加将会分别在3.4和3.3节中进一步讨论

2.3 架构

一个GFS集群由单个master和数个*chunkserver1*组成,可以被多个客户端访问,如图1所示。每个客户端通常都是由一台商用Linux服务器来运行用户级的服务器进程。只要机器的资源允许,并且能够接受由于运行可能的碎片应用代码导致的较低的可靠性,那么在同一台机器上运行块服务器和客户端是很容易的

文件分为固定大小的块,每个块都是由块创建时被master分配的一个全局且不可变的唯一64位块句柄标识,块服务器将块Linux文件存储在本地磁盘上,块数据的读写由块句柄和字节范围来指定。出于可靠性的考虑,每个块都在多个块服务器上进行复制。默认情况下,我们存储三个副本,但是用户可以为文件命名空间的不同区域指定不同的拷贝级别

master维护文件系统中包括命名空间,访问控制信息,文件到块的映射关系,以及块的当前位置在内的所有的元数据,同样也控制着系统范围内的活动,比如块的租约管理,孤立块的垃圾收集,还有块服务器之前的块迁移。master通过心跳消息和每个chunserver进行周期性的通信,以发送指令并收集它们的状态

连接到每个应用程序的GFS客户端代码实现了文件系统API,并代替应用程序与master和块服务器进行通信,从而完成数据的读取和写入。客户端通过与master交互可以进行元数据的操作,但是所有承载数据的通信都必须直接进入块服务器,我们不提供POSIX API,因此也不需要挂载到Linux的vnode层

客户端和块服务器都不缓存文件数据。客户端的缓存没有意义,因为大多数的应用程序使用大型文件流工作,或是工作集过大导致难以缓存。没有这些地方的缓存就消除了缓存一致性,从而简化了客户端和整个系统(但是客户端会缓存元数据)。块服务器不需要缓存是因为块作为本地文件存储,Linux的缓存已经将频繁访问的数据保存在内存中了

图1

2.4 单个Master

只拥有一个master可以极大简化我们的设计,同时保证master能够应用全局信息来针对块的放置和备份作出复杂的决策。然而,我们必须将其在读写操作中的参与度降到最小,以避免成为瓶颈。客户端永远不会通过master进行文件的读写,相反的,客户端只是询问master它应该与哪个块服务器建立通信。客户端会在一定时间内缓存此信息,并与块服务器直接进行交互来执行许多后续操作

我们参考图1来解释一个简单的读取教交互过程。首先,使用固定的块大小,客户端将应用程序中指定的文件名和字节偏移量转化为块索引。然后,客户端向master发送包含有文件名和块索引的请求,master会回复对应的块句柄和副本的位置。客户端会使用文件名和块索引作为键值来缓存这些信息

接下来客户端会向其中一个副本发送请求,通常是最近的那一个。请求中指定了块句柄和块中的字节范围。在缓存过期或是文件重新打开之前,对同一个块的后续读取操作不需要与master再进行交互。实际上,客户端通常会在一次请求中请求多个块,master也可以将这些请求的块信息包裹在一起返回。这些额外的信息几乎不需要什么开销就避免接下来的一些客户端和master的交互

2.5 块大小

块大小是一个关键的设计参数。我们选择了64MB作为块大小,这比一般的文件系统的块大小要大得多。每个块副本都作为一个普通的Linux文件在块服务器上存储,并在需要时进行扩展。惰性空间分配避免了由于内部碎片导致的空间浪费,可能出现的最大碎片要比我们设定的这么大的块大小还要大

大的块有一些重要的优点。首先,减少了客户端与master的交互需要,因为同一个块上的读写只需要给master发送一个初始化请求来获得块定位信息。这些工作量的减少对我们的工作负载尤其重要,因为应用程序大多数情况下都是按序读写大文件,即使对于少量随机读写,客户端也可以方便地缓存一个数TB工作集的所有的块定位信息。其次,因为块很大,客户端更可能会在给定的块上执行大量操作,因此可以通过在较长时间内维持与块服务器的TCP长连接来减少网络开销。最后,减少了master需要存储的元数据的大小,所以允许我们将元数据保存在内存中,这又给我们带来了将在2.6.1节中讨论的其他优点

另一方面,即使采用了惰性空间分配,大的块也有其缺点。一个小文件由一些或一个小块组成,如果许多客户端都来访问相同的文件,存储这些块的块服务器可能会成为热点。在实际情况中,热点并不是主要的问题,因为我们的应用程序主要是按序读取多个大块

然而,当批处理队列系统首次使用GFS时,确实产生了热点:一个可执行文件作为单独的块文件写入GFS,紧接着在数百台机器上同时开始执行。存储此文件的少部分块服务器因为数百个并发请求而导致过载。我们的解决办法是使用更高的备份级别来存储这样的可执行文件,以及使用批处理队列来错开应用程序的启动时间。一个潜在的长远的解决方案是允许客户端在这样的情况下从其他的客户端读取数据

2.6 元数据

master存储了3种主要类型的元数据:文件和块的命名空间,文件到块的映射,以及每个块副本的位置。所有元数据都保存在master的内存中。前两种类型(命名空间和文件到块的映射)通过将更新操作记录到存储在master本地磁盘的操作日志上来保证持久化,这份日志也会在远程服务器上进行备份。使用日志可以让我们简便可靠地更新master的状态,同时也避免了由于master故障导致不一致的风险。master并不会永久存储块的位置信息,相反地,会在master启动或是一个块服务器加入集群时,来询问每一台块服务器的块信息

2.6.1 内存数据结构

由于元数据存储在内存中,所有master的操作很快。此外,master可以简单高效地在后台对其整个状态进行定期扫描。这个定期扫描被用来实现块的垃圾收集,当出现故障时进行重备份,以及在块服务器之间进行块迁移时平衡负载和磁盘空间。我们将在4.3和4.4节进一步讨论这些行为

这种局限于内存的方式有一个潜在的限制,就是块的数量,因此整个系统的容量受到master的内存大小限制,不过在实际场景中并不是很严重的限制。master为每个64MB的块维护了至少64字节的元数据。大多数块都是满的,因为大部分文件都包含了许多块。类似地,对于每个文件而言,因为使用了前缀压缩算法来压缩存储,文件命名空间数据一般需要的字节数要少于64

如果需要支持更大的文件系统,与我们将元数据存储在内存中获得的简便性、可靠性、高性能,以及灵活性相比,为master增加额外的内存的开销几乎算不了什么

2.6.2 块的位置

master不会保留哪些块服务器拥有给定块的副本这样的持久化信息,而只会在启动时对块服务器进行轮询来获取这些信息。master可以让自己保持最新状态,因为它控制着所有的块的放置,并通过定期的心跳消息来监控块服务器的状态

我们最初尝试将块的位置信息持久化保存在master上,但我们认为在启动时以及之后定期的从块服务器请求这些数据要简单得多。这种方式避免了块服务器加入和退出集群,更改名称,失效和重启等等情况下需要保持master和块服务器的同步的问题。当集群中有数百台服务器时,这些情况会频繁地发生

要想理解这样的设计决策,我们要知道,只有块服务器它本身才能够确定一个块是否存于它的磁盘中,在master中维护这个一致性视图信息是没有意义的,因为块服务器上的错误可能导致文件块自行消失(例如,磁盘损坏导致不可用),或是管理员可能对块服务器进行重命名

2.6.3 操作日志

操作日志包含了关键元数据更改的历史记录,它是GFS的核心。操作日志不仅是元数据的唯一持久记录,而且还充当了定义并发操作顺序的逻辑时间线。文件和块,以及它们的版本(参见4.5节),都由它们创建的逻辑时间进行唯一永久的标识

由于操作日志至关重要,所以我们必须要进行可靠的存储,并且在元数据的变更被持久化之前应当对客户端不可见。否则,即使块处于存活状态,我们也会在根本上丢失整个文件系统或是最近的客户端操作信息。因此,我们对操作日志在多台机器上进行备份,并且只有当本地和远程均刷新了磁盘上对应的日志记录后,再响应客户端的操作。master在刷新之前对多个日志记录执行批处理,从而减少了刷新和备份对系统整体的影响

master通过重新执行操作日志来恢复文件系统的状态。为了最大限度缩短启动时间,我们必须让日志尽量小。只要日志增长超过了一定大小,master就会给当前状态设置检查点,以便可以在这之后通过从本地磁盘加载最近的检查点,并重放有限数量的日志来实现系统的恢复。检查点采用压缩的类似B树的结构,不需要额外的解析就可以直接映射到内存中,并使用命名空间来进行查找,这进一步的提高了恢复的速度和系统的可用性

因为构建检查点需要一段时间,所以master的内部状态被构建为可以使得新的检查点在无需对到来的改变进行延时就能够创建的形式。master会使用另一个线程来切换新的日志文件,并创建新的检查点,新检查点包括切换前的所有变更。对于有着数百万文件的集群,可以在一分钟左右被创建。当创建完成后,会写入本地和远程的磁盘。

只需要最新的检查点和其后的日志文件就能够恢复系统状态。更早的检查点和日志文件可以自由删除,但我们也保存了一部分来防止意外情况发生。在检查点生成期间的错误不会影响系统的正确性,因为恢复代码会检测并跳过不完整的检查点。

2.7 一致性模型

GFS具有宽松的一致性模型,可以很好地支持我们的高度分布式应用程序,而且实现起来仍是相对简单和高效的。我们现在讨论GFS提供的保证和其对于应用程序的意义,我们还会重点介绍GFS是如何实现这些保证,具体的细节会在论文的其他部分呈现

2.7.1 GFS提供的保证

文件命名空间的更改(例如文件创建)是原子性的,仅由master负责处理:命名空间锁保证了原子性和正确性(4.1节);master的操作日志定义了所有这些操作的全局顺序

数据变更后,文件区域的状态取决于变更的类型,即变更是否成功,以及是否存在并发更新。表1是对结果的一个概述。如果所有的客户端无论从哪个副本读取,都能看到同样的数据,那我们就说文件区域是一致的。如果区域是一致的,那么我们称区域块在文件数据更新后是已定义的,所有的客户端都能从整体上看到变更的结果。如果一个变更成功执行,且没有被其他并发的写入干扰,那么被影响的区域就是已定义的(意味着一致性):所有的客户端都能够看到更新写入的结果。同时成功执行的变更操作会让该区域具有不确定性,但是依然是一致的:所有客户端都能够看到相同的数据,但是它可能无法反映其中任何一个变更写入的结果。通常,这部分数据由来自多个变更操作的混合片段组成。一个失败的更新可能会导致区域变得不一致(也因此导致不确定性):不同的客户端可能会在不同的时间看到不同的数据。我们会在下面来描述应用程序如何区分已定义和未定义的区域。应用程序不需要对未定义区域做进一步的区分。

写操作或是记录追加都会导致数据变更。写操作在应用程序指定的偏移位置写入数据,记录追加操作即使存在并发的数据变更,也会原子性的执行至少一次追加数据(即“记录”)操作,但是偏移量由GFS指定(3.3节)(相反地,一个“常规”的追加操作仅仅是一个偏移量是客户端认为的当前文件结尾的写操作)。偏移位置会返回给客户端,并标记了包含该记录的已定义区域的起始位置。此外,GFS还可能在其间插入填充或者记录的副本,它们会占用那些被认为是不一致的区域,通常这些数据和用户的数据比起来要小得多

在一系列成功的变更后,更新后的文件区域能够确保是已定义的,并且包含了最后一次更新写入的数据。GFS通过以下方式来实现:(a) 对所有块副本上以同样的顺序来对块执行更新(3.1节),(b) 使用块版本号来检测那些因为块服务器宕机造成修改丢失的过期的副本(4.5节)。过期的副本将不会参与更新,也不会在客户端向master询问块位置时被返回,它们会被尽早的执行垃圾回收

因为客户端缓存了块的位置,因此在数据刷新之前,可能会读到过期的副本。窗口的大小收到缓存条目的超时时间和下一次打开文件的限制,打开文件的操作会从缓存中清除文件的所有块信息。此外,我们的大多数文件都是只能执行追加操作的,一个过期的副本通常会提前返回块的结束,而不是过时的数据。当读取者进行重试并与master联系时,会立即获得块的当前位置

变更成功执行很久之后,很明显组件故障依然能够污染或损坏数据。GFS通过master和所有块服务器之间的定期握手来标识那些失效的块服务器,并通过校验和来检测被污染的数据(5.2节)。一旦出现问题,数据会尽快地从有效的副本中恢复(4.3节)。只有在GFS作出响应前(通常在几分钟之内)所有块的副本均丢失,才会导致真正不可逆的块丢失。即使在这种情况下,也仅仅是变得不可用,而不是损坏:应用程序会接收到明确的报错信息,而不是损坏的数据

2.7.2 对应用程序的影响

GFS应用程序可以通过一些用于其他目的的简单的技术来适应这种弱一致性模型:依赖于追加而不是覆写操作,检查点技术,以及写入自检查和自认证的记录

实际上我们所有的应用程序都是通过追加操作来更新文件,而不是覆写操作。在一个典型的应用场景中,一个写入者从头到尾创建了一个文件,在写完数据后自动将文件重命名为一个永久的名称,或是使用检查点周期性地确认有多少数据被写入。检查点同样包括应用程序级的校验和。读取者仅仅会验证最后一个检查点之前的区域,这些区域可以确认处于已定义的状态。无论一致性和并发性如何要求,这种方法都对我们是很有帮助的。与随机写入相比,追加操作更为高效,对于应用程序的故障的处理也更为灵活。检查点技术允许写入者以增量的方式重启,也让读取者避免处理成功写入的数据,这在应用程序的角度来看仍然是不完整的

另一种典型的应用场景中,许多写入者为了合并结果或是作为生产者-消费者队列,同时对一个文件执行追加操作。记录追加的append-at-least-once(至少一次追加)的语义保证了每一个写入者的输出。读取者处理临时的填充和副本,如下所示。写入者的每条记录都包含了像校验和之类的额外信息,以便验证其有效性。读取者可以通过校验和来识别并丢弃额外的填充和记录段。如果不能容忍偶然的重复数据(例如,触发了非幂等的操作),可以在记录中使用唯一标识来对重复的部分进行过滤,通常不管怎样都需要这些标识来命名对应的应用程序实体,比如网页文档。这些用于记录的输出输出的功能函数(除了重复删除)都在我们应用程序共享的代码库中,同时适用于Google中其他文件接口的实现。使用这些工具,同一序列的记录,再加上一小部分的重复,总是会分发给记录的阅读者

3. 系统交互

我们是出于最大限度地减少master在所有操作中的参与度来设计系统的。在此背景之下,现在我们再来描述客户端、master,以及块服务器是如何进行交互以实现数据变更,原子性记录追加和快照的操作

3.1 租约与更新顺序

数据更新是指改变元数据或是块的内容的操作,例如写入或追加操作。每一个变更都会在块的所有副本上执行。我们使用租约来维护副本间更新顺序的一致性。master将块的租约授予副本的其中一个,我们可以称其为主副本。主副本会为所有在该块上的更新操作选择一个顺序,其余所有副本都会在执行时遵循这一顺序。因此,全局变更顺序首先是被master选择的租约授予顺序所确定的,然后才是在租约中被主副本所分配的序列号来确定

租赁机制被设计用来最大限度地减轻master的管理负担。一个租约的初始超时时间为60秒,然而,只要块被更新,主副本就可以无限制地向master请求延长租约,通常都会得到回应。这些额外的请求和授权是在master和所有的块服务器之间的定期交换的心跳消息中捎带的。master有时可能会尝试在到期前撤销租约(例如,master想要禁止一个对文件重命名的更新操作)。即使master和主副本之间断开通信,也可以在租约到期后将新的租约授予另一个副本

在图2中,我们通过编号的写入控制流来说明这一步骤 图2

  1. 客户端询问master哪个块服务器保存着块当前的租约和其他的副本位置。如果都没有拥有租约,则master将从副本中选择一个来授予租约(未在图中显示)
  2. master给客户端回复主副本和其他(次要)副本的位置。客户端会缓存这些数据用于近期的更新操作。只有当主副本无法访问或是不再拥有租约时,客户端才需要重新联系master
  3. 客户端将数据推送给所有副本,顺序并不固定。每一个块服务器都会把数据保存在内部的LRU缓存中,直到数据被使用或是过期。通过将数据流与控制流分离,我们可以基于网络拓扑来调度昂贵的数据流,从而提高性能,也不用关心哪一个是主副本。3.2节中对这一点进行了更深入的讨论
  4. 一旦所有副本都确认收到了数据,客户端就会向主副本发送写请求,这个请求标识了之前推送给所有副本的数据。主副本会为收到的所有数据变更操作(可能来自于多个客户端)分配连续的序列号,这提供了必要的串行化机制,它会按照序列号的顺序将更新应用于本地
  5. 主副本将写请求发送给其他所有的次级副本,每一个次级副本都会按照主副本分配的同样的序列号顺序来执行数据变更
  6. 次级副本在完成操作后需要向主副本回复消息
  7. 主副本向客户端返回应答。在任何副本上遇到的错误都会汇报给客户端。出现错误时,该写操作可能已经在主副本或其余一部分次级副本上执行成功了(如果在主副本上发生错误,将不会进行序列号的分发),客户端请求仍会被认为是失败的,并且被修改的区域将会处于不一致的状态。我们的客户端代码会通过重试失败的数据变更操作来处理这样的错误,将会在完全退回重写之前,先在第(3)步到第(7)步之间进行一些尝试

如果应用程序的写入操作过于庞大,或是超过了块的边界,GFS客户端代码将会将其分解为多个写入操作。它们都会遵循上述的写入流程,但可能会被来自其他客户端的并发操作交错覆盖。因此,共享文件区域最终可能包含来自于不同客户端的片段,虽然这些副本是一致的,因为各个操作都以相同的顺序在所有副本上成功执行,这会让文件区域保持一致但不确定的状态,如2.7节所述

3.2 数据流

为了更高效地使用网络资源,我们将数据流从控制流中分离出来。控制流从客户端到达主副本,再到其他所有次级副本的这一过程,数据是在认真挑选的块服务器链上以流水线形式被线性推送的。我们的目的是充分利用每台机器的网络带宽,来避免网络瓶颈和高延迟的链路,同时最小化推送所有数据的延迟

为了充分利用每台机器的网络带宽,数据是沿着一条块服务器链被线性推送的,而不是像其他拓扑结构的分布那样(例如树型结构)。因此,每一台机器全部的出站带宽都被用来尽快地传送数据,而不是用于为多个接收者进行切分

为了尽可能的避免网络瓶颈和高延迟链路(例如,通常情况下的内部交换链路),每一台机器都将数据转发到网络拓扑结构中尚未接收到数据的离他“最近”的机器。假设客户端将数据发送给块服务器 S1到S4,首先会将数据发送到离它最近的机器,比如S1。S1接下来就将数据转发到S2~S4中离它最近的机器,比如S2。类似地,S2再转发给S3或S4,看哪一个离S2更近,等等。我们的网络拓扑可以简单到能够从ip地址准确地估算“距离”

最后,我们通过在TCP连接上使用流水线来传送数据以最小化延迟。一旦块服务器接收到一部分数据,就会立刻开始转发。流水线这里对我们来说尤其有用,因为我们使用了全双工链路的交换网络。立即发送数据并不会降低接收速率,在没有网络拥塞的情况下,将B个字节发送到R个副本上经过的时间理想情况下是B/R + RL,其中T是网络的吞吐量, L是两台机器间传送字节的延迟。我们的网络链路一般是100Mbps(T),L远低于1ms。因此,理想情况下,1MB的数据可以在80ms内完成分发

3.3 原子性的记录追加

GFS提供了一个名为record append的原子性追加操作。在传统的写操作中,客户端指定数据写入的位置。在同一区域的并发写操作是不可串行化的:该区域最终可能包含来自多个客户端的数据段。然而在一个记录追加操作中,客户端仅仅需要指定数据,GFS至少会将其原子性的(即,作为一个连续的字节序列)追加到文件中至少一次,追加的位置由GFS选择,并会向客户端返回这个偏移量。这很像Unix中O_APPEND的文件打开模式,当多个写入者并发操作时不会产生竞争条件

我们的分布式应用程序大量使用了记录追加操作,不同机器上的多个客户端并发地向同一个文件中进行数据的追加。如果使用传统的写入操作,客户端将会额外需要复杂且昂贵的同步开销,例如分布式锁管理器。在我们的工作负载中,这样的文件通常会作为多生产者/单消费者队列,或是不同客户端的归并结果

记录追加是一种数据变更,除了一些主副本上的额外逻辑操作之外,依然会遵循3.1节中所述的控制流。客户端将数据推送给文件最后一个块的所有副本后,会向主副本发送请求,主副本会检查当前块记录的追加操作是否导致块超过了最大大小(64MB),如果超过了,就将块填充到最大大小,并通知其余副本也这么做,然后告诉客户端应在下一个块上进行重试(追加的记录被限制为最大块大小的1/4,以将最坏情况下的碎片控制在一个可接受的水平上)。一般情况下,记录的大小都不会超过最大块大小,如果在这种情况下,主副本所在节点会将数据追加到它的副本上,并让其余的副本节点将数据写在它们控制的副本中确定的偏移量处,最后返回给客户端一个成功应答

如果任何副本上的记录追加操作失败,客户端会对操作进行重试。因此,同一个块的副本上的数据可能包含了不同的数据,这些数据可能包含了一份同样记录的全部或部分的重复值。GFS不保证所有副本是字节层面一致的,它仅能保证数据能被作为原子单位写入至少一次。这个特性很容易从对那些成功响应的报告中简单地观察出来:数据一定被写入到某些块所有副本的相同偏移位置处,此外,所有副本至少和记录结束的长度相同。因此,之后的任何记录都会被分配到更大的偏移位置处,或是不同的块上,即使有另一个不同的副本成为了主副本。就我们的一致性保证而言,成功执行写入数据的记录追加操作的区域是已定义的(所以是一致的),而介于其间是不一致的(所以是未定义的)。我们的应用程序可以如2.7.2节所述中来处理这种不一致的区域

3.4 快照

快照操作几乎可以在瞬间生成文件或目录树(“源文件”)的一份副本,同时会尽可能地避免中断任何正在进行的数据变更。我们的用户使用快照来迅速创建大型数据集的分支副本(通常是递归地拷贝这些副本),或是在调整实验前为当前状态建立检查点,以便可以在之后方便地进行提交或回滚

例如AFS,我们使用标准的copy-on-write(写时复制)技术来实现快照。当master收到一份快照的请求时,会首先撤销那些在即将进行快照的文件块上的未完成的租约。这种行为确保了这些块上任何连续的写操作都需要与主机交互,以找到租约的持有者,这首先给了master有一个创建块副本的机会

在租约被撤销或过期之后,master会将操作以日志形式记录到磁盘中。然后,master会通过拷贝源文件或目录树的元数据来将此日志记录应用到它的内存状态中。新创建的快照文件会指向与源文件相同的块

客户端在快照生效后首次对块C进行写入时,会向master发送请求来寻找当前租约的持有者。master注意到块C的引用计数大于1,会推迟对客户端请求的响应,并选择一个新的块句柄C'。然后会让拥有块C副本的每一个块服务器都创建一个新的块,叫做C'。通过在与原块相同的块服务器上创建新块,我们可以确保数据在本地进行拷贝,而不是通过网络(我们磁盘的速度是100MB以太网链路的3倍左右)。从这一点来看,处理任何块的请求都没有什么不同:master在新块C'上为其副本之一授予租约,并给客户端回复,使其可以正常地对块进行写入,而不知道这个块是从已存在的块刚创建出来的

4. Master节点操作

Master节点执行所有命名空间操作。此外,它还管理整个系统中的块副本:它可以决定块的放置位置,创建新块以及副本,并协调各种系统范围内的活动以保证块的完全复制,平衡所有块服务器上的负载并回收未使用的存储。现在我们再来讨论之前的话题

4.1 命名空间管理与锁机制

许多master节点的操作可能需要很长时间:例如,快照操作必须撤销快照涉及的所有块上的块服务器租约。我们不希望因为运行这些操作而延迟其他master节点的操作。因此,我们允许多个操作同时处于活动状态,并在命名空间的区域上使用锁以确保正确的序列化

与许多传统文件系统不同,GFS没有按目录的数据结构列出该目录中的所有文件。它也不支持相同文件或目录的别名(即Unix术语中的硬链接或符号链接)。GFS在逻辑上将其命名空间表示为完整路径名映射到元数据的查找表。采用前缀压缩的方式,可以有效地在内存中表示该表。命名空间树中的每个节点(绝对文件名或绝对目录名)都有一个关联的读写锁

每个master节点操作在运行之前都需要获取一组锁。通常,如果涉及到/d1/d2/.../dn/leaf,它将获取目录名称/d1/d1/d2、...、/d1/d2/.../dn上的所有读锁,并在完整路径名/d1/d2/.../dn/leaf上具有读锁或写锁。注意,叶节点可能是文件或目录,这取决于操作

现在,我们来说明在将/home/user快照到/save/user时,该锁定机制是如何防止创建文件/home/user/foo的。快照操作在/home/save上获取读锁,在/home/user/save/user上获取写锁。文件创建会获取对/home/home/user的读锁,以及对/home/user/foo的写锁。这两个操作将正确按序执行,因为它们试图获取/home/user上的锁是相互冲突的。创建文件不需要在父目录上获取写锁,因为没有“目录”或类似inode的数据结构需要加以保护来避免被修改。文件名称上的读锁足以保护父目录以免遭删除

这种锁定方式的一个优点是,它允许在同一目录中进行并发修改。例如,可以在同一目录中同时执行多个文件创建操作:每个文件创建都获得对目录名称的读锁和对文件名的写锁。目录名上的读锁成功防止目录被删除、重命名或快照,文件名的写锁可以序列化相同名称的文件的重复创建操作2

由于命名空间可以有许多节点,因此读锁对象会延迟进行分配,并且在不使用时将其删除。同样会以一个全局一致的顺序来获取锁以避免死锁:它们在命名空间树中按层级进行排序,同一级别按字典序进行排列

4.2 副本位置

一个GFS集群高度分布在多个层次上,通常具有分布在许多机架上数百个块服务器。这些块服务器被来自相同或不同机架的数百个客户端轮流访问。不同机架上的两台计算机之间的通信可能会跨越一个或多个网络交换机。另外,进出机架的带宽可能小于机架内所有计算机的总带宽。多级分发的架构方式对数据分发的可伸缩性,可靠性和可用性提出了独特的挑战

块副本的放置策略需要达到两个目的:最大化数据可靠性和可用性,以及最大化网络带宽利用率。对于两者而言,仅在不同计算机之间分别存储这些副本是不够的,这只能实现减少磁盘或计算机故障带来的影响,以及充分利用每台计算机的网络带宽这两点。我们还必须将大块副本散布在机架上。这样可以确保即使整个机架损坏或掉线(例如,由于共享资源(如网络交换机或电源电路)故障),大块的某些副本也依然存在并保持可用。这也意味着块的流量(尤其是读取)可以利用多个机架的总带宽。另一方面,写入流量必须经过多个机架3,这点代价是我们愿意付出的

4.3 块创建、重新复制与重新平衡

创建块副本有三个原因:块创建、重新复制,以及重新平衡

当master创建块时,它会选择将最初的空副本的放置位置,考虑以下几个因素。(1)希望将新副本放置在磁盘空间利用率低于平均值的块服务器上。随着时间的推移,这样可以均衡块服务器之间的磁盘利用率。(2) 我们希望限制每个块服务器上“最近”创建的数量。尽管创建操作本身花费很少,但是却意味着大量的写操作随之而来,因为块是在写操作执行时创建的。而且在我们的append once read many工作模式下,它们通常在完成写操作后变为只读。(3)综上所述,我们希望将数据块的副本分布在多个机架上

一旦可用副本的数量低于用户指定的值时,master服务器就会进行这个块的重新复制。这种情况可能是由于各种原因造成的:一个块服务器不可用;块服务器报告其副本可能已损坏;块服务器一个磁盘由于错误而不可用;或者复制因子增大。需要重新复制的每个块都根据以下几个因素按优先级进行排序:一是当前的副本数和复制因子相差有多大。例如,我们给予丢失两个副本的块比只丢失一个副本的块更高的优先级。此外,在第一次操作时,我们更愿意为活跃的文件执行重新复制,而不是最近被删除文件过的块(见第4.4节)。最后,为了将执行失败对运行中的应用程序的影响降到最低,我们将阻塞客户端进程的块的优先级升至最高

master节点选择最高优先级的块并“克隆”,该操作通过指定一些块服务器,使其直接从当前有效副本中拷贝块数据来实现。新副本的放置策略与创建时类似:平衡磁盘空间利用率,限制任何单个块服务器上的活跃克隆操作的数量,以及跨机架分发副本。为了使克隆事务不受客户机事务的影响,主机会限制集群和每个块服务器上的活跃的克隆操作数量。此外,每个chunkserver通过限制对源chunkserver的读请求频率4来限制其在每个克隆操作上花费的带宽量

最后,master服务器定期重新对副本进行均衡分布:它会检查当前副本的分布情况,并移动副本以获得更好的磁盘空间利用率和负载均衡。同样利用这个过程,master节点逐渐填满了一个新的块服务器,而不是让其立即淹没在随之而来的新块和沉重的写事务中。新副本的放置标准与上面讨论的类似。此外,master节点还必须选择需要删除的现有副本。一般来说,它倾向于删除可用空间低于平均值的块服务器上的那些,以便均衡磁盘空间的利用率

4.4 垃圾收集

GFS在文件删除之后,不会立即回收可用的物理存储空间。它只能采用惰性策略,在文件级和块级的常规垃圾收集时执行。我们发现这种方法使系统更加简单可靠

4.4.1 机制

当文件被应用程序删除时,master服务器会像其他更改一样立即记录当次删除。但是,文件并没有立即回收资源,而是被重命名为一个包含删除时间戳的隐藏名称。在master对文件系统命名空间做常规扫描时,如果发现它们存在超过三天(这个间隔时间可设置)则将其删除。在此之前,文件仍然可以用新的特殊名称进行读取,并且可以通过将其重命名为普通名称来恢复删除。当隐藏文件从命名空间中删除时,其内存元数据将被删除,有效地切断了它与所有块的链接

在对块命名空间进行类似的常规扫描时,master节点会识别出孤立块(即无法从任何文件访问的块),并删除这些块的元数据。在定期与master交互的心跳消息中,每个块服务器报告其拥有块的一个子集,master节点会回复已经不存在于master保存的元数据中的所有chunk的标识,块服务器可以自由删除这些块的副本

4.4.2 讨论

尽管分布式垃圾回收是一个难题,在编程语言领域中需要复杂的解决方案,但在我们的例子中却非常简单。我们可以很容易地识别出对块的所有引用:它们位于由master节点维护的文件到块的映射表中。我们还可以很容易地识别所有的块副本:它们是每个块服务器上指定目录下的Linux文件。Master服务器不能识别的任何此类副本都属于“垃圾”

垃圾回收在空间回收方面比直接删除有几点优势。首先,在一个组件故障是常态的大型分布式系统中,垃圾回收是简单可靠的。块创建操作可能在某些块服务器上可以成功,但对于其他的块服务器却不行,创建失败的副本处于无法被master识别的状态。副本删除的消息可能会丢失,master服务器必须在发生故障时重新发送它们,包括自身和块服务器的。垃圾回收提供了一种统一可靠的方法来清理任何不能确定是否有用的副本。其次,它将空间回收合并到master服务器的有规律的后台活动中,例如定期扫描命名空间和与块服务器握手等。因此,它是分批完成的,成本是摊销的。而且,只有在master相对空闲的情况下才能这样做,这样对于需要及时关注的客户端请求,master服务器可以更迅速地做出响应。第三,空间回收的延迟为意外的、不可逆的删除操作提供了安全保障

根据我们的经验,主要的缺点是当存储空间紧张时,回收的延迟有时会妨碍用户在手动调优后的使用。当应用程序重复创建和删除临时文件时,释放的空间可能无法被立即重用。如果删除的文件再次被显式删除,我们将加快空间回收来解决这一问题。我们还允许用户对命名空间的不同部分应用不同的复制和回收策略。例如,用户可以指定某个目录树中文件中的所有块都将在不复制的情况下存储,任何删除的文件都将立即从文件系统状态中不可撤销地删除

4.5 过期副本检测

如果块服务器发生故障,并且在块服务器关闭时丢失了对该块的更新,那么块副本可能会过期。对于每个数据块,Master服务器都维护一个数据块版本号,以区分最新副本和过期副本

每当Master服务器授予块新租约时,都会增加该块版本号并通知最新副本。Master节点和这些副本都会将新版本号保存在其持久存储的状态信息中。这个操作在任何客户端被通知之前就已经执行,也就是说它发生在块写入开始之前。如果另一个复制副本当前不可用,则其块版本号就不会再被增加。当块服务器重新启动,并报告其下的块及对应版本号时,master服务器将检测此块服务器所有过期的副本。如果master服务器在其记录中看到的版本号大于该版本号,则master服务器就认为其在授予租约时失败,因此会选择更高的版本号作为最新版本

Master服务器在其常规垃圾回收中删除过期的副本。在此之前,当它回复客户端对块信息的请求时,通常认为不存在过期的副本。作为另一种保护措施,当master通知客户端哪个块服务器持有某一个块租约,或在克隆操作中指示块服务器从另一个块服务器读取块时,会包含块版本号的信息。客户端或块服务器在执行操作时验证版本号,以确保访问的始终是最新数据

5. 容错与诊断

在设计系统时,我们面临的最大挑战之一就是应对频繁出现的组件故障。由于组件的数量和质量,这样的情况比一般的系统意外发生的频率更高:我们无法完全保证机器一直稳定运行,磁盘也同理。组件故障可能会导致系统不可用,更糟的是数据的损坏。我们将讨论如何应对这些问题,以及当出现不可避免的故障时,如何使用我们已在系统中内置的工具进行诊断

5.1高可用

在GFS集群的数百台服务器中,在任何时间,总存在一些服务器是不可用的。我们通过两种简单而有效的策略来保持整个系统的高可用性:快速恢复和复制

5.1.1 快速恢复

Master服务器和块服务器均被设计为可以无视机器之前终止的方式,在几秒钟内启动并恢复状态。而实际上,我们并不区分正常终止和异常终止。通常使用杀死进程的方式来关闭服务器,客户端和其他服务器会感觉到轻微的卡顿,未成功发送的请求会超时,并在稍后重连到重启中的服务器,同时重试该请求。在6.2.2节中,我们记录了实际观察到的启动时间

5.1.2 块复制

如之前所述,每个块的副本被散落在不同机架上的多个块服务器上。用户可以为文件命名空间的不同部分指定不同的复制级别,默认值为3。Master服务器根据需要来对现有的副本进行克隆,以确保在块服务器运行时每个块都被完全复制,或是通过校验和来检测损坏的副本(参见5.2节)。尽管复制操作给我们带来了很多好处,但我们仍在探索其他形式的跨服务器冗余解决方案,例如奇偶校验或代码擦除,以适应不断增长的只读存储需要。我们认为在过于松耦合的系统中实现这些更为复杂的冗余方案很具有挑战性,但并不是无法实现的,因为我们的流量主要是追加和读取操作,而很少有随机写操作

5.1.3 Master节点复制

复制master节点的状态可以提高服务器可靠性,其上的所有操作日志和检查点都会被复制到多台计算机上。只有当状态的日志记录在本地磁盘和所有master节点的副本上刷新后,我们才会认为状态发生了变化。为了简单起见,一个master服务进程仍然负责所有的更新操作以及后台活动,例如垃圾回收等改变服务器内部活动的状态。当它失败时,几乎可以立即重启。如果其计算机或磁盘出现故障,则GFS外部的监控进程将使用操作日志的副本在其他位置启动新的master进程。客户端只使用master服务器的规范名称(例如gfs test),这是一个DNS别名,如果master服务器重新定位到另一台计算机上,则可以更改该别名

除此之外,还存在一些master服务器的“影子”服务器,它们即使在最核心的master服务器宕机时也能提供对文件系统的只读访问。它们是影子,而不是镜像,因为它们可能会稍微滞后于主master服务器5,但通常不会超过1秒。对于那些不经常修改的的文件或是不介意结果稍微有延时的应用程序,这些影子服务器可以提高读取服务的可用性。实际上,由于文件内容是从块服务器读取的,因此应用程序不会看到过时的文件内容。在这个短暂的窗口期内,文件元数据可能会过期,例如目录内容或访问控制信息

影子服务器为了是自身状态随时保持为最新,它会读取了正在进行的操作的日志副本,并且按照与master完全一致的顺序对其自身数据结构进行修改。像master节点一样,它在启动时(且之后很少)对块服务器进行轮询以查找块副本,并与它们交换频繁的握手消息以监视其状态。仅当主master服务器决定创建和删除副本导致副本位置更新时,影子服务器才会与主master服务器维持依赖关系

5.2 数据完整性

每个块服务器使用校验和来检测存储数据是否损坏。考虑到GFS集群通常有数百台计算机、数千个磁盘,所以经常会遇到磁盘故障,导致读写磁道上的数据损坏或丢失(请参阅第7节了解其中的一个原因)。我们可以使用其他块副本来修复故障,但通过比较不同块服务器之间的副本来检测损坏是不实际的。此外,副本之间有些许差异也是合法的,比如GFS中变更操作的语义,特别是前面讨论过的原子记录追加操作,并不能保证相同的副本。因此,每个块服务器都必须通过维护校验和来独立地验证自己副本的完整性

每个块都会被拆分成数个64KB的块,各自都有相应的32位校验和。与其他元数据一样,校验和保存在内存中,与用户数据分开存储,并通过日志进行持久化存储

对于读取操作,块服务器在将任何数据返回给请求者(无论是客户端还是另一个块服务器)之前验证与读取范围重叠的数据块的校验和。因此,块服务器不会将错误数据发送到其他计算机。如果块与记录的校验和不匹配,块服务器会将向请求程序返回错误提示信息,并向主服务器报告不匹配。作为响应,请求者将从其他副本读取数据,而主机将从另一个副本克隆数据块。在一个有效的新副本就位后,Master服务器将指示报告不匹配的块服务器对错误副本进行删除

因为以下的一些原因,校验和对读取性能几乎没有影响。我们的大多数读取操作至少跨越几个块,因此我们只需要读取相对较少的额外数据来进行校验。GFS客户机代码通过尝试把每次读取操作对齐在校验和块的边缘,进一步减少了这一开销。此外,块服务器上的校验和的查找和比较不需要任何I/O,校验和的计算通常可以与I/O同时进行

校验和的计算操作对于块末尾的追加写操作(与覆盖现有数据的写相反)进行了高度优化,因为它们是我们的工作负载中的主要部分。我们只是增量地更新最后一个部分校验和块的校验和,并通过追加操作填充的校验和块来计算新的校验和。即使最后一个部分校验和块已经损坏,且我们现在无法检测出来,由于新的校验和会与存储的数据不匹配,则在下一次读取块时将会正常地检测到数据错误

相反,如果写操作覆盖了已存在的一定范围的块,则必须读取并验证覆盖的范围内的最后一个块,然后执行写入操作,并且最后计算并记录新的校验和。如果在部分重写之前不验证第一个和最后一个块,则新的校验和可能隐藏没有被覆盖的区域内存在的数据错误

在空闲期间,块服务器可以扫描并验证非活动块的内容。这允许我们检测那些很少被读取的块中的数据损坏。一旦检测到损坏,Master服务器可以创建一个新的完好的副本并删除损坏的副本。这样可以防止非活动但已损坏的块副本欺骗master节点,使其认为有足够的有效块副本

5.3 诊断工具

广泛而详细的诊断日志记录通过最小的开销,给我们在问题隔离、调试与性能分析方面带来了不可估量的帮助。如果没有日志,就很难理解机器之间的瞬时态和不可重复的交互。GFS服务器生成诊断日志,记录许多重要事件(例如上下运行的块服务器),以及所有RPC请求和响应。这些诊断日志可以自由删除而不会影响系统的正确性,不过我们会在存储空间允许的情况下尽可能地保存这些日志

RPC日志中包含了网络上发送的确切请求和响应,除了正在读取或写入的文件数据。通过将请求与应答进行匹配,并在不同的机器上对RPC记录进行整理,我们可以重建整个交互历史来诊断问题。除此之外日志还用来跟踪负载测试和性能分析

日志记录对性能的影响很小(和带来的好处相比不值一提),因为这些日志是按序异步写入的。最近发生的事件日志也会保存在内存中,可用于连续在线的监视

Footnotes

  1. 即“块服务器”,中英两种表述可能会在后文中混用,不过意义是完全一致的

  2. 即解决了重名文件并发创建的冲突

  3. 指写入操作必须与多个机架上的机器进行网络通信

  4. 原文为“throttling its read requests”,并没有说明限制请求的手段,这里译作限制请求频率只为了语意通顺,仅供参考

  5. 这里的“主master服务器”指的是Master服务器集群中的“主服务器”,原文表述为“primary”