GFS
GFS
概要
我们设计并实现了google文件系统,这是一个可扩展的分布式文件系统,适用于大型分布式数据密集型应用程序。它在廉价的商用硬件上运行时提供容错性,并为大量客户机提供高聚合性能。
虽然与以前的分布式文件系统有许多相同的目标,但我们的设计是由对应用程序工作负载和技术环境的观察(包括当前的和预期的)驱动的,这反映了与早期文件系统假设的明显背离。这促使我们重新审视传统的选择,并探索完全不同的设计要点。
文件系统已成功满足我们的存储需求。它在谷歌内部被广泛部署为存储平台,用于生成和处理我们的服务所使用的数据,以及需要大量数据集的研究和开发工作。迄今为止最大的集群在1000多台机器上的数千个磁盘上提供了数百tb的存储,并且可以由数百个客户机并发访问。
在本文中,我们介绍了为支持分布式应用程序而设计的文件系统接口扩展,讨论了我们设计的许多方面,并报告了来自微基准测试和实际使用的测量结果。
介绍
我们设计并实现了谷歌文件系统(GFS),以满足谷歌快速增长的数据处理需求。GFS与以前的分布式文件系统有许多相同的目标,比如性能、可伸缩性、可靠性和可用性。然而,它的设计是由我们的应用程序工作负载和技术环境的关键观察驱动的,包括当前的和预期的,这反映了与早期一些文件系统设计假设的明显背离。我们重新审视了传统的选择,并在设计空间中探索了截然不同的观点。
首先,组件故障是常态,而不是例外。文件系统由数百甚至数千台存储机器组成,这些存储机器由廉价的商品部件组成,并由相当数量的客户机访问。组件的数量和质量实际上保证了一些组件在任何给定的时间都不能正常工作,一些组件将无法从当前的故障中恢复。我们已经看到了由应用程序错误、操作系统错误、人为错误以及磁盘、内存、连接器、网络和电源故障引起的问题。因此,持续监控、错误检测、容错和自动恢复必须是系统的组成部分。
其次,按照传统标准,文件是巨大的。多gb文件很常见。每个文件通常包含许多应用程序对象,例如web文档。当我们经常处理由数十亿个对象组成的tb级快速增长的数据集时,管理数十亿个接近kb大小的文件是非常笨拙的,即使文件系统可以支持它。因此,必须重新考虑设计假设和参数,例如I/O操作和块大小。
第三,大多数文件都是通过附加新数据而不是覆盖现有数据来改变的。在文件中随机写实际上是不存在的。一旦写入,文件就只能被读取,而且通常只能按顺序读取。各种数据都具有这些特征。有些可能构成数据分析程序扫描的更大的存储库。有些可能是由运行的应用程序不断生成的数据流。有些可能是档案资料。有些可能是在一台机器上产生的中间结果,在另一台机器上处理,无论是同时处理还是稍后处理。考虑到这种对大文件的访问模式,追加成为性能优化和原子性保证的重点。而在客户端缓存数据块就失去了吸引力。
第四,共同设计应用程序和文件系统API通过增加我们的灵活性使整个系统受益。例如,我们放宽了GFS的一致性模型,以极大地简化文件系统,而不会给应用程序带来繁重的负担。我们还引入了原子追加操作,以便多个客户机可以并发地追加到一个文件,而无需在它们之间进行额外的同步。这些将在本文后面进行更详细的讨论。
目前部署了多个GFS集群用于不同的目的。最大的数据库有超过1000个存储节点,超过300 TB的磁盘存储,并且在不同的机器上有数百个客户端连续地大量访问。
设计概况
假设
在为我们的需求设计文件系统的过程中,我们一直受到既有挑战又有机会的假设的指导。我们之前提到了一些关键的观察结果,现在更详细地列出我们的假设。
- 该系统是由许多经常失效的廉价商品组件构建而成的。它必须不断地监控自身,并在常规基础上检测、容忍组件故障,并迅速从组件故障中恢复。
- 系统存储少量的大文件。我们期望有几百万个文件,每个文件的大小通常为100mb或更大。多gb文件是常见的情况,应该有效地管理。必须支持小文件,但我们不需要针对它们进行优化。
- 工作负载主要包括两种类型的读:大的流读和小的随机读。在大型流读取中,单个操作通常读取数百kb,更常见的是1mb或更多。来自同一客户机的连续操作通常读取文件的连续区域。一个小的随机读取通常以一些任意偏移量读取几个kb。注重性能的应用程序通常对它们的小读取进行批处理和排序,以便在文件中稳定地前进,而不是来回移动。
- 这些工作负载还有许多大的、顺序的写操作,将数据附加到文件中。典型的操作大小与读操作大小相似。一旦写入,文件就很少再被修改。支持在文件中的任意位置进行小的写操作,但不一定要高效。
- 系统必须有效地为并发追加到同一文件的多个客户端实现定义良好的语义。我们的文件通常用作生产者-消费者队列或用于多路合并。数百个生产者,每台机器运行一个,将并发地附加到一个文件。具有最小同步开销的原子性是必不可少的。该文件可以稍后读取,或者消费者可以同时读取该文件。
- 高持续带宽比低延迟更重要。我们的大多数目标应用程序都非常重视以高速率批量处理数据,而很少有对单个读或写有严格的响应时间要求。
接口
GFS提供了一个熟悉的文件系统接口,尽管它没有实现像POSIX这样的标准API。文件在目录中按层次结构组织,并由路径名标识。我们支持创建、删除、打开、关闭、读取、写入文件等常用操作
此外,GFS还具有快照和记录追加操作。快照以较低的成本创建文件或目录树的副本。记录追加允许多个客户端并发地将数据追加到同一个文件,同时保证每个客户端追加的原子性。这对于实现多路合并结果和生产者-消费者队列非常有用,因为许多客户机可以在不附加锁定的情况下同时挂起它们。我们发现这些类型的文件在构建大型分布式应用程序时是无价的。快照和记录追加将分别在3.4节和3.3节中进一步讨论。
架构
一个GFS文件系统是由多个块服务器和多个主服务器组成,并由多个客户端访问,如图1所示。这些机器通常都是运行用户级服务器进程的普通Linux机器。在同一台机器上同时运行chunkserver和客户机是很容易的,只要机器资源允许,并且运行可能不可靠的应用程序代码所导致的较低的可靠性是可以接受的。

文件被分成固定大小的块。它由一个不可变的、全局唯一的64位块句柄来标识,这个句柄是在创建块时由主机分配的。chunkserver将块以Linux文件的形式存储在本地磁盘上,并通过块句柄和字节范围指定读取或写入块数据。为了提高可靠性,每个块都在多个块服务器上复制。默认情况下,我们存储三个副本,不过用户可以为文件名称空间的不同区域指定不同的复制级别。
主服务器维护所有文件系统元数据。这包括名称空间、访问控制信息、从文件到块的映射以及块的当前位置。它还控制系统范围的活动,如块租赁管理、孤立块的垃圾收集和块服务器之间的块迁移。主服务器定期与HeartBeat消息中的每个chunkserver通信,向其提供指令并收集其状态。
链接到每个应用程序中的GFS客户端代码实现文件系统API,并与主服务器和块服务器通信,以代表应用程序读取或写入数据。客户端与主服务器交互进行元数据操作,但所有承载数据的通信都直接到chunkserver。我们不提供POSIX API,因此不需要挂钩到Linux vnode层。
客户端和chunkserver都不缓存文件数据。客户机缓存提供的好处很少,因为大多数应用程序都要处理巨大的文件,或者工作集太大而无法缓存。没有它们可以通过消除缓存一致性问题来简化客户端和整个系统。(不过,客户端确实会缓存元数据。)chunkserver不需要缓存文件数据,因为块存储为本地文件,因此Linux的缓冲区缓存已经将频繁访问的数据保存在内存中。
单个master
拥有单个主节点极大地简化了我们的设计,并使主节点能够使用全局知识做出复杂的块放置和复制决策。然而,我们必须尽量减少它对读写的参与,这样它才不会成为瓶颈。客户端从不通过主服务器读写文件数据。相反,客户端询问主服务器它应该联系哪个块服务器。它将这些信息缓存一段有限的时间,并直接与chunkserver交互以进行许多后续操作。
让我们参照图1来解释一个简单读取的交互。首先,使用固定的块大小,客户机将应用程序指定的文件名和字节偏移量转换为文件中的块索引。然后,它向主机发送一个包含文件名和块索引的请求。主服务器返回相应的块句柄和副本的位置。客户端使用文件名和块索引作为键来缓存这些信息。
然后,客户端向其中一个副本(很可能是最近的副本)发送请求。请求指定块句柄和该块中的字节范围。在缓存的信息过期或文件被重新打开之前,对同一块的进一步读取不需要更多的客户机-主交互。实际上,客户端通常在同一个请求中请求多个数据块,而主服务器也可以在被请求的数据块之后立即包含数据块的信息。这些额外的信息避免了未来的几个客户机-主交互,几乎没有额外的成本。
块大小
块大小是关键的设计参数之一。我们选择64 MB,这比典型的文件系统块大小大得多。每个块副本以普通Linux文件的形式存储在chunkserver上,仅在需要时进行扩展。惰性空间分配避免了由于内部碎片而造成的空间浪费,这可能是对这种方法最大的反对意见
大块大小提供了几个重要的优势。首先,它减少了客户端与主服务器交互的需要,因为对同一块进行读写只需要向主服务器发出一次初始请求以获取块位置信息。这种减少对于我们的工作负载来说尤其重要,因为应用程序大多是顺序地读取和写入大量数据。即使是小的随机读取,客户端也可以轻松地缓存所有的块位置信息为多TB的工作集。其次,由于在较大的块上,客户端更有可能在给定的块上执行许多操作,因此可以通过在较长时间内保持与块服务器的持久TCP连接来减少网络开销。第三,它减少了存储在主服务器上的元数据的大小。这允许我们将元数据保存在内存中,这反过来又带来了其他优势,我们将在第2.6.1节中讨论。
另一方面,大块大小(即使使用惰性空间分配)也有其缺点。小文件由少量块组成,可能只有一个块。如果许多客户端访问同一个文件,存储这些块的块服务器可能会成为热点。在实践中,热点并不是一个主要问题,因为我们的应用程序大多是按顺序读取大的多块文件
然而,当批处理队列系统首次使用GFS时,热点确实出现了:可执行文件作为单个块文件写入GFS,然后同时在数百台机器上启动。存储此可执行文件的几个块服务器由于数百个同时请求而超载。我们通过使用更高的复制因子来存储这些可执行文件,并通过使批处理队列系统错开应用程序启动时间来解决这个问题。一个潜在的长期解决方案是允许客户机在这种情况下从其他客户机读取数据。
元数据
master存储三种主要类型的元数据:文件和块名称空间、从文件到块的映射以及每个块副本的位置。所有元数据都保存在主机内存中。前两种类型(名称空间和文件到块映射)也通过将变化记录到存储在主机本地磁盘上的操作日志并在远程机器上复制来保持持久化。使用日志允许我们简单、可靠地更新主状态,并且在主崩溃时不会冒不一致的风险。主服务器不持久化存储块位置信息。相反,它会在主启动时询问每个chunkserver有关其chunks的信息,以及每当一个chunkserver加入集群时。
内存数据结构
由于元数据存储在内存中,所以主操作速度很快。此外,主服务器可以在后台周期性地扫描其整个状态,这既简单又有效。这种周期性扫描用于实现块垃圾收集、出现chunkserver故障时的重新复制以及块迁移,以平衡chunkserver之间的负载和磁盘空间使用。第4.3节和4.4节将进一步讨论这些活动
这种只使用内存的方法的一个潜在问题是,块的数量以及整个系统的容量受到主服务器拥有多少内存的限制。这在实践中并不是一个严重的限制。master为每个64mb的块维护少于64字节的元数据。大多数块都是满的,因为大多数文件包含许多块,只有最后一个块可能被部分填充。类似地,文件名称空间数据通常需要每个文件少于64字节,因为它使用前缀压缩紧凑地存储文件名。
如果有必要支持更大的文件系统,向主服务器添加额外内存的成本与将元数据存储在内存中所获得的简单性、可靠性、性能和灵活性相比是微不足道的。
块位置
主服务器不会持久记录哪些块服务器拥有给定块的副本。它只是在启动时轮询块服务器以获取该信息。此后,主服务器可以使自己保持最新状态,因为它控制所有块的放置,并使用常规的HeartBeat消息监视块服务器状态
我们最初试图将块位置信息持久地保存在主服务器上,但我们认为在启动时从chunkserver请求数据要简单得多,之后定期请求数据。这消除了在chunkserver加入和离开集群、更改名称、失败、重新启动等过程中保持主服务器和chunkserver同步的问题。在拥有数百台服务器的集群中,这些事件经常发生。
理解这种设计决策的另一种方法是认识到,chunkserver对它自己的磁盘上有什么块或没有什么块拥有最终决定权。试图在主服务器上维护这些信息的一致视图是没有意义的,因为chunkserver上的错误可能会导致块自动消失(例如,磁盘可能坏掉并被禁用),或者操作员可能会重命名chunkserver
操作日志
操作日志记录了元数据重大变更的历史记录。这是GFS的核心。它不仅是元数据的唯一持久记录,而且还用作定义并发操作顺序的逻辑时间线。文件和块,以及它们的版本(参见第4.5节),都是由它们被创建时的逻辑时间唯一且永久地标识的。
由于操作日志是至关重要的,我们必须可靠地存储它,并且在元数据更改持久化之前,不让更改对客户机可见。否则,即使块本身幸存下来,我们也会有效地丢失整个文件系统或最近的客户端操作。因此,我们在多台远程机器上复制它,并且只有在本地和远程将相应的日志记录刷新到磁盘之后才响应客户机操作。主服务器在刷新之前将多个日志记录批处理在一起,从而减少刷新和复制对整个系统吞吐量的影响
主服务器通过回放操作日志恢复文件系统状态。为了最小化启动时间,我们必须保持日志较小。每当日志增长超过一定大小时,主服务器就会检查其状态,以便通过从本地磁盘加载最新的检查点并在此之后仅重播有限数量的日志记录来进行恢复。检查点采用类似b树的紧凑形式,可以直接映射到内存中,并用于名称空间查找,而无需进行额外的解析。这进一步加快了恢复速度并提高了可用性。
因为构建检查点可能需要一段时间,所以master的内部状态是这样构建的,即可以创建新的检查点,而不会延迟传入的突变。主服务器切换到新的日志文件,并在一个单独的线程中创建新的检查点。新的检查点包括转换前的所有突变。对于拥有几百万个文件的集群,可以在一分钟左右的时间内创建它。完成后,将其写入本地和远程磁盘。
恢复只需要最新的完整检查点和后续的日志文件。旧的检查点和日志文件可以自由删除,但我们保留了一些以防止灾难。检查点期间的失败不会影响正确性,因为恢复代码会检测并跳过不完整的检查点。
一致性模型
GFS有一个宽松的一致性模型,可以很好地支持我们的高度分布式应用程序,但实现起来仍然相对简单和高效。现在我们讨论GFS的保证以及它们对应用程序的意义。我们还强调了GFS如何维持这些保证,但将细节留给本文的其他部分。
GFS担保
文件名称空间的变化(例如,文件创建)是原子性的。命名空间锁定保证原子性和正确性(章节4.1);主操作日志定义了这些操作的全局总顺序(章节2.6.3)。
数据突变后文件区域的状态取决于突变的类型、成功还是失败以及是否存在并发突变。表1总结了结果。如果所有客户端总是看到相同的数据,无论他们从哪个副本读取,那么文件区域就是一致的。如果一个文件数据突变是一致的,那么在它之后定义一个区域,并且客户端将看到整个突变所写的内容。当一个突变成功而不受并发写入器的干扰时,受影响的区域就被定义了(并且暗示是一致的):所有客户端都将始终看到突变所写的内容。并发成功的突变使区域未定义,但保持一致:所有客户端都看到相同的数据,但它可能不反映任何一个突变所写的内容。通常,它由来自多个突变的混合片段组成。失败的突变使区域不一致(因此也是未定义的):不同的客户端可能在不同的时间看到不同的数据。我们将在下面描述应用程序如何区分已定义的区域和未定义的区域。应用程序不需要进一步区分不同类型的未定义区域。

数据变化可能是写入或追加记录。写操作导致在应用程序指定的文件偏移量处写入数据。记录追加导致数据(“记录”)至少自动追加一次,即使在并发突变存在的情况下,但在GFS选择的偏移量(3.3节)。(相反,“常规”追加只是在客户端认为是文件当前结束的偏移量处进行写操作。)偏移量返回给客户端,并标记包含该记录的已定义区域的开始。此外,GFS可以在两者之间插入填充或记录重复。它们占用的区域被认为是不一致的,并且通常与用户数据量相比显得微不足道。
在一系列成功的突变之后,保证被突变的文件区域被定义,并包含由最后一次突变写入的数据。GFS通过(a)在其所有副本上以相同的顺序对块应用突变(章节3.1),以及(b)使用块版本号来检测任何由于在其chunkserver关闭时错过突变而变得过时的副本(章节4.5)来实现这一点。失效副本将永远不会涉及到突变,也不会给客户端请求主服务器的块位置。他们是垃圾收集在最早的机会。
由于客户端缓存块位置,因此它们可能会在更新该信息之前从过时的副本中读取数据。该窗口受缓存项超时和下次打开文件的限制,该文件将从缓存中清除该文件的所有块信息。此外,由于我们的大多数文件都是仅追加的,过期的副本通常返回块的过早结束,而不是过时的数据。当读取器重新尝试并联系主服务器时,它将立即获得当前块的位置。
在成功的突变之后很长一段时间,组件故障当然仍然会损坏或破坏数据。GFS通过主服务器和所有chunkserver之间的定期握手来识别故障的chunkserver,并通过校验和来检测数据损坏(章节5.2)。一旦问题出现,数据会尽快从有效的副本中恢复(章节4.3)。只有在GFS做出反应之前(通常在几分钟内)所有副本都丢失时,块才会不可逆转地丢失。即使在这种情况下,它也变得不可用,而不是损坏:应用程序接收到明显的错误,而不是损坏的数据。
对应用的影响
GFS应用程序可以使用一些其他用途已经需要的简单技术来适应宽松的一致性模型:依赖追加而不是覆盖、检查点和编写自我验证、自我识别的记录。
实际上,我们所有的应用程序都是通过追加而不是覆盖来改变文件的。在一种典型的用法中,编写器从头到尾生成一个文件。它在写入所有数据后自动将文件重命名为永久名称,或者定期检查成功写入了多少数据。检查点还可能包括应用程序级别的校验和。读取器只验证和处理文件区域,直到最后一个检查点,该检查点已知处于定义状态。不考虑一致性和并发性问题,这种方法对我们很有帮助。对于应用程序故障,追加操作比随机写操作更有效,更有弹性。检查点允许写入器以增量方式重新启动,并防止读取器处理从应用程序的角度来看仍然不完整的已成功写入的文件数据。
在另一种典型使用中,许多写入器并发地挂起到一个文件,用于合并结果或作为生产者-消费者队列。Record append的“至少追加一次”语义保留了每个写入器的输出。读者按如下方式处理偶尔出现的填充和重复。编写者准备的每条记录都包含额外的信息,如校验和,以便验证其有效性。阅读器可以使用校验和识别并丢弃多余的填充和记录片段。如果它不能容忍偶尔的重复(例如,如果它们会触发非幂等操作),它可以使用记录中的唯一标识符将它们过滤掉,这通常需要为相应的应用程序实体(如web文档)命名。这些记录I/O的功能(除去重复)是在我们的应用程序共享的库代码中,并适用于Google的其他文件接口实现。这样,相同的记录序列,加上很少的重复,总是传递给记录读取器。
系统交互
我们设计这个系统是为了尽量减少master对所有操作的参与。在此背景下,我们现在描述客户端、主服务器和块服务器如何交互以实现数据突变、原子记录追加和快照。
租约和突变顺序
突变是一种改变数据块内容或元数据的操作,例如写操作或追加操作。每个突变都在所有块的副本上执行。我们使用租约来维护跨副本的一致的突变顺序。主服务器将块租期授予其中一个副本,我们称之为主服务器。主节点为数据块的所有突变选择一个serialorder。当应用突变时,所有副本都遵循这个顺序。因此,全局mutationorder首先由master选择的租期授予顺序定义,在租期内由primary分配的序列号定义。
租期机制的设计目的是最小化主服务器上的管理开销。租约的初始超时为60秒。但是,只要数据块被修改,主服务器就可以无限期地请求和接收主服务器的扩展。这些扩展请求和授予承载在主服务器和所有块服务器之间定期交换的HeartBeat消息上。有时,主服务器可能会尝试在租约到期之前撤销租约(例如,当主服务器想要禁用正在重命名的文件的突变时)。即使主服务器失去了与主服务器的通信,它也可以在旧租约到期后安全地将新租约授予另一个副本。
在图2中,我们通过这些编号的步骤,遵循写操作的控制流来说明这个过程。

- 客户端询问主服务器哪个chunkserver持有该块的当前租约以及其他副本的位置。如果没有人拥有租约,主服务器就会将租约授予它选择的副本(未显示)。
- 主服务器返回主服务器的身份和其他(辅助)副本的位置。客户端缓存这些数据以备将来发生变化。只有当主服务器无法访问或回复它不再持有租约时,它才需要再次与主服务器联系
- 客户端将数据推送到所有副本。客户端可以以任何顺序执行此操作。每个chunkserver将数据存储在内部LRU缓冲区缓存中,直到数据被使用或老化。通过将数据流与控制流解耦,我们可以根据网络拓扑来调度昂贵的数据流,而不管哪个chunkserver是主服务器,从而提高性能。第3.2节进一步讨论了这一点。
- 一旦所有副本都确认接收到数据,客户机就向主服务器发送写请求。请求标识早先推送到所有副本的数据。主服务器为它接收到的所有突变(可能来自多个客户机)分配连续的序列号,这提供了必要的序列化。它按序列号顺序将突变应用到自己的局部状态。
- 主服务器将写请求转发给所有从服务器副本。每个次要副本按照主副本分配的相同序列号顺序应用突变。
- 次要服务器都回复主服务器,表明它们已完成操作。
- 主服务器响应客户端。在任何副本上遇到的任何错误都会报告给客户端。如果出现错误,则可能在主副本和辅助副本的任意子集上写操作成功。(如果它在主服务器上失败了,它就不会被分配序列号并转发。)客户端请求被认为失败,修改后的区域处于不一致状态。我们的客户端代码通过重新尝试失败的突变来处理此类错误。它将在步骤(3)到步骤(7)上进行一些尝试,然后从写入的开始重新尝试。
如果应用程序的写操作很大或跨越块边界,GFS客户机代码将其分解为多个写操作。它们都遵循上面描述的控制流,但可能与来自其他客户机的并发操作交叉或覆盖。因此,共享文件区域最终可能包含来自不同客户端的片段,尽管副本将是相同的,因为在所有副本上以相同的顺序成功完成了单个操作。这使得文件区域处于一致但未定义的状态,如2.7节所述。
数据流
我们将数据流与控制流解耦,以有效地利用网络。当控制从客户机流向主服务器,然后流向所有辅助服务器时,数据以流水线方式沿着精心挑选的块服务器链线性推送。我们的目标是充分利用每台机器的网络带宽,避免网络瓶颈和高延迟链接,并最大限度地减少延迟来推送所有数据。
为了充分利用每台机器的网络带宽,数据沿着块服务器链线性推送,而不是分布在一些其他拓扑结构中(例如,树)。因此,每台机器的全部出站带宽用于尽可能快地传输数据,而不是在多个接收方之间分配。
为了尽可能避免网络瓶颈和高延迟链路(例如,交换机间链路通常两者都有),每台机器将数据转发到网络拓扑中“最近的”尚未接收到数据的机器。假设客户端将数据推送到块服务器S1到S4。它将数据发送到最近的块服务器,比如S1。S1转发到最近的块服务器S2,通过最接近S1的S4,比如S2。同样,S2将其转发给S3或S4,哪个更接近S2,依此类推。我们的网络拓扑结构非常简单,可以从IP地址准确地估计“距离”。
最后,我们通过在TCP连接上进行数据传输来最小化延迟。一旦chunkserver接收到一些数据,它就立即开始转发。流水线对我们特别有帮助,因为我们使用全双工链路的交换网络。立即发送数据不会降低接收速率。在没有网络拥塞的情况下,将B个字节传输到R个副本的理想耗时是B/T + RL,其中T是网络吞吐量,L是两台机器之间传输字节的延迟时间。我们的网络链路通常是100mbps (T),而L远低于1ms。因此,理想情况下,1mb可以在大约80ms内分配。
原子记录追加
GFS提供了一个称为recordappend的原子追加操作。在传统的写操作中,客户端指定要写入数据的偏移量。并发写到同一区域是不可序列化的:该区域可能最终包含来自多个客户端的数据片段。然而,在recordappend中,客户端只指定数据。在GFS选择的偏移量处,它至少自动地将它发送到文件一次(即,作为一个连续的字节序列),并将该偏移量返回给客户端。这类似于书面。在Unix中,当多个写入器并发地以0_APPEND模式打开文件时,不存在任何条件。
记录追加被我们的分布式应用程序大量使用,在这些应用程序中,不同机器上的许多客户端并发地追加同一个文件。如果客户端使用传统的写操作,则需要额外的复杂和昂贵的同步,例如通过分布式锁管理器。在我们的工作负载中,此类文件通常用作多生产者/单消费者队列,或者包含来自许多不同客户机的合并结果。
记录追加是一种变异,它遵循第3.1节中的控制流,只是在主端增加了一点额外的逻辑。客户端将数据推送到文件最后一个块的所有副本,然后将其请求发送到主服务器。主要检查将记录追加到当前块是否会导致块超过最大大小(64 MB)。如果是,它将块填充到最大大小,告诉辅助服务器也这样做,并回复客户端,指示应该在下一个块上重试该操作。(Record append被限制最多为最大块大小的四分之一,以使最坏情况下的碎片保持在可接受的水平。)如果记录符合最大大小(这是常见的情况),则主服务器将数据附加到其副本,告诉辅助服务器将数据写入它所处的精确偏移位置,并最终向客户端返回成功。
如果在任何副本上添加记录失败,则客户端会重试该操作。因此,同一块的副本可能包含不同的数据,可能包括相同记录的全部或部分副本。GFS不保证所有副本在字节方面都是相同的。它只保证数据作为一个原子单元至少写入一次。这个属性很容易从一个简单的观察中得出,即为了使操作报告成功,数据必须在某个块的所有副本上以相同的偏移量写入。此外,在此之后,所有副本至少与记录结束一样长,因此任何未来的记录将被分配更高的偏移量或不同的块,即使不同的副本后来成为主副本。就我们的一致性保证而言,成功的记录追加操作写入数据的区域是定义的(因此是一致的),而中间区域是不一致的(因此是未定义的)。我们的应用程序可以处理不一致的区域,正如我们在2.7.2节中讨论的那样。
快照
快照操作几乎是立即生成文件或目录树(“源”)的副本,同时最小化正在进行的更改的任何中断。我们的用户使用它来快速创建庞大数据集的分支副本(通常是这些副本的副本,递归地),或者在实验可以稍后提交或回滚的更改之前检查当前状态。
像AFS一样,我们使用标准的写时复制技术来实现快照。当主服务器接收到快照请求时,它首先撤销即将快照的文件中所有未到期的租约。这确保了对这些块的任何后续写操作都需要与主服务器进行交互才能找到租约持有者。这将使主服务器有机会首先创建块的新副本。
在租约被撤销或过期后,主服务器将操作记录到磁盘。然后,它通过复制源文件或目录树的元数据,将此日志记录应用于其内存状态。新创建的快照文件指向与源文件相同的块。
在快照操作之后,客户端第一次想要写入块C时,它向主服务器发送一个请求以查找当前的租约持有者。主服务器注意到块C的引用计数大于1。它延迟响应客户端请求,而是选择一个新的块句柄C’。然后,它要求每个拥有当前C副本的chunkserver创建一个名为C ‘的新块。通过在与原始块服务器相同的chunkserver上创建新块,我们可以确保数据可以在本地复制,而不是通过网络复制(我们的磁盘的速度大约是100 Mb以太网链路的三倍)。从这一点来看,请求处理与任何块的处理没有什么不同:主服务器授予其中一个副本对新块C ‘的租约,并回复客户端,客户端可以正常写块,而不知道它刚刚从现有块中创建。
master操作
主节点执行所有命名空间操作。此外,它管理整个系统的块副本:它做出放置决策,创建新的块和副本,并协调各种系统范围的活动以保持块完全复制,平衡所有块服务器之间的负载,并回收未使用的存储。我们现在讨论每一个主题。
命名空间管理和锁
许多主操作可能需要很长时间:例如,快照操作必须撤销快照覆盖的所有块的chunkserver租约。我们不希望在其他主操作运行时延迟它们。因此,我们允许多个操作处于活动状态,并在名称空间的区域上使用锁来确保正确的序列化。
与许多传统文件系统不同,GES没有列出该目录中所有文件的每个目录数据结构。它也不支持同一文件或目录的别名(即Unix术语中的硬链接或符号链接)。GFS逻辑上将其命名空间表示为将完整路径名映射到元数据的查找表。通过前缀压缩,这个表可以有效地在内存中表示。命名空间树中的每个节点(绝对文件名或绝对目录名)都有一个关联的读写锁。
每个主操作在运行前都会获取一组锁。通常,如果它涉及/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上获得冲突的锁。文件创建不需要在父目录上加写锁,因为没有类似于orinode的“目录”数据结构需要保护以免被修改。名称上的读锁足以保护父目录不被删除。
这种锁定方案的一个很好的特性是,它允许在同一目录中并发地进行修改。例如,可以在同一目录中并发地执行多个文件创建:每个文件都获得目录名上的读锁和文件名上的写锁。目录名上的读锁足以防止目录被删除、重命名或快照。文件名上的写锁序列化两次创建同名文件的尝试。
由于名称空间可以有许多节点,因此会惰性地分配读写锁对象,并在不使用时删除它们。此外,锁是按照一致的总顺序获取的,以防止死锁:它们首先在名称空间树中按级别排序,然后在同一级别内按字典顺序排序
副本放置
GFS集群在多个级别上高度分布。它通常有数百个块服务器分布在许多机器机架上。这些块服务器反过来可能被来自相同或不同轨道的数百个客户端访问。不同轨道上的两台机器之间的通信可以通过一个或多个网络交换机。此外,进出机架的带宽可能小于机架内所有机器的总带宽。多级分布对数据的可伸缩性、可靠性和可用性提出了独特的挑战。
块副本放置策略有两个目的:最大限度地提高数据的可靠性和可用性,最大限度地提高网络带宽利用率。对于这两种情况,将副本分散到机器上是不够的,这只能防止磁盘或机器故障,并充分利用每台机器的网络带宽。我们还必须跨机架散布块副本。这确保了即使整个机架损坏或故障(例如,由于网络交换机或电源电路等共享资源的故障),块的一些副本仍然存在并保持可用。这也意味着数据块的流量,尤其是读取,可以利用多个机架的聚合带宽。另一方面,写流量必须流经多个机架,这是我们愿意做出的权衡。
创建、重新复制、再平衡
创建块副本有三个原因:块创建、重新复制和再平衡。
当主服务器创建一个块时,它选择在哪里放置最初的空副本。它考虑了几个因素:(1)我们希望将新的副本放置在磁盘空间利用率低于平均水平的块服务器上。(2)我们希望限制每个chunkserver上“最近”创建的数量,尽管创建本身很便宜,但它可靠地预测了即将到来的繁重写流量,因为块是在写请求时创建的,而在我们的“附加一次读多次”工作负载中,一旦它们被完全写入,它们通常实际上是只读的。(3)如上所述,我们希望在机架上分散块的副本。
只要可用副本的数量低于用户指定的目标,主服务器就会重新复制一个块。发生这种情况的原因有很多:块服务器不可用,报告其副本可能损坏,其中一个磁盘因错误而禁用,或者复制目标增加。需要重新复制的每个块都基于几个因素进行优先级排序。一个是它离复制目标有多远。例如,我们给予丢失两个副本的块比只丢失一个副本的块更高的优先级。此外,我们更倾向于首先为活文件重新复制块,而不是属于最近删除的文件的块(参见第4.4节)。最后,为了最小化故障对运行应用程序的影响,我们提高了阻塞客户端进程的任何块的优先级。
master选择优先级最高的块,并通过指示一些chunkserver直接从现有的有效副本复制块数据来“克隆”它。放置新副本的目标与创建副本的目标类似:均衡磁盘空间利用率,限制任何单个chunkserver上的活动克隆操作,以及跨机架分布副本。为了防止克隆流量压倒客户端流量,主服务器限制了集群和每个块服务器的活动克隆操作的数量。此外,每个chunkserver通过限制对源chunkserver的读请求来限制它在每个克隆操作上花费的带宽量
最后,主服务器定期重新平衡副本:它检查当前副本分布并移动副本以获得更好的磁盘空间和负载平衡。此外,通过这个过程,主服务器逐渐填满一个新的chunkserver,而不是立即用新的块和随之而来的沉重的写流量淹没它。新副本的放置标准与上面讨论的相似。此外,主服务器还必须选择要删除的现有副本。通常,它倾向于删除空闲空间低于平均水平的块服务器上的那些块服务器,以便均衡磁盘空间使用。
垃圾回收
文件删除后,GFS不会立即回收可用的物理存储。它仅在文件和块级别的常规垃圾收集期间惰性地执行此操作。我们发现这种方法使系统更简单,更可靠。
机制
当应用程序删除一个文件时,主机会立即记录删除操作,就像其他更改一样。但是,不是立即回收资源,而是将文件重命名为包含删除时间戳的隐藏名称。在主服务器对文件系统名称空间进行常规扫描期间,如果这些隐藏文件存在超过三天(时间间隔是可配置的),它将删除它们。在此之前,文件仍然可以在新的特殊名称下读取,并且可以通过将其重命名为正常名称来恢复删除。当从名称空间中删除隐藏文件时,将擦除其内存中的元数据。这有效地切断了它与所有块的链接。
在对块命名空间进行类似的常规扫描时,主服务器会识别孤立的块(即无法从任何文件访问的块),并擦除这些块的元数据。在与主服务器定期交换的HeartBeat消息中,每个chunkserver报告它拥有的块的一个子集,主服务器用不再出现在主元数据中的所有块的标识进行回复。chunkserver可以自由地删除这些块的副本。
讨论
尽管分布式垃圾收集是一个难题,在编程语言的上下文中需要复杂的解决方案,但在我们的示例中它非常简单。我们可以很容易地识别所有对块的引用:它们位于由主服务器专门维护的文件到块的映射中。我们也可以很容易地识别所有的块副本:它们是每个块服务器上指定目录下的Linux文件。任何不为主机所知的副本都是“垃圾”。
与急于删除相比,垃圾收集存储回收方法有几个优点。首先,它在组件故障常见的大规模分布式系统中简单可靠。在一些块服务器上创建块可能会成功,而在其他块服务器上则不能,从而留下主服务器不知道存在的副本。副本删除消息可能会丢失,并且主服务器必须记住在失败时重新发送它们,无论是它自己的还是块服务器的。垃圾收集提供了一种统一且可靠的方式来清理任何已知无用的副本。其次,它将存储回收合并到主服务器的常规后台活动中,例如定期扫描名称-空间和与块服务器的握手。因此,它是分批完成的,成本是平摊的。此外,只有在主人相对自由的情况下才会这样做。对于需要及时关注的客户端请求,主服务器可以更迅速地作出响应。第三,回收存储的延迟提供了一个安全网,防止意外的、不可逆转的删除。
在我们的经验中,主要的缺点是,当存储空间很紧时,延迟有时会妨碍用户优化使用。重复创建和删除临时文件的应用程序可能无法立即重用存储。如果已删除的文件再次显式删除,我们通过加速存储回收来解决这些问题。我们还允许用户对名称空间的不同部分应用不同的复制和回收策略。例如,用户可以指定在某个目录树中存储文件中的所有块而不进行复制,并且任何被删除的文件将立即且不可撤销地从文件系统状态中删除。
失效副本检测
如果块服务器发生故障,并且在它关闭时错过了对块的更改,则块副本可能会过时。对于每个块,主服务器维护一个块版本号,以区分最新副本和过期副本。
每当主服务器授予一个块的新租约时,它都会增加块的版本号,并通知最新的副本。主服务器和这些副本都在它们的持久状态中记录新的版本号。这发生在任何客户机收到通知之前,因此在它可以开始向块写入之前。如果另一个副本当前不可用,则不会提高其块版本号。当chunkserver重新启动时,主服务器将检测到这个chunkserver有一个过时的副本,并报告它的块集及其相关的版本号。如果主服务器看到的版本号大于其记录中的版本号,则主服务器认为它在授予租约时失败,因此采用较高的版本作为最新版本。
主服务器在其常规垃圾收集中删除过时的副本。在此之前,当它应答客户端请求块信息时,它有效地认为陈旧的副本根本不存在。作为另一种保护措施,当主服务器通知客户端哪个chunkserver持有某个块的租约,或者在克隆操作中指示一个chunkserver从另一个chunkserver读取该块时,主服务器包含了块的版本号。客户端或块服务器在执行操作时验证版本号,以便始终访问最新的数据。
容错与诊断
我们在设计系统时面临的最大挑战之一是处理频繁的组件故障。部件的质量和数量加在一起,使这些问题成为常态,而不是例外:我们不能完全相信机器,也不能完全相信磁盘。组件故障可能导致系统不可用,或者更糟的是数据损坏。我们将讨论如何应对这些挑战,以及我们在系统中内置的工具,以便在不可避免地出现问题时进行诊断。
高可用
在GFS集群中的数百台服务器中,某些服务器在任何给定时间都不可用。我们通过两种简单而有效的策略来保持整个系统的高可用性:快速恢复和复制
快速恢复
主服务器和chunkserver都被设计为重新存储它们的状态并在几秒钟内启动,无论它们如何终止。实际上,我们不区分正常终止和异常终止;服务器通常通过终止进程来关闭。客户机和其他服务器会遇到一个小问题,因为它们会超时处理未完成的请求,重新连接到重新启动的服务器,然后重试。第6.2.2节报告观察到的启动时间。
块复制
如前所述,每个块被复制到不同机架上的多个块服务器上。用户可以为文件命名空间的不同部分指定不同的复制级别。默认值是3。当chunkserver脱机或通过校验和验证检测损坏的副本时,主服务器根据需要克隆现有副本以保持每个块的完全复制(参见5.2节)。尽管复制已经为我们提供了很好的服务,但我们正在探索其他形式的跨服务器冗余,例如奇偶校验或擦除码,以满足我们不断增长的只读存储需求。我们预计,在我们非常松散耦合的系统中实现这些更复杂的冗余方案是具有挑战性的,但也是可以管理的,因为我们的流量主要是追加和读取,而不是小的随机写入。
主节点复制
复制主状态是为了提高可靠性。它的操作日志和检查点被复制到多台机器上。只有在其日志记录被刷新到本地磁盘和所有主副本上后,才认为状态的突变已提交。为简单起见,一个主进程仍然负责所有的突变以及在内部改变系统的后台活动,如垃圾收集。当它失败时,它几乎可以立即重新启动。如果它的机器或磁盘发生故障,监视GFS外部的基础设施就会在其他地方启动一个新的主进程,并使用复制的操作日志。客户端只使用主服务器的规范名称(例如gfs-test),这是一个DNS别名,如果主服务器被重新定位到另一台机器上,则可以更改该别名。
此外,“影子”主服务器提供对文件系统的只读访问,即使主服务器关闭时也是如此。它们是影子,而不是镜子,因为它们可能会稍微落后于主光源,通常是几分之一秒。它们增强了没有主动变化的文件或不介意得到稍微陈旧的结果的应用程序的读可用性。实际上,由于文件内容是从chunkserver读取的,所以应用程序不会观察到过时的文件内容。在短窗口内可能过时的是文件元数据,如目录内容或访问控制信息。
为了保持自己的信息,一个shadowmaster读取一个不断增长的操作日志的副本,并将相同的更改顺序应用到它的数据结构中,就像primary所做的那样。与主服务器一样,它在启动时轮询块服务器(之后很少轮询)以定位块副本,并与它们频繁交换握手消息以监视它们的状态。它仅在由主服务器决定创建和删除副本而产生的副本位置更新时依赖于主服务器。
数据完整性
每个chunkserver使用校验和来检测存储数据的损坏。考虑到GFS集群通常在数百台机器上有数千个磁盘,它经常会遇到磁盘故障,导致读取和写入路径上的数据损坏或丢失。(原因之一见第7节。)我们可以使用其他块副本从损坏中恢复,但是通过比较跨块服务器的副本来检测损坏是不切实际的。此外,不同的副本可能是合法的:GFS突变的语义,特别是前面讨论的原子记录追加,不能保证相同的副本。因此,每个chunkserver必须通过维护校验和来独立地验证其副本的完整性。
一个块被分成64 KB的块。每个都有一个相应的32位校验和。与其他元数据一样,校验和保存在内存中,并使用日志进行持久存储。与用户数据分离。
对于读取,chunkserver在返回任何数据给请求者(无论是客户端还是另一个chunkserver)之前,验证与读取范围重叠的数据锁的校验和。因此,块服务器不会将损坏传播到其他机器。如果一个块与recordedchecksum不匹配,chunkserver返回一个错误给requestoland,并将不匹配报告给master。作为响应,请求者将从其他副本读取数据,而主服务器将从另一个副本克隆数据块。在一个有效的newreplica到位之后,master指示报告不匹配的chunkserver删除它的副本。
校验和对读性能的影响很小,原因如下。由于我们的大多数读取至少跨越几个块,因此我们只需要读取和校验相对少量的额外数据以进行验证。GES客户端代码通过尝试在校验和块边界上对齐读取来进一步减少这种开销。此外,chunkserver上的校验和查找和比较不需要任何io,校验和计算通常可以与io重叠。
校验和计算在很大程度上优化了附加到块末尾的写操作(而不是覆盖现有数据的写操作),因为它们在我们的工作负载中占主导地位。我们只是增量地更新最后一个部分校验和块的校验和,并为任何由追加部分填充的全新校验和块计算新的校验和。即使最后的部分校验和块已经损坏,我们现在也无法检测到它,新的校验和值将与存储的数据不匹配,并且在下次读取块时将像往常一样检测到损坏。
相反,如果写操作覆盖了块的现有范围,我们必须读取并验证被覆盖范围的第一个和最后一个块,然后执行写操作,最后计算并记录新的校验和。如果我们在部分覆盖第一个和最后一个块之前不验证它们,那么新的校验和可能会隐藏存在于未被覆盖的区域中的损坏。
在空闲期间,chunkserver可以扫描和验证非活动块的内容。这允许我们检测很少被读取的块中的损坏。一旦检测到损坏,主服务器就可以创建一个新的未损坏的副本,并删除损坏的副本。这可以防止不活动但损坏的块副本欺骗主服务器,使其认为它有足够的有效块副本。
诊断工具
广泛而详细的诊断日志记录在问题隔离、调试和性能分析方面提供了显著的帮助,同时只产生最小的成本。如果没有日志,就很难理解机器之间短暂的、不可重复的交互。GFS服务器生成诊断日志,这些日志记录了许多重要事件(例如chunkserver上升和下降)以及所有RPC请求和应答。这些诊断日志可以随意删除,不会影响系统的正确性。然而,我们尽量在空间允许的范围内保留这些日志。
RPC日志包括在网络上发送的准确的请求和响应,但正在读取或写入的文件数据除外。通过将请求与应答进行匹配并整理不同机器上的RPC记录,我们可以重建整个交互历史以诊断问题。日志还可以作为负载测试和性能分析的跟踪。
日志记录对性能的影响很小(而且远远超过了它的好处),因为这些日志是按顺序异步写入的。最近的事件也保存在内存中,可用于连续在线监视。
总结
Google文件系统展示了在商品硬件上支持大规模数据处理工作负载所必需的质量。虽然有些设计决策是特定于我们独特的环境,但许多设计决策可能适用于类似规模和成本意识的数据处理任务。
我们首先根据当前和预期的应用程序工作负载和技术环境重新检查传统的文件系统假设。我们的观察导致了设计空间中完全不同的观点。我们将组件故障视为常态,而不是异常,优化主要追加(可能并发)然后读取(通常顺序)的大文件,扩展和放松标准文件系统接口,以改进整个系统。
我们的系统通过持续监控、复制关键数据和快速自动恢复提供容错功能。块复制允许我们容忍块服务器故障。这些故障的频率激发了一种新的在线修复机制,该机制定期和透明地修复损坏并尽快补偿丢失的副本。此外,我们使用校验和来检测磁盘或IDE子系统级别的数据损坏,鉴于系统中的磁盘数量,这种情况变得非常常见。
我们的设计为执行各种任务的许多并发读取器和写入器提供了高聚合吞吐量。我们通过分离文件系统控制(通过主服务器)和数据传输(直接在块服务器和客户端之间传递)来实现这一点。通过较大的块大小和块租约(将权限委托给数据突变中的主副本),可以最大限度地减少主操作的参与。这使得一个简单的、集中式的主机不会成为瓶颈成为可能。我们相信,网络堆栈的改进将解除当前对单个客户端看到的写吞吐量的限制。
GFS已经成功地满足了我们的存储需求,并在Google内部广泛用作研发和生产数据处理的存储平台。它是一个重要的工具,使我们能够继续创新,并在整个网络的规模上解决问题。