经典论文翻译导读之《Google File System》

【译者预读】

GFS这三个字母无需过多修饰,《Google File System》的论文也早有译版。但是这不妨碍我们加点批注、重温经典,并结合上篇Haystack的文章,将GFS、TFS、Haystack进行一次全方位的对比,一窥各巨头的架构师们是如何权衡利弊、各取所需。

1. 介绍

我们设计和实现了GFS来满足Google与日俱增的数据处理需求。与传统的分布式文件系统一样,GFS着眼在几个重要的目标,比如性能、可伸缩性、可靠性和可用性。不过它也会优先考虑我们自身应用场景的特征和技术环境,所以与早先一些文件系统的设计思想还是有诸多不同。我们取传统方案之精华、根据自身需求做了大胆的设计创新。在我们的场景中:

首先,组件故障是常态而不是异常。文件系统包含成百上千的存储机器,而且是廉价的普通机器,被大量的客户端机器访问。这样的机器质量和数量导致任何时间点都可能有一些机器不可用,甚至无法从当前故障中恢复。导致故障的原因很多,比如应用bug、操作系统bug、人为错误,以及磁盘、内存、连接器、网络等硬件故障,甚至是电力供应。因此,持续监控、错误侦测、故障容忍和自动恢复必须全面覆盖整个系统。

其次,用传统视角来看,我们要处理的文件很多都是巨型的,好几GB的文件也很常见。通常情况下每个文件中包含了多个应用对象,比如web文档。面对快速增长、TB级别、包含数十亿对象的数据集合,如果按数十亿个KB级别的小文件来管理,即使文件系统能支持,也是非常不明智的。因此,一些设计上的假设和参数,比如I/O操作和块大小,需要被重新审视。

第三,大部分文件发生变化是通过append新数据,而不是覆盖、重写已有的数据,随机写几乎不存在。被写入时,文件变成只读,而且通常只能是顺序读。很多数据场景都符合这些特征。比如文件组成大型的库,使用数据分析程序对其扫描。比如由运行中的程序持续生成的数据流。比如归档数据。还可能是分布式计算的中间结果,在一台机器上产生、然后在另一台处理。这些数据场景都是由制造者持续增量的产生新数据,再由消费者读取处理。在这种模式下append是性能优化和保证原子性的焦点。然而在客户端缓存数据块没有太大意义。

第四,向应用提供类似文件系统API,增加了我们的灵活性。松弛的一致性模型设计也极大的简化了API,不会给应用程序强加繁重负担。我们将介绍一个原子的append操作,多客户端能并发的对一个文件执行append,不需考虑任何同步。

当前我们部署了多个GFS集群,服务不同的应用。最大的拥有超过1000个存储节点,提供超过300TB的磁盘存储,被成百上千个客户端机器大量访问。

 

2 设计概览

 

2.1 假设

设计GFS过程中我们做了很多的设计假设,它们既意味着挑战,也带来了机遇。现在我们详细描述下这些假设。

  • 系统是构建在很多廉价的、普通的组件上,组件会经常发生故障。它必须不间断监控自己、侦测错误,能够容错和快速恢复。
  • 系统存储了适当数量的大型文件,我们预期几百万个,每个通常是100MB或者更大,即使是GB级别的文件也需要高效管理。也支持小文件,但是不需要着重优化。
  • 系统主要面对两种读操作:大型流式读和小型随机读。在大型流式读中,单个操作会读取几百KB,也可以达到1MB或更多。相同客户端发起的连续操作通常是在一个文件读取一个连续的范围。小型随机读通常在特定的偏移位置上读取几KB。重视性能的应用程序通常会将它们的小型读批量打包、组织排序,能显著的提升性能。
  • 也会面对大型的、连续的写,将数据append到文件。append数据的大小与一次读操作差不多。一旦写入,几乎不会被修改。不过在文件特定位置的小型写也是支持的,但没有着重优化。
  • 系统必须保证多客户端对相同文件并发append的高效和原子性。我们的文件通常用于制造者消费者队列或者多路合并。几百个机器运行的制造者,将并发的append到一个文件。用最小的同步代价实现原子性是关键所在。文件被append时也可能出现并发的读。
  • 持久稳定的带宽比低延迟更重要。我们更注重能够持续的、大批量的、高速度的处理海量数据,对某一次读写操作的回复时间要求没那么严格。
2.2 接口

GFS提供了一个非常亲切的文件系统接口,尽管它没有全量实现标准的POSIX API。像在本地磁盘中一样,GFS按层级目录来组织文件,一个文件路径(path)能作为一个文件的唯一ID。我们支持常规文件操作,比如create、delete、open、close、read和write。

除了常规操作,GFS还提供快照和record append操作。快照可以用很低的花费为一个文件或者整个目录树创建一个副本。record append允许多个客户端并发的append数据到同一个文件,而且保证它们的原子性。这对于实现多路合并、制造消费者队列非常有用,大量的客户端能同时的append,也不用要考虑锁等同步问题。这些特性对于构建大型分布式应用是无价之宝。快照和record append将在章节3.4、3.3讨论。

 

2.3 架构

一个GFS集群包含单个master和多个chunkserver,被多个客户端访问,如图1所示。图1中各组件都是某台普通Linux机器上运行在用户级别的一个进程。在同一台机器上一起运行chunkserver和客户端也很容易,只要机器资源允许。

文件被划分为固定大小的chunk。每个chunk在创建时会被分配一个chunk句柄,chunk句柄是一个不变的、全局唯一的64位的ID。chunkserver在本地磁盘上将chunk存储为Linux文件,按照chunk句柄和字节范围来读写chunk数据。为了可靠性,每个chunk被复制到多个chunkserver上,默认是3份,用户能为不同命名空间的文件配置不同的复制级别。

master维护所有的文件系统元数据。包括命名空间,访问控制信息,从文件到chunk的映射,和chunk位置。它也负责主导一些影响整个系统的活动,比如chunk租赁管理、孤儿chunk的垃圾回收,以及chunkserver之间的chunk迁移。master会周期性的与每台chunkserver通讯,使用心跳消息,以发号施令或者收集chunkserver状态。

每个应用程序会引用GFS的客户端API,此API与正规文件系统API相似,并且负责与master和chunkserver通讯,基于应用的行为来读写数据。客户端只在获取元数据时与master交互,真实的数据操作会直接发至chunkserver。我们不需提供严格完整的POSIX API,因此不需要hook到Linux的vnode层面。

客户端和chunkserver都不会缓存文件数据。客户端缓存文件数据收益很小,因为大部分应用通常会顺序扫描大型文件,缓存重用率不高,要么就是工作集合太大缓存很困难。没有缓存简化了客户端和整个系统,排除缓存一致性问题。(但是客户端会缓存元数据。)chunkserver不需要缓存文件数据因为chunk被存储为本地文件,Linux提供的OS层面的buffer缓存已经保存了频繁访问的文件。

2.4 单一Master

单一master大大的简化了我们的设计,单一master能够放心使用全局策略执行复杂的chunk布置、制定复制决策等。然而,我们必须在读写过程中尽量减少对它的依赖,它才不会成为一个瓶颈。客户端从不通过master读写文件,它只会询问master自己应该访问哪个chunkserver。客户端会缓存这个信息一段时间,随后的很多操作即可以复用此缓存,与chunkserver直接交互。

我们利用图1来展示一个简单读操作的交互过程。首先,使用固定的chunk size,客户端将应用程序指定的文件名和字节偏移量翻译为一个GFS文件及内部chunk序号,随后将它们作为参数,发送请求到master。master找到对应的chunk句柄和副本位置,回复给客户端。客户端缓存这些信息,使用GFS文件名+chunk序号作为key。

客户端然后发送一个读请求到其中一个副本,很可能是最近的那个。请求中指定了chunk句柄以及在此chunk中读取的字节范围。后面对相同chunk的读不再与master交互,直到客户端缓存信息过期或者文件被重新打开。事实上,客户端通常会在一个与master的请求中顺带多索要一些其他chunk的信息,而且master也可能将客户端索要chunk后面紧跟的其他chunk信息主动回复回去。这些额外的信息避免了未来可能发生的一些client-master交互,几乎不会导致额外的花费。

2.5 chunk size

chunk size是其中一个关键的设计参数。我们选择了64MB,这是比典型的文件系统的块大多了。每个chunk副本在chunkserver上被存储为一个普通的Linux文件,只在必要的时候才去扩展。懒惰的空间分配避免了内部碎片导致的空间浪费,chunk size越大,碎片的威胁就越大。

chunk size较大时可以提供几种重要的优势。首先,它减少了客户端与master的交互,因为对同一个chunk的读写仅需要对master执行一次初始请求以获取chunk位置信息。在我们的应用场景中大部分应用会顺序的读写大型文件,chunk size较大(chunk数量就较少)能有效的降低与master的交互次数。对于小型的随机读,即使整个数据集合达到TB级别,客户端也能舒服的缓存所有的chunk位置信息(因为chunk size大,chunk数量小)。其次,既然用户面对的是较大的chunk,它更可能愿意在同一个大chunk上执行很多的操作(而不是操作非常多的小chunk),这样就可以对同一个chunkserver保持长期的TCP连接以降低网络负载。第三,它减少了master上元数据的大小,这允许我们放心的在内存缓存元数据,章节2.6.1会讨论继而带来的各种好处。

不过chunk size如果很大,即使使用懒惰的空间分配,也有它的缺点。一个小文件包含chunk数量较少,可能只有一个。在chunkserver上这些chunk可能变成热点,因为很多客户端会访问相同的文件(如果chunk size较小,那小文件也会包含很多chunk,资源竞争可以分担到各个小chunk上,就可以缓解热点)。不过实际上热点没有导致太多问题,因为我们的应用大部分都是连续的读取很大的文件,包含很多chunk(即使chunk size较大)。

然而,热点确实曾经导致过问题,当GFS最初被用在批量队列系统时:用户将一个可执行程序写入GFS,它只占一个chunk,然后几百台机器同时启动,请求此可执行程序。存储此可执行文件的chunkserver在过多的并发请求下负载较重。我们通过提高它的复制级别解决了这个问题(更多冗余,分担压力),并且建议该系统交错安排启动时间。一个潜在的长期解决方案是允许客户端从其他客户端读取数据(P2P模式~)。

2.6 元数据

master主要存储三种类型的元数据:文件和chunk的命名空间,从文件到chunk的映射,每个chunk副本的位置。所有的元数据被保存在master的内存中。前两种也会持久化保存,通过记录操作日志,存储在master的本地磁盘并且复制到远程机器。使用操作日志允许我们更简单可靠的更新master状态,不会因为master的当机导致数据不一致。master不会持久化存储chunk位置,相反,master会在启动时询问每个chunkserver以获取它们各自的chunk位置信息,新chunkserver加入集群时也是如此。

2.6.1 内存中数据结构

因为元数据存储在内存中,master可以很快执行元数据操作。而且可以简单高效的在后台周期性扫描整个元数据状态。周期性的扫描作用很多,有些用于实现chunk垃圾回收,有些用于chunkserver故障导致的重新复制,以及为了均衡各机器负载与磁盘使用率而执行的chunk迁移。章节4.3和4.4将讨论其细节。

这么依赖内存不免让人有些顾虑,随着chunk的数量和今后整体容量的增长,整个系统将受限于master有多少内存。不过实际上这不是一个很严重的限制。每个64MB的chunk,master为其维护少于64byte的元数据。大部分chunk是填充满数据的,因为大部分文件包含很多chunk,只有少数可能只填充了部分。同样的,对于文件命名空间数据,每个文件只能占用少于64byte,文件名称会使用前缀压缩紧密的存储。

如果整个文件系统真的扩展到非常大的规模,给master添点内存条、换台好机器scale up一下也是值得的。为了单一master+内存中数据结构所带来的简化、可靠性、性能和弹性,咱豁出去了。

2.6.2 Chunk位置

master不会持久化的保存哪个chunkserver有哪些chunk副本。它只是在自己启动时拉取chunkserver上的信息(随后也会周期性的执行拉取)。master能保证它自己的信息时刻都是最新的,因为它控制了所有的chunk布置操作,并用常规心跳消息监控chunkserver状态。

我们最初尝试在master持久化保存chunk位置信息,但是后来发现这样太麻烦,每当chunkserver加入或者离开集群、改变名称、故障、重启等等时候就要保持master信息的同步。一般集群都会有几百台服务器,这些事件经常发生。

话说回来,只有chunkserver自己才对它磁盘上存了哪些chunk有最终话语权。没理由在master上费尽心机的维护一个一致性视图,chunkserver上发生的一个错误就可能导致chunk莫名消失(比如一个磁盘可能失效)或者运维人员可能重命名一个chunkserver等等。

2.6.3 操作日志

操作日志是对重要元数据变更的历史记录。它是GFS的核心之一。不仅因为它是元数据唯一的持久化记录,而且它还要承担一个逻辑上的时间标准,为并发的操作定义顺序。各文件、chunk、以及它们的版本(见章节4.5),都会根据它们创建时的逻辑时间被唯一的、永恒的标识。

既然操作日志这么重要,我们必须可靠的存储它,而且直至元数据更新被持久化完成(记录操作日志)之后,才能让变化对客户端可见。否则,我们有可能失去整个文件系统或者最近的客户端操作,即使chunkserver没有任何问题(元数据丢了或错了,chunkserver没问题也变得有问题了)。因此,我们将它复制到多个远程机器,直到日志记录被flush到本地磁盘以及远程机器之后才会回复客户端。master会捆绑多个日志记录,一起flush,以减少flush和复制对整个系统吞吐量的冲击。

master可以通过重放操作日志来恢复它的元数据状态。为了最小化master的启动时间,日志不能太多(多了重放就需要很久)。所以master会在适当的时候执行“存档”,每当日志增长超过一个特定的大小就会执行存档。所以它不需要从零开始回放日志,仅需要从本地磁盘装载最近的存档,并回放存档之后发生的有限数量的日志。存档是一个紧密的类B树结构,它能直接映射到内存,不用额外的解析。通过这些手段可以加速恢复和改进可用性。

因为构建一个存档会消耗点时间,master的内部状态做了比较精细的结构化设计,创建一个新的存档不会延缓持续到来的请求。master可以快速切换到一个新的日志文件,在另一个后台线程中创建存档。这个新存档能体现切换之前所有的变异结果。即使一个有几百万文件的集群,创建存档也可以在短时间完成。结束时,它也会写入本地和远程的磁盘。

恢复元数据时,仅仅需要最后完成的存档和其后产生的日志。老的存档和日志文件能被自由删除,不过我们保险起见不会随意删除。在存档期间如果发生故障(存档文件烂尾了)也不会影响正确性,因为恢复代码能侦测和跳过未完成的存档。

2.7 一致性模型

GFS松弛的一致性模型能很好的支持我们高度分布式的应用,而且实现起来非常简单高效。我们现在讨论GFS的一致性保证。

2.7.1 GFS的一致性保证

文件命名空间变化(比如文件创建)是原子的,只有master能处理此种操作:master中提供了命名空间的锁机制,保证了原子性的和正确性(章节4.1);master的操作日志为这些操作定义了一个全局统一的顺序(章节2.6.3)

各种数据变异在不断发生,被它们改变的文件区域处于什么状态?这取决于变异是否成功了、有没有并发变异等各种因素。表1列出了所有可能的结果。对于文件区域A,如果所有客户端从任何副本上读到的数据都是相同的,那A就是一致的。如果A是一致的,并且客户端可以看到变异写入的完整数据,那A就是defined。当一个变异成功了、没有受到并发写的干扰,它写入的区域将会是defined(也是一致的):所有客户端都能看到这个变异写入的完整数据。对同个区域的多个并发变异成功写入,此区域是一致的,但可能是undefined:所有客户端看到相同的数据,但是它可能不会反应任何一个变异写的东西,可能是多个变异混杂的碎片。一个失败的变异导致区域不一致(也是undefined):不同客户端可能看到不同的数据在不同的时间点。下面描述我们的应用程序如何区分defined区域和undefined区域。

数据变异可能是写操作或者record append。写操作导致数据被写入一个用户指定的文件偏移。而record append