软件系统的设计跟其他物品的设计是一样的,它不是抽象的存在于纸上,还需要考虑真实的物理硬件性能和限制条件,GFS就是典型,它充分考虑了硬件特性来做设计的取舍。什么硬件?又有哪些特性限制?
GFS是基于廉价计算机硬件来设计的,我们知道单台机器性能瓶颈通常是硬盘,大数据系统是为海量数据而设计的,也必然要追求高性能,单台机器有硬盘的性能瓶颈,那就搭建成百上千台服务器,通过网络组成一个大数据集群,这个时候我们发现网络也会成为新的性能瓶颈。
GFS的硬件配置
从论文第6部分BenchMark的测试介绍看:
1)19 台服务器、1 台 master、2 台 master 的只读副本、16 台 chunkserver,以及另外 16 台 GFS 的客户端。
2)所有服务器的硬件配置完全相同,都是双核 1.45 GHz 的奔腾 3 处理器 + 2GB 内存 + 两块 80GB 的 5400rpm 的机械硬盘 + 100 Mbps 的全双工网卡。
3)19 台 GFS 集群的机器连接在一台交换机上,16 台客户端连接在另外一台交换机上,两台交换机之间通过带宽是 1Gbps 的网线连接起来。
此外,还列出了两个GFS集群的数据信息:
5400 转(rpm)的硬盘,读写数据的吞吐量通常是在 60MB/s~90MB/s 左右,多插入硬盘可提高硬盘吞吐量,但是百兆网卡的吞吐量只有12.5MB/s,所以说网络就会成为性能瓶颈,那么我们来看看GFS是怎么利用网络硬件的特性做设计的。
GFS根据硬件特性做设计
我们先来看GFS的数据写入过程:
1)客户端会去问Master要写入的数据应该在哪些ChunkServer上。
2)Master会告诉客户端数据可以写入的副本位置信息,还要告诉客户端哪个是主副本Primary Replica,数据此时以主副本Primary Replica为准。
3)客户端拿到副本位置信息后,即:应该写入哪些ChunkServer里,客户端会把要写入的数据发送给所有副本。此时ChunkServer把接收到的数据放在一个LRU缓存中,并不真正地写数据。
4)等所有次副本Secondary Replica都接收完数据后,客户端就会给主副本Primary Replica发送一个写请求。因为客户端有成百上千个,会产生并发的写请求,因此主副本Primary Replica有可能收到很多客户端的写请求,主副本Primary Replica会将这些写请求排序,确保所有数据的写入是按照一个固定的顺序。主副本Primary Replica将LRU缓存中的数据写入实际的Chunk里。
5)主副本Primary Replica把写请求发送给所有的次副本Secondary Replica,次副本Secondary Replica会和主副本Primary Replica以同样的数据写入顺序,将数据写入磁盘。
6)次副本Secondary Replica数据写完后,将回复主副本Primary Replica写入完毕。
7)主副本Primary Replica再去告诉客户端,这次数据写入成功了。如果有某个副本写入失败,也会告诉客户端,本次数据写入失败了。
由上面的数据写入过程可知,因为数据是写入到不同的ChunkServer上,这就会出现部分写入成功,部分写入失败的情况,这就涉及到数据的一致性问题,这个我们在“根据应用做设计取舍”里再分析,这里我们集中分析“根据硬件特性做设计”,怎么体现的这一点呢?我们看数据的复制流程:
从这张设计图中,我们看到控制流和数据流是分开的,数据流的复制过程是流水线式的。为什么要这么设计?
我们先来看控制流和数据流的分离:客户端只是从Master节点上拿到了数据应该写入哪些ChunkServer,而实际的数据传输并不经过Master节点,多个副本在ChunkServer上的协调写入过程也不经过Master节点。这就意味着提供指令的动作跟数据的传输和写入是完全分开的,这就使得Master节点不会承受太多的负载,避免Master节点成为性能瓶颈。
前面硬件分析时已说过,网络可能成为最大的瓶颈,如果用最直观的方法,即:客户端把数据直接发给所有ChunkServer,客户端的出口网络会立刻成为性能瓶颈。比如:1GB的数据,客户端的出口带宽为100MB/s,那么数据需要10s就能发送完,由于客户端需要同时发送给三个ChunkServer,这就需要30s才能发送完,而每个ChunkServer的入口带宽也并没有充分的利用起来。那要怎么设计才能充分利用硬件特性,避免网络瓶颈呢?既然同时发送给三个ChunkServer不好,那就一个一个的发,如果只是让客户端一个一个的发,似乎还未收到数据的ChunkServer就只能被动等待,有没有更好的方式?福特发明的流水线车间大幅提高了生产效率,一端送进原材料,另一端就源源不断的生产出汽车来,把这种流水线模式引入数据传输同样提高了数据传输的效率。
流水线(pipeline)式的网络数据传输
数据不一定是先给到主副本,而是看网络中离哪个ChunkServer更近,就给那个最近的ChunkServer,如上图所示,客户端先把数据传输到离自己网络最近的次副本Secondary Replica A上,而且次副本Secondary Replica A是一边接收客户端发来的数据,一边又把数据发送给离自己最近的主副本Primary Replica上,同理,主副本Primary Replica也是一边接收次副本Secondary Replica A发来的数据,一边又把数据发送给离自己最近的次副本Secondary Replica B,这就是流水线式的数据传输方式。这样只要网络上没有拥塞情况,只需要10s多一点,就可以把数据从客户端传递给三个副本所在的ChunkServer服务器上。流水线式的数据传输方式可以有效利用满客户端和ChunkServer的网络带宽:
这里有个问题:为什么要强调客户端“先把数据传输到离自己网络最近的次副本Secondary Replica A上”,而不是直接发送给主副本Primary Replica呢?我们看下这张传统三层交换的数据中心网络拓扑图就明白了。
如上图所示,数据中心有几百台服务器,通过三层网络连接起来:
1)同一个机架Rack上的服务器都会接入一台接入层交换机(Access Switch);
2)各个机架上的接入层交换机都会连接到一台汇聚层交换机(Aggregation Switch);
3)汇聚层交换机会再连接到核心交换机(Core Switch);
由此构成一个三层网络拓扑图,这样你就会发现:如果两台服务器在同一个机架Rack上,它们之间的网络传输只需要经过接入层交换机,即:除了两台服务器自身的网络带宽外,只会占用所在的接入层交换机的带宽。如果不在同一个机架上,甚至不在同一个VLAN内,就需要通过汇聚层交换机,甚至核心交换机,这个时候汇聚层交换机或核心交换机就有可能成为性能瓶颈。
这样我们再来看流水线式的数据传输过程,就明白GFS为何这样设计,既充分利用了网络带宽,又减少了交换机的网络瓶颈。
除了上面的两点外,我们再来看GFS还有哪些优化的设计?GFS为常见的文件复制操作设计了一个单独的指令:文件复制Snapshot操作。
复制文件我们通常会怎么做呢?读一个文件,然后重新写一份,在GFS分布式文件系统中,读数据要经过网络传输,写数据又要经过网络传输,这样做貌似也可以,但是这是不是最优解呢?GFS为文件复制单独设计了一条Snapshot指令,客户端通过这条指令复制文件时,指令会通过控制流下发到主副本Primary Replica,主副本Primary Replica再下达到次副本Secondary Replica,然后各个副本所在的ChunkServer直接在本地复制一份对应的Chunk数据,这就避免了来回的网络传输问题。
以上就是GFS依照当时数据中心的网络架构特点和硬件服务器的规格所在的设计,非常地接地气,这也是非常重要地核心设计思想。在大数据之前,数据中心地流量是“南北大,东西小”,即:数据是从一台服务器经过层层网络交换机,返回互联网,最终发送到用户手中。而大数据兴起后,东西向地数据流也变得很大,这就需要新的设计。
============================================
分布式系统的数据一致性问题一直是个重要的话题,分布式系统天生通过更多的服务器提升了性能,这是它的优势,那么伴随而来的数据一致性问题就是一个很大的挑战,因为数据是通过网络分布在不同的硬件服务器上的,网络的不可靠性必然导致数据的一致性问题。为了保持“简单原则”,GFS在涉及数据一致性问题上非常的宽松,又兼顾了机械硬盘顺序写操作性能较高的特性。我们来看下具体是怎么做的。
前面我们已分析GFS的写操作,3副本的情况下,同一份数据要写入不同的ChunkServer服务器,这必然会有部分写入成功,部分写入失败的问题,这就是数据写入的一致性问题。一致性就是CAP理论中的Consistent,在GFS中,一致性具体什么含义?论文中是这样定义的:
数据的修改分为:写入Write和记录追加Record Append。其结果有:顺序写入成功Serial success、并发写入成功Concurrent successes、写入失败。两种写入方式,三种写入结果,那么数据就会有不同的状态,GFS使用“确定的defined”和“一致的consistent”在标识数据的状态。怎么理解呢?
一致的consistent:就是客户端无论从主副本Primary Replica读数据,还是从次副本Secondary Replica读数据,读到的数据都是一样的。即:多个副本Replica读出来的数据是一样的。
确定的defined:就是客户端写入的数据能够完整地被读到。即:每个客户端写入指定offset的数据 和 再从offset读出来的数据是相同的。
这就是GFS定义的数据一致性问题,首先,如果数据写入是失败的(部分副本成功,部分副本失败也是写入失败),那么GFS中的数据必然是不一致的inconsistent。 这个很好理解,GFS的数据写入并不是一个事务,前面已述,客户端发送写入指令给主副本Primary Replica,主副本Primary Replica再下发指令到次副本Secondary Replica,如果主副本Primary Replica和其中部分次副本Secondary Replica写入成功了,而部分次副本Secondary Replica写入失败,那么客户端读取到的数据就是不一致的。
其次,如果是顺序写入,写入成功,那么文件中的数据就是确定的defined,客户端读到的数据也是确定的defined。客户端写入数据需要指定offset,数据写入的位置和大小是已知的,比如:客户端A和客户端B分别写入数据,因为是顺序写入的,客户端A写Chunk成功,客户端A从offset处读上来的就是A的数据,这是确定的;同理,客户端B也写Chunk成功,客户端B从offset处读上来的就是B的数据,这也是确定的。这种情况下的数据写入就是确定的defined,也就是说,客户端A写入的数据就是客户端A的数据,这次写操作成功后,其数据就是确定的,无疑的,不会出现不确定的情况。即便客户端B后来也写入了同样的位置,那是客户端B的写操作,客户端B写成功后,它的数据也是确定的、无疑的,不会出现不确定的情况。
如果是并发写入成功呢? 由于多个客户端并发地向同一个文件中写入数据,三副本都写入成功了,如果多个客户端写入的数据有可能出现交叉,比如客户端A写入数据范围[50, 80],客户端B写入数据范围[70, 100],就会出现下面的情况:客户端A写入成功后,客户端B有可能会覆盖了客户端A的数据;也可能是客户端B写入成功后,客户端A可能覆盖了客户端B的数据。但是客户端A和客户端B都写成功了,其3个副本都是按同样的顺序写的数据,客户端无论从哪个副本读,数据都是一致的consistent,但是客户端去读自己写入的数据,就会出现读出来的数据有部分交叉的内容,这部分内容是客户端A写入的,还是客户端B写入的是不确定的undefined。
所以并发写入成功时,这种情况文件数据就是一致的但是不确定的consistent but undefined状态。为什么会出现这种情况?一是,数据的写入顺序并不需要通过Master来协调,而是直接发送给ChunkServer,由ChunkServer来管理数据的写入顺序;二是,随机写操作大概率会横跨多个Chunk。这就导致一个Chunk可能既包含数据A,又包含数据B。随机数据的写入并非原子性的,也没有事务性保证,因此需要客户端在写入数据时是顺序写入的,避免并发写入的出现。那么问题也来了,GFS允许几百个客户端读写访问,那该怎么避免这种一致但非确定的状态呢?GFS设计了记录追加Record Append来解决这个问题。
记录追加Record Append的“至少一次”的保障
我们希望是什么样呢?并发写入的数据是确定的defined,客户端A写入的数据就是客户端A的,不会被客户端B覆盖掉,客户端B写入的数据就是客户端B的,不会被客户端A覆盖掉,这就要求保障写入是原子性的。看看GFS到底是怎么做的:写操作时,GFS并不指定在Chunk的哪个位置offset写入数据,而是告诉Chunk的主副本Primary Replica服务器“我要追加记录”,由主副本ChunkServer根据当前Chunk位置决定写入的offset,在写入成功后将该offset返回给客户端,客户端根据offset确切知道写入结果,无论是串行写入,还是并发写入,其结果都是确定的defined。
具体的处理流程:
1)首先,主副本ChunkServer会校验当前的Chunk能否容纳下要追加的数据,如果能,主副本ChunkServer就将数据追加到当前Chunk中,然后给次副本ChunkServer发送指令,次副本ChunkServer也将数据追加到副本Chunk中。
2)如果当前的Chunk不能容纳,主副本ChunkServer就把当前Chunk剩余空间填充空数据,然后发送给次副本ChunkServer也把副本Chunk剩余空间填充空数据;然后主副本ChunkServer回复客户端“在下一个Chunk上写入”,客户端重新从Master节点获取新的Chunk位置,数据传输完毕后,再次向新的Chunk所在的主副本ChunkServer发起写指令。
3)因为数据写入的顺序是由主副本来控制的,且数据写入只有追加操作,因此主副本ChunkServer会将并发写入的数据进行排队,然后依次追加写入,这样就不会出现相互覆盖的情况了。
4)为了确保Chunk剩下的空间能存的下需要追加的数据,GFS限制了一次记录追加的数据大小为16MB,而Chunk大小默认是64MB,所以当空间不足需要补空数据时,最多也就是16MB,也就是最多也就浪费1/4的空间,不至于浪费太多。
那么“defined interspersed with inconsistent确定的但夹杂着不一致的数据”该怎么理解呢?我们看两个例子:
例子1:无并发的记录追加
客户端追加了一个数据a,第一次执行一半Secondary2失败了,那么此时这个Chunk的副本情况如下:
于是,客户端重新发起一次追加写操作,Primary先操作offset2后再将请求发给Secondary操作offset2,由于Secondary2上次操作offset1未成功,所以会先补空数据,然后再从offset2处写数据,那么此时副本情况如下:
并返回给客户端offset2。于是中间的offset1部分就是不一致的inconsistent,但对于新追加的数据就是确定的defined,客户端读offset2,就可以确定的读到a,这就是“defined interspersed with inconsistent确定的但夹杂着不一致的数据”。
同样的,例子2:并发记录追加
两个客户端分别向同一个文件追加数据a和数据b,Client1追加数据a,Client2追加数据b。假如Primary排序b,a。然后执行追加写操作,假如执行一半Secondary2失败了,同理,Client2会再次追加一遍,那么这个Chunk的副本情况如下:
于是Client1收到offset2,Client2收到offset3,offset2和offset3都是确定的defined,而offset1是不一致的inconsistent,同样是“defined interspersed with inconsistent确定的但夹杂着不一致的数据”。
不同的副本中可能被追加了不同的次数,但“至少一次”是确定的defined,这就是“At Least Once”,这就是GFS承若的一致性。可见GFS对写入数据的一致性保障相当低,它只是保障了所有数据追加至少被写入一次。不过,这其实很符合Google的实际应用情况,因为Google作为搜索引擎是不断抓取网页存到GFS上,其实并不在意被重复存储了几次。对于数据写入失败带来的部分数据不完整问题,GFS在客户端自带了对写入的数据去添加校验和(checksum),在读取数据的时候计算验证数据的完整性功能。而对于重复写入多次的问题,也可以对每一条要写入的数据生成一个唯一的ID,带上时间戳,那么即便数据顺序不对,有重复,也很容易在数据处理中根据ID进行排序和去重。
这就是,根据实际应用做设计取舍。 这样的设计——“至少一次”,带来了很大的好处:一是,高并发和高性能,因为只涉及到追加Append操作,对于机械硬盘来说效率最高,性能最好,又满足多客户端并发操作;二是,简单,单一的Master设计,Master只负责控制协调,不需要复杂的一致性算法。而且在当年只有晦涩难懂的Paxos,还没有相对简单的Raft算法出现,现如今Raft算法已经是数据一致性问题使用最广泛的解决方案。
===============================================