The Google File System
摘要
GFS 是一个可扩展的分布式文件系统,用于大型分布式数据密集型应用上。它可以运行在便宜的普通硬件上,提供了高性能和一定的容错性。
1. 分布式文件系统介绍
GFS 与过去的分布式文件系统有很多相似之处,如性能、扩展性、可靠性和高可用性。但对 GFS 的设计是由我们对应用负载和技术环节的探索所驱动的,它与之前的文件系统还是有设计上的明显区别。
我们考虑下面一些设计分布式文件系统时的关键点:
-
首先,**组件出错是一个普遍情况,而不是异常事件。**我们几乎可以确定在一个大型文件系统中,有一部分机器不可用,一部分机器无法从错误中恢复。造成错误的原因可能有:
- 应用程序或操作系统的 bug。
- 人为错误。
- 磁盘、内存、网络等的错误。
- 等等。
因此,实时监控、错误检测、容错设计、错误恢复是这个系统不可或缺的一部分。
-
其次,**文件是巨大的。**每个文件通常包含很多对象,例如网页文档。当我们经常使用由数十亿个对象组成的、大小达到许多个 TB 且依然在快速增长的数据集时,即使文件系统不拒绝这样的需求,我们也必须重新审视设计的假设和参数,例如 IO 操作和存储块的大小。
-
第三,**大多数文件通过追加数据而不是覆盖数据来进行操作,文件的随机写事实上并不存在。**这些文件写入后只用作读需求,且经常只是顺序读,各种数据共享这些属性。
- 一些组成了数据仓库,用于供数据分析程序使用;
- 一些是正在运行的程序连续产生的数据流;
- 一些是档案资料;
- 一些是存储在一台机器上并在另一台机器上处理的中间数据(如 MapReduce 的中间键值对)
对于上述这种文件的处理,在客户端缓存数据块已经没有意义,而追加操作成为了性能优化和原子性保证的焦点。
-
第四,应用程序和文件系统 API 在设计上的协作提高了整个系统的灵活性,从而优化了整个系统。例如我们放松了 GFS 在一致性上的要求,这大大简化了文件系统,同时没有为应用程序引入额外的负担。我们还采用了原子性的追加操作,以此使多个用户可以同时对一个文件进行追加操作,但又不用带来额外的同步开销。
在 Google,多个 GFS 集群以不同的目的进行部署,最大的一个集群包含了超过 1000 个存储节点,300TB 以上的磁盘存储,能让数百个不同机器上的客户端进行连续的频繁访问。
2. 设计概要
2.1 设想
在根据需求设计文件系统的过程中,我们也会依照一些挑战和机会并存的假设进行设计。在第一节提到的一些事实的基础上,我们在细节上展示我们的一些假设:
- 系统是由许多廉价的、普通的、易出错的组件组成的。它必须有不间断的自我监控、探测、容错,以及组件错误状态的快速恢复。
- **系统存储了适量的大文件。**我们期望有几百万的文件,每个文件通常是 100MB 或者更大。GB 级别的文件是很常见的,并且能够有效的进行管理。小文件必须被支持,但是我们无需对它们进行优化。
- **工作负载主要由两种读操作组成:大规模的流读取和小规模的随机读取。**在大规模的流读取中,单个的操作通常会读几百 KB 的数据,更常见的是 1MB 或者更多的数据。来自同一个客户端的连续操作通常读取一个文件的一个连续范围。小规模的随机读取通常在任意的位置读取几 KB 的数据。追求性能的应用通过批处理和排序小规模的读操作,用以顺序的读取文件,而不是在读取过程中前后移动。
- **工作负载同样还有很多大量的、序列写操作,它们将数据追加到文件末尾。**一般操作的大小与相应的读操作相近。一旦写入,文件将很少再次改变。在文件任意位置进行的小规模的写操作虽然是支持的,但效率很低。
- 系统必须是高效的,这里的高效是指有很多客户端能够同时对一个文件进行数据追加。我们的文件通常用于生产者-消费者队列或者多路合并。运行在不同机器上的数百个生产者,将并发地对一个文件进行数据追加,使用最小同步开销的原子化操作是很有必要的。这个文件可能会在以后被读取,或者正在同时被一个消费者读取。
- **持续的高带宽比低时延更重要。**大多数目标应用更看重大量地、高效地处理数据,而很少有应用对单个的读或写操作有严格的响应时间要求。
2.2 接口
GFS 提供了常见的文件系统接口,文件被存放到目录中,并由路径名进行标识。我们支持常见的操作如create
、delete
、open
、close
、read
以及write
文件。
特殊地,GFS 还有**快照 snapshot **和 记录追加 record append 操作。
- 快照低开销地创建了一个文件或目录树的拷贝。
- 记录追加允许多个客户端同时向一个文件追加数据,并保证每个单独的客户端追加操作的原子性。可以用于实现多路结果合并和生产者-消费者队列,它们使很多客户端在不加锁的情况下可以同时进行追加操作。
2.3 架构
**一个 GFS 集群由一个 Master 和多个块服务器 chunkservers 组成,可以被多个客户端访问。**每个节点都是一个运行在 Linux 上的普通进程。
![image-20220507170039046](https://i-blog.csdnimg.cn/blog_migrate/e852364d30b8dcb4deb2bc2ab00af632.png)
GFS 文件被划分为固定大小的块,每个块由一个不变的、全局唯一的 64bit 块句柄标识,它是由主节点在创建块时分配的。块服务器存储这些块并对其进行读写操作,为了提高可靠性,每个块都会在多个块服务器上进行复制。默认情况下,我们会存储三个副本,用户也可以对不同的命名空间设置不同的复制级别。
Master 存储了整个文件系统的元数据,包含命名空间、访问控制信息、文件到块的映射,以及块的当前位置等等。它也控制了一些系统层的行为,如块的租约管理,孤儿块的垃圾回收,以及块服务器之间的块迁移。Master 周期性地与每个块节点进行通信,通过心跳信息发送指令并收集块服务器状态。
GFS 客户端代码嵌入到应用中,实现了文件系统 API,代表客户端进行读写数据,与主节点和块服务器进行通信。客户端与主节点进行元数据的交互操作,而与数据相关的通信则直接与块服务器进行。
2.4 单一的主节点
单一的主节点简化了我们的设计,令主节点能够根据整体信息确定块的位置,以及进行复制决策。
**由于主节点是单一的,我们必须最小化对主节点的读写操作,以保证它不会成为系统性能的瓶颈。**客户端不会通过主节点读写数据,而只会向主节点询问需要与哪些块服务器进行联系。客户端会将主节点的答复缓存一段时间,并在后续直接和块服务器交互。
![image-20220507170039046](https://i-blog.csdnimg.cn/blog_migrate/e852364d30b8dcb4deb2bc2ab00af632.png)
简单解释一下上图中的一个读操作交互。
-
首先,使用固定的块大小,客户端将文件名和应用指定的字节便宜转换为文件块的索引。
-
然后,它向主节点发送一个包含文件名和块索引的请求,主节点回复相应的块句柄和副本的位置。客户端使用文件名和块索引作为 Key 缓存这条信息。
-
客户端向其中一个副本发送请求,大多数时候选择最近的那个。请求指定了块句柄和那个块的一个字节范围。后续客户端无需和主节点交互,除非缓存信息过期或文件被重新打开。
事实上,客户端通常在一个请求中询问多个块,主节点的回复也会包含紧跟在请求块后面的块的信息,这在实际中往往可以在未来减少一些客户端-主节点通信。
2.5 块大小
块大小是设计的关键参数,我们选择 64MB,这要比一般文件系统的块要大许多。每个块副本在块服务器上被存储为一个普通的 Linux 文件,只有在需要时才扩大。
令块的大小很大的最大隐患在于内部碎片。GFS 使用惰性空间分配避免了因内部碎片带来的空间浪费。
采用大小比较大的块有以下几个重要优点:
- 它能减少客户端和主节点的交互次数,因为一个块的位置信息代表了更多的数据的位置。
- 由于大的块大小,客户端能在一个块上进行更多操作。这样可以通过与块服务器在一段时间内保持一个 TCP 长连接赖减少网络开销。
- 主节点可以存储更少的元数据,这样我们可以把元数据存储在内存中。所带来的好处在 2.6.1 节中讨论。
采用大小大的块也有下面这个缺点:
当大量客户端访问相同的文件时,存储这个块的块服务器会成为热点。显然当块的大小比较大,出现热点块服务器的概率也就更大。
在实际中,热点不是一个主要问题,因为我们的应用大多会顺序读取大量的包含多个块的文件。
但是当 GFS 第一次用于一个批处理序列系统时,还是发生了热点问题:一个可执行文件以单个块的形式写入 GFS,然后在数百台机器上同时启动。少量的存储这个文件的块服务器会由于数百台机器的同时访问而过载。我们通过以下两个手段解决这个问题:
- 提高可执行文件的复制因子 replication factor。让更多的块服务器存储这些热点数据。
- 令批处理序列系统和应用程序不同时, 而是交替启动。
一个可能的长期的解决方案是让客户端能从其他客户端读取数据。
2.6 元数据
主节点存储了三种主要类型的元数据:文件和块的命名空间,文件到块的映射,以及每个块副本的位置,所有元数据都保留在主节点的内存中。
**命名空间,以及文件到块的映射,通过将操作记录存储在本地磁盘上的日志文件中得以永久保存,并在远程的机器上进行日志备份。**使用日志使我们能够简单可靠地更新主节点状态,并且不用担心主节点崩溃造成的不一致。主节点不会永久保存块位置信息,而会在启动时,以及有新的块服务器加入集群时,询问每个块服务器的块信息。
2.6.1 内存中的数据结构
**得益于元数据存放在内存中,主节点的操作非常快。它也能使主节点能够周期性地在后台简单有效的地浏览整个系统的状态。**这个周期性的浏览操作用于实现块的垃圾回收、块服务器出错后的重复制,负载均衡和磁盘空间使用的块迁移,4.3 和 4.4 节会深入的讨论这些行为。
但将元数据放在内存中有一个潜在问题,即块的数量和将来整个系统的容量受到主节点的内存大小限制。但由于块的元数据大小实际很小,所以在实际中这不是一个严重的问题。更何况,即使需要为主服务器增加额外的内存,这个花费相比将元数据存放在内存中带来的简单性、可靠性、有效性和扩展性来说,也是很值得的。
2.6.2 块位置
主节点不会永久保留类似哪些块服务器含有一个给定的块的记录这样的信息。而是在启动时轮询块服务器获取这些信息。主节点可以将自己保持在最新状态,因为它控制所有块的放置,并且通过常规的心跳消息来监控块服务器的状态。
起初我们考虑在主节点永久地保存块位置信息,但后来发现**在启动时向块服务器请求数据并在此后进行周期性更新要简单许多。这消除了当块服务器加入或离开集群、更改名字、出错、重启等异常发生时,保持主节点和块服务器同步的问题。**在大型集群中这些问题发生得很频繁。
理解这种设计的另一个角度是:**认识到块服务器对他的磁盘上最终存储或不存储某个块有最终的决定权。**在主节点上维护这些信息的一致性视图是没有意义的,因为块服务器上的错误可能会导致块发生主服务器不能及时知悉的变动,例如块被删除或重命名等。
2.6.3 操作日志
**操作日志包含了关键的元数据变化的历史记录,是 GFS 的核心。**它不仅永久的记录了元数据,还能提供确定并发操作顺序的逻辑时间线服务。文件和块,连同它们的版本,都是由它们创建的逻辑时间唯一的、永久的进行标识的。
因为操作日志是临界资源,我们必须可靠的存储它,在元数据的变化持久化之前,客户端是无法看到这些操作日志的。否则,即使块本身保存下来,仍然有可能丢失整个文件系统或者客户端最近的操作。因此,**我们将它复制到几个远程的机器上,并在将相应的操作刷新(flush)到本地和远程磁盘后再回复客户端。**主节点会在刷新之前批处理一些日志记录,以此减少刷新和系统内复制对整个系统吞吐量的影响。
**主节点通过重新执行操作日志来恢复状态。**为了使启动时间尽量短,我们必须保持日志较小。当日志超过一个特定的大小时,主节点会检查它的状态。这样一来,以使它能够在足够小的代价下通过载入本地磁盘的最后一个检查点及之后的日志记录进行恢复。检查点是一个类似 B 树的数据结构,能够直接映射到内存中,并且在用于命名空间查询时无需额外的解析。这大大提高了恢复速度,增加了可用性。
因为创建检查点需要一定的时间,所以主节点的内部状态会被格式化,格式化的结果保证了新检查点的创建不会阻塞正在进行的修改操作。当主节点切换到新的日志文件时,GFS 通过另一个线程进行新检查点的创建。新检查点包括切换前所有的修改操作。对于一个有几百万文件的集群来说,创建一个新检查点大概需要1分钟。当创建完成后,它将写入本地和远程磁盘。
恢复只需要最近完成的检查点和在此之后的日志文件。老的检查点和日志文件能够被删除,但为了应对灾难性故障,我们会保留其中的一部分。检查点的失败不会影响恢复的正确性,因为恢复代码会探测并跳过未完成的检查点。
2.7 一致性模型
GFS 采用弱一致性模型,能很好的支持分布式应用,同时实现上比较简单。我们在这里将讨论 GFS 的一致性保障机制以及其对于应用的意义。我们也将着重描述了 GFS 如何维持这些一致性保障机制,但将一些细节留在了其它章节。