上篇文章中我们对并发编程和RPC做了简要的介绍,这是构建分布式系统的基础,相信对这两个知识点只要有自己的理解,那么后续学习一些经典的分布式系统设计一定会更容易上手。本文将介绍一个经典的分布式文件系统GFS(Google File System)。由于本文的性质是一篇学习笔记,所以会大量引用GFS官方论文的论述片段,配合我自己的理解进行介绍。
文件系统(File system)是一种存储和组织计算机数据的方法。它的任务是组织数据、管理存储空间,并提供文件/目录的接口。在上世纪早期的计算机系统设计中,并不存在文件系统的概念,计算机通过直接操作物理介质(如磁带,打孔纸条)来进行数据的IO操作,数据并没有进行结构化的组织,所有数据都是简单的顺序读写,毫无疑问这种方式是效率低下的。随着计算机技术的发展,文件系统应运而生,逐步解决了数据管理效率低下的问题。最早期的扁平式文件系统(Flat FS)并没有目录层次结构,所有文件都存放在单一命名空间下。但是难以支持多用户,多进程间的隔离,且文件名容易冲突。由此衍生出了层次式的文件系统,如微软研发的供MS-DOS,非NT核心的Windows系统使用的文件系统FAT,其通过链表结构来管理文件存储块。还有UNIX文件系统UFS,通过引入inode机制,实现更高效的元数据管理和磁盘空间分配。随着网络技术的发展,网络文件系统NFS诞生实现了跨网络的文件共享。随着互联网行业的发展,数据量呈指数级增长,这些系统仍在扩展性和容错性上存在局限,难以应对大规模的数据存储需求。
互联网行业的爆发式增长催生了大规模的分布式文件系统。在21世纪初,随着数据量的增长,海量网页抓取数据和搜索索引数据,且服务器节点故障频繁,还要面对以写操作为主的业务场景。传统单机文件系统在容量和性能上无法满足需求,于是分布式文件系统应运而生。分布式存储系统通过将数据分散存储在多个节点上,改善了单机存储的资源瓶颈。Google公司专门为内部打造了一套满足”海量数据+高容错+高可扩展+高吞吐“的大型分布式文件系统GFS。起初,Google的任何人都能通过它来命名和读取其中所共享的任何文件,仅供内部使用,会出售某些内部使用了GFS的服务,但不直接对外出售GFS。
GFS的官方论文我贴在这里,强烈建议感兴趣的朋友精读细读: GFS论文原文
也许你会因为这长篇大段的论述而感到无从下手,没关系,下面的内容是我精读GFS论文总结出的心得体会,尽可能用最浅显易懂的方式向大家介绍,如果朋友们能够从中获得一点启示,我会很开心!
一.设计动机
系统的设计并不是空穴来风,需要有明确的设计动机。首先,对于大型分布式系统而言,我们要明白,节点失效是常态,因为系统就是部署运行在普通,廉价的机器上的。其次,GFS是专门为大文件的存储而设计的,GB,甚至TB量级的文件是非常普遍的。如果我们采用管理上亿个小文件的方式来管理这些大文件,这显然不是个好方法。尽管GFS也支持对小文件的存储,但是没有必要为了满足小文件存储进行特别设计或者优化,也没必要高效执行。其次系统负载主要来自如下两种操作:大规模的流式读取和小规模的随机读取,以及对文件的追加(append)写入(而非覆写(overwrite))。此外,系统对多客户端并发追加内容到文件的场景做出了优化,即需要高效地支持原子追加文件的操作,对于这种针对海量文件的访问模式,客户端对数据块缓存是没有意义的,数据的追加操作是性能优化和原子性保证的主要考量因素。最后对于该系统而言,持续的高吞吐比低延迟更重要。
二.架构概述
一.整体架构

如果简单来介绍GFS的架构,除了客户端之外,其包含一个Master节点(这里的Master节点是逻辑概念,不是物理概念,后面会提到Master节点的主从复制),和若干Chunkserver。每个节点通常为一个运行着用户级服务进程的Linux主机。在机器资源允许的前提下,我们可以把Chunkserver和客户端运行在同一台机器上。
在存储文件时,文件会被划分成若干个拥有固定长度的Chunk(块)。Master在创建这些Chunk时,会赋予它们不可变的全局唯一的64位Chunk handle(块标识符)作为唯一标识,并将其移交给对应的Chunkserver。Chunkserver会将Chunk作为Linux文件存储到本地磁盘中,并根据给定的Chunk handle和字节范围来确定要读写的Chunk以及块数据。为保证Chunk的可靠性,默认情况下,我们会使用三个Chunkserver作为备用节点来存储副本。
GFS的Master节点负责维护管理系统所有的元数据。其维护的元数据包括:命名空间,文件到Chunk的映射,Chunk的每个副本的位置,Chunk handle到Chunkserver的映射等。同时,Master节点还管理着系统级的活动,比如Chunk租约管理,孤儿Chunk回收,以及Chunk之间的迁移等等。Master节点使用心跳周期性的与每个Chunkserver通信,下发指令并采集其状态信息。
而GFS的客户端主要是给应用使用的API,与POSIX API相似。由于整个GFS集群只有一个Master节点,所以客户端与GFS集群通信时,首先会与Master节点通信从而获取到元数据,客户端会缓存从Master节点获取的元数据,以尽可能减少与Master节点的交互。获取到元数据后实际文件的传输会与Chunkserver直接交互。这么设计的原因是避免Master节点称为系统瓶颈。此外,无论是客户端还是Chunkserver都没有必要缓存文件数据。客户端不缓存文件数据的原因是大部分应用程序,需要流式处理大文件,这个数据集过大以致于我们无法缓存。而Chunkserver不缓存文件数据的原因是Chunk以本地文件的方式保存,Linux的文件系统缓存会把经常访问的数据缓存在内存中。换个角度来看,不缓存反而还消除了缓存一致性的相关问题,大大简化了系统设计。
二.数据存储单元
Chunk(块)是GFS中的数据存储单元。对于GFS而言,Chunk的尺寸是比较关键的设计参数,而GFS将其定为64MB。这个尺寸一般远远大于一般文件系统的块大小,这也满足了GFS流式处理大文件的设计初衷。使用较大的Chunk有以下几个好处:①降低了客户端与Master交互的频率,有效降低工作负载。②可以提升客户端操作落到同一个Chunk上的概率,通过与chunkserver保持更长时间的TCP连接来减少网络开销。③减少了Master节点需要保存的元数据的数量,由此我们可以把元数据全部放在内存中。此外,Chunk采用惰性分配存储空间的策略,仅在有需要时才进行扩展,避免了因内部碎片而造成的空间浪费。
另一方面,即使配合惰性分配空间的策略,较大的Chunk尺寸也有其设计缺陷。一般来讲,小文件包含较少的Chunk,甚至只有一个Chunk。如果客户端频繁访问这个Chunk,它就会变成一个“热点”,引发单点过热的问题。但是在Google的实际场景下,这种问题并不普遍存在,热点并不是主要问题。而GFS第一次被批处理队列系统应用时,确实出现了单点过热的问题。实际上可以通过提升副本数,分担负载来保证系统的可用性。一个潜在的长期解决方案是在让客户端在这种场景下从其他客户端读取数据。
三.元数据管理
Master服务器主要存储三种元数据:文件和chunk的命名空间(namespace)、文件到chunk的映射和chunk的每个副本的位置。GFS集群的所有元数据都会存储在Master的内存中,这使得Master可以很轻松的对这些元数据进行更改,此外,在内存中存储元数据可以使master周期性扫描整个的状态变得简单高效。由此,可以更高效的实现垃圾回收、chunkserver故障时重做副本、以及chunkserver间为了负载均衡和磁盘空间平衡的chunk迁移。美中不足在于chunk的数量及整个系统的容量受限于master的内存大小。但是在实际情况下,因为大部分文件包含多个Chunk,所以大部分Chunk是满的,仅仅是最后一个Chunk被部分填充。并且因为采用了前缀压缩的方式紧凑地存储文件名,对于一个64MB的Chunk,Master只用维护不到64字节的元数据,所以这并不能成为该系统的瓶颈所在。即便是需要支持更大的文件系统,为Master服务器增加额外内存的成本是很低的,而通过增加有限的费用,我们就能够把元数据全部保存在内存里,增强了系统的简洁性、可靠性、高性能和灵活性。
Master并不会持久化哪台Chunkserver含有给定的Chunk的副本的有关记录,而是简单地在启动时从Chunkserver获取信息。Master可以保证自己的记录是最新的,因为Master控制着所有Chunk的分配并通过周期性的心跳消息监控Chunkserver的状态。这种设计简化了在有Chunk服务器加入集群、离开集群、失效、重命名以及重启的时候,Master服务器和Chunk服务器数据同步的问题。
操作日志实际上是GFS元数据的核心,也是元数据中唯一被持久化的记录,实际上完全是个“状态机”的设计原理。实际上,在Master在对元数据做任何操作之前,都会用写日志的方式将操作进行持久化,日志写入完成后再进行实际操作,而这些日志也会被复制到多台机器上进行保存。如果操作日志没有被可靠存储,即使Chunk本身没有出现任何问题,我们仍有可能丢失整个文件系统,或者丢失客户端最近的操作。Master服务器会批量处理这些操作日志,以减少写入磁盘和复制对系统整体性能的影响。
在灾难恢复时,Master通过重放操作日志来恢复其文件系统的状态,所以操作日志要尽可能小以减少启动时间。当日志超过一定大小时,Master会对其状态创建一个检查点(Checkpoint),其本质就是对数据库状态作一次快照。在灾难恢复的时候,Master可以从磁盘加载最后一个检查点并重放该检查点后的日志来恢复状态。检查点的结构是个很紧凑的B-Tree,可以直接映射到内存,在用于命名空间查询时无需额外的解析。这大大提高了恢复速度,增强了可用性。由于创建一个检查点需要一定的时间,所以Master服务器的内部状态被组织为一种格式,这种格式要确保在检查点创建过程中不会阻塞正在进行的修改操作。创建检查点时,Master会切换到一个新的日志文件并在一个独立的线程中创建检查点。这个新的检查点包含了在切换前的所有变更。对于一个包含数百万个文件的集群,创建一个检查点需要1分钟左右的时间。创建完成后,检查点会被写入在本地和远程的硬盘里。恢复仅需要最后一个完整的检查点和后续的日志文件。旧的检查点和日志文件确实可以随意删除,不过保险起见,我们会保留一段时间以容灾。
四.命名空间管理
事实上,Master的相关操作是很耗时的。例如:快照操作必须收回其涉及到的Chunk所在的Chunkserver的租约。我们并不希望由于这些操作的进行,延缓了Master的其他操作。由此,我们希望多个Master操作可以同时执行,使用命名空间的上的锁来保证执行的正确顺序。而命名空间会作为GFS元数据的重要组成部分,存储在Master节点的内存中。在GFS中,命名空间的作用是管理文件和目录的层次结构。可以理解为 GFS 的命名空间就是 整个分布式文件系统里的目录树。核心思想是通过名字的组织方式,让大规模的“对象”不会乱套。
理解归理解,可是实际上,不同于许多传统文件系统,GFS没有针对每个目录实现能够列出目录下所有文件的数据结构,也不像UNIX系统一样支持软硬链接,而是在逻辑上用一个完整路径名到元数据的利用前缀压缩技术的查找表来表示命名空间,这个查找表可在内存中高效地表示。命名空间树上的每个节点(不论是文件还是目录)都有一个与之关联的读写锁,而这也是Master管理命名空间的关键。
在实际情况下,Master的每个操作执行前都会请求一系列的锁。通常,如果master的操作包含命名空间/d1/d2/…/dn/leaf,master会在目录/d1、/d1/d2,…,/d1/d2/…/dn(所有上级目录)上请求读锁,获取上级目录读锁的目的是防止当前操作删除,重命名或者快照上级目录。并在完整路径名/d1/d2/…/dn/leaf上请求读锁或写锁(取决于当前操作)。文件名上的写锁可以防止相同同名文件被创建两次。
在真实的GFS集群中,命名空间的数量是很庞大的,而Master内存资源有限,大量的读写锁会造成很高的内存占用,所以读写锁对象会在使用时被惰性分配,在不再使用时会被立即删除。同样,为了避免死锁,锁的获取也要遵循一个全局一致的顺序:首先按命名空间的层次排序,在同一个层次内按字典顺序排序。
五.副本管理
Chunk副本的分配策略直接决定了GFS集群的效率。在分配Chunk副本时,我们既要最大化的保证系统的高可用,同时也要尽可能最大化利用网络带宽。简单的把Chunk副本分散在所有机器上显然是不明智的,我们无法保证当一整个机架都被损坏或离线时(例如由交换机、电源电路等共享资源问题引起的故障),Chunk的一些副本仍存在并保持可用状态。我们必须在机架间分散Chunk的副本,这样不仅能保证集群的高可用,在不同客户端对同一 Chunk 进行读取时,可以利用不同机架的出口带宽,从而提升读取效率。
Chunk副本的创建通常由Chunk创建、重做副本和重均衡这三个原因引起。一般来讲,①Master节点会在平均硬盘使用率低的Chunk服务器上存储新的副本。②其次,需要限制每台Chunkserver上最近创建的Chunk的数量,虽然创建操作在GFS集群中本身是廉价的,但是创建操作也意味着随之会有大量的写入操作,若某台Chunkserver上由很多的副本,不言而喻,这台Chunkserver也一定承担了更多的写入压力,这也是Master分配副本所考虑的点之一。③最后就是如上文所说,我们希望将Chunk的副本跨机架分散。
当Chunk可用的副本数少于用户设定的目标值时,Master会重做副本。这种情况可能由Chunkserver不可用,磁盘损坏,或者用户提高了副本数目标值等原因导致。每个需要重做副本的Chunk会参考一些因素按照优先级排序。常见的参考因素有:①当前副本数与目标副本数的差值,差值越高优先级越高。②优先为仍存在的文件的Chunk重做副本。③Master会提升任意阻塞用户客户端操作的Chunk的优先级。Master会选取Chunk中优先级最高的那个,并通过命令若干Chunkserver直接从一个存在且合法的副本拷贝的方式来克隆这个Chunk。在克隆操作中,每个Chunkserver还会限制对源Chunkserver的读请求,以限制每个克隆操作占用的总带宽。
最后,Master节点会周期性地对副本进行重均衡,以更好地利用各节点的磁盘和负载。在这个过程中,Master会逐渐填充一个新的Chunkserver,而不会立刻让来自新chunk的高负荷的写入流量压垮新的Chunkserver。新副本存储位置的分配策略和上文所介绍的相同,这里不再赘述。此外,Master必须删除一个已有副本。一般来说,为均衡磁盘空间的使用,Master会选择删除空闲磁盘空间最多的chunkserver上的副本。
六.垃圾回收
分布式系统中的垃圾回收问题通常是个很难的课题,往往需要复杂的编程逻辑来解决。由于Master的设计机制,我们可以很容易的拿到Chunk的引用,这使得垃圾回收的设计方案能够被大大简化。在GFS中,所有Master中没有记录的副本会被视为垃圾。
当用户删除某个文件时,GFS不会立刻回收可用的物理存储空间,而是采用惰性回收的策略。当一个文件被应用程序删除时,和执行其他操作一样,Master会立刻将删除操作写入日志,但是并不会在命名空间上直接移除相关的记录。其核心机制是将文件重命名为一个带删除时间戳的隐藏文件名。用户可对这些文件配置间隔时间(有点类似于过期机制),Master节点对文件系统进行常规扫描时,会删除超出这个间隔时间的隐藏文件。在这些隐藏文件被真正删除之前,用户仍然可以对其内容进行读取,甚至“反删除”。只有当这个隐藏文件删除之后,Master节点的内存才不再维护与之相关的元数据。
与上面介绍的文件级垃圾回收机制类似,Master内存中维护了文件到Chunk的映射,当命名空间中的文件被删除时,其对应的Chunk的引用计数就会自减1。Master在对命名空间做常规扫描时,也会找出孤儿Chunk(不被任何文件包含的Chunk,即引用计数为0)并将其元数据删除。在Chunkserver与Master定期心跳通信的过程中,二者可以确定哪些Chunk是有效的,从而Chunkserver方也能随意删除这些已经不存在的孤儿Chunk副本。
这种惰性回收策略(“懒汉式回收”)比起“饿汉式回收”策略有以下几个好处:①在大规模分布式系统中,这种方式的可靠程度更高。因为组件失效对于大规模分布式系统是常态,垃圾回收机制为清理那些不明确是否有用的副本提供了一个统一且可靠的方法。②垃圾回收机制将对存储空间的回收操作合并为Master的后台活动,异步化处理,由此来分散操作的执行开销。同时,这也能够让Master更主动,在其相对空闲时执行这些操作。③延迟回收存储空间可以防止意外的不可逆删除操作。
不过,当存储空间资源比较紧缺的时候,这种策略的问题也由之显现。惰性回收会阻碍用户调优存储空间的使用,比如,快速创建并删除临时文件的应用程序可能无法立刻重用存储空间。由此,GFS客户端可以通过让用户选择再次删除该文件,加快存储空间的回收,这相当于一个兜底策略。此外,GFS还允许用户对不同的命名空间应用不同的副本与回收策略。
七.一致性模型
GFS的一致性模型是相对宽松的,前面也有提到,比起强一致性,GFS更关心的是高吞吐量和存储大文件的能力。下面我们将对GFS的一致性模型进行介绍。
首先,文件命名空间是由Master的内存空间来管理的,我们可以通过一些机制比如说加锁(命名空间锁)来保证与命名空间相关的操作是原子的。而这些操作的执行顺序是由Master内存中的操作日志来决定的。而在数据变更后,不论是否成功,文件区域的变更状态取决于其变更类型,可能会出现如下几种情况:①客户端无论读哪个副本都会读取到相同的内容,那么这部分的文件就是一致的(consistent),这里的一致并不一定就代表着一定符合应用预期,只是说副本之间不存在分歧。②客户端读取不同的副本时,读取到的内容不同,那么这部分的文件就是不一致的(inconsistent)。③在一个文件区域的数据变更后,如果它是一致的,且客户端总能看到其写入的内容,那么这部分文件就是已定义的(defined),也就是说,写入的内容是完全可预测的。可以认为,已定义状态包含了一致状态。而文件的状态取决于修改的类型和结果,如下表所示。①如果是一系列串行写操作执行成功,那么这部分的文件的状态是已定义的,同时也是一致的。②并发写操作执行成功后文件的状态为一致但未定义的(大家覆盖同一范围,最后的数据是拼接还是覆盖掉,应用无法事先确定)。③不论是串行场景还是并行场景,追加记录时,只要执行成功,文件的状态为已定义但部分不一致的,这是因为GFS 的 append 语义保证:写入的数据最终会 至少一次完整出现 在文件里,但可能会被别的客户端的写“打断”,导致不同副本尾部不一样,暂时不一致,但最终会通过恢复机制修复。④执行失败的读写操作,全部视为未定义且不一致的。

GFS支持两种写入操作,在偏移量确定的情况下,分为覆写和追加两种。对于覆写来说,GFS并没有为其做太多的一致性的保证。实际上在这种覆写的情况下,因为数据的来源可能是多个客户端,所以文件会进入未定义的状态,但是追加完全不同。即使在并发的情况下,追加操作会将数据原子且至少一次(at least once)地写入文件,追加完成后会将偏移量返回给客户端,这个偏移量代表的是已定义的文件区域的起点。由于至少有一次写入,所以可能会在某种程度上导致重复写入,降低了局部一致性,但是这只是偶发现象,影响甚微。在实际使用中,我们所有的应用程序都通过追加而不是覆写的方式对文件进行变更,这是因为追加的方式效率远高于随机写入,而且在应用程序出故障时会更容易恢复。
实际上,即使在修改操作成功执行很长时间之后,组件的失效也可能导致数据的损坏或者删除,这是我们对GFS的读取时不得不考虑的问题。在数据写入之前,可以在文件中添加一些其他校验信息,比如校验和,方便后续对可用性的验证,这样客户端在读取时会通过校验机制自动跳过损坏的数据。
八.容错与恢复
在设计大规模分布式系统的过程中,由于设备发生故障是常态,设备的故障在某些情况下会使得整个系统瘫痪,甚至是会使得数据不完整,所以系统的容错性设计是我们必须深度思考的命题。在GFS服务器集群中,总会有不可用的节点。我们可以通过快速恢复和副本复制来保证系统的高可用性。
如果要实现快速恢复,实际上,不论是在Master还是Chunkserver节点,都备份了其运行状态,所以恢复起来是很快的。我们不需要区分服务器是正常关闭还是异常关闭,从而引发一系列复杂逻辑的设计。如果要关闭某个服务节点,使用kill -9发送信号即可。
前文中我们有提到,Master会通过日志将Chunkserver的元数据持久化。为了保证Master服务器的可靠性,Master服务器的状态也需要进行复制,备份到多台机器上,包括其操作日志和检查点。一般来讲,只有当Master节点的状态被持久化到本地和备份机的磁盘中,我们才认为修改是已提交的。实际上,同一时间集群中只会有一个Master节点是起作用的。如果运行Master服务进程的机器或者磁盘出现故障,会有一个监控进程,从之前备份有操作日志和检查点的机器上,通过重放操作记录的方式,重启一个Master进程。此外,还有一个非常有意思的设计“影子Master节点”(shadow master),是只读的。虽然比起Master节点它们会稍有滞后(所以其实它们不是镜像服务器),不过它们确实会通过读取Master操作日志的备份,同步其状态的变更。由于Master节点属于典型的读多写少的场景,这个设计主要目的是为了缓解Master节点的读压力。和Master节点一样,影子Master节点也会轮询Chunkserver并拉数据,也会定期与Chunkserver通过“心跳”的机制来确认状态。实际上,只有当Master节点决定创建或删除副本时,影子Master中的副本位置才会受到影响,并且二者需要通信来变更位置。
前文我们也介绍过,每个Chunk会在不同机架的多个Chunkserver上存有副本。实际上,在GFS集群中,由于承载Chunkserver进程的服务器数量要比Master节点的个数多得多,所以其出故障的概率也是最高的。我们采用跨越Chunkserver比较副本的方式检查数据是否损坏也很不经济,所以每个Chunkserver都维护一个校验和,来检查保存的数据是否损坏。实际上,GFS的文件会被切分成64MB的Chunk,而每个Chunk又会被切分成64MB的Block,这样做的原因是校验和只有粒度小才能及时的发现问题。GFS对每个64KB的Block用诸如CRC32的算法计算一个校验和,所以Chunkserver会存储实际的数据块(64KB)和校验和(4B)。假如检测到数据损坏,GFS可以根据其他副本的正确数据进行恢复,以保证副本数据的一致性。校验和与其他用户数据是分开存储的,会保存在内存中并以日志化到本地硬盘上。
由于校验和这种检测机制的存在,不论是来自哪一方的读取操作,Chunkserver都不会返回损坏的数据。而是会给Master报错误信息,当正确的副本被拷贝到Chunkserver后,再将损坏的数据删除。事实上,校验和对读取操作的性能影响很小,因为绝大多数的读操作都至少要读取几个块,而校验和机制只需读取并校验少量数据,且基本上不存在额外的I/O开销。
在GFS中,采用追加的方式进行写入操作占据主导地位,所以GFS对这种场景下的校验和计算机制做出了比较大规模的优化。在进行追加时,Chunk尾部的校验和是可以被增量式更新的,并且也会为这些追加的Block重新计算校验和。如果是旧数据已经出现了被污染,损坏的情况,在追加了新的校验和之后依然没有办法与实际数据相匹配,这是可以被检查出来的。而如果是采用在某个区间上覆写的操作,我们必须读取并且验证该范围的头尾Block,执行写操作后重新计算该区间的校验和。注意,如果在写入之前不校验头尾Block的话,原数据段中的问题可能会被新生成的正确校验和所掩盖,这一点非常关键!
实际上,GFS集群中有很多已损坏且不活跃的Chunk副本,如果不及时发现并纠正,它们会“欺骗”Master节点,让Master节点误以为集群中仍有大量可用副本。在Chunkserver空闲时,会使用前面介绍的校验和机制,轮询校验那些不活跃的副本,能够及时发现错误,并通知Master节点创建正确的新副本之后删掉这个错误副本。
三.系统交互
一.租约的概念
了解基本架构之后,我们再回头来看系统交互的相关流程,应该就能相对好理解很多了。在很多常见的分布式系统中,通常只要类似主从架构,不管是我们熟知的MapReduce框架,Etcd,还是很多涉及Raft一致性算法的分布式集群,租约这个概念都是核心。在GFS中,对元数据或Chunk的修改统称为“变更”。在Chunk变更时,其对应的每个副本都需要发生变更,而租约不仅仅是Master给的一段授权时间,更是隐含了由谁来决定变更的顺序。Master节点会为其中一个Chunkserver授权一个租约,我们将这个Chunkserver称为Primary,这一动作就决定了,其他Chunkserver的变更顺序都要完全遵照Primary的来。租约可以被延长,也可以被提前取消。实际上,在GFS中全局顺序 = Master 的租约决定谁当 Primary + Primary 内部分配编号的顺序。
二.读写操作
GFS客户端从集群中读取文件内容的大致过程如下:
①读取时,入参为文件名和指定的偏移量,而客户端可以据此算出想要读取的位置在该文件的哪个Chunk中。
②客户端据此请求Master,Master响应想要读取的Chunk handle以及所有的副本位置,客户端会将Master响应的相关数据缓存。
③最后,客户端可以选取任意副本并请求其对应的Chunk server。根据Chunk handle和偏移量确定读取范围并进行读取。
GFS客户端向集群中写入文件内容的大致过程如下:(可参考下面官方论文插图)
①客户端首先询问Master哪个Chunkserver是Primary,以及其他副本的位置。如果没有Chunk持有租约,那么Master会选择一个副本并建立租约。
②Master向客户端返回Primary和其他副本(在论文中称为Secondary,译为“二级副本”)的位置。
③客户端以任意顺序,把数据推送到所有副本上,Chunkserver会将其接收并缓存,直到数据被使用或者过期。
④当所有副本都接收到客户端的数据后,客户端会Write请求Primary,而Primary会为其收到的可能来自多个客户端的所有变更分配执行序列号。Primary会在本地应用这个顺序进行变更。
⑤Primary会将这个写请求传递给其他Secondary,而这些Chunkserver也会按照相同的顺序进行变更。
⑥待Secondary执行完变更之后,会响应Primary,表示自己已经完成所有操作。
⑦Primary响应客户端,如果过程中有错误会上报。

值得注意的是,如果应用程序一次的写入量很大,或者说数据范围跨越了多个Chunk,GFS往往会将其拆分为多个写操作。尽管这些写操作都遵循上面的过程,但是可能会被其他客户端运行的写操作打断。就算这样,由于Primary能够保证这些分解之后的写操作能够以相同的顺序在不同Secondary上执行,各副本间数据其实是一致的。这就是我们前面介绍过的一致但未定义的状态。
三.网络层优化
解耦数据流和控制流一直是在系统设计中很常见的高效利用网络资源的方法,在GFS的设计中,同样也采取了这个思路。如果使用树型广播的模式(客户端分别把所有数据分发给Chunkserver),这样客户端侧的网络带宽就要撑好几倍。所以GFS使用了流水线式的链式推送数据流。各个Chunkserver就像传接力棒一样,每个节点只需利用其全部出口带宽发一次数据即可。各个链路并行执行,也可以很有效的降低网络时延。
四.原子性追加
在传统写入操作中,客户端会指定数据写入的偏移量。如果多个客户端同时对同一个文件的同一个区域进行写操作,情况可能会很混乱,文件区域可能处于我们前面所介绍的一致但未定义的状态。而Record append则是一个专门解决并发写问题的方案。在这个方案中,客户端不指定偏移量,只管提供追加的数据,偏移量由GFS来指定,保证这段数据在文件里是一个 连续、原子写入的整体。所以,前面也介绍过,GFS append的核心语义就是至少一次写入成功,且写入的数据是一个完整的连续字节序列。
其主要过程也和我们上面介绍的写操作过程大体相同。不同的点在于,Primary会检查追加操作会不会超过该Chunk的最大尺寸(64MB)(一般情况下,追加数据的大小不能超过Chunk容量的1/4)。如果超过,Primary会将当前Chunk填满,之后通知所有Secondary进行同样的操作,然后响应客户端,要求其对下一个Chunk重新进行记录追加。如果追加操作在任何一个副本中失败,那么客户端将进行重试操作,由此,同一个Chunk的不同副本中可能会包含不同数据,但是GFS的append语义并未保证这个一致性,它只保证追加的数据能够被原子性的至少写入一次。而前面提到的校验和只能修复损坏的数据,但是无法消除语义层面的重复。这也是我们做系统设计的一个取舍点:应用程序必须能够容忍在文件中偶尔出现的重复记录。比如在日志系统中,必须天然接受“重复一条日志”,但绝对不能接受“只写一半日志”。
五.快照
GFS也提供了快照功能,能够快速对文件或者目录完成拷贝,在创建副本时非常方便,我个人感觉这个部分略微会有点难理解。快照的过程采用写入时复制的设计思想。当Master节点接收到快照请求时,为保证快照时的数据一致性,会撤销其涉及的文件所在Chunk上所有未完成的租约,确保没有正在进行的写入。这样以后如果客户端想继续写这些Chunk,就必须先向Master重新申请租约。这时Master就有机会“先复制一份再写”,这便是写入时复制的触发点。Master 把“我要做一个快照”记录日志并刷盘,即使 master 挂了,重启时也能根据日志恢复。Master在内存中复制一份源文件的元数据(注意这里不是真实的Chunk),由此,快照文件和源文件此时指向同样的Chunk。即使现在有客户端向某个Chunk进行写入操作,快照文件的内容也不变。
结语
学习 GFS 的过程,不仅让我理解了一个分布式文件系统在设计上的取舍与巧妙之处,也让我对一致性、容错、扩展性等分布式系统的核心问题有了更深的体会。GFS 展现的思想已经影响了后续无数系统(比如 HDFS、Bigtable、MapReduce),也为当下的云计算与大数据处理奠定了基石。以上就是我现阶段对 GFS 的一些学习心得与粗浅理解,难免存在偏颇和不足,恳请大家批评指正。也希望能借此机会,与同样对分布式系统充满兴趣的朋友们多多交流、互相启发、共同进步。
1705

被折叠的 条评论
为什么被折叠?



