Skip to content

Lec 15 可靠性

阅读参考书 §8.1 §8.2 §8.3

可靠性,Reliability。从这章开始我们转向分布式相关主题的学习,重点将关注当系统出现失败时候,你该如何处理?即围绕一个框架展开: 如何在不可靠的组件上,构建可靠的系统。同时,这也是模块化的一个最重要的应用。

思考题

  • 从高层次看,处理故障的通用思维是什么?
  • 如何测量可靠性?
  • 讲座中我们将聚焦磁盘的可靠性, 为什么要选择磁盘?
  • RAID相关
    • 给定两个比特串A和B,如何计算A XOR B?给定A XOR B和A,如何计算B?
    • 如果给定三个比特串A、B和C,如何计算A XOR B XOR C?
    • 这些思想如何扩展到RAID中?
    • RAID系统如何从单个故障磁盘中恢复?
    • 读写操作在以下RAID系统中是如何工作的:
      • RAID 1?
      • RAID 4?
      • RAID 5?
    • 不同RAID级别之间的性能权衡是什么?
    • 讲座中描述的RAID系统是否能在两个磁盘同时故障的情况下恢复?为什么或为什么不可以?
    • 附加问题:如果一个磁盘先故障,然后另一个磁盘在1秒后故障,系统能否恢复?在10秒后故障呢?通常情况下,你会如何考虑这些系统恢复所需的故障间隔时间?

Outline

  • 构建可靠的系统
  • 可靠性的测量
  • 案例分析: 磁盘RAID技术

1. 构建可靠的系统

处理主动故障

在处理主动故障时,模块设计者可采用如下策略:

  • Do nothing。错误会导致模块故障,其所在的更大系统或子系统将承担发现和处理问题的责任。这种情况下,由更高层次子系统的设计者决定如何应对。在具有多层模块的系统中,故障可能会沿着多个层级向上传递,直到被发现并处理。随着“不采取任何措施”的层级增加,故障的隔离(containment)通常会变得越来越困难。
  • Be fail-fast, 快速失败。模块在其接口处报告发生了错误。这种方式同样将问题交给更高层系统的设计者处理,但方式更为优雅。例如,当以太网收发器检测到正在发送的帧发生冲突(collision)时,它会尽快停止发送,广播一个简短的干扰信号(jamming signal),确保所有网络参与者都能迅速意识到发生了冲突,并将冲突报告给下一个更高层级的硬件模块,由其决定是否重新发送该帧。
  • Be fail-safe,安全失败。模块将任何不正确的值转换为已知可接受的值,尽管这些值可能并非完全正确或最优。 例如,数字交通灯控制器在检测到其序列发生故障时,会将所有方向的信号灯切换为红灯闪烁模式。
  • Be fail-soft, 柔性失败。系统在发生故障后,仍能按照某种可预测的降级方式正确运行,尽管某些功能可能缺失或性能有所降低。 例如,一架三发动机飞机在一个发动机发生故障时,仍能继续飞行,但速度较慢且机动性较差。
  • Mask the error, 屏蔽错误。对于任何不正确的值,模块通过某种方式纠正错误,并确保模块能够按规范要求正常运行,仿佛错误从未发生过。

我们重点放在屏蔽错误上, 因为用于实现错误屏蔽的基数通常可以以更简化的形式,应用于实现快速失败、安全失败、和柔性失败的系统。

错误的分类

通常,算法和程序只能应对特定且可预见的故障,并且只能处理实际被检测的故障。因此,在评估系统是否具有足够的容错性时,将错误进行分类是很有帮助的:

  • 可检测错误(Detectable error)。指的是可以可靠检测的错误,如果系统中有检测过程,且该错误发生,则系统几乎可以确定发现它,这种错误称为已检测错误(detected error)
  • 可屏蔽错误(Maskable error)。指可以通过某种恢复正确性的程序进行纠正的错误。如果系统具备掩蔽程序,并且该错误被检测到且成功掩蔽,那么该错误被认为是已容忍错误(tolerated error)
  • 不可容忍错误(Untolerated error):反之,若错误无法检测未被检测无法掩蔽未被掩蔽,则称为不可容忍错误

image-20250324152209040

容错模型

可检测、已检测、可屏蔽和可容忍错误之间的区别能使我们能够为系统指定容错模型。设计过程可描述如下:

  1. 分析系统,对可能的错误事件进行分类,分为能可靠检测的和不能可靠检测出的,这个阶段,无论是否可检测,所有错误都是不可容忍的。
  2. 对于每个不可检测的错误,评估它发生的概率。如果概率不可忽略, 尽一切必要的方式使其变成能可靠检测的错误
  3. 对于每一个可检测的错误,实施其检测过程,并将检测该错误的模块重新归类为快速失败(fast-error)。
  4. 对于每个可检测错误,尝试设计一种掩盖它的方法。如果有办法,则将此错误重新归类为可掩盖错误。
  5. 对于每个可掩盖错误,评估其发生的概率、失败成本以及上一步中设计的掩盖方法的成本。如果评估表明这是值得的,则实施掩盖方法并将此错误重新归类为可容忍错误。

在完成此类模型的开发后,设计者应当为系统制定一个有用的容错规范。某些错误由于发生概率极低,或因为采用屏蔽错误措施代价过高,可标注为不可容忍错误。当此类错误发生时,系统将无法继续运行,用户自行应对其后果。而针对其他错误,系统应配备明确的恢复算法,确保错误发生时,系统仍然正确运行。

系统恢复策略的审查可聚焦于以下两个核心问题:

  • 设计者列举的潜在错误事件是否全面?对每种错误发生概率的评估是否现实?
    • 通过在设计过程中迭代,积累经验
  • 设计者为检测和掩蔽预期错误而设计的算法、过程和实现是否完整且正确?

2. 可靠性的测量

最典型的是关注其可用性(availability):有多大的比例的时间处于运行且可供使用。

相关指标

  • MTTF: 平均故障时间(mean time to failure)

  • MTTR: 平均修复时间(mean time to repair)

  • MTBF:平均故障间隔时间(mean time between failures) ,MTBF = MTTF + MTTR

  • 可用性availability = MTTF / MTBF = 43200 / 43210 = 99.99%

    • 与此相对应的是, 停机率 = 1 - 可用性

Ex: MTTF 为30天,MTTR为10分钟, 请问他的可用性是多少?

Availability = MTTF / MTBF = 43200 / 43210 = 99.99%

3. 案例分析: 磁盘RAID技术

RAID, Redundant Array of independent disks 冗余独立磁盘阵列。至于为什么在可靠性的话题上,主要聚焦于磁盘,主要原因有

  • 最常见的存储设备之一
  • 比较容易出现故障
  • 存储着关键设备

RAID 1 ——数据镜像

RAID 1 将相同数据同时写入两块或以上磁盘,每个磁盘都有完全相同的副本。每次写入数据时,必须同步写入所有镜像磁盘。

截屏2024-07-01 21.54.23

特点:

  • 能够从单磁盘故障恢复过来
  • 缺点: 但是需要2N个磁盘

RAID 4 —— 带有专用奇偶校验磁盘

数据按固定大小(通常为 512KB 或 1MB)分割,依次写入多个数据磁盘(至少需要3 块磁盘)。每次数据写入时,计算奇偶校验值(Parity)并将其存储在一个专门的校验磁盘中,若任意一块数据磁盘损坏,可通过其他数据磁盘 + 奇偶校验恢复丢失的数据

截屏2024-07-02 00.28.43

奇偶校验是通过对相应位置的数据扇区进行异或(XOR)操作计算得出的。即:parity = data_disk1 ⊕ data_disk2 ⊕ data_disk3 ⊕ ... ⊕ data_diskN

恢复过程:

恢复方法是对剩余磁盘上的第i扇区数据进行异或操作,再与奇偶校验信息进行异或。即:data_Ai = parity ⊕ data_disk1i ⊕ data_disk2i ⊕ ... ⊕ data_disk(N-1)i

特点:

  • 能够从单磁盘故障恢复过来
  • 只需要N+1个磁盘
  • 将单个文件跨多个数据磁盘进行条带化存储(striping)可以带来性能上的好处
    • 当一个文件分成多个数据块并分别存储在多个磁盘上时, 多个磁盘共同分担读写负载,可以并行读写
  • 缺点:所有的写操作都会涉及到奇偶校验盘。

RAID 5 ——分散奇偶校验

企业中常用的 RAID 级别。每个数据块对应的奇偶校验值通过异或运算(XOR)计算,均匀分布在各个磁盘中,而不是像 RAID 4 那样集中在一个奇偶校验磁盘上。

image-20250324155545390

特点:

  • 能够从单磁盘故障恢复过来
  • 只需要N+1个磁盘
  • 将单个文件跨多个数据磁盘进行条带化存储(striping)可以带来性能上的好处
  • 写操作分布在多个磁盘上,从而避免单个磁盘成为性能瓶颈。

RAID 6 —— 双独立奇偶校验

也是企业级RAID级别。 可以应对多个硬盘故障的情况。

典型应用包括:

  • 虚拟化场景
  • 数据中心
  • 企业级数据库

特点: 可检测双磁盘故障

总结

系统总是会有故障,要构建可靠,容错系统的系统我们必须将它们考虑在内。可靠性会带来一些消耗,就有了可靠性和简便性的权衡,可靠性与性能的权衡。我们的提升可靠性的主要工具,就是冗余,或者说复制技术

论文阅读:GFS

GFS是一种跨机器复制文件的系统,适用于大量用户写入文件、文件非常大且故障常见的环境。本文2-4节描述了GFS的设计,第5节讨论了如何处理故障,第6-7节是介绍其评估效果和实际使用情况。

The Google File System SOSP'03

为了检查是否真正理解了GFS的设计,应该能够回答如下问题

思考题

  • GFS依赖于什么假设?

  • 它如何利用这些假设?为什么需要这些假设?

  • 你认为谷歌在GFS中存储了什么类型的数据(即文件中包含什么)?谁使用这些数据?

  • 控制器的作用是什么?读取如何工作?写入如何工作?

  • 为什么GFS使用大块大小?

  • 如果副本故障会发生什么?

  • 如果控制器故障会发生什么?

  • 我们能否在广域网中使用类似RAID的奇偶校验方案?挑战是什么?

摘要

我们设计并实现了Google文件系统(Google File System,GFS),这是一种面向大型分布式数据密集型应用的可扩展分布式文件系统。它能够在廉价的商用硬件上提供故障容错功能,同时为大量客户端提供高聚合性能。

尽管与之前的分布式文件系统共享许多相同的目标,但我们的设计是基于对当前及未来应用负载和技术环境的观察。这些观察显示出与早期文件系统假设的显著差异,因此我们重新审视了传统的设计选择,并探索了截然不同的设计点。

该文件系统已成功满足了我们的存储需求,广泛部署在Google内部,作为数据生成和处理的平台,支持我们服务的运行以及需要大规模数据集的研究和开发工作。迄今为止,最大规模的集群提供了跨越数千块磁盘和超过一千台机器的数百TB的存储容量,支持数百个客户端的并发访问。

在本文中,我们介绍了设计的文件系统接口扩展,以支持分布式应用程序,讨论了设计的多个方面,并报告了微基准测试和实际使用中的测量结果。

1. 引言

我们审视了传统的文件系统设计的选择,并探索了截然不同的设计点。

首先,组件故障是常态而非例外。文件系统由廉价商用存储机器组成,并由数量相当的客户端机器访问。我们遇到的问题包括应用程序漏洞、操作系统漏洞、人工错误以及磁盘、内存、连接器、网络和电源供应故障。因此,系统必须具有持续监控、错误检测、容错和自动恢复的能力。

其次,与传统标准相比,文件规模巨大。多GB的文件很常见,每个文件通常包含许多应用对象(如网页文档),管理数十亿个KB级大小的文件即使在文件系统支持的情况下也非常困难。因此,I/O操作和块大小等设计假设和参数必须重新评估。

第三, 大多数文件是通过追加新数据而改变的,而非覆盖现有数据。文件内部的随机写入几乎不存在。文件一旦写入,通常只会被读取,而且通常是顺序读取。鉴于这种大文件的访问模式,追加操作成为性能优化和原子性保证的重点,而客户端缓存数据块的价值则下降了。

第四,共同设计应用程序和文件系统API有利于提升系统的灵活性。例如,我们放宽了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集群由一个控制器和多个块服务器(chunkservers)组成,并由多个客户端访问,如图1所示。每台服务器通常是运行用户级别服务进程的商用Linux机器。只要机器资源允许并且运行可能不稳定的应用代码带来的可靠性降低是可接受的,则可以在同一台机器上同时运行块服务器和客户端。

image-20250324163542805

文件被划分为固定大小的块。每个块由一个在创建时由主服务器分配的不可变且全局唯一的64位块句柄标识。块服务器将块作为Linux文件存储在本地磁盘上,并根据块句柄和字节范围读写块数据。为保证可靠性,每个块在多个块服务器上复制。默认情况下,我们存储三个副本,但用户可以为文件命名空间的不同区域指定不同的复制级别。

主服务器维护所有文件系统的元数据。这包括命名空间、访问控制信息、从文件到块的映射以及块的当前位置。它还控制系统范围的活动,如块租约管理、孤立块的垃圾回收和块服务器之间的块迁移。主服务器通过心跳消息定期与每个块服务器通信,以发出指令并收集其状态。

GFS客户端代码链接到每个应用程序中,负责实现文件系统API,并代表应用程序与主服务器和块服务器通信以读写数据。客户端与主服务器进行元数据操作的交互,但所有数据传输直接与块服务器通信。我们不提供POSIX API,因此不需要连接到Linux的inode层。

客户端和块服务器均不缓存文件数据。客户端缓存几乎没有优势,因为大多数应用程序通过大型文件进行流式处理,或者其工作集过大无法缓存。不使用缓存简化了客户端和整体系统,消除了缓存一致性问题。(不过,客户端会缓存元数据。)块服务器不需要缓存文件数据,因为块存储为本地文件,因此Linux的缓冲区缓存已经将频繁访问的数据保留在内存中。

2.4 单一控制器

使用单一主服务器大大简化了我们的设计,使主服务器能够利用全局信息做出复杂的块放置和复制决策。然而,我们必须尽量减少主服务器在读写过程中的参与,以免成为瓶颈。客户端在读取或写入文件数据时从不经过主服务器。相反,客户端向主服务器询问它应联系哪些块服务器。客户端会缓存这些信息一段时间,并在后续操作中直接与块服务器交互。

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

接着,客户端向一个副本发送请求,通常是距离最近的那个副本。请求包含块句柄和该块内的字节范围。对同一块的后续读取不再需要与主服务器交互,直到缓存信息过期或文件被重新打开。实际上,客户端通常会在同一请求中请求多个块,主服务器还可以包含紧随所请求块之后的块信息。这些额外信息几乎不增加成本,却避免了未来几次客户端-主服务器的交互。

2.5 块大小

块大小是设计中的关键参数之一。我们选择了64 MB的块大小,这远大于典型文件系统的块大小。每个块副本作为普通Linux文件存储在块服务器上,并在需要时进行扩展。延迟空间分配避免了由于内部碎片化而浪费空间,解决了对大块大小的主要质疑。

大块大小带来了几个重要优势。首先,它减少了客户端与主服务器交互的需求,因为同一块上的读写操作只需要一次初始请求来获取块位置信息。对于我们主要顺序读取和写入大文件的工作负载而言,这种减少尤为显著。即使是小的随机读取,客户端也可以轻松缓存多TB工作集的所有块位置信息。其次,由于客户端在一个大块上执行的操作较多,它可以通过在较长时间内保持与块服务器的持久TCP连接来减少网络开销。第三,它减少了主服务器上存储的元数据大小。这允许我们将元数据保存在内存中,带来其他优势,这将在第2.6.1节讨论。

另一方面,即使有延迟空间分配,大块大小也有其缺点。小文件仅包含少量块,可能只有一个块。存储这些块的块服务器可能会在许多客户端同时访问同一文件时成为热点。在实践中,热点并不构成主要问题,因为我们的应用程序大多顺序读取包含多个块的大文件。

然而,当GFS最初被批处理队列系统使用时,确实出现了热点:一个可执行文件以单块文件写入GFS,然后在数百台机器上同时启动。存储该可执行文件的少数块服务器因数百个同时请求而超负荷。我们通过为此类可执行文件设置更高的复制因子,并让批处理队列系统错开应用程序启动时间来解决此问题。一个潜在的长期解决方案是在此类情况下允许客户端从其他客户端读取数据。

2.6 元数据

主服务器(master)存储三种主要类型的元数据:文件和块的命名空间、文件到块的映射,以及每个块的副本位置。所有元数据都保存在主服务器的内存中。前两种类型(命名空间和文件到块的映射)通过记录操作日志保存在主服务器的本地磁盘上,并在远程机器上进行备份。这种日志方式可以简单、可靠地更新主服务器的状态,同时在主服务器崩溃时不会导致不一致。主服务器不持久化存储块位置信息,而是在主服务器启动或块服务器加入集群时询问每个块服务器以获取该信息。

2.6.1 内存数据结构

由于元数据保存在内存中,主服务器的操作速度很快。此外,主服务器可以定期在后台扫描整个状态,这种定期扫描用于实现块垃圾回收、在块服务器故障情况下重新复制块、以及平衡块服务器间的负载和磁盘空间(见4.3和4.4节)。这种只在内存中存储的方式的潜在问题是,块的数量和整个系统的容量受到主服务器内存大小的限制,但在实践中这并不是一个严重的限制。主服务器为每个64MB块维护的元数据少于64字节。大多数块是满的,因为大多数文件包含许多块,只有最后一个块可能是部分填充的。同样,文件命名空间数据每个文件通常占用不到64字节,因为它使用前缀压缩来紧凑地存储文件名。

如果需要支持更大的文件系统,增加主服务器内存的成本是很小的代价,而这种方式带来了元数据存储在内存中的简单性、可靠性、性能和灵活性。

2.6.2 块位置

主服务器不持久化记录哪个块服务器拥有某个块的副本,而是在启动时简单地向块服务器请求该信息。主服务器可以在之后保持更新,因为它控制所有块的放置,并通过定期的心跳消息监控块服务器的状态。

最初我们尝试在主服务器中持久化保存块位置信息,但最终决定在启动时从块服务器获取数据,并定期更新,这样可以避免块服务器在加入和离开集群、更改名称、故障、重启等情况下造成的不一致问题。在包含数百台服务器的集群中,这种事件频繁发生。

另一个理解这一设计决策的方式是认识到块服务器对其拥有的块具有最终决定权。例如,由于块服务器出现错误可能导致块意外消失(如磁盘故障导致禁用),或者操作人员可能重命名块服务器,因此在主服务器上维护该信息的一致视图并没有意义。

2.6.3 操作日志

操作日志包含关键元数据更改的历史记录,是GFS的核心。它不仅是元数据的唯一持久记录,还作为逻辑时间线定义了并发操作的顺序。文件和块以及它们的版本(见4.5节)都由创建时的逻辑时间唯一标识。

由于操作日志至关重要,我们必须可靠地存储它,且在元数据更改持久化之前不向客户端展示更改。否则,即使块本身幸存下来,我们也可能丢失整个文件系统或近期的客户端操作。因此,我们在多台远程机器上复制操作日志,并在对应的日志记录本地和远程写入磁盘后才响应客户端操作。主服务器在将多个日志记录组合后进行写入,减少写入和复制对系统总体吞吐量的影响。

主服务器通过重放操作日志恢复文件系统状态。为减少启动时间,需保持日志简短。主服务器在日志超过一定大小时检查其状态,通过加载本地磁盘上的最新检查点并仅重放少量日志记录来恢复。检查点采用紧凑的B树形式,可以直接映射到内存中并用于命名空间查找,无需额外解析。这进一步加速了恢复并提高了可用性。

由于创建检查点可能需要一些时间,主服务器的内部状态结构允许创建新检查点而不延迟新进来的变动。主服务器切换到新日志文件,并在单独的线程中创建新检查点。新检查点包括切换前的所有变动。对于拥有几百万个文件的集群,检查点可以在一分钟左右完成。检查点完成后会写入本地和远程磁盘。

恢复只需要最新的完整检查点和随后的日志文件。较旧的检查点和日志文件可以自由删除,不过我们会保留几个以防灾难性事件。检查点期间的失败不会影响正确性,因为恢复代码会检测并跳过不完整的检查点。

2.7 一致性模型

GFS(Google File System)采用了一种宽松的一致性模型,既支持我们高度分布式的应用程序,又相对简单且高效实现。我们将讨论GFS的保证及其对应用程序的意义,并介绍GFS如何保持这些保证,但详细实现将在文中其他部分描述。

2.7.1 GFS的保证

文件命名空间的变动(如文件创建)是原子操作。此类操作由主服务器独占处理,命名空间的锁定保证了操作的原子性和正确性(见4.1节),主服务器的操作日志定义了这些操作的全局顺序(见2.6.3节)。

在数据变动后,文件区域的状态取决于变动类型、变动是否成功以及是否有并发变动。表1总结了结果。一个文件区域是“一致的”是指所有客户端从任意副本读取时都能看到相同的数据。“定义状态”指在数据变动后,区域是一致的,且所有客户端都能看到变动写入的完整数据。如果一次变动在没有其他写入干扰的情况下成功,受影响的区域即为定义状态(且隐含一致):所有客户端都能看到变动写入的内容。并发成功变动使区域处于未定义但一致的状态:所有客户端看到的内容一致,但可能不代表任何一次变动的具体写入内容,通常是多个变动内容的混合片段。失败的变动使区域处于不一致(因此也是未定义)的状态:不同客户端可能在不同时刻看到不同的数据。我们接下来描述如何让应用区分定义区域和未定义区域。应用程序无需进一步区分不同种类的未定义区域。

数据变动可以是写入或记录追加。写入是指在应用程序指定的文件偏移处写入数据。记录追加指的是在有并发变动的情况下,以GFS选择的偏移处原子性地追加记录,保证记录至少写入一次(见3.3节)。与之相比,普通的“追加”只是客户端认为的文件末尾偏移处的写入。GFS返回的偏移标志着记录的开始,是已定义区域。此外,GFS可能在记录之间插入填充或记录重复,通常与用户数据相比,这些填充或重复所占区域不多,且被视为不一致区域。

在一系列成功变动之后,变动的文件区域可保证为定义状态,并包含最后一次变动的数据。GFS通过以下方式实现此点:(a) 在所有副本上以相同顺序对一个数据块进行变动(见3.1节);(b) 使用块版本号检测因服务器故障错过变动而变为过期的副本(见4.5节)。过期的副本不会参与新的变动,且在客户端请求块位置时不会返回给客户端。一旦有机会,这些副本会被垃圾回收。

由于客户端会缓存块位置,它们可能在缓存信息刷新前从过期副本中读取数据。此窗口期受缓存条目的超时限制和文件重新打开的影响,重新打开文件时缓存的所有块信息会被清除。此外,由于大多数文件仅追加写入,过期副本通常返回块的早期结束信息,而非过期数据。当读取请求重新联系主服务器时,会立即获得当前的块位置。

在成功变动之后,如果组件发生故障,仍可能损坏或丢失数据。GFS通过主服务器与所有块服务器的定期握手确认失败的块服务器,并通过校验和检测数据损坏(见5.2节)。一旦问题出现,GFS会尽快从有效的副本恢复数据(见4.3节)。数据块只有在所有副本在GFS反应之前(通常几分钟内)丢失时才会不可恢复。这种情况下,数据块不会被破坏,只是变得不可用,应用会收到清晰的错误,而不是损坏的数据。

2.7.2 对应用程序的影响

GFS的应用程序可以通过一些简单的技术来适应这种宽松的一致性模型,这些技术本来就需要用于其他目的,比如依赖追加操作而非覆盖写入、设置检查点以及写入自验证和自标识的记录。

实际上,几乎所有应用程序都是通过追加数据而非覆盖数据来变动文件。在一种典型用法中,写入者从文件的起始写到结束。写入所有数据后,它会将文件原子性地重命名为永久名称,或者周期性地检查已成功写入的部分。检查点可能还包含应用级的校验和。读取者仅验证并处理到最后一个检查点之前的文件区域,因为这一部分在定义状态中。无论一致性和并发性问题如何,这种方法对我们很有帮助。追加操作比随机写入更高效且对应用故障更具容错性。检查点允许写入者按增量重启,并防止读取者处理到应用认为尚未完成的已成功写入文件数据。

在另一种典型用法中,许多写入者并发地向一个文件追加数据,用于合并结果或作为生产者-消费者队列。记录追加的“至少一次”追加语义保留了每个写入者的输出。读取者可以通过以下方式处理偶尔的填充和重复。每个写入者准备的记录包含额外信息(如校验和),以便验证其有效性。读取者可以通过校验和识别并丢弃额外的填充和记录片段。如果无法容忍偶尔的重复(如触发非幂等操作),可以通过记录中所含的唯一标识符过滤它们。这些标识符通常也是命名应用程序实体(如网页文档)所需的。记录I/O的这些功能(除重复移除外)在我们应用程序共享的库代码中实现,适用于Google的其他文件接口实现。因此,相同的记录序列加上少量重复内容始终会传递到记录读取者。

3. 系统交互

我们设计系统的目标是最小化主节点在所有操作中的参与。基于这一背景,下面我们描述客户端、主节点和块服务器如何交互,以实现数据变更、原子记录追加和快照功能

3.1 租约与变更顺序

变更(Mutation)是指对数据块(chunk)的内容或元数据进行更改的操作,如追加。每次变更都需要在该数据块的所有副本上执行。我们使用租约(lease)来保证所有副本之间的一致性变更顺序。控制器会将一个数据块的租约授予某个副本,我们称该副本为主副本(primary)。主副本为该数据块上的所有mutation分配一个串行顺序,所有副本都按照这个顺序执行变更。因此,全局变更顺序由以下两个部分定义:

  1. 租约授予顺序(由主控节点决定)
  2. 主副本分配的序列号(在租约有效期内由主副本决定)

租约机制旨在最小化主控节点的管理开销。租约的初始有效期为60 秒。只要数据块持续发生变更,主副本可以向主控节点请求租约续期,通常可以无限期地获得延长。这些续期请求续期授权信息会通过主控节点与数据块服务器(chunkserver)之间定期交换的心跳(HeartBeat)消息进行携带。

在某些情况下,主控节点可能会在租约到期前主动撤销租约(例如,当主控节点想要禁止对正在重命名的文件进行变更时)。即使主控节点与主副本失去通信,只要旧租约到期,主控节点就可以安全地将租约授予另一个副本。图2,跟随控制流,描述一个写操作的具体流程如下:

image-20250324171652289

  1. 客户端向控制器请求,哪个chunkserver持有该数据块的租约,以及其他副本的位置,如果没有副本持有租约,控制器会随机选定一个副本并授予租约(图中没有展示)
  2. 控制器回复客户端,将以下信息返回给客户端:主副本的身份(持有租约的副本),其他secondary replicas的位置。只有在主副本不可达或无租约时,才需要重新联系控制器。
  3. 客户端推送数据到所有的副本。顺序不受限制,每个副本会将数据暂存其内部的LRU缓存中,直到数据被使用或被淘汰。这种数据流与控制流解耦方式,可以根据网络拓扑优化数据传输,二部依赖于主副本的位置。
  4. 当所有副本都确认接收数据后,客户端向主副本发送写请求,标识该操作需要使用的数据。主副本会为所有接收到的变更(包括来自多个客户端的请求)分配连续的序列号,确保变更按序执行,并按照该序列号更新自己的本地数据。
  5. 主副本按照分配的序列号将变更请求转发给所有次副本。每个次副本都会严格按照主副本分配的顺序执行变更,确保数据一致性。
  6. 所有次副本在成功执行变更后,向主副本发送确认(ACK)。
  7. 主副本在接收到所有次副本的确认后,向客户端发送最终的操作结果。如果任何副本在此过程中遇到错误,错误会立即报告给客户端。

如果应用的写入过大,超过了一个chunk的界限,GFS客户端会将其分裂成多个写操作,

3.2 数据流

我们将数据流与控制流解耦,虽然控制流从客户端传输到Primary,再到所有的Secondary,但是数据是按照精心挑选的chunkserver链以流水线(pipelined)的方式线性推送的,我们的目标是:

  • 充分利用每台机器的网络带宽
  • 避免网络瓶颈和高延迟links(比如,例如交换机之间的连接通常是瓶颈),每台机器将数据转发给在网络拓普中

例如,假设客户端向数据块服务器 S1 至 S4 发送数据。客户端首先将数据发送给最近的数据块服务器,例如 S1。S1 然后将数据转发给 S2 至 S4 中距离 S1 最近的服务器,例如 S2。同样,S2 将数据转发给 S3 或 S4 中离其最近的服务器,依此类推。由于我们的网络拓扑相对简单,因此可以根据IP 地址准确估算机器之间的“距离”。

通过流水线技术最小化延迟。我们通过TCP 连接对数据传输进行流水线处理以减少延迟。每当数据块服务器接收到一部分数据时,立即开始向下一个服务器转发。由于我们使用的是全双工(full-duplex)交换式网络,立即发送数据不会降低接收速度。

在没有网络拥塞的情况下,将 B 字节数据传输到 R 个副本所需的理想时间为 =TB+R×L​, L是两台机器之间的传输延迟,T是网络吞吐量,B是数据的字节数。

3.3 原子记录追加

GFS 提供了一种称为 记录追加(record append) 的原子操作。

在传统写操作中,客户端需要指定数据要写入的偏移量(offset)。当多个客户端同时对同一区域进行写操作时,这些写操作无法被序列化,最终该区域可能包含来自多个客户端的混合数据片段。

而在 记录追加 操作中,客户端只需指定要写入的数据,GFS 会将该数据以原子方式(即作为一个连续的字节序列)追加到文件的末尾,并将最终写入的偏移量返回给客户端。 这一操作类似于 Unix 系统中以 O_APPEND 模式打开文件进行写入,但避免了多个客户端同时追加时可能产生的竞争条件(race condition)。如果使用传统写操作,客户端需要借助于 分布式锁管理器进行负载且高昂的同步操作,而记录追加操作可以简化这一过程。

记录追加是一种变更操作(mutation),遵循3.1 节所描述的控制流程,但在主副本(primary)处增加了少量额外的逻辑。具体过程如下:

  1. 数据推送:客户端将数据推送到目标文件最后一个块的所有副本(replicas)。
  2. 请求发送:客户端向主副本发送追加请求。
  3. 主副本检查块大小
    • 如果追加记录后会导致当前块超过最大大小(64 MB)
      • 主副本将该块填充(pad)至最大大小,指示所有副本执行相同操作。
      • 主副本向客户端回复,指示其在下一个块中重试该操作。
      • :为了控制碎片化,记录追加的大小限制为最大块大小的 1/4
    • 如果记录在当前块内能容纳(常见情况):
      • 主副本在其本地副本中追加数据。
      • 指示所有次副本(secondary)*在*相同偏移量处写入相同数据。
      • 主副本向客户端返回成功响应。

如果记录追加在任何副本上失败,客户端会重新尝试该操作。由于这种重试机制,同一个块的不同副本可能包含不一致的数据,例如:

  • 重复的记录(完整或部分重复)。
  • 未完成的追加

GFS 不保证所有副本的内容在字节级完全一致,但它保证以下两点:

  1. 数据会以原子方式至少写入一次
    • 只有在数据被成功写入所有副本的相同偏移量后,GFS 才会向客户端返回成功。
  2. 所有副本的长度至少会达到已追加记录的末尾。
    • 因此,未来的记录要么会写入更高的偏移量,要么会写入新的块,即使主副本发生了变化。

在 GFS 的一致性模型中:

  • 成功追加操作的区域是已定义(consistent)的。
  • 未涉及追加操作的中间区域可能是未定义(inconsistent)的。

3.4 快照

4. 主服务器操作

主服务器执行所有的命名空间操作。此外,它管理系统中的块副本:做出放置决策、创建新块(以及相应的副本),并协调各项系统范围的活动,以保持块的充分复制、平衡所有块服务器的负载并回收未使用的存储。接下来,我们将讨论这些主题。

4.1 命名空间管理和锁定

许多主服务器操作可能需要较长时间。例如,快照操作需要撤销所有受快照覆盖的块的块服务器租约。我们不希望在这些操作进行时延迟其他主服务器操作。因此,我们允许多个操作同时进行,并使用命名空间区域上的锁来确保正确的序列化。

与许多传统文件系统不同,GFS 没有按目录存储的结构来列出该目录中的所有文件。它也不支持文件或目录的别名(例如,Unix 中的硬链接或符号链接)。GFS 将其命名空间逻辑上表示为一个查找表,将完整路径映射到元数据。通过前缀压缩,可以在内存中高效地表示该表。命名空间树中的每个节点(绝对文件名或绝对目录名)都有一个关联的读写锁。

每个主服务器操作在运行前会获取一组锁。通常情况下,如果操作涉及路径 /d1/d2/.../dn/leaf,那么它将获取目录名 /d1, /d1/d2, ..., /d1/d2/.../dn 上的读锁,并在完整路径名 /d1/d2/.../dn/leaf 上获取读锁或写锁。注意,leaf 可能是文件或目录,这取决于操作类型。

以下说明了此锁机制如何防止在 /home/user 被快照到 /save/user 时创建文件 /home/user/foo。快照操作会在 /home 和 /save 上获取读锁,在 /home/user 和 /save/user 上获取写锁。文件创建操作则会在 /home 和 /home/user 上获取读锁,在 /home/user/foo 上获取写锁。这两个操作将会被正确序列化,因为它们在 /home/user 上尝试获取相互冲突的锁。文件创建不需要在父目录上获取写锁,因为不存在需要保护不被修改的“目录”或 inode 类数据结构。目录名上的读锁足以防止父目录被删除。

这个锁机制的一个优点是它允许在同一目录中进行并发的更改。例如,可以在同一目录中并发执行多个文件创建操作:每个操作在目录名上获取读锁,在文件名上获取写锁。目录名上的读锁足以防止目录被删除、重命名或快照。文件名上的写锁序列化了创建具有相同名称的文件的尝试。

由于命名空间可能包含许多节点,因此读写锁对象在需要时会被懒加载,并在不再使用时删除。为了防止死锁,锁按照一致的总顺序获取:首先按照命名空间树中的层级排序,在同一级别内按字典序排序。

4.2 副本放置

一个 GFS 集群通常在多个级别上高度分布。它通常拥有数百个分布在许多机架上的块服务器。这些块服务器可能会被同一机架或不同机架上的数百个客户端访问。两个不同机架上的机器之间的通信可能需要经过一个或多个网络交换机。此外,某个机架的带宽进出可能小于该机架内所有机器的总带宽。

多级分布为实现数据的可扩展性、可靠性和可用性带来了独特的挑战。块副本放置策略有两个目的:最大化数据的可靠性和可用性,以及最大化网络带宽的利用率。为此,仅在机器之间分布副本是不够的,这只能够防范磁盘或机器故障并充分利用每台机器的网络带宽。我们还必须在机架之间分布块副本。这确保了即使整个机架受损或离线(例如,由于共享资源如网络交换机或电路的故障),某些块副本仍然能够存活并保持可用性。这也意味着可以利用多个机架的总带宽来处理块的流量,尤其是读取流量。另一方面,写流量必须通过多个机架传输,这是我们愿意接受的权衡。

4.3 创建、重复制、重新平衡

块副本的创建有三个原因:块创建、重复制和重新平衡。

当主服务器创建一个块时,它会选择放置最初为空的副本的位置。它会考虑几个因素:(1)我们希望将新的副本放置在磁盘空间利用率低于平均水平的块服务器上。随着时间的推移,这将使块服务器之间的磁盘利用率趋于平衡。(2)我们希望限制每个块服务器上的“最近”创建数量。尽管创建操作本身是廉价的,但它可靠地预测了即将到来的重写流量,因为块是在写入时按需创建的,在我们“追加一次,读取多次”的工作负载中,它们通常在完全写入后几乎变成只读状态。(3)如前所述,我们希望将块的副本分布在不同的机架上。

当块副本数量低于用户指定的目标时,主服务器会立即重新复制该块。这种情况可能由多种原因引起:块服务器变得不可用,报告其副本可能已损坏,某个磁盘因错误被禁用,或者复制目标增加。每个需要重新复制的块根据几个因素进行优先级排序。一个因素是副本距离复制目标的远近。例如,我们会给失去两个副本的块更高的优先级,而不是仅失去一个副本的块。此外,我们倾向于首先重新复制活跃文件的块,而不是最近已删除文件的块(见 4.4 节)。最后,为了减少故障对正在运行的应用程序的影响,我们提高了任何阻止客户端进度的块的优先级。

主服务器选择优先级最高的块,并通过指示某个块服务器直接从现有有效副本复制数据来“克隆”它。新副本的放置目标类似于创建时的目标:平衡磁盘空间利用率,限制单一块服务器上的活跃克隆操作,并将副本分布在不同的机架上。为了避免克隆流量压倒客户端流量,主服务器限制了整个集群和每个块服务器上活跃克隆操作的数量。此外,每个块服务器通过限制其读取请求的带宽,来限制每个克隆操作的带宽消耗。

最后,主服务器会定期进行副本重新平衡:它检查当前的副本分布,并移动副本以便更好地平衡磁盘空间和负载。同时,在此过程中,主服务器会逐渐填充新的块服务器,而不是立即将新的块和随之而来的重写流量压垮它。新副本的放置标准类似于前面讨论的标准。此外,主服务器还必须选择要移除的现有副本。一般来说,它倾向于移除那些位于磁盘空间低于平均水平的块服务器上的副本,以便平衡磁盘空间的使用。

4.4 垃圾回收

文件删除后,GFS 并不会立即回收可用的物理存储空间。它仅在常规垃圾回收时,以懒惰的方式回收文件和块层次的存储。我们发现这种方式使得系统更加简单和可靠。

4.4.1 机制

当应用程序删除文件时,主服务器会像其他更改一样立即记录删除操作。然而,主服务器不会立即回收资源,而是将文件重命名为一个隐藏名称,其中包含删除时间戳。在主服务器定期扫描文件系统命名空间时,它会删除所有存在超过三天(此时间间隔是可配置的)的隐藏文件。在此之前,文件仍然可以通过新的特殊名称读取,并可以通过将文件重命名回正常名称来恢复。当隐藏文件从命名空间中删除时,其内存中的元数据会被清除。这有效地断开了文件与所有块的链接。

在对块命名空间的类似定期扫描中,主服务器会识别孤立块(即无法从任何文件访问的块)并删除这些块的元数据。在与主服务器定期交换的心跳消息中,每个块服务器报告它拥有的部分块,主服务器则回复所有不再存在于主服务器元数据中的块的标识。块服务器可以自由删除这些块的副本。

4.4.2 讨论

尽管分布式垃圾回收在编程语言的上下文中是一个难题,通常需要复杂的解决方案,但在我们的案例中却相对简单。我们可以轻松地识别所有对块的引用:它们存在于由主服务器独占维护的文件到块的映射中。我们还可以轻松识别所有的块副本:它们是每个块服务器上指定目录下的 Linux 文件。任何不被主服务器知晓的副本都可以视为“垃圾”。

垃圾回收的存储回收方法相比于急切删除具有几个优势。首先,在一个分布式系统中,组件故障是常见的,而这种方法简单且可靠。块创建可能在一些块服务器上成功,但在其他块服务器上失败,导致主服务器无法知道副本的存在。副本删除消息可能会丢失,主服务器需要跨越故障重新发送这些消息,包括它自己的故障和块服务器的故障。垃圾回收为清理任何不被认为有用的副本提供了一个统一且可靠的方式。其次,它将存储回收合并到主服务器的常规后台活动中,例如对命名空间的定期扫描和与块服务器的握手。因此,它是批量执行的,成本得到了摊销。此外,它仅在主服务器相对空闲时执行,主服务器可以更及时地响应那些需要立即处理的客户端请求。第三,存储回收的延迟为防止意外不可逆的删除提供了安全网。

在我们的经验中,主要的缺点是,延迟有时会妨碍用户在存储紧张时进行精细调整。那些重复创建和删除临时文件的应用程序可能无法立即重新使用这些存储。我们通过加速存储回收来解决这些问题,尤其是当已删除的文件再次显式删除时。我们还允许用户对命名空间的不同部分应用不同的复制和回收策略。例如,用户可以指定某个目录树中的所有文件块不需要复制,任何删除的文件都会立即且不可逆地从文件系统状态中移除。

4.5 陈旧副本检测

当块服务器故障且在恢复期间错过块的变更时,块副本可能变得陈旧。对于每个块,主服务器维护一个块版本号,用以区分最新的副本和陈旧的副本。

每当主服务器授予一个块新的租约时,它会增加块版本号并通知所有最新的副本。主服务器和这些副本都在其持久化状态中记录新的版本号。这一过程发生在任何客户端收到通知之前,因此客户端在开始写入块之前会确保数据是最新的。如果另一个副本当前不可用,其块版本号将不会被更新。主服务器在块服务器重新启动并报告其块及其关联版本号时会检测到陈旧副本。如果主服务器看到版本号大于其记录中的版本号,主服务器会假设授予租约时发生了故障,因此它会认为较高版本的副本是最新的。

主服务器会在其常规垃圾回收中删除陈旧副本。在此之前,主服务器实际上会将陈旧副本视为不存在,在回复客户端请求块信息时不会考虑这些副本。作为另一种保护措施,主服务器在告知客户端哪个块服务器持有块租约,或者在指示块服务器从另一个块服务器读取块进行克隆操作时,会包括块版本号。客户端或块服务器在执行操作时会验证版本号,以确保始终访问最新的数据。

5. 容错和诊断(Diagnosis)

设计这个系统的最大的挑战之一是如何处理频繁的组件级失效。面对这样的情况,我们不能完全相信机器,也不能完全相信磁盘。基于以上,我们探讨如何满足这些挑战,以及如何构建工具去诊断不可避免发生的故障

5.1 高可用性

在一个包含数百台服务器的GFS集群中,任何时刻都可能会有部分服务器不可用。为了保证系统的高可用性,我们采用了两种简单而有效的策略:快速恢复和数据复制。

5.1.1 快速恢复

无论是主服务器还是块服务器,都被设计成在结束运行后可以在数秒内恢复状态并重新启动。实际上,我们并不区分正常和异常终止;服务器通常通过杀死进程来关闭。客户端和其他服务器在遇到当前请求超时时会出现小小的“顿卡”,它们会重新连接到已重启的服务器并重试请求。第6.2.2节报告了观察到的启动时间。

5.1.2 块复制

如前所述,每个数据块都在不同的机架上的多个块服务器上复制。用户可以为文件命名空间的不同部分指定不同的复制级别,默认情况下为三份。当块服务器下线或通过校验和验证检测到已损坏的副本时,主服务器会根据需要克隆现有的副本以确保每个块都保持完全复制状态。

5.1.3 主服务器的复制

主服务器的状态被复制以确保可靠性。其操作日志和检查点在多台机器上被复制。只有当日志记录被写入本地磁盘和所有主服务器副本后,状态的变更才被视为已提交。为了简化操作,系统中的所有变更(如垃圾回收等内部操作)均由一个主服务器进程负责。当它发生故障时,可以几乎瞬间重启。如果它的机器或磁盘发生故障,GFS系统外的监控基础设施会在其他地方启动新的主服务器进程,利用已复制的操作日志。客户端只使用主服务器的规范名称(例如gfs-test),这是一个DNS别名,如果主服务器迁移到其他机器上,可以更改该别名。

此外,“影子”主服务器提供只读访问,即使主服务器宕机时也能读取文件系统。影子服务器并不是主服务器的完全镜像,它们可能比主服务器略有延迟,通常只有几毫秒。它们可以为未被积极修改的文件或不介意获取略旧结果的应用程序提高读取可用性。实际上,由于文件内容是从块服务器读取的,应用程序不会观察到过时的文件内容。在短时间窗口内可能会变旧的是文件的元数据,如目录内容或访问控制信息。

为了保持信息更新,影子主服务器会读取操作日志的副本,并按照主服务器相同的变更序列更新其数据结构。像主服务器一样,它会在启动时(以及之后不定期)轮询块服务器,以定位块副本,并通过频繁的握手消息来监控块服务器的状态。影子主服务器只依赖主服务器提供的副本位置信息更新,这些更新来自主服务器创建或删除副本的决定。

5.2 数据完整性

每个块服务器都使用校验和来检测存储数据的损坏。由于GFS集群通常包含数千个磁盘,分布在数百台机器上,磁盘故障经常会导致读写路径上的数据损坏或丢失(第7节中将提到其中一个原因)。尽管我们可以通过其他块副本恢复数据,但通过在各块服务器间比较副本来检测损坏是不切实际的。此外,副本不一致在某些情况下是正常的:GFS的变更操作语义(特别是之前提到的原子记录追加)并不保证副本完全一致。因此,每个块服务器必须通过维护校验和独立验证其副本的完整性。

每个数据块被划分为64KB的子块,每个子块对应一个32位的校验和。与其他元数据一样,校验和存储在内存中,并通过日志记录持久保存,且与用户数据分开存储。

在读取数据时,块服务器在返回数据给请求者(无论是客户端还是其他块服务器)之前会先验证与读取范围重叠的数据块的校验和。因此,块服务器不会将损坏的数据传播到其他机器上。如果块的数据与记录的校验和不匹配,块服务器会向请求者返回错误并将不匹配情况报告给主服务器。请求者会从其他副本中读取数据,同时主服务器会从另一副本中克隆块。新的有效副本到位后,主服务器会指示报告不匹配的块服务器删除其副本。

校验和对读取性能的影响较小,原因有几个。首先,我们的读取操作通常跨越多个数据块,只需读取和验证少量额外数据即可。GFS客户端代码通过尝试将读取操作对齐到校验和块边界,进一步减少了这类开销。此外,块服务器上的校验和查找与比较无需任何I/O操作,且校验和计算往往可以与I/O操作重叠执行。

校验和计算对追加写(即向块末尾添加数据)进行了大量优化,因为这类操作在我们的工作负载中占主导地位。我们只需对最后一个部分校验和块进行增量更新,并对追加填充的新校验和块计算校验和。如果最后的部分校验和块已损坏且未被检测到,新计算的校验和值将无法与存储数据匹配,损坏将在下次读取该块时被检测到。

相比之下,如果写操作覆盖块的现有范围,则我们必须先读取并验证被覆盖范围的首尾块,然后执行写操作,并最终计算并记录新的校验和。如果在部分覆盖前未验证首尾块,新校验和可能会掩盖未覆盖区域中的损坏。

在空闲期,块服务器可以扫描并验证非活跃块的内容。这使我们能够检测到很少被读取的数据块中的损坏。一旦检测到损坏,主服务器可以创建一个新的未损坏的副本,并删除损坏的副本。这避免了非活跃但已损坏的块副本欺骗主服务器,使其误以为该块有足够的有效副本。

5.3 诊断工具

详细的诊断日志在问题隔离、调试和性能分析中起到了巨大的帮助作用,且成本极低。没有日志记录,很难理解机器之间瞬时、不可重现的交互。GFS服务器会生成诊断日志,记录许多重要事件(例如块服务器的上下线)以及所有RPC请求和回复。这些诊断日志可以自由删除,不会影响系统的正确性。不过,我们会尽量在空间允许的情况下保留这些日志。

RPC日志包含传输中发送的确切请求和响应(不包括被读或写的文件数据)。通过匹配请求与回复并整理不同机器上的RPC记录,我们可以重建完整的交互历史,以诊断问题。这些日志还可作为负载测试和性能分析的痕迹。

日志记录对性能的影响最小(且其带来的好处远大于影响),因为这些日志是按顺序异步写入的。最新事件也会保存在内存中,便于持续在线监控。

6. 测量

在本节中,我们将展示一些微基准测试,以说明 GFS 架构和实现中的瓶颈,并提供 Google 内部使用的真实集群的相关数据。

6.1 微基准测试

我们在一个包含 1 个主节点、2 个主节点副本、16 个块服务器和 16 个客户端的 GFS 集群上进行了性能测量。需要注意的是,这个配置是为了方便测试而设置的,典型的集群通常有数百个块服务器和客户端。

所有机器配置了双 1.4 GHz PIII 处理器、2 GB 内存、两个 80 GB 5400 rpm 硬盘以及连接至 HP 2524 交换机的 100 Mbps 全双工以太网。所有 19 个 GFS 服务器机器连接到一个交换机,而所有 16 个客户端机器连接到另一个交换机。两个交换机之间通过 1 Gbps 链接连接。

6.1.1 读取

N 个客户端同时从文件系统中读取数据。每个客户端从 320 GB 文件集中随机选择一个 4 MB 区域进行读取。这个操作重复 256 次,使得每个客户端总共读取 1 GB 数据。块服务器总共只有 32 GB 内存,因此我们预计 Linux 缓存命中率最多为 10%,测量结果应接近冷缓存的情况。

图 3(a) 显示了 N 个客户端的总读取速率及其理论上限。当连接两个交换机的 1 Gbps 链接达到饱和时,总读取速率的上限为 125 MB/s,或者当 100 Mbps 的网络接口达到饱和时,每个客户端的上限为 12.5 MB/s。当只有一个客户端读取时,观察到的读取速率为 10 MB/s,占每客户端上限的 80%。当有 16 个客户端时,总读取速率达到 94 MB/s,占 125 MB/s 链接上限的 75%,即每客户端 6 MB/s。效率从 80% 降至 75%,原因是随着读者数量的增加,同时从同一个块服务器读取的概率也随之增加。

6.1.2 写入

N 个客户端同时写入 N 个不同的文件。每个客户端通过一系列 1 MB 写入操作向一个新文件写入 1 GB 数据。图 3(b) 显示了总写入速率及其理论上限。由于每个字节需要写入 16 个块服务器中的 3 个,因此总写入速率的上限为 67 MB/s,每个块服务器的输入连接速率为 12.5 MB/s。

一个客户端的写入速率为 6.3 MB/s,大约是上限的一半。主要原因是我们的网络栈与我们用于向块副本推送数据的流水线方案之间的交互不佳。从一个副本传播数据到另一个副本的延迟降低了总体写入速率。当有 16 个客户端时,总写入速率达到 35 MB/s(即每客户端 2.2 MB/s),约为理论上限的一半。与读取一样,随着客户端数量的增加,同时写入到同一个块服务器的概率也增加。此外,由于每次写入涉及三个不同的副本,与读取相比,发生冲突的可能性更高。

尽管写入速度较慢,但在实际中这并不是主要问题。虽然这会增加个别客户端的延迟,但并未显著影响系统为大量客户端提供的总写入带宽。

6.1.3 记录追加

图 3(c) 显示了记录追加的性能。N 个客户端同时追加到一个文件。性能受限于存储该文件最后一个块的块服务器的网络带宽,与客户端数量无关。当有一个客户端时,速率为 6.0 MB/s,而当有 16 个客户端时,速率降至 4.8 MB/s,这主要是由于网络拥塞和不同客户端的传输速率波动。

我们的应用通常同时生成多个这样的文件。换句话说,N 个客户端同时追加到 M 个共享文件,这里的 N 和 M 都在几十或几百之间。因此,在实验中观察到的块服务器网络拥塞在实践中不是一个显著问题,因为一个客户端可以在一个文件的块服务器忙碌时在另一个文件上继续写入。

6.2 真实集群

我们现在来分析 Google 内部使用的两个具有代表性的 GFS 集群的情况。集群 A 主要用于研究和开发,由数百名工程师定期使用。典型的任务由用户发起,持续数小时不等,读取几 MB 到几 TB 的数据,分析或转换数据后将结果写回集群。集群 B 主要用于生产数据处理,任务持续更长时间,持续生成和处理多 TB 数据集,偶尔会有人为干预。

项目集群 A集群 B
块服务器数量342227
可用磁盘空间72 TB180 TB
已用磁盘空间55 TB155 TB
文件数量735 k737 k
无效文件数量22 k232 k
数据块数量992 k1550 k
块服务器元数据大小13 GB21 GB
主服务器元数据大小48 MB60 MB

6.2.1 存储

如表格中前五项所示,两个集群都包含数百个块服务器,支持数 TB 的磁盘空间,且几乎已被填满。“已用空间”包括所有块的副本,几乎所有文件都被复制三次。因此,这两个集群分别存储了 18 TB 和 52 TB 的实际文件数据。

两个集群的文件数量相似,但 B 集群中有更多的无效文件,即已删除或被新版本替代但尚未回收的文件。B 集群的块数量也较多,因为其文件通常更大。

6.2.2 元数据

块服务器总共存储了数十 GB 的元数据,主要是用户数据的 64 KB 数据块的校验和。块服务器上的其他元数据包括数据块的版本号(见 4.5 节)。

主服务器存储的元数据较小,仅有数十 MB,平均每个文件约 100 字节。这与我们的假设一致,即主服务器内存大小不会限制系统容量。大部分的文件元数据是以前缀压缩形式存储的文件名。其他元数据包括文件所有权和权限、文件到块的映射以及每个块的当前版本。此外,对于每个块,我们还存储当前副本位置和一个用于实现写时复制的引用计数。

每台服务器(包括块服务器和主服务器)仅包含 50 到 100 MB 的元数据。因此,恢复速度很快:从磁盘读取元数据仅需几秒钟,即可让服务器开始响应查询。但主服务器在短期内会受到一些影响,通常需要 30 到 60 秒才能从所有块服务器获取块位置信息。

6.2.3 读写速率

表 3 显示了在不同时间段的读写速率。两个集群在测量时已运行一周左右,期间经历了版本升级。自重启以来,平均写入速率小于 30 MB/s。在测量时,B 集群正处于写入高峰,生成约 100 MB/s 的数据,产生 300 MB/s 的网络负载,因为写入操作会传播到三个副本。

读取速率远高于写入速率。总工作负载中读取操作多于写入操作。A 集群在前一周中持续保持 580 MB/s 的读取速率,其网络配置可支持 750 MB/s,因此资源利用率较高。B 集群的峰值读取速率可达 1300 MB/s,但应用程序仅使用了 380 MB/s。

6.2.4 主服务器负载

表 3 显示了主服务器每秒的操作率大约在 200 到 500 次之间。主服务器可以轻松处理这个负载,因此在这些工作负载下不会成为瓶颈。

在早期版本的 GFS 中,主服务器偶尔会成为某些工作负载的瓶颈。主服务器大部分时间会按顺序扫描包含数十万个文件的大目录,寻找特定文件。我们已更改了主服务器的数据结构,以便通过命名空间进行高效的二分查找。现在,它可以轻松支持每秒数千次的文件访问。如果需要,我们可以在命名空间数据结构前添加名称查找缓存,以进一步提升速度。

6.2.5 恢复时间

在块服务器故障后,一些数据块会变为复制不足状态,必须进行克隆以恢复其复制级别。恢复这些数据块的时间取决于可用资源。在一项实验中,我们终止了 B 集群中的一个块服务器,该块服务器包含约 15,000 个数据块,总计 600 GB 的数据。为了减少对正在运行的应用程序的影响,并为调度决策提供余地,我们的默认参数限制该集群只能同时进行 91 个克隆(块服务器数量的 40%),每个克隆操作的带宽上限为 6.25 MB/s(50 Mbps)。所有数据块在 23.2 分钟内恢复,实际复制速率为 440 MB/s。

在另一项实验中,我们终止了两个块服务器,每个服务器拥有约 16,000 个数据块和 660 GB 的数据。这一双重故障导致 266 个数据块仅有一个副本。这些数据块被优先克隆,并在 2 分钟内恢复到至少双倍复制,从而使集群处于可承受另一个块服务器故障而不会丢失数据的状态。

6.3 工作负载分析

在本节中,我们详细分析了两个 GFS 集群的工作负载,这些集群与 6.2 节中的集群相似,但不完全相同。集群 X 用于研究和开发,而集群 Y 用于生产数据处理。

6.3.1 方法和注意事项

这些结果仅包含客户端发起的请求,因此反映了我们的应用程序对文件系统整体产生的工作负载。它们不包括服务器间的请求(用于处理客户端请求)或内部后台活动,例如转发写入或重新平衡操作。

I/O 操作的统计数据基于从 GFS 服务器记录的实际 RPC 请求信息中启发式重建。例如,GFS 客户端代码可能会将一次读取操作分解为多个 RPC,以增加并行性,我们据此推断原始读取请求。由于访问模式高度规范化,我们预期误差微乎其微。显式记录应用程序可能会提供稍微更准确的数据,但要重新编译和重新启动成千上万个正在运行的客户端以实现这一点,且收集数据也较为麻烦。

在对我们的工作负载进行分析时,不应过度泛化。由于 Google 完全控制 GFS 和其应用程序,应用程序通常会针对 GFS 进行优化,反之亦然。类似的相互影响也可能存在于一般应用程序和文件系统之间。

6.3.2 块服务器工作负载

表 4 显示了按操作大小划分的分布情况。读取操作大小呈双峰分布。较小的读取(小于 64 KB)来自密集定位的客户端,这些客户端在巨大文件中查找小块数据。较大的读取(超过 512 KB)则来自对整个文件的长序列读取。

在集群 Y 中,大量读取操作未返回任何数据。我们的应用程序,特别是生产系统中的应用程序,经常使用文件作为生产者-消费者队列。生产者并发地追加数据到文件中,而消费者读取文件结尾的数据。当消费者超过生产者的速度时,偶尔会出现没有数据返回的情况。集群 X 中较少出现这种情况,因为它通常用于短期的数据分析任务,而非长期的分布式应用程序。

写入操作大小也呈现双峰分布。较大的写入(超过 256 KB)通常源自写入者的显著缓冲。缓冲较少数据的写入者、检查点或更频繁地同步的写入者,或者只是生成较少数据的写入者则导致较小的写入(小于 64 KB)。

对于记录追加操作,集群 Y 中大记录追加操作的比例明显高于集群 X,因为我们的生产系统(使用集群 Y)针对 GFS 进行了更为积极的调优。

表 5 显示了不同操作大小的数据传输总量。对于所有类型的操作,较大的操作(超过 256 KB)通常占据大部分传输的字节量。较小的读取(小于 64 KB)也传输了少量但显著的读取数据,因为其为随机定位的工作负载。

6.3.3 追加与写入

记录追加操作在我们的生产系统中得到了广泛应用。对于集群 X,写入到记录追加的字节传输比为 108:1,操作计数比为 8:1。对于生产系统使用的集群 Y,这两个比率分别为 3.7:1 和 2.5:1。此外,这些比率表明,对于两个集群,记录追加操作的大小通常比写入操作更大。然而,集群 X 中记录追加的总体使用率在测量期间相对较低,因此结果可能被一两个特定缓冲大小的应用程序所偏差。

正如预期的那样,我们的数据变更工作负载以追加操作为主,而不是覆盖操作。我们测量了主副本上被覆盖的数据量。这大致相当于客户端故意覆盖之前写入的数据,而非追加新数据。对于集群 X,覆盖操作的数据字节占变更数据的不到 0.0001%,变更操作数的不到 0.0003%。对于集群 Y,这两个比例分别为 0.05%。尽管这微不足道,但仍高于我们的预期。结果显示,这些覆盖操作大部分来自客户端由于错误或超时而进行的重试。它们并非工作负载本身的一部分,而是重试机制的结果。

6.3.4 主服务器工作负载

表 6 显示了按请求类型划分的主服务器请求。大多数请求为读取的块位置查找(FindLocation)和数据变更的租约持有者信息查找(FindLeaseLocker)。

集群 X 和 Y 的删除请求数量显著不同,因为集群 Y 存储的生产数据集会定期重新生成并替换为新版本。部分差异在打开请求(Open requests)中被掩盖,因为旧版本的文件可能通过从头开始写入(类似于 Unix 中“w”模式打开文件)而被隐式删除。

FindMatchingFiles 是支持“ls”及类似文件系统操作的模式匹配请求。与其他主服务器请求不同,它可能处理命名空间的大部分,因此可能代价较高。集群 Y 中的使用频率更高,因为自动数据处理任务往往会检查文件系统的部分内容以了解全局应用程序状态。相比之下,集群 X 的应用程序受用户显式控制,通常事先已知所有需要的文件名。

7. 经验教训

在构建和部署 GFS 的过程中,我们遇到了一些操作和技术问题。最初,GFS 是作为生产系统的后端文件系统构建的。随着时间的推移,它的用途逐渐扩展,涵盖了研究和开发任务。最初它几乎不支持权限和配额等功能,但现在包含了这些功能的初级版本。虽然生产系统运行严格受控,但用户有时并不遵守规则。我们需要更多的基础设施来防止用户互相干扰。

我们遇到的一些最大问题与磁盘和 Linux 有关。许多磁盘向 Linux 驱动声明支持各种 IDE 协议版本,但实际上只对较新的版本响应可靠。由于这些协议版本非常相似,这些驱动器大多可以正常工作,但偶尔会因不匹配导致驱动器和内核对驱动器状态的理解不一致。这会因为内核中的问题而悄无声息地损坏数据。此问题促使我们使用校验和来检测数据损坏,同时我们还修改了内核以处理这些协议不匹配问题。

早期我们在使用 Linux 2.2 内核时也遇到了一些问题,原因是 fsync() 的开销。其开销与文件的大小成正比,而不是修改部分的大小。对于我们的大型操作日志,这尤其是个问题,特别是在实现检查点之前。我们曾通过使用同步写入来绕过这一问题,并最终迁移到 Linux 2.4。

另一个 Linux 问题是一个单一的读写锁,地址空间中的任何线程在从磁盘调入页面(读取锁)或在 mmap() 调用中修改地址空间(写入锁)时都必须持有该锁。我们在轻载下看到系统中的短暂超时,并努力寻找资源瓶颈或偶发的硬件故障。最终我们发现这个单一锁会阻止主要网络线程将新数据映射到内存中,而磁盘线程则正在将之前映射的数据调入内存。由于我们主要受限于网络接口而非内存拷贝带宽,我们通过用 pread() 替换 mmap() 来解决此问题,代价是多一次拷贝。

尽管偶尔遇到问题,但 Linux 代码的开源性多次帮助我们探索和理解系统行为。我们在适当的时候改进了内核,并与开源社区共享这些更改。

8. 相关工作

和其他大型分布式文件系统(如 AFS [5])一样,GFS 提供了一个位置无关的命名空间,使数据能够透明地迁移以实现负载均衡或容错。与 AFS 不同的是,GFS 将文件数据分布到存储服务器上,更类似于 xFS [1] 和 Swift [3] 的方式,以提供聚合性能并提高容错性。

由于磁盘相对便宜,且复制比更复杂的 RAID [9] 方法简单,GFS 目前仅使用复制来实现冗余,因此耗费的原始存储比 xFS 或 Swift 更多。

与 AFS、xFS、Frangipani [12] 和 Intermezzo [6] 等系统不同,GFS 在文件系统接口之下不提供任何缓存。我们的目标工作负载在单个应用程序运行期间几乎没有重复利用,因为它们要么是对大型数据集的流式处理,要么是在其中随机定位并每次读取少量数据。

一些分布式文件系统(如 Frangipani、xFS、Minnesota 的 GFS[11] 和 GPFS [10])移除了集中式服务器,依靠分布式算法来实现一致性和管理。我们选择了集中式方法,以简化设计、提高可靠性并增加灵活性。尤其是集中式主服务器使得实现复杂的数据块放置和复制策略变得更为容易,因为主服务器已经掌握了大部分相关信息并控制其变化。通过将主服务器状态保持较小并在其他机器上完全复制,我们解决了容错问题。我们的影子主服务器机制目前提供了可扩展性和高可用性(针对读取操作)。对主服务器状态的更新通过追加到预写日志来持久化。因此,我们可以采用类似 Harp [7] 中的主备副本方案,以提供比当前方案更强的一致性保证和高可用性。

在向大量客户端提供聚合性能方面,我们正处理与 Lustre [8] 类似的问题。然而,我们通过专注于应用程序需求而不是构建一个符合 POSIX 的文件系统,显著简化了问题。此外,GFS 假设大量组件不可靠,因此容错性是设计的核心。

GFS 最类似于 NASD 架构 [4]。尽管 NASD 架构基于网络附加磁盘驱动器,GFS 使用商用机器作为块服务器,这一点与 NASD 原型相同。不同于 NASD,GFS 的块服务器使用延迟分配的固定大小块,而不是可变长度对象。此外,GFS 实现了生产环境所需的重新平衡、复制和恢复等功能。

与 Minnesota 的 GFS 和 NASD 不同的是,我们不寻求改变存储设备的模型。我们专注于利用现有商用组件解决复杂分布式系统的日常数据处理需求。GFS 的原子记录追加实现的生产者-消费者队列解决的问题类似于 River [2] 中的分布式队列。虽然 River 使用分布在多台机器上的基于内存的队列和精确的数据流控制,GFS 使用一个可以由多个生产者并发追加的持久化文件。River 模型支持 m 对 n 的分布式队列,但缺乏持久存储带来的容错性,而 GFS 只能高效地支持 m 对 1 的队列。多个消费者可以读取同一文件,但必须协调以分担传入的负载。

9. 结论

Google 文件系统(GFS)展示了在商用硬件上支持大规模数据处理工作负载的关键特性。虽然有些设计决策是针对我们独特的环境,但其中许多可能适用于具有类似规模和成本考量的数据处理任务。

我们从重新审视传统文件系统的假设出发,以适应当前和未来的应用工作负载和技术环境。我们的观察结果引导我们在设计空间中做出根本不同的选择。我们将组件故障视为常态而非例外,优化系统以处理大文件,这些文件大多会被追加写入(可能是并发的),然后再被读取(通常是顺序的);此外,我们扩展并放宽了标准文件系统接口,以改善整体系统性能。

我们的系统通过持续监控、关键数据的复制以及快速自动的恢复机制来实现容错能力。块的复制使我们能够应对块服务器故障。由于这些故障频繁发生,我们设计了一种新的在线修复机制,以便定期透明地修复损坏并尽快补偿丢失的副本。此外,我们还通过校验和检测磁盘或 IDE 子系统层面的数据损坏,这在系统中大量使用磁盘的情况下变得尤为重要。

我们的设计为众多并发读写用户执行各种任务提供了高聚合吞吐量。我们通过将文件系统的控制(由主服务器处理)与数据传输(直接在块服务器和客户端之间进行)分离来实现这一点。大块大小和块租约将数据变更的权限委托给主要副本,从而减少了主服务器在常规操作中的参与。这使得主服务器能够简单集中,不会成为瓶颈。我们相信,网络栈的改进将提升单个客户端所看到的写入吞吐量限制。

GFS 成功满足了我们的存储需求,被广泛应用于 Google 内部的研发和生产数据处理中。它是一个重要的工具,使我们能够继续创新并解决全网级别的规模化问题。

论文阅读总结

概述

  • GFS的目标:分布在数百或数千台物理机器上的共享文件系统

  • 增加的复杂性:复制、许多用户同时写入相同的文件、超大文件

  • 依赖一些假设

    :主要是只追加的工作负载,写入后的只读

    • 意味着不需要复杂的一致性模型和原子性保证

组件

  • 控制器(论文中的“master”):具有组织/控制角色
  • 块服务器:数据实际存储的地方
  • :大块的数据

工作原理

GFS论文中的图1和图2是你在6.033任何论文中见到的最好的图之一。为你的设计项目系统图获取灵感!

  • 读取:
    • 客户端将文件名和文件内的偏移量发送给控制器
    • 控制器回复包含有该块的服务器集合
    • 客户端请求最近的块服务器
  • 写入:
    • 客户端向控制器询问存储文件的位置;控制器回应
    • 客户端将数据推送到最近的块服务器,块服务器将数据转发给其他服务器
    • 主块服务器应用序列号
  • 注意,控制器处理控制流量/元数据类型的内容。它不移动真实的文件数据。

讨论

  • GFS中大量顺序读取和写入、追加操作效果很好
  • 三重复制使其具有很高的容错性(阅读MapReduce时多思考这一点)
  • 控制器是单点故障,但它也可以被复制,而且负载较轻
    • 还要记住:特定机器故障的概率相对较低。GFS解决的问题是某些机器故障(从而丢失数据)的概率较高