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

摘要

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

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

这个文件系统已经成功满足我们的存储需要,已经作为存储平台在谷歌内部广泛部署,用于生成并处理我们用于需要大量数据集的搜索和研发服务的数据。迄今为止最大的集群,在超过一千台机器上的的数千个磁盘上提供了数百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和数个chunkserver组成,可以被多个客户端访问,如图1所示。每个客户端通常都是由一台商用Linux服务器来运行用户级的服务器进程。只要机器的资源允许,并且能够接受由于运行可能的碎片应用代码导致的较低的可靠性,那么在同一台机器上运行chunkserver和客户端是很容易的

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

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

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

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

图1

2.4 单个Master

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

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

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

2.5 块大小

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

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

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

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

2.6 元数据

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

2.6.1 内存数据结构

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

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

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

2.6.2 块的位置

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

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

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

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) 使用块版本号来检测那些因为chunkserver宕机造成修改丢失的过期的副本(4.5节)。过期的副本将不会参与更新,也不会在客户端向master询问块位置时被返回,它们会被尽早的执行垃圾回收

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

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

2.7.2 对应用程序的影响

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

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章