作者简介 陈东明,饿了么北京技术中心架构组负责人,负责饿了么的产品线架构设计以及饿了么基础架 构研发工作。曾任百度架构师,负责百度即时通讯产品的架构设计。具有丰富的大规模系统构 建和基础架构的研发经验,善于复杂业务需求下的大并发、分布式系统设计和持续优化。作者微信公众号dongming_cdm,欢迎关注。
GFS(Google File System)是Google公司开发的一款分布式文件系统。
在2003年,Google 发表一篇论文详细描述了GFS的架构。GFS,MapReduce,Bigt able并称为Google的三架⻢ ⻋,推动了Google的高速发展。其他互联公司和开源领域纷纷模仿,构建自己的系统。可以 这么说,GFS,MapReduce,Bigt able引领了互联网公司的分布式技术的发展。但GFS架构设 计并不是一个完美的架构设计,它有诸多方面的问题,一致性的欠缺就是其中的一个问题。
本文探讨一下GFS架构设计、分析其在一致性方面的设计不足,并且看一下一致性对分布式系统 的重要性。
我们从GFS的接口设计说起。
接口
GFS采用了人们非常熟悉的接口,但是被没有实现如POSIX的标准接口。通常的一些操作包 括:create, delete, open, close, read, write, record append等。create,delete,open,close 和POSIX的接口类似,这里就不强调说了。这里详细讲述一下write, record append提供的语意。
write操作可以将任意⻓度len的数据写入到任意指定文件的位置off set
record append操作可以原子的将len<=16MB的数据写入到指定文件的末尾
之所以GFS设计这个接口,是因为record append不是简单的offset等于文件末尾的write操作。 record append是具有原子特性的操作,本文后面会详细解释这个原子特性。
write和record append操作都允许多个客户端并发操作。
架构
GFS架构如下图。(摘自GFS的论文)
主要的架构部件有GFS client, GFS master, GFS chunkserver。一个GFS集群包括:一个 master,多个chunkserver,集群可以被多个GFS client访问。
GFS客户端(GFS client)是运行在应用(Application)进程里的代码,通常以SDK形式 存在。
GFS中的文件被分割成固定大小的块(chunk),每个块的⻓度是固定64MB。GFS chunkserver把这些chunk存储在本地的Linux文件系统中,也就是本地磁盘中。通常每个 chunck会被保存3个副本(replica)。一个chunkserver会保存多个块,每个块都会有一个 标识叫做块柄(chunk handle)
GFS 主(mast er)维护文件系统的元数据(met adat a),包括名字空间(namespace, 也就是常规文件系统的中的文件树),访问控制信息,每个文件有哪些chunk构成,chunk 存储在哪个chunkserver上,也就是位置(locat ion)。
在这样的架构下,文件的读写基本过程简化、抽象成如下的过程:
写流程
1.client 向mast er发送creat e请求,请求包含文件路径和文件名。mast er根据文件路径和文件 名,在名字空间里创建一个对象代表这个文件。
2.client 向3个chunkserver发送要写入到文件中的数据,每个chunkserver收到数据后,将数据 写入到本地的文件系统中,写入成功后,发请求给mast er告知mast er一个chunk写入成功, 与此同时告知client 写入成功。
3.mast er收到chunkserver写入成功后,记录这个chunk与机器之间的对应关系,也就是chunk 的位置。
4.client 确认3个chunkserver都写成功后,本次写入成功。
这个写流程是一个高度简化抽象的流程,实际的写流程是一个非常复杂的流程,要考虑到写入 类型(即,是随机写还是尾部追加),还要考虑并发写入,后面我们继续详细的描述写流程, 解释GFS是如何处理不同的写入类型和并发写入的。
读流程
1.应用发起读操作,指定文件路径,偏移量(off set )
2.client 根据固定的chunk大小(也即64MB),计算出数据在第几个chunk上,也就是chunk索 引号(index)
3.client 向mast er发送一个请求,包括文件名和索引号,mast er返回3个副本在哪3台机器上, 也就是副本位置(location of replica)。
4.client 向其中一个副本的机器发送请求,请求包换块柄和字节的读取范围。
5.chunkserver根据块柄和读取范围从本地的文件系统读取数据返回给client
这个读流程未做太多的简化和抽象,但实际的读流程还会做一些优化,这些优化和本文主题关 系不大就不展开了。
写流程详述
我们详细的讲一下写入流程的几个细节。
1.名字空间管理(namespace management)和锁保护(locking)
写入流程需要向主发送creat e这样请求,来操作保存在主上的名字空间。 如果有多个客户端同时进行写入操作,那么这些客户端也会同时操作向主发送creat e请求。主 在同一时间收到多个请求,通过加锁的方式,防止多个客户端同时修改同一个文件的元数据。
2.租约(Lease)
client 需要向3个副本写入数据,在并发的情况下会有多个client 同时向3个副本写入数据。GFS 需要一个规则来管理这些数据的写入。 这个规则简单来讲,每个chunk都只有一个副本来管理多个client 的并发写入。也就是说,对 于一个chunk,mast er会授予其中一个副本一个租约(lease),具有租约的副本来管理所有要写 入到这个chunk的数据写入,这个具有租约的副本称之为首要副本(primary)。其他的副本称之 为二级副本(secondary)
3.变更次序(Mutation Order)
将对文件的写入(不管是在任意位置的写入,还是在末尾追加)称之为变更(Mut at ion)。 Primary管理所有client 的并发请求,让所有的请求按照一定顺序(Order)应用到chunk上。
4.基本变更流程
1).client 询问mast er哪个chunkserver持有这个chunk的lease,以及其他的副本的位置。如果 没有副本持有lease,mast er挑选一个副本,通知这副本它持有lease。
2).mast er回复client ,告述客户端首要副本的位置,和二级副本的位置。客户端联系首要副 本,如果首要副本无响应或者回复客户端它不是首要副本,则客户端重新联系主。
3).客户端向所有的副本推送数据。客户端可以以任意的顺序推送。每个chunkserver会缓存这 些数据在缓冲区中。
4).当所有的副本都回复说已经收到数据后,客户端发送一个写入请求(write request)给首 要副本,这个请求里标识着之前写入的数据。首要副本收到请求后,会给写入分配一个连续的 编号,首要副本会按照这个编号的顺序,将数据写入到本地磁盘。
5).首要副本将这个带有编号写入请求转发给其他二级副本,二级副本也会按照编号的顺序, 将数据写入本地,并且回复首要副本数据写入成功。
6).当首要副本收到所有二级副本的回复时,说明这次写入操作成功。
7).首要副本回复客户端写入成功。在任意一个副本上遇到的任意错误,都会报告给客户端失败。
前面讲的writ e接口就是按照这个基本流程进行的。 下图描述了这个基本过程。(摘自GFS的论文)
5.跨边界变更
如果一次写入的数据量跨过了一个块的边界,那么客户端会把这次写入分解成向多个chunk的 多个写入。
6.原子记录追加(Atomic Record Appends)
Record Appends在论文中被称之为原子记录追加(Atomic Record Appends),这个接口也 遵循基本的变更流程,有一些附加的逻辑:客户端把数据推送给所有的副本后,客户端发请求 给首要副本,首要副本收到写入请求后,会检查如果把这个record附加在尾部,会不会超出块 的边界,如果超过了边界,它把块中剩余的空间填充满(这里填充什么并不重要,后面我们会 解释这块),并且让其他的二级副本做相同的事,再告述客户端这次写入应该在下一块重试。 如果记录适合块剩余的空间,则首要副本把记录追加尾部,并且告述其他二级副本写入数据在 同样的位置,最后通知客户端操作成功。
原子性
讲完架构和读写流程,我们开始分析GFS的一致性,首先从原子性开始分析。
Write和Atomic Record Append的区别 前面讲过,如果一次写入的数量跨越了块的边界,那么会分解成多个操作,writ e和record append在处理数据跨越边界时的行为是不同的。我们举例2个例子来说明一下。
例子1,文件目前有2个chunk,分别是chunk1, chunk2。
Client 1要在54MB的位置写入20MB数据。这写入跨越了边界,要分解成2个操作,第一个操 作写入chunk1最后10 MB,第二个操作写入chunk2的开头10MB。Client 2也要在54MB的位置写入20MB的数据。这个写入也跨越边界,也要分解为2个操作, 作为第三个操作写入chunk1最后10 MB,作为第四个操作写入chunk2的开头10MB。
2个客户端并发写入数据,那么第一个操作和第三个操作在chunk1上就是并发执行的,第二个 操作和第四个操作在chunk2上并发执行,如果chunk1的先执行第一操作再执行第三个操作, chunk2先执行第四个操作再执行第二个操作,那么最后,在chunk1上会保留client 1的写入的 数据,在chunk2上保留了client 2的写入的数据。虽然client 1和client 2的写入都成功了,但最 后既不是client 1想要的结果,也不是client 2想要的结果。最后的结果是client 1和client 2写入 的混合。对于client 1和client 2来说,他们操作都不是原子的。
例子2,文件目前有2个chunk,分别是chunk1, chunk2。
Client 要在54MB的位置写入20MB数据。这写入跨越了边界,要分解成2个操作,第一个操作 写入chunk1最后10 MB,第二个操作写入chunk2的开头10MB。chunk1执行第一个操作成功 了,chunk2执行第二个操作失败了,也就是写入的这部分数据,一部分是成功的,一部分是 失败的,这也不是原子操作。
接下来看record append。由于record append最多能写入16MB的数据,并且当写入的数据量 超过块的剩余空间时,剩余的空间会被填充,这次写入操作会在下个块重试,这2点保证了 record append操作只会在一个块上生效。这样就避免了跨越边界分解成多个操作,从而也就 避免了,写入的数据一部分成功一部分失败,也避免了并发写入数据混合在一起,这2种非原 子性的行为。
GFS原子性的含义
所以,GFS的原子性不是指多副本之间原子性,而是指发生在多chunk上的多个操作的的原子性。可以得出这样的推论,如果Writ e操作不跨越边界,那么没有跨越边界的writ e操作也满足GFS 所说的原子性。
GFS多副本不具有原子性
GFS一个chunk的副本之间是不具有原子性的,不具有原子性的副本复制,它的行为是:
一个写入操作,如果成功,他在所有的副本上都成功,如果失败,则有可能是一部分副本 成功,而另外一部分失败。
在这样的行为如下,失败是这样处理的:
如果是write失败,那么客户端可以重试,直到write成功,达到一致的状态。但是如果在 重试达到成功以前出现宕机,那么就变成了永久的不一致了。
Record Append在写入失败后,也会重试,但是与writ e的重试不同,不是在原有的off set 上重试,而是接在失败的记录后面重试,这样Record Append留下的不一致是永久的不一 致,并且还会有重复问题,如果一条记录在一部分副本上成功,在另外一部分副本上失 败,那么这次Record Append就会报告给客户端失败,并且让客户端重试,如果重试后成 功,那么在某些副本上,这条记录就会成功的写入2次。
我们可以得出,Record Append保证是至少一次的原子操作(at least once atomic)。
一致性
GFS把自己的一致性称为松弛的一致性模型(relaxed consistency model)。这个模型分析元 数据的一致性和文件数据的一致性,松弛主要是指文件数据具有松弛的一致性。
元数据的一致性元数据的操作都是由单一的mast er处理的,并且操作通过锁保护,所以是保证原子的,也保 证正确性的。
文件数据的一致性
在说明松弛一致性模型之前,我们先看看这个模型中的2个概念。对于一个文件中的区域:
如果无论从哪个副本读取,所有的客户端都能总是看到相同的数据,那么就叫一致的 (consist ent );
在一次数据变更后,这个文件的区域是一致的,并且客户端可以看到这次数据变更写入的 所有数据,那么就叫界定的(defined)。
GFS论文中,用下面的这个表格总结了松弛一致性:
Write | Record Append | |
Serial success | defined | defined interspersed with inconsistent |
Concurrent successes | consistent but undefined | |
Failure | inconsistent |
分别说明表中的几种情况:
1.在没有并发的情况下,写入不会有相互干扰,成功的写入是界定的,那么必然也就是一致的
2.在有并发的情况下,成功的写入是一致的,但不是界定的,也就是我们前面所举的例子2。
3.如果写入失败,那么副本之间就会出现不一致。
4.Record Append能够保证是界定的,但是在界定的区域之间夹杂着一些不一致的区域。 Record Append会填充数据,不管每个副本是否填充相同的数据,这部分区域都会认为是 inconsist ent 的。
如何适应松弛的一致性模型
这种松弛的一致性模型,实际上是一种不能保证一致性的模型,或者更准确的说是一致性的数 据中间夹杂不一致数据。
这些夹杂其中的不一致数据,对应用来说是不可接受的。在这种一致性下,应该如何使用GFS 那?GFS的论文中,给出了这样几条使用GFS的建议:依赖追加(append)而不是依赖覆盖 (overwrite),设立检查点(checkpointing),写入自校验(write self-validating),自记录标识 (self-identifying records)。
使用方式1:只有单个写入的情况下,按从头到尾的方式生成文件。
方法1.1:先临时写入一个文件,再全部数据写入成功后,将文件改名成一个永久的名 字,文件的读取方只能通过永久的文件名访问到这个文件。
方法1.2:写入方按一定的周期,写入数据,写入成功后,记录一个写入进度检查点 (checkpoint ),这个检查点包括应用级的校验数(checksum)。读取方只去校验、处理检查点之前的数据。即便写入方出现宕机的情况,重启后的写入方或者新的写入方,会 从检查点开始,继续重新写入数据,这样就修复了不一致的数据。
使用方式2:多个写入并发向一个文件尾部追加,这个使用方式就像是一个生产消费型的消息 队列,多个生产者向一个文件尾部追加消息,消费者从文件读取消息
方法2.1:使用record append接口,保证数据至少被成功的写入一次。但是应用需要应对 不一致数据,和重复数据。
为了校验不一致数据,给每个记录添加校验数(checksum),读取方通过校验数识别 出不一致的数据,并且丢弃不一致的数据。
如果应用读取数据没有幂等处理,那么应用就需要过滤掉重复数据。写入方写入记录时 额外写入一个唯一的标识(ident ifier),读取方读取数据后通过标识辨别之前是否已经 处理过这个标识的数据。
GFS的设计哲学
可以看出基于GFS的应用需要通过一些特殊的手段来应对GFS松弛的一致性模型带来的各种问 题。GFS的一致性保证对于使用者是非常不友好的,很多人第一次看到这样的一致性保证都是 比较吃惊的。
那么GFS为什么要设计这样的一致性模型那?GFS在架构上选择这样的设计有它自己的设计哲 学。GFS追求的是简单、够用的原则。GFS主要要解决的问题是如何使用廉价的服务器存储海 量的数据,并且达到非常高的吞吐量(GFS非常好的做到了这2点,但不是本文的主题,这里 就不展开了),并且文件系统本身的实现要简单,能够快速的实现出来(GFS的开发者在开发 完GFS之后,很快就去开发BigT able了)。GFS很好的完成了完成了这样的目标。但是留下了 一致性问题,给使用者带来的负担。但是在GFS应用的前期,一致性不是问题,GFS的主要使 用者(BigT able)就是GFS开发者,他们深知应该如何使用GFS,这种不一致在BigT able中被 很好屏蔽掉(采用上面所说的方法),BigT able提供了很好的一致性保证。
但是随着GFS使用的不断深入,GFS简单够用的架构开始带来很多问题,一致性问题仅仅是其 中的一个。主导了GFS很⻓时间的Leader Sean Quinlan在一次采访中,详细说明了在GFS度 过了应用的初期之后,这种简单的架构带来的各种问题[1]。
开源分布式文件系统HDFS,清晰的看到了GFS的一致性模型给使用者带来的不方便,坚定地 摒弃了GFS的一致性模型,提供了很好的一致性保证。HDFS达到了怎样的一致性,这里就不 在展开了,另外再做详细的讨论。
总之,保证一直性对于分布式文件系统,甚至所有的分布式系统都是很重要的。
1.GFS: Evolution on Fast-forward, Sean Quinlan, 2009,
https://queue.acm.org/detail.cf m?id=1594206
往期推荐:
微服务的历史与陷阱
侵入式服务治理方案,读这一篇就够
OpenResty 完全开发指南:构建百万级别并发的 Web 应用
阿里高级技术专家:研发效能的追求永无止境
案例诊断:“交易耗时8S”缉凶记
技术琐话
以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。