自己动手写网络爬虫(修订版)
上QQ阅读APP看书,第一时间看更新

2.3 Google的成功之道——GFS

Google之所以能成功,很大程度上是应用了GFS+BigTable+MapReduce的架构。由于这种架构,使得Google在当前风起云涌的云计算热潮中始终保持领先地位。Google的爬虫也是采用GFS作为存储网页的底层数据结构。为何GFS能如此受Google的青睐?本节将为你揭示这个谜团。

2.3.1 GFS详解

GFS(Google File System)是Google自己研发的一个适用于大规模分布式数据处理相关应用的可扩展的分布式文件系统。它基于普通的不算昂贵的硬件设备,实现了容错的设计,并且为大量客户端提供了极高的聚合处理性能。GFS正好与Google的存储要求相匹配,因此在Google内部广泛用作存储平台,适用于Google的服务产生和处理数据应用的要求,以及Google的海量数据的要求。Google最大的集群通过上千个计算机的数千个硬盘,提供了数百TB的存储,并且这些数据被数百个客户端并行操作。Google之所以要研发自己的分布式文件系统,是因为在分布式存储环境中,常常会产生以下一些问题。

1. 在分布式存储中,经常会出现节点失效的情况

因为在分布式存储中,文件系统包含几百个或者几千个廉价的普通机器,而且这些机器要被巨大数量的客户端访问。节点失效可能是由于程序的bug、操作系统的bug、人工操作的失误,以及硬盘坏掉,内存、网络、插板的损坏,电源的坏掉等原因造成的。因此,持续监视,错误检测,容错处理,自动恢复必须集成到这个文件系统的设计中。

2. 分布式存储的文件都是非常巨大的

GB数量级的文件是常事。每一个文件都包含很多应用程序对象,比如Web文档等。搜索引擎的数据量是迅速增长的,它通常包含数十亿数据对象。如果使用一般的文件系统,就需要管理数十亿KB数量级大小的文件,而且每次I/O只能读出几字节也不能满足搜索引擎吞吐量的要求。因此,Google决定设计自己的文件系统,重新规定每次I/O的块的大小。

3. 对搜索引擎的业务而言,大部分文件只会在文件尾新增加数据,不会修改已有数据

对一个文件的随机写操作实际上几乎是不存在的。一旦写完,文件就是只读的,并且一般都是顺序读取。比如,在网络爬虫中,把网页抓取下来之后不会做修改,而只是简单地存储,作为搜索结果的快照。在实际的系统中,许多数据都有这样的特性。有些数据可能组成很大的数据仓库,并且数据分析程序从头扫描到尾。有些可能是运行应用而不断地产生数据流。对这些巨型文件的访问模式来说,增加模式是最重要的,所以我们首先要优化性能的就是它。

4. 与应用一起设计的文件系统API对于增加整个系统的弹性和适用性有很大的好处

为了满足以上几点需要,Google遵照下面几条原则设计了它的分布式文件系统(GFS):

● 系统建立在大量廉价的普通计算机上,这些计算机经常出故障,必须对这些计算机进行持续检测,并且在系统的基础上进行检查、容错,以及从故障中进行恢复。

● 系统存储了大量的超大文件。数GB的文件经常出现并且应当对大文件进行有效的管理。同时必须支持小型文件,但是不必为小型文件进行特别的优化。

● 一般的工作都是由两类读取组成——大的流式读取和小规模的随机读取。在大的流式读取中,每个读操作通常一次就要读取几百字节以上的数据,每次读取1MB或者以上的数据也很常见。因此,在大的流式读取中,对同一个客户端来说,往往会发起连续的读取操作顺序读取一个文件。小规模的随机读取通常在文件的不同位置,每次读取几字节数据。对性能有过特别考虑的应用通常会做批处理并且对它们读取的内容进行排序,这样可以使得它们的读取始终是单向顺序读取,而不需要往回读取数据。

● 通常基于GFS的操作都有很多超大的、顺序写入的文件操作。通常写入操作的数据量和读入的数据量相当。一旦完成写入,文件就很少会更改。应支持文件的随机小规模写入,但是不需要为此做特别的优化。

5. 系统必须非常有效地支持多个客户端并行添加同一个文件

GFS文件经常使用生产者/消费者队列模式,或者以多路合并模式进行操作。好几百个运行在不同机器上的生产者,将会并行增加一个文件。

6. 高性能的稳定带宽的网络要比低延时更加重要

GFS目标应用程序一般会大量操作处理比较大块的数据。

基于以上几点考虑,GFS采用了如图2.9所示的架构。

图2.9 Google File System架构

GFS集群由一个主服务器(master)和多个块服务器(chunkserver)组成,GFS集群会有很多客户端访问。每一个节点都是一个普通的Linux计算机,运行的是一个用户级别的服务器进程。

在GFS下,每个文件都被拆成固定大小的块(chunk)。每一个块都由主服务器根据块创建的时间产生一个全局唯一的以后不会改变的64位的块处理(chunk handle)标志。块服务器在本地磁盘上用Linux文件系统保存这些块,并且根据块处理标志和字节区间,通过Linux文件系统读写这些块的数据。出于可靠性的考虑,每一个块都会在不同的块处理器上保存备份。

主服务器负责管理所有的文件系统的元数据,包括命名空间、访问控制信息、文件到块的映射关系、当前块的位置等信息。主服务器同样控制系统级别的活动,比如块的分配管理,孤点块的垃圾回收机制,块服务器之间的块镜像管理。

连接到各个应用系统的GFS客户端代码包含文件系统的API,并且会和主服务器及块服务器进行通信处理,代表应用程序进行读写数据的操作。客户端和主服务器进行元数据的操作,但是所有的与数据相关的通信是直接和块服务器进行的。

由于在流式读取中,每次都要读取非常多的文件内容,并且读取动作是顺序读取,因此,在客户端没有设计缓存。没有设计缓存系统使得客户端以及整个系统都大大简化了(少了缓存的同步机制)。块服务器不需要缓存文件数据,因为块文件就像本地文件一样被保存,所以Linux的缓存已经把常用的数据缓存到了内存里。

下面简单介绍图2.9中的读取操作分段。首先客户端把应用要读取的文件名和偏移量,根据固定的块大小,转换为文件的块索引,然后向主服务器发送这个包含文件名和块索引的请求。主服务器返回相关的块处理标志以及对应的位置。客户端缓存这些信息,把文件名和块索引作为缓存的关键索引字。

于是这个客户端就向对应位置的块服务器发起请求,通常这个块服务器是离这个客户端最近的一个。请求给定了块处理标志以及需要在所请求的块内读取的字节区间。在这个块内,再次操作数据将不用再通过客户端--主服务器的交互,除非这个客户端本身的缓存信息过期了,或者这个文件重新打开了。实际上,客户端通常都会在请求中附加向主服务器询问多个块的信息,主服务器会立刻给这个客户端回应这些块的信息。这个附加信息是通过几个几乎没有任何代价的客户端--主服务器的交互完成的。

块的大小是设计的关键参数。Google选择的块大小为64MB,远远大于典型的文件系统的块大小。每一个块的实例(复制品)都是作为在主服务器上的Linux文件格式存放的,并且只有在需要的情况下才会增长。滞后分配空间的机制可以通过文件内部分段来避免空间浪费,对这样大的块大小来说,内部分段可能是一个最大的缺陷。

选择一个很大的块可以提供一些重要的好处。首先,它减少了客户端和主服务器的交互,因为在同一个块内的读写操作只需要客户端初始询问一次主服务器关于块的位置信息就可以了。对主服务器访问的减少可以显著提高系统性能,因为使用GFS的应用大部分是顺序读写超大文件的。即使是对小范围的随机读,客户端也可以很容易缓存许多大的数据文件的位置信息。其次,由于是使用一个大的块,客户端可以在一个块上完成更多的操作,它可以通过维持一个到主服务器的TCP持久连接来减少网络管理量。第三,它减少了元数据在主服务器上的大小,使得Google应用程序可以把元数据保存在内存中。

下面简单介绍图2.9中的主服务器。

主服务器节点保存了三个主要的数据类型:文件和块的命名空间、文件到块的映射关系和每一个块的副本位置。所有的元数据都保存在主服务器的内存里。头两个类型(namesaces和文件到块的映射)同时也保存在主服务器本地磁盘的日志中。通过日志,在主服务器宕机的时候,我们可以简单、可靠地恢复主服务器的状态。主服务器并不持久化保存块位置信息。相反,它在启动的时候以及主服务器加入集群的时候,向每一个主服务器询问它的块信息。

因为元数据都是在内存保存的,所以在主服务器上操作很快。另外,主服务器很容易定时扫描后台所有的内部状态。定时扫描内部状态可以用来实现块的垃圾回收,当主服务器失效的时候重新复制,还可以作为服务器之间的块镜像,在执行负载均衡和磁盘空间均衡任务时使用。

因为我们采用内存保存元数据的方式,如果需要支持更大的文件系统,我们可以简单、可靠、高效、灵活地通过增加主服务器的内存来实现。

主服务器并不持久化保存块服务器上的块记录,它只是在启动的时候简单地从块服务器中取得这些信息。主服务器可以在启动之后一直保持自己的这些信息是最新的,因为它控制所有的块的位置。

上文提到的主服务器的日志信息保存了关键的元数据变化历史记录,它是GFS的核心。不仅仅因为它是唯一持久化的元数据记录,而且日志记录逻辑时间基线,定义了并行操作的顺序。块以及文件,都是用它们创建时刻的逻辑时间基线来作为唯一的并且永远唯一的标志。

由于日志记录是极关键的,因此必须可靠保存,在元数据改变并且持久化之前,对客户端来说都是不可见的(也就是说保证原子性)。否则,就算是在块服务器完好的情况下,也可能会丢失整个文件系统,或者最近的客户端操作。因此,把这个文件保存在多个不同的主机上,并且只有当刷新这个相关的日志记录到本地和远程磁盘之后,才会给客户端操作应答。主服务器可以每次刷新一批日志记录,以减少刷新和复制这个日志导致的系统吞吐量。

主服务器通过自己的日志记录进行自身文件系统状态的反演。为了减少启动时间,我们必须尽量减少操作日志的大小。主服务器在日志增长超过某一大小的时候,执行检查点动作,这样可以使下次启动的时候从本地硬盘读出这个最新的检查点,然后反演有限记录数。检查点是一个类似B-树的格式,可以直接映射到内存,而不需要额外的分析。这进一步加快了恢复的速度,提高了可用性。

对于主服务器的恢复,只需要最新的检查点以及后续的日志文件。旧的检查点及其日志文件可以删掉了,但我们还是要保存几个检查点以及日志文件,用来防止发生比较大的故障。

GFS是一个松散的一致性检查的模型,通过简单高效的实现来支持高度分布式计算的应用。下面详细讲解GFS的一致性模型。

文件名字空间的改变(如文件的创建)是原子操作,由主服务器来专门处理。名字空间的锁定保证了操作的原子性以及正确性,主服务器的操作日志定义了这些操作的全局顺序。

什么是文件区,文件区就是在文件中的一小块内容。

不论对文件进行何种操作,文件区所处的状态都包括三种:一般成功、并发成功和失败;表2.1列出了这些结果。当所有的客户端看到的都是相同的数据,并且与这些客户端从哪个数据的副本读取无关的时候,这个文件区是一致性的。当一个更改操作成功完成,而且没有并发写冲突时,那么受影响的区就是确定的(并且潜在一致性):所有客户端都可以看到这个变化是什么。并发成功操作使得文件区是不确定的,但是是一致性的:所有客户端都看到了相同的数据,但是并不能确定到底什么变化发生了。通常,这种变化由好多个变动混合片断组成。一个失败的改变会使得一个文件区不一致(因此也不确定):不同的用户可能在不同时间看到不同的数据。

如果表2.1中数据更改可能是写一个记录或者一个记录增加,那么写操作会导致一个应用指定的文件位置的数据写入动作。记录增加会导致数据(记录)增加,这个增加即使是在并发操作中也至少是一个原子操作,但是在并发记录增加中,GFS选择一个偏移量增加(与之对应的是,一个“普通”增加操作是类似写到当前文件最底部的一个操作)。我们把偏移量返回给客户端,并且标志包含这个记录的确定区域的开始位置。另外,GFS可以在这些记录之间增加填充,或者仅仅是记录的重复。这些确定区间之间的填充或者记录的重复是不一致的,并且通常是因为用户记录数据比较小造成的。

表2.1 文件区所处状态

在一系列成功的改动之后,改动后的文件区是确定的,并且包含了最后一个改动所写入的数据。GFS通过对所有的数据副本,按照相同顺序对块进行提交数据的改动来保证这样的一致性,并且采用块的版本号码控制机制来检查是否有过期的块改动,这种检查通常在主服务器宕机的情况下使用。

另外,由于客户端会缓存这个块的位置,因此可能会在信息刷新之前读到这个过期的数据副本。这个故障潜在发生的区间受块位置缓存的有效期限制,并且受到下次重新打开文件的限制,重新打开文件会把这个文件所有的块相关的缓存信息全部丢弃而重新设置。此外,由于多数文件只是追加数据,过期的数据副本通常返回一个较早的块尾部(也就是说这种模式下,过期的块返回的仅仅是这个块——它以为是最后一个块,其实不是),而不是返回一个过期的数据。

2.3.2 开源GFS——HDFS

Google的文件系统究竟是如何写的,我们不得而知。但是根据Google发表的论文以及GFS的相关资料,可以知道Apache下有一个开源实现——HDFS。这一小节,我们来介绍HDFS的架构与设计。HDFS的架构如图2.10所示。

图2.10 HDFS架构

根据GFS中主服务器/块服务器的设计,HDFS采用了主服务器/从属服务器架构。一个HDFS集群是由一个名称节点和一定数目的数据节点组成的。名称节点是一个中心服务器,负责管理文件系统的名称空间和客户端对文件的访问。数据节点在集群中一般是一个节点一个,负责管理节点上附带的存储。在内部,一个文件会分成一个或多个块,这些块存储在数据节点集合里。名称节点执行文件系统的名称空间操作,例如打开、关闭、重命名文件和目录,同时决定块到具体数据节点的映射。数据节点在名称节点的指挥下进行块的创建、删除和复制。名称节点和数据节点都被设计成可以运行在普通的廉价机器上。HDFS采用Java语言开发,因此可以部署在不同的操作系统平台上。一个典型的部署场景是一台机器运行一个单独的名称节点,集群中的其他机器各自运行一个数据节点实例。这个架构并不排除一台机器上运行多个数据节点,不过比较少见。

名称节点运行在单一节点上,大大简化了系统的架构。名称节点负责保管和管理所有的HDFS元数据,而用户与数据节点的通信不需要通过名称节点,也就是说文件数据直接在数据节点上读写。

HDFS支持传统的层次型文件组织,与大多数其他文件系统类似,用户可以创建目录,并在其中创建、删除、移动和重命名文件。名称节点维护文件系统的名称空间,任何对文件系统的名称空间和文件属性的修改都将被名称节点记录下来。用户可以设置HDFS保存的文件的副本数目,文件副本的数目称为文件的复制因子,这个信息也是由名称节点保存。

HDFS被设计成在一个大集群中可靠地存储海量文件的系统。它将每个文件存储成块序列,除了最后一个块,所有的块大小都是相同的。文件的所有块都会被复制。每个文件的块大小和复制因子都是可配置的。复制因子可以在文件创建的时候配置,而且以后也可以改变。HDFS中的文件是单用户写模式,并且严格要求在任何时候只能有一个用户写入。名称节点全权管理块的复制,它周期性地从集群中的每个数据节点接收心跳包和一个数据块报告(Blockreport)。心跳包的接收表示该数据节点正常工作,而数据块报告包括该数据节点上所有的块组成的列表。

名称节点存储HDFS的元数据。对于任何修改文件元数据的操作,名称节点都用一个名为Editlog的事务日志记录下来。例如,在HDFS中创建一个文件,名称节点就会在Editlog中插入一条记录来表示;同样,修改文件的复制因子也会在Editlog中插入一条记录。名称节点在本地OS的文件系统中存储这个Editlog。整个文件系统的名称空间,包括块到文件的映射、文件的属性,都存储在名为FsImage的文件中,这个文件也放在名称节点所在系统的文件系统中。

名称节点在内存中保存着整个文件系统的名称空间和文件块的映像。这个关键的元数据设计得很紧凑,一个带有4GB内存的名称节点足以支撑海量的文件和目录。当名称节点启动时,它从硬盘中读取Editlog和FsImage,将Editlog中的所有事务作用在内存中的FsImage,并将这个新版本的FsImage从内存中创新到硬盘上。这个过程称为检查点(checkpoint)。在当前实现中,检查点只在名称节点启动时发生。

所有的HDFS通信协议都是构建在TCP/IP协议上的。客户端通过一个可配置的端口连接到名称节点,并通过各个端协议组件(Client Protocol)与名称节点交互,而数据节点使用数据节点协议组件(Datanode Protocol)与名称节点交互。

使用HDFS的应用都是处理大数据集合的。这些应用都是写数据一次,而读是一次到多次,并且读的速度要满足流式读。HDFS支持文件的一次写入多次读取。一个典型的块大小是64MB,因而,文件总是按照64MB大小切分成块,每个块存储于不同的数据节点中。

客户端创建文件的请求其实并没有立即发给名称节点,事实上,HDFS客户端会将文件数据缓存到本地的一个临时文件。应用的写操作被透明地重定向到这个临时文件。当这个临时文件累积的数据超过一个块的大小(默认为64MB)时,客户端才会联系名称节点。名称节点将文件名插入文件系统的层次结构中,并且给它分配一个数据块,然后返回数据节点的标识符和目标数据块给客户端。客户端将本地临时文件刷新到指定的数据节点上。当文件关闭时,在临时文件中剩余的没有刷新的数据也会被传输到指定的数据节点,然后客户端告诉名称节点文件已经关闭,此时名称节点才将文件创建操作提交到持久存储。如果名称节点在文件关闭前挂了,则该文件将丢失。

当某个客户端向HDFS文件写数据的时候,一开始是写入本地临时文件,假设该文件的复制因子设置为3,那么客户端会从名称节点获取一张数据节点列表来存放副本。接着客户端开始向第一个数据节点传输数据,第一个数据节点一小部分一小部分(4KB)地接收数据,并将每个部分写入本地仓库,同时将该部分传输到第二个数据节点。第二个数据节点也是这样边收边传,一小部分一小部分地收,存储在本地仓库,同时传给第三个数据节点;第三个数据节点仅仅是接收并存储。这就是流水线式的复制。

HDFS给应用提供了多种访问方式,可以通过DFSShell命令行与HDFS数据进行交互,也可以通过Java API调用,还可以通过C语言的封装API访问,并且提供了浏览器访问的方式。

最后,通过一个简单的小例子,展示一下如何使用Java访问HDFS。

import java.io.InputStream;
import java.net.URL;
import org.apache.hadoop.fs.FsUrlStreamHandlerFactory;
import org.apache.hadoop.io.IOUtils;

public class DataReadByURL {
     static{
          URL.setURLStreamHandlerFactory(new FsUrlStreamHandlerFactory());
     }
     public static void main(String[] args) throws Exception{
          InputStream in = null;
          try{
                    in = new URL("hdfs://127.0.0.1:9000/data/mydata").openStream();
                    IOUtils.copyBytes(in,System.out,2048,false);
          }finally{
                    IOUtils.closeStream(in);
          }
     }
}

上面讨论了利用HDFS的URL方式读取HDFS内文件内容的方法,下面讨论如何使用HDFS中的API读取HDFS内的文件。

HDFS主要通过FileSystem类来完成对文件的打开操作。和Java使用java.io.File来表示文件不同,HDFS文件系统中的文件是通过Hadoop的Path类来表示的。

FileSystem通过静态方法get(Configuration conf)获得FileSystem的实例。通过FileSystem的open()、seek()等方法,可以实现对HDFS的访问,具体的方法如下:

public FSDataInputStream open(Path f) throws IOException
public abstract FSDataInputStream open(Path
     f, int bufferSize) throws IOException;

下面来看一个通过HDFS的API访问文件系统的例子。

import org.apache.hadoop.fs.*;
import org.apache.hadoop.conf.*;
import org.apache.hadoop.io.*;

public class HDFSCatWithAPI {
     public static void main(String[] args) throws Exception{
// 指定Configuration
Configuration conf = new Configuration();
//定义一个DataInputStream
FSDataInputStream in = null;
try{
//得到文件系统的实例
FileSystem fs = FileSystem.get(conf);
//通过FileSystem的open方法打开一个指定的文件
in = fs.open(new Path("hdfs://localhost:9000/user/myname/input/fixFontsPath.sh"));
//将InputStream中的内容通过IOUtils的copyBytes方法复制到System.out中
IOUtils.copyBytes(in,System.out,4096,false);
//seek到position 1
in.seek(1);
//执行一边复制一边输出工作
IOUtils.copyBytes(in,System.out,4096,false);
}finally{
IOUtils.closeStream(in);
}
}

}

输出如下:

#!/bin/sh

# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version
 ...(中间内容略去)
</map:sitemap>
EOF
!/bin/sh

# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version
 ...(中间内容略去)
</map:sitemap>
EOF