周末躺不平,卷不动,摆不烂,随便翻译一篇Paper吧。配合MIT6.824 Lecture3来看可能效果会更好。
原文:The Google File System
作者:Sanjay Ghemawat, Howard Gobioff, and Shun-Tak Leung @ Google
引言
我们设计并实现了Google File System(GFS)来满足谷歌日益增长的数据处理需求。与先前的分布式文件系统一样,GFS的目标中也包括性能、扩展性、可靠性以及可用性等,只不过它的设计是由我们的应用在当前以及未来的工作负载以及技术环境来驱动的,这与早期的那些系统的一些设计假设是明显偏离的。我们重新审视了传统的设计选择,并从根本上探寻设计上的不同点。
首先,组件错误(component failures)并不是意外,而是常态。文件系统是由成百上千的廉价设备构成的,并且会被数量相当可观的客户端机器访问。机器的数量和质量导致在某个时间点,一些机器可能使无法正常工作的,并且可能有一些并不能从当前的错误中恢复。从我们的经验来看,错误可能来自应用bug、操作系统bug、人工错误以及磁盘、内存、连接器、网络甚至电源故障。因此,一个持久的检测、错误检测、容错以及自动恢复机制必须整合到系统中。
其次,如果用传统的标准来看,我们的文件非常庞大,几个GB的文件是家常便饭,每一个文件通常包含许多应用对象,比如web文档。当我们要经常处理大量TB级的,由数十亿对象构成的快速增长的数据集时,没人会想要用几十亿个KB级的文件来保存它们,即使操作系统可以支持你这样做。所以,一些设计假设以及类似IO操作、块大小这种参数需要被重新考量。
第三,许多文件的修改都是追加新数据,而非覆盖已有数据,实践中几乎不会对一个文件进行随机写,那些被写入的数据通常都只会被读取,并且经常是顺序读取的,很多种数据都具有这种特性。比如,一些可能构建成了一个大的仓库,并且有一个数据分析程序会扫描这些数据;一些可能使由正在运行的程序持续生成的数据流;另一些则可能是归档数据;一些可能作为媒介存储一个机器生产的结果,另一个机器会同步的或在稍后去处理它。在这种大文件的访问模式下,追加操作变成了性能优化以及原子性保证的焦点,也正是因此,在客户端缓存数据块的做法也许就没那么有吸引力了。
译者:如果读写是随机的,那么在客户端缓存数据块更容易带来性能提升,考虑操作系统的page cache、数据的buffer pool、CPU上的三级缓存。但一旦这个读写是顺序的,缓存数据块的提升便没那么大,而且还要考虑分布式系统中缓存一致性的问题,导致整个系统复杂化。
最后,采用应用与文件系统API一起设计提升了我们的灵活性,从而使得整个系统受益。举个例子,我们已经放宽了GFS的一致性模型以在不用给程序带来额外负担的情况下大量简化我们的系统。我们也引入了原子追加操作,这样多个客户端可以向一个文件中并发的追加而不需要在它们之间有任何额外的同步。我们会在后面讨论这些。
目前,我们已经为不同的需求部署了多个GFS集群,最大的已经超过1000个存储节点,300TB的磁盘空间,数以百计的客户端在不同的机器上持续且频繁的访问这些集群。
概要设计
假设
在设计一个符合我们需求的文件系统时,我们一直以这些机会与挑战并存的假设作为指导。之前我们已经提到了一些,现在我们将更详细的说明我们的假设。
- 系统使用很多便宜的、经常出错的组件构建。系统必须持续监控自己并探测、容错以及从错误组件中恢复。
- 系统存储中等数量的大文件,预期是几百万个文件,通常在100MB或更大的大小。几个GB的文件将更为常见,并且我们需要它被高效的管理。小文件也是受支持的,但并不需要为它们而优化。
- 主要的工作负载包含两类读取:大的流式读取以及小的随机读取。在大的流式读取中,单个操作通常读取几百KB,更常见的是1MB或更多,同一客户端的后续操作通常会读取一个文件的连续区域。小的随机读取通常在任意offset读取几KB的数据。注重性能的程序通常会将小读取打包并排序以在程序中稳步前进,而非来回移动。
- 我们也会有很多大的,顺序写的工作负载,即向文件中追加数据。通常,这些操作的大小和读取时的非常类似,一旦写入,文件几乎不会被修改。在任意位置的小型写入也支持,但是不会太高效。
- 系统必须高效的实现多个客户端并发追加到同一文件的well-define的语义。我们的文件通常被用在生产者、消费者队列或多方合并上,几百个生产者在独立的机器上运行,会并发的追加到文件中,所以最小化同步开销的原子性保证是必要的。文件在稍后会被读取,或者同时被消费者读取。
- 高持续的带宽比低延迟更加重要。我们的很多目标程序都非常重视以高速率批量的处理数据,而很少有程序对单个读或写的响应时间有严格要求。
接口
GFS提供了一个与文件系统接口类似的接口,但它没有实现如POSIX之类的标准API。文件被目录组织出层级结构,并且可以通过路径名唯一标识。我们支持常见操作:create、delete、open、close、read以及write文件。
此外,GFS具有snapshot(快照)以及record append(记录追加)操作。快照可以以低成本创建一个文件或目录树的拷贝;记录追加允许多个客户端并发地向同一个文件追加数据,同时保证每一个单独的客户端追加的原子性。这在实现多方合并结果以及生产者消费者队列时非常有用,在这些场景中,许多客户端会在不使用额外的锁的情况下同时追加,我们发现这种类型的文件在构建大型分布式应用时是很有用的。快照以及记录追加将在3.4、3.3节进行相应的讨论。
架构
一个GFS集群包含单一的master以及多个chunkserver,集群会被多个client访问,如图一所示。每一个都是一个典型的运行用户级服务进程的商用Linux计算机。在机器资源允许并且可以忍受由于较为不稳定的应用代码带来的低可靠性可以被忍受的情况下,将chunkserver和client运行在同一台机器上也没什么不妥。

文件被分割成固定大小的chunk,每一个chunk被一个全局的、不可变的64位chunk handle标识,chunk handle由master节点在chunk创建时分配。chunkserver将chunk以Linux文件的形式存储在本地磁盘上,并且对chunk文件的读写操作都是被一个chunk handle和字节范围指定的。出于可靠性,每一个chunk被复制到多个chunkserver上。默认情况下,我们保存三份副本,并且用户可以对文件命名空间的不同区域设置不同的复制级别。
master维护所有文件的元数据,包括命名空间(namespace)、访问控制信息(access control information)、文件到chunks的映射、chunks的当前位置(location)。它也会控制系统范围的活动,比如chunk租期管理、孤儿chunk的垃圾回收以及chunk在chunkserver之间的合并。master会周期性的通过HeartBeat消息与每一个chunkserver交流,以给它们发送指令以及收集它们的状态。
译者:考虑单个大文件被分成多个大小相等的chunk,并且每一个chunk会被复制到多台chunkserver上,chunkhandle是一个chunk的标识。那么client想要访问某一个文件的某一个chunk时,它要计算出一个chunk id(比如文件第一个chunk的id为0,第二个为1),所以master必须维护一个文件名、chunk id到对应chunkhandle的映射。此外,一个chunkhandle所标识的chunk会在多个副本上,master必须有一个chunkhandle到它代表的所有chunk列表的映射。
链接到每个应用程序的GFS客户端代码实现文件系统的API,并于master和chunkserver通信,代表该应用程序来读写数据。客户端与master交互来获得元数据,但所有数据交流都是直接和chunkserver进行的。我们不需要实现POSIX API,因此不需要hook到Linux的vnode层。
client和chunkserver都不需要缓存文件数据。client缓存的收益很小,因为大多数应用流式访问大文件,或者有着大到无法缓存的工作集。而不使用缓存会消除缓存一致性问题,从而简化client以及系统整体。而chunkserver不需要缓存文件数据,因为chunk就存在于它们的本地文件中,Linux的buffer cache已经将频繁访问的数据保持在内存中了。
单Master
使用单master极大程度的简化了我们的设计,并且允许master使用它的全局知识去做精确的chunk放置以及复制决策。然而,我们必须最小化它在读写操作中的参与程度,以让它不至于成为系统的瓶颈。client永远不会通过master来读写文件数据,它只是询问master它应该联系哪个chunkserver,它在有限的时间内缓存这些信息,并且,余下的操作都直接和chunkserver交互。
我们来解释下图1中那个简单读操作的交互吧。首先,使用固定大小的chunk,client可以将文件名和byte offset翻译成文件的chunkindex,然后,它给master发送一个包含文件名和chunkindex的请求,master回复对应的chunkhandle以及副本们的位置,client使用文件名和chunkindex作为key缓存这个信息。
然后,client便可以发送请求到这些副本其中之一了,通常是最近的那个。请求指定要读取的chunkhandle以及该chunk中的字节范围。在同一个chunk的后续读取不再需要client-master之间的交互了,直到缓存信息过期或者文件被重新打开。事实上,client通常会在一个请求中询问多个chunk,master也可以包含那些它们后续即将请求的那些chunk的信息,这些额外信息避免了未来的几次client-master交互,并且几乎是无成本的。
chunk大小
chunk大小是一个关键设计参数。我们选择了64MB,这要比常见的文件系统块大小大得多。每一个chunk的副本在一个chunkserver上以一个普通Linux文件存储,并且它们是按需扩展的。惰性空间分配避免了由于内部碎片带来的空间浪费,或许这也是对“使用如此之大的chunk大小”最大的一个反对原因。
大chunk提供了很多重要的优势。首先,它降低了客户端与master的交互需求,因为对一个chunk的读写只需要一个初始的请求。对于我们的工作负载,这种降低更加明显,因为应用大部分都是顺序的读写大文件。即使是对于小的随机读,对于一个几TB级别的working set,client也可以轻易的缓存所有的chunk位置信息。另外,由于大chunk,client将会更加倾向于在一个给定chunk上执行许多操作,通过延长一段时间持久化到chunkserver的TCP连接,可以减少网络开销。第三,这减少了保存在master上的元数据的大小,这允许我们在内存中保持metadata,这会带来2.6.1节中我们将讨论的另一个优势。
在另一方面,一个大的chunk大小,即使是使用惰性空间分配也有它自己的劣势。一个只有几个chunk的小文件,或者说也许只有一个chunk,如果很多客户端在访问相同的数据时,chunkserver上存储的chunk可能会成为热点。在实践中,热点不会成为一个主要问题,因为我们的应用通常顺序读取一个大的,多chunk的文件。
不管咋说,热点问题还是在GFS首次被用在一个批量队列系统时被发现了:一个可执行程序作为一个单chunk文件被写入到GFS中,然后在同一时间开启数百台机器,存储这个可执行程序的少量的chunkserver被数百个同时的请求打垮。我们通过使用更高的复制因子以及让批量队列系统的应用程序启动时间错峰来解决这一问题。一个潜在的长期解决办法是允许client在这种情况下从其它client读取数据。
元数据
master存储了三类主要数据:文件和chunk命名空间、文件到chunk的映射、每一个chunk副本的位置,所有metadata都保存在master的内存中。前两种类型同时会被持久化到磁盘上,通过将改动记录到一个保存在master本地磁盘并且复制到远程机器上的操作日志来持久化。使用日志允许我们简单、可靠的更新master的状态,并且在master崩溃这种事件发生时我没有不一致的风险。master不持久化存储chunk位置信息,它只是在自己启动时来询问每一个chunkserver它的chunk信息,在一个chunkserver加入集群中时也会询问。
内存数据结构
因为元数据被存储在内存中,master的操作是很快的。并且,master可以简单且高效的在后台周期性的扫描它的整体状态。这种周期性扫描被用于实现chunk的垃圾回收、在chunkserver错误时的重复制以及chunk整合(在chunkserver间提供负载以及磁盘空间的均衡)。4.3,4.4节会进一步讨论这些活动。
这种使用内存方式的一个潜在的问题是,chunk的数量以及整个系统的容量被master具有多少内存限制。在实践中,这个限制没那么严重,对于每一个64MB的chunk,master维护小于64字节的元数据。大量的chunk是满的,因为大部分文件包含多个chunk,只有最后一个是部分填充的。类似的,文件命名空间数据中,一个文件通常也只需要小于64字节,因为它使用前缀压缩来保存文件名。
如果必须要支持更大的文件系统,给master添加额外的内存的成本对于通过在内存中存储元数据而获得的简单性、可靠性、性能以及灵活性来说是一个很小的代价。
chunk位置
master不持久化保存一个chunkserver具有一个给定chunk的副本的数据,它只是简单的在启动时从chunkserver拉取这些信息,此后,master便可以保持最新状态(up-to-date),因为它控制着所有的chunk存放(placement)以及通过常规的HeartBeat消息来监视chunkserver状态。
我们一开始尝试将chunk位置数据也持久化在master上,但是我们决的在启动时请求chunkserver以及后续周期性的请求更加简单。这消除了在chunserver加入或者离开集群时、改名时、错误时以及重启时使master和chunkserver同步的问题。在一个具有几百台服务器的集群中,这些事件将经常发生。
理解这个设计决策的另一个方式是意识到chunkserver对于一个chunk是否在它的磁盘上有最终解释权。我们无需在master上维护这些信息的一致性视图,因为chunkserver上的异常可能导致chunk凭空消失(比如磁盘坏了)或者一个操作员可能重命名了chunkserver。
操作日志
操作日志包含了关键元数据改动的历史记录,它是GFS的核心。它不仅仅只是元数据的持久化记录,也可以作为一个逻辑时间线来定义并发操作的顺序。文件以及chunk,和它们的版本(version)一样(见4.5节),它们都是被它们创建时的逻辑时间唯一且永久的标识的。
因为操作日志很关键,我们必须可靠的存储它,并且直到元数据修改被持久化之前都不能对用户可见。否则,即使chunk本身存在,我们也可能丢失整个文件系统或最近的client操作。因此,我们在多个远程机器上复制它并且只有在将对应的日志刷到本地和远端的磁盘后才会响应client的操作。master会在刷盘前打包多个日志记录以减少刷盘和复制对整个系统吞吐量带来的冲击。
master通过重放操作日志来恢复它的文件系统状态。为了最小化启动时间,我们必须让保持log在很小的大小。master的checkpoint是log增长到一个特定大小时的状态,我们可以通过从磁盘中从最后的checkpoint加载并只重放在那之后有限数量的log记录。checkpoint是一个紧凑的B-tree形式,可以直接被映射到内存中用于名称空间解析,而不需要额外的parsing这进一步加快了恢复速度,提高可用性。
因为构建一个checkpoint可能会花一段时间,master的内部状态结构设计可以让checkpoint在创建的同时不会对新来的改动产生延迟。master切换到一个新的log文件,并在一个新的线程中创建新的检查点,新的检查点包括在此次切换之前的所有变化。在一个具有几百万文件的集群中,它可以在几分钟内创建检查点,当完成时,它会写入本地和远端磁盘。
恢复只需要最近完成的checkpoint以及后续的log文件,更老的checkpoint以及log文件可以被自由的删除,不过我们会保留一些检查点和日志以做灾备。检查点期间的错误不会影响正确性,因为恢复代码会检测并跳过不完整的checkpoint。
一致性模型
GFS拥有一个宽松的一致性模型去很好的支持我们高度分布式的应用,并且实现起来依然相对简单和高效。我们现在讨论GFS的保证并且这些保证对应用来说意味着什么。我们也会着重介绍GFS如何维护这些保证,但是会把细节留到本文的其它部分。
GFS的保证
文件名称空间修改(比如文件创建)是原子的。它们被master互斥的处理:名称空间锁会保护原子性和正确性(见4.1)。master操作日志定义了这些操作的全局的总体顺序。
在一次数据修改后,一个文件区域的状态取决于修改的类型、它是成功或是失败了,并且它们是否是并发修改的。表1总结了结果。

一个文件区域是一致的(consistent)如果所有的客户端都能看到相同的数据,不论它们是从哪个副本读取。在一次文件数据操作后,一个文件区域是确定的(defined),如果它是一致的并且客户端将会看到它完整的写入。当一次修改在没有并发写的情况下成功了,受影响的区域就是确定的(并且也一定是一致的):所有的client总会看到改动已经写入了。并发的成功修改会使得文件区域是不确定的(undefined),但却是一致的:所有的客户端都会看到相同的数据,但是这并不能反映任何一次修改的写入,通常,它包含来自多个修改的混合片段的组合。一个失败的修改使得region不一致(并且也是不确定的):不同的客户端可能在不同的时间看到不同的数据。我们会在下面描述我们的应用程序如何从非确定的区域中识别出确定的区域。程序不需要进一步识别不同类型的非确定区域。
译者:是否这里的失败修改的意思是某一些副本上修改成功了,某一些失败了,此时就会不一致且不确定。
数据修改可能是写入(writes)或记录追加(record appends)。写入会导致数据被写到一个应用程序指定的文件offset处,而记录追加会导致即使在并发修改出现的情况下,数据(“记录”)也能被自动的至少一次追加,不过是在一个GFS选择的offset处(3.3节)。(相反的,一个“常规的”追加操作只是一次在client认为的当前文件的尾部的offset处的写入)。offset被返回给客户端