从 GFS 失败的架构设计来看一致性的重要性

(本文最初发于https://mp.weixin.qq.com/s/GuJ6VqZJy3ONaVOWvQT9kg,现转回到自己的博客。)

GFS(Google File System)是Google公司开发的一款分布式文件系统。在2003年,Google发表一篇论文详细描述了GFS的架构。GFS,MapReduce,Bigtable并称为Google的三架马车,推动了Google的高速发展。其他互联公司和开源领域纷纷模仿,构建自己的系统。可以这么说,GFS,MapReduce,Bigtable引领了互联网公司的分布式技术的发展。但GFS架构设计并不是一个完美的架构设计,它有诸多方面的问题,一致性的欠缺就是其中的一个问题。本文探讨一下GFS架构设计、分析其在一致性方面的设计不足,并且看一下一致性对分布式系统的重要性。

我们从GFS的接口设计说起。
接口
GFS采用了人们非常熟悉的接口,但是被没有实现如POSIX的标准接口。通常的一些操作包括:create, delete, open, close, read, write, record append等。create,delete,open,close和POSIX的接口类似,这里就不强调说了。

这里详细讲述一下write, record append提供的语意。

write操作可以将任意长度len的数据写入到任意指定文件的位置offset.
record append操作可以原子的将len<=16MB的数据写入到指定文件的末尾,之所以GFS设计这个接口,是因为record append不是简单的offset等于文件末尾的write操作。record append是具有原子特性的操作,本文后面会详细解释这个原子特性。
write和record append操作都允许多个客户端并发操作。
架构
GFS架构如下图。(摘自GFS的论文)

从 GFS 失败的架构设计来看一致性的重要性_第1张图片

主要的架构部件有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 主(master)维护文件系统的元数据(metadata),包括名字空间(namespace,也就是常规文件系统的中的文件树),访问控制信息,每个文件有哪些chunk构成,chunk存储在哪个chunkserver上,也就是位置(location)。

在这样的架构下,文件的读写基本过程简化、抽象成如下的过程:

写流程:
1.client向master发送create请求,请求包含文件路径和文件名。master根据文件路径和文件名,在名字空间里创建一个对象代表这个文件。
2.client向3个chunkserver发送要写入到文件中的数据,每个chunkserver收到数据后,将数据写入到本地的文件系统中,写入成功后,发请求给master告知master一个chunk写入成功,与此同时告知client写入成功。
3.master收到chunkserver写入成功后,记录这个chunk与机器之间的对应关系,也就是chunk 的位置。
4.client确认3个chunkserver都写成功后,本次写入成功。

这个写流程是一个高度简化抽象的流程,实际的写流程是一个非常复杂的流程,要考虑到写入类型(即,是随机写还是尾部追加),还要考虑并发写入,后面我们继续详细的描述写流程,解释GFS是如何处理不同的写入类型和并发写入的。

读流程:
1.应用发起读操作,指定文件路径,偏移量(offset)
2.client根据固定的chunk大小(也即64MB),计算出数据在第几个chunk上,也就是chunk索引号(index)
3.client向master发送一个请求,包括文件名和索引号,master返回3个副本在哪3台机器上,也就是副本位置(location of replica)。
4.client向其中一个副本的机器发送请求,请求包换块柄和字节的读取范围。
5.chunkserver根据块柄和读取范围从本地的文件系统读取数据返回给client

这个读流程未做太多的简化和抽象,但实际的读流程还会做一些优化,这些优化和本文主题关系不大就不展开了。

写流程详述
我们详细的讲一下写入流程的几个细节。

1.名字空间管理(namespace management)和锁保护(locking)
写入流程需要向主发送create这样请求,来操作保存在主上的名字空间。
如果有多个客户端同时进行写入操作,那么这些客户端也会同时操作向主发送create请求。主在同一时间收到多个请求,通过加锁的方式,防止多个客户端同时修改同一个文件的元数据。

2.租约(Lease)
client需要向3个副本写入数据,在并发的情况下会有多个client同时向3个副本写入数据。GFS需要一个规则来管理这些数据的写入。
这个规则简单来讲,每个chunk都只有一个副本来管理多个client的并发写入。也就是说,对于一个chunk,master会授予其中一个副本一个租约(lease),具有租约的副本来管理所有要写入到这个chunk的数据写入,这个具有租约的副本称之为首要副本(primary)。其他的副本称之为二级副本(secondary)

3.变更次序(Mutation Order)
将对文件的写入(不管是在任意位置的写入,还是在末尾追加)称之为变更(Mutation)。Primary管理所有client的并发请求,让所有的请求按照一定顺序(Order)应用到chunk上。

4.基本变更流程
1).client询问master哪个chunkserver持有这个chunk的lease,以及其他的副本的位置。如果没有副本持有lease,master挑选一个副本,通知这副本它持有lease。
2).master回复client,告述客户端首要副本的位置,和二级副本的位置。客户端联系首要副本,如果首要副本无响应或者回复客户端它不是首要副本,则客户端重新联系主。
3).客户端向所有的副本推送数据。客户端可以以任意的顺序推送。每个chunkserver会缓存这些数据在缓冲区中。
4).当所有的副本都回复说已经收到数据后,客户端发送一个写入请求(write request)给首要副本,这个请求里标识着之前写入的数据。首要副本收到请求后,会给写入分配一个连续的编号,首要副本会按照这个编号的顺序,将数据写入到本地磁盘。
5).首要副本将这个带有编号写入请求转发给其他二级副本,二级副本也会按照编号的顺序,将数据写入本地,并且回复首要副本数据写入成功。
6).当首要副本收到所有二级副本的回复时,说明这次写入操作成功。
7).首要副本回复客户端写入成功。在任意一个副本上遇到的任意错误,都会报告给客户端失败。

前面讲的write接口就是按照这个基本流程进行的。

下图描述了这个基本过程。(摘自GFS的论文)

从 GFS 失败的架构设计来看一致性的重要性_第2张图片

5.跨边界变更
如果一次写入的数据量跨过了一个块的边界,那么客户端会把这次写入分解成向多个chunk的多个写入。

6.原子记录追加(Atomic Record Appends)
Record Appends在论文中被称之为原子记录追加(Atomic Record Appends),这个接口也遵循基本的变更流程,有一些附加的逻辑:客户端把数据推送给所有的副本后,客户端发请求给首要副本,首要副本收到写入请求后,会检查如果把这个record附加在尾部,会不会超出块的边界,如果超过了边界,它把块中剩余的空间填充满(这里填充什么并不重要,后面我们会解释这块),并且让其他的二级副本做相同的事,再告述客户端这次写入应该在下一块重试。如果记录适合块剩余的空间,则首要副本把记录追加尾部,并且告述其他二级副本写入数据在同样的位置,最后通知客户端操作成功。

原子性
讲完架构和读写流程,我们开始分析GFS的一致性,首先从原子性开始分析。

Write和Atomic Record Append的区别
前面讲过,如果一次写入的数量跨越了块的边界,那么会分解成多个操作,write和record append在处理数据跨越边界时的行为是不同的。

我们举例2个例子来说明一下。
例子1,文件目前有2个chunk,分别是chunk1, chunk2。
Client1要在54MB的位置写入20MB数据。这写入跨越了边界,要分解成2个操作,第一个操作写入chunk1最后10MB,第二个操作写入chunk2的开头10MB。
Client2也要在54MB的位置写入20MB的数据。这个写入也跨越边界,也要分解为2个操作,作为第三个操作写入chunk1最后10MB,作为第四个操作写入chunk2的开头10MB。

2个客户端并发写入数据,那么第一个操作和第三个操作在chunk1上就是并发执行的,第二个操作和第四个操作在chunk2上并发执行,如果chunk1的先执行第一操作再执行第三个操作,chunk2先执行第四个操作再执行第二个操作,那么最后,在chunk1上会保留client1的写入的数据,在chunk2上保留了client2的写入的数据。虽然client1和client2的写入都成功了,但最后既不是client1想要的结果,也不是client2想要的结果。最后的结果是client1和client2写入的混合。对于client1和client2来说,他们操作都不是原子的。

例子2,文件目前有2个chunk,分别是chunk1, chunk2。
Client要在54MB的位置写入20MB数据。这写入跨越了边界,要分解成2个操作,第一个操作写入chunk1最后10MB,第二个操作写入chunk2的开头10MB。chunk1执行第一个操作成功了,chunk2执行第二个操作失败了,也就是写入的这部分数据,一部分是成功的,一部分是失败的,这也不是原子操作。

接下来看record append。由于record append最多能写入16MB的数据,并且当写入的数据量超过块的剩余空间时,剩余的空间会被填充,这次写入操作会在下个块重试,这2点保证了record append操作只会在一个块上生效。这样就避免了跨越边界分解成多个操作,从而也就避免了,写入的数据一部分成功一部分失败,也避免了并发写入数据混合在一起,这2种非原子性的行为。

GFS原子性的含义
所以,GFS的原子性不是指多副本之间原子性,而是指发生在多chunk上的多个操作的的原子性。

可以得出这样的推论,如果Write操作不跨越边界,那么没有跨越边界的write操作也满足GFS所说的原子性。

GFS多副本不具有原子性
GFS一个chunk的副本之间是不具有原子性的,不具有原子性的副本复制,它的行为是:
一个写入操作,如果成功,他在所有的副本上都成功,如果失败,则有可能是一部分副本成功,而另外一部分失败。

在这样的行为如下,失败是这样处理的:
如果是write失败,那么客户端可以重试,直到write成功,达到一致的状态。但是如果在重试达到成功以前出现宕机,那么就变成了永久的不一致了。
Record Append在写入失败后,也会重试,但是与write的重试不同,不是在原有的offset上重试,而是接在失败的记录后面重试,这样Record Append留下的不一致是永久的不一致,并且还会有重复问题,如果一条记录在一部分副本上成功,在另外一部分副本上失败,那么这次Record Append就会报告给客户端失败,并且让客户端重试,如果重试后成功,那么在某些副本上,这条记录就会成功的写入2次。
我们可以得出,Record Append保证是至少一次的原子操作(at least once atomic)。
一致性
GFS把自己的一致性称为松弛的一致性模型(relaxed consistency model)。这个模型分析元数据的一致性和文件数据的一致性,松弛主要是指文件数据具有松弛的一致性。

元数据的一致性
元数据的操作都是由单一的master处理的,并且操作通过锁保护,所以是保证原子的,也保证正确性的。

文件数据的一致性
在说明松弛一致性模型之前,我们先看看这个模型中的2个概念。对于一个文件中的区域:
如果无论从哪个副本读取,所有的客户端都能总是看到相同的数据,那么就叫一致的(consistent);
在一次数据变更后,这个文件的区域是一致的,并且客户端可以看到这次数据变更写入的所有数据,那么就叫界定的(defined)。

GFS论文中,用下面的这个表格总结了松弛一致性:
分别说明表中的几种情况:
1.在没有并发的情况下,写入不会有相互干扰,成功的写入是界定的,那么必然也就是一致的
2.在有并发的情况下,成功的写入是一致的,但不是界定的,也就是我们前面所举的例子2。
3.如果写入失败,那么副本之间就会出现不一致。
4.Record Append能够保证是界定的,但是在界定的区域之间夹杂着一些不一致的区域。Record Append会填充数据,不管每个副本是否填充相同的数据,这部分区域都会认为是inconsistent的。
如何适应松弛的一致性模型

这种松弛的一致性模型,实际上是一种不能保证一致性的模型,或者更准确的说是一致性的数据中间夹杂不一致数据。

这些夹杂其中的不一致数据,对应用来说是不可接受的。在这种一致性下,应该如何使用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),读取方通过校验数识别出不一致的数据,并且丢弃不一致的数据。
如果应用读取数据没有幂等处理,那么应用就需要过滤掉重复数据。写入方写入记录时额外写入一个唯一的标识(identifier),读取方读取数据后通过标识辨别之前是否已经处理过这个标识的数据。
GFS的设计哲学
可以看出基于GFS的应用需要通过一些特殊的手段来应对GFS松弛的一致性模型带来的各种问题。GFS的一致性保证对于使用者是非常不友好的,很多人第一次看到这样的一致性保证都是比较吃惊的。

那么GFS为什么要设计这样的一致性模型那?GFS在架构上选择这样的设计有它自己的设计哲学。GFS追求的是简单、够用的原则。GFS主要要解决的问题是如何使用廉价的服务器存储海量的数据,并且达到非常高的吞吐量(GFS非常好的做到了这2点,但不是本文的主题,这里就不展开了),并且文件系统本身的实现要简单,能够快速的实现出来(GFS的开发者在开发完GFS之后,很快就去开发BigTable了)。GFS很好的完成了完成了这样的目标。但是留下了一致性问题,给使用者带来的负担。但是在GFS应用的前期,一致性不是问题,GFS的主要使用者(BigTable)就是GFS开发者,他们深知应该如何使用GFS,这种不一致在BigTable中被很好屏蔽掉(采用上面所说的方法),BigTable提供了很好的一致性保证。

但是随着GFS使用的不断深入,GFS简单够用的架构开始带来很多问题,一致性问题仅仅是其中的一个。主导了GFS很长时间的Leader Sean Quinlan在一次采访中,详细说明了在GFS度过了应用的初期之后,这种简单的架构带来的各种问题[1]。

开源分布式文件系统HDFS,清晰的看到了GFS的一致性模型给使用者带来的不方便,坚定地摒弃了GFS的一致性模型,提供了很好的一致性保证。HDFS达到了怎样的一致性,这里就不在展开了,另外再做详细的讨论。

总之,保证一直性对于分布式文件系统,甚至所有的分布式系统都是很重要的。

作者简介
陈东明,饿了么北京技术中心架构组负责人,负责饿了么的产品线架构设计以及饿了么基础架构研发工作。曾任百度架构师,负责百度即时通讯产品的架构设计。具有丰富的大规模系统构建和基础架构的研发经验,善于复杂业务需求下的大并发、分布式系统设计和持续优化。个人微信公众号dongming_cdm。

1.GFS: Evolution on Fast-forward, Sean Quinlan, 2009, https://queue.acm.org/detail.cfm?id=1594206

你可能感兴趣的:(并发,分布式,存储)