Hadoop基础知识笔记

学习笔记相关代码:https://github.com/hackeryang/Hadoop-Exercises

一、基础

1. Hadoop分布式文件系统( HDFS) 分布在集群内多台机器上。使用适度的复制,集群可以并行读取数据,进而提供很高的吞吐量。这样一组通用机器比一台高端服务器更加便宜。代码向数据迁移的理念被应用在Hadoop集群自身。这种理念符合Hadoop面向数据密集型处理的设计目标。要运行的程序代码在规模上比数据小几个数量级,更容易移动。此外,在网络上移动数据要比在其上加载代码更花时间。不如让数据不动,而将可执行代码移动到数据所在的机器上去通过采用分布式存储、迁移代码而非迁移数据, Hadoop在处理大数据集时避免了耗时的数据传输问题。Hadoop尽量在计算节点上存储数据,实现数据本地快速访问,数据本地化是Hadoop处理数据的核心

使用性能4倍于标准PC的机器,其成本将大大超过将同样的4台PC放在一个集群中。而Hadoop可以将多个普通机器组合起来变成一个总计算能力强大的集群,大大节省成本。添加更多的资源,对于Hadoop集群就是增加更多的机器。一个Hadoop集群的标配是十至数百台计算机。

2. 许多当前的应用所处理的数据类型并不能很好地适合关系型数据库的结构化模型。文本、图片和XML文件是最典型的例子。此外,大型数据集往往是非结构化或半结构化的。Hadoop使用键值对作为基本数据单元,可足够灵活地处理较少结构化的数据类型。Hadoop 中,数据的来源可以有任何形式,但最终会转化为键值对以供处理

在MapReduce中,实际的数据处理步骤是由自己指定的,也很类似SQL引擎的一个执行计划。SQL使用查询语句,而MapRcduce则使用脚本和代码。利用MapReduce可以用比SQL查询更为一般化的数据处理方式。例如,可以建立复杂的数据统计模型,或者改变图像数据的格式。而SQL就不能很好地适应这些任务。Hadoop中的MapReduce是最适合一次写入、多次读取的数据存储需求。在这方面它就像SQL世界中的数据仓库,但是RDBMS更适合持续更新的数据集,不适合大量数据更新的场合。MapReduce与RDBMS的比较如下所示(读时模式指在处理数据时才对数据进行解释,这种模式在提供灵活性的同时避免了RDBMS数据加载阶段带来的高开销):

MapReduce也是一个批量数据处理模型,它最大的优点是容易扩展到多个计算节点上处理数据。如果文件都存在一个中央存储服务器上,那么瓶颈就是该服务器的带宽。让更多的机器参与处理的办法不会一直有效,因为有时存储服务器的性能会跟不上。因此,需要将文档分开存放,使每台机器可以仅处理自己所存储的文档,从而消除单个中央存储服务器的瓶颈。因此,数据密集型分布式应用中存储和处理不得不紧密地绑定在一起。

有时候数据量过大,无法全放到内存里处理,必须修改程序以便在磁盘上存储该数据的散列表。这意味着将实现一个基于磁盘的散列表。但是汇总分布式计算的结果时,如果只有一台计算机汇总结果,单台计算机将成为瓶颈。为了使该汇总阶段以分布的方式运转,必须以某种方式将其分割到在多台计算机上,使之能够独立运行。例如,假设在第二阶段有26台计算机,让每台计算机上的wordCount只处理以特定字母开头的单词计数。

MapReduce程序的执行分为两个主要阶段,为mapping和reducing。每个阶段均定义为一个数据处理函数,分别称为mapper和reducer。mapping阶段,MapReduce获取输入数据并将数据单元装入mapper。在reducing阶段,reducer处理来自mapper的所有输出,井给出最终结果。简而言之,mapper意味着将输入进行过滤与转换,使reducer可以完成聚合。MapReduce使用列表和键值对作为其主要的数据原语。map和reduce函数必须遵循以下对健和值类型的约束:

(1)应用的输入必须组织为一个键值对的列表list(<k1v1>)用于处理多个文件的输入格式通常为list (<String filename, String file_content>)。用于处理日志文件这种大文件的输入格式为list(<Integer line_number , String log_event>)。

(2)含有键值对的列表被拆分,进而通过调用mappermap函数对每个单独的键值对<k1,v1>进行处理。对于单词统计,<string filename , String file_content>被输入mapper,而其中的filename被忽略。mapper可以输出一个<String word, Integer count>的列表。

(3)所有mapper的输出(在概念上)被聚合到一个包含<k2,v2>对的巨大列表中。所有共享相同k2的对被组织在一起形成一个新的键值对<k2,list(v2)>。回到单词统计的例子,一个文档的map输出的列表中可能出现三次< “foo”,1> ,而另一个文档的map输出列表可能出现两次<"foo ",1> 。reducer所看到的聚合的对为< " foo",list(l,l,l,l,l)>。在单词统计中,reducer的输出为< "foo ",5>。一个MapReduce的代码例子如下:

Mapper类:

package Temperature;

import java.io.IOException;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapred.MapReduceBase;
import org.apache.hadoop.mapreduce.Mapper;

public class MaxTemperatureMapper extends Mapper<LongWritable,Text,Text,IntWritable> {
    private static final int MISSING=9999;

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {  //输入是一个键值对,Context实例用于输出内容的写入
        String line=value.toString();  //将包含有一行输入的Text值转换为String类型
        String year=line.substring(15,19);
        int airTemperature;
        if(line.charAt(87)=='+'){
            //parseInt doesn't like leading plus signs
            airTemperature=Integer.parseInt(line.substring(88,92));
        }else{
            airTemperature=Integer.parseInt(line.substring(87,92));
        }
        String quality=line.substring(92,93);
        if(airTemperature!=MISSING && quality.matches("[01459]")){
            context.write(new Text(year),new IntWritable(airTemperature));
        }
    }
}

Reducer类:

package Temperature;

import java.io.IOException;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

public class MaxTemperatureReducer extends Reducer<Text,IntWritable,Text,IntWritable> {  //输入参数类型要匹配map()函数的输出类型
    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int maxValue=Integer.MIN_VALUE;
        for(IntWritable value:values){
            maxValue=Math.max(maxValue,value.get());
        }
        context.write(key,new IntWritable(maxValue));
    }
}

执行任务的主类:

package Temperature;

import java.io.IOException;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class MaxTemperatureWithCombiner {
    public static void main(String[] args) throws Exception{
        if(args.length!=2){
            System.err.println("Usage: MaxTemperature <input path> <output path>");
            System.exit(-1);
        }
        Job job=new Job();  //Job对象指定作业执行规范,用于控制整个作业的运行。
        job.setJarByClass(MaxTemperatureWithCombiner.class);  //不必明确指定jar文件的名称,在setJarByClass()方法中传递一个类即可,Hadoop利用这个类查找包含它的jar文件
        job.setJobName("Max temperature");

        FileInputFormat.addInputPath(job,new Path(args[0]));  //定义输入数据的路径,多路径输入可以多次调用该方法
        FileOutputFormat.setOutputPath(job,new Path(args[1]));  //指定输出路径,指定的是reduce()函数输出文件的写入目录。运行作业前输出目录不应该存在,否则会报错并拒绝运行作业
        job.setMapperClass(MaxTemperatureMapper.class);  //指定要使用的map类
        job.setCombinerClass(MaxTemperatureReducer.class);  //指定要使用的combiner类
        job.setReducerClass(MaxTemperatureReducer.class);  //指定要使用的reduce类

        job.setOutputKeyClass(Text.class);  //设置reduce()函数输出的键类型
        job.setOutputValueClass(IntWritable.class);  //设置reduce()函数输出的值类型

        System.exit(job.waitForCompletion(true)?0:1);  //提交作业并等待执行完成,具有一个唯一的标识用于指示是否已生成详细输出,标识为true时作业把其进度信息写到控制台,将true或false转换成程序退出代码0或1
    }
}

二、HDFS与MapReduce

3."运行Hadoop" 意味着在网络分布的不同服务器上运行一组守护进程(daemons)。这些守护进程有特殊的角色,一些仅存在于单个服务器上,一些则运行在多个服务器上。其中有:

(1)NameNode (名字节点) ;(2)DataNode (数据节点) ;(3)Secondary NameNode (次名字节点) 。

1)Hadoop在分布式计算与分布式存储中都采用了主/(masterlslave) 结构。分布式存储系统被称为Hadoop文件系统,或简称为HDFS。NameNode位于HDFS的主端,它指导从端的DataNode执行底层的IO任务。NameNodeHDFS的书记员,它跟踪文件如何被分割成文件块,而这些块又被哪些节点存储,以及分布式文件系统的整体运行状态是否正常。运行NameNode消耗大量的内存和IO资源。因此,为了减轻机器的负载,驻留NameNode的服务器通常不会存储用户数据或者执行MapReduce程序的计算任务。这意味着NameNode服务器不会同时是DataNode。不过NameNode的重要性也带来了一个负面影响:Hadoop集群的单点失效。对于任何其他的守护进程,如果它们所驻留的节点发生软件或硬件失效,Hadoop集群很可能还会继续平稳运行,不然还可以快速重启这个节点。但这样的方法井不适用于NameNode。

2)每个集群上的从节点都会驻留一个DataNode守护进程,来执行分布式文件系统的繁重工作,HDFS数据块读取或者写入到本地文件系统的实际文件中。当希望对HDFS文件进行读写时,文件被分割为多个块,由NameNode告知客户端每个数据块驻留在哪个DataNode。客户端直接与DataNode守护进程通信,来处理与数据块相对应的本地文件。然后, DataNode会与其他DataNode进行通信,复制这些数据块以实现冗余,如下所示:

3)Sccondary NameNode (SNN)是一个用于监测HDFS集群状态的辅助守护进程。像NameNode一样,每个集群有一个SNN,它通常也独占一台服务器,该服务器不会运行其他的DataNode守护进程。SNN与NameNode的不同在于它不接收或记录HDFS的任何实时变化。相反,它与NameNode通信,根据集群配置的时间间隔获取HDFS元数据的快照。如前所述, NameNode是Hadoop集群的单一故障点,而SNN 的快照可以有助于减少停机的时间并降低数据丢失的风险。然而,NameNode的失效处理需要人工的干预,即手动地配置集群,将SNN用作主要的NameNode

4.客户端及与之交互的HDFS、namenode和datanode之间的数据流过程如下图所示,显示了在读取文件时事件的发生顺序。

(1)客户端通过调用FileSystem对象的open()方法来打开希望读取的文件。对HDFS来说,这个对象是DistributedFileSystem的一个实例,DistributedFileSystem类返回一个FSDataInputStream对象(支持文件定位的输入流)给客户端以便读取该数据。

(2)DistributedFileSystem通过使用远程过程调用(RPC)来调用namenode,以确定文件起始块的位置。对于每一个块,namenode返回存有该块副本的datanode地址。这些datanode根据它们与客户端的拓扑距离来排序。

(3)客户端对这个FSDataInputStream输入流调用read()方法。存储文件起始几个块的datanode地址的DFSInputStream随即连接距离最近的文件中第一个块所在的datanode。

(4)通过对数据流反复调用read()方法,将数据从datanode传输到客户端。

(5)读取到块的末尾时,DFSInputStream关闭与该datanode的连接,然后寻找下一个块的最佳datanode。

(6)客户端从输入流中读取数据时,块是按照打开DFSInputStream与datanode新建连接的顺序读取的。一旦客户端完成读取,就对FSDataInputStream调用close()方法关闭输入流。

在读取数据的时候,如果DFSInputStream在与datanode通信时遇到错误,会尝试从这个块的另外一个最邻近datanode读取数据。它也会记住那个故障datanode,以保证以后不会再读取该节点上后续的块。DFSInputStream也会通过校验和确认从datanode发来的数据是否完整,如果发现有损坏的块,DFSInputStream会试着从其他datanode读取副本,也会将被损坏的块通知给namenode。

以上数据流读取设计的优点是,客户端可以直接连接到datanode读取数据,且namenode告知客户端每个块所在的最佳datanode。这种设计使HDFS扩展到大量的并发客户端。同时,namenode只需要响应块位置的请求,这些位置信息存储在内存中,十分高效,而无需响应客户端的数据请求,否则随着客户端数量增长,namenode会成为性能瓶颈。

而对于在HDFS中写入文件,可以用下图来说明步骤:

(1)客户端通过对DistributedFileSystem对象调用create()方法来新建文件。

(2)DistributedFileSystem对namenode创建一个RPC调用,在文件系统的命名空间中新建一个文件,此时文件中还没有相应的数据块。Namenode执行各种检查确保该文件不存在以及客户端有新建该文件的权限,检查通过后namenode就会为创建新文件添加一条记录。

(3)DistributedFileSystem向客户端返回一个FSDataOutputStream对象,这样客户端可以开始写入数据。客户端写入数据时,DFSOutputStream将它分成一个个数据包,并写入内部队列,称为“数据队列”(data queue)。

(4)DataStreamer处理数据队列,挑选出适合存储数据副本的一组datanode,并据此要求namenode分配新的数据块。这一组datanode构成一个管线(pipeline)来备份同一个数据块,DataStreamer将数据包流式传输到管线中第一个datanode,该datanode存储数据包并将它发送到管线中的第2个datanode,然后不断最后倒数第二个datanode存储数据副本后再发送给最后一个datanode。

(5)DFSOutputStream维护着一个内部数据包队列来等待datanode的收到确认信息,称为“确认队列”(ack queue)。收到管道中所有datanode确认信息后,该数据包条目才会从确认队列删除。

(6)客户端完成数据的写入后,对数据流调用close()方法。该操作将剩余的所有数据包写入datanode管线,并在联系到namenode告知其文件写入完成之前等待确认。

如果有datanode在写入期间发生故障,则执行以下操作:

(1)关闭管线,把确认队列中所有数据包都添加回数据队列的最前端,以确保故障节点下游的datanode不会漏掉任何一个数据包。

(2)为存储在另一个正常datanode的当前数据块指定一个新的标识,并将该标识传给namenode,以便故障datanode恢复后可以删除存储的部分数据块。

(3)从管线中删除故障datanode,基于两个正常datanode构建一跳新管线,余下的数据块写入管线中正常的datanode。Namenode注意到副本量不足时,会在另一个节点上创建一个新的副本。

HDFS的默认布局策略是在运行client的节点上放第一个数据副本(如果client上也运行datanode),如果client在集群之外没有运行datanode,则namenode随机选择一个节点,会避免选择存储太满或者太忙的节点;第二个副本放在与第一个节点处于同一机架上的随机节点上;第三个副本放在与第一、二个副本不同机架的另一个随机节点上,如下所示:

HDFS提供了一种强行将所有缓存刷新到datanode中的方法,即对FSDataOutputStream调用hflush()方法。当hflush()方法返回成功后,对所有新的reader而言,HDFS能保证文件中到目前为止写入的数据均到达所有datanode的写入管道并对所有新的reader均可见:

但是,hflush()不保证datanode已经将数据写到磁盘上,仅确保数据在datanode的内存中,因此,如果数据中心断电,数据会丢失。为确保数据写入到磁盘上,可以替代为hsync()。在HDFS中关闭文件其实隐含了执行hflush()方法。

5.为了让主节点登录到从节点,Hadoop使用了无口令的(passphraseless) SSH协议。SSH采用标准的公钥加密来生成一对用户验证密钥,包含一个公钥、一个私钥。公钥被本地存储在集群的每个节点上,私钥则由主节点在试图访问远端节点时发送过来。结合这两段信息,目标机可以对这次登录尝试进行验证。

集群登录更准确的描述应该是从一个节点的用户帐号到目标机上的另一个用户帐号。对于Hadoop,所有节点上的账号应该有相同的用户名,出于安全的考虑,建议把这个帐号设置为用户级别。它仅用于管理Hadoop集群。

6.单机模式是Hadoop的默认模式。当配置文件为空时,Hadoop会完全运行在本地。因为不需要与其他节点交互,单机模式就不使用HDFS,也不加载任何Hadoop的守护进程。该模式主要用干开发调试Map Reduce程序的应用逻辑,而不会与守护进程交互,避免引起额外的复杂性。

伪分布模式在"单节点集群" 上运行Hadoop。其中所有的守护进程都运行在同一台机器上。该模式在单机模式之上增加了代码调试功能,允许检查内存使用情况、HDFS输入输出,以及其他的守护进程交互。虽然所有的守护进程都运行在同一节点上,它们仍然像分布在集群中一样,彼此通过相同的SSH协议进行通信

7. HDFS是一种文件系统,专为MapReduce这类框架下的大规模分布式数据处理而设计。可以把一个大数据集(比如100 TB ) 在HDFS中存储为单个文件,感觉就像在处理单个文件一样。文件在HDFS底层被切分成文件块,这些块分散地存储在不同的DataNode上,每个块还可以复制几份存储在不同的DataNode上备份,而大多数其他的文件系统无力实现这一点。一个典型的Hadoop工作流会在别的地方生成数据文件(如日志文件)再将其复制到HDFS 中,接着由MapReduce程序处理这个数据,但它们通常不会直接读任何一个HDFS 文件。相反,它们依靠MapReduce框架来读取HDFS 文件,并将之解析为独立的记录(键值对),这些记录才是MapReduce程序所处理的数据单元。

MapReduce程序通过操作键值对来处理数据, 一般形式为:

map: (K1,V1)→List(K2,V2)

reduce: (K2,list(V2))→list(K3,V3)

流程图如下所示:

Mapper接口负责数据处理阶段。它采用的形式为Mapper<Kl,Vl,K2,V2>Java泛型,Mapper只有一个方法map ,用于处理一个单独的键/值对。MapReduce,顾名思义在map之后的主要数据流操作是reduce,如图3 -1底部所示。当reduce任务接收来自各个mapper的输出时,它按照键值对中的键对输入数据进行排序,并将相同键的值归并。然后调用reduce()函数,并通过迭代处理那些与指定键相关联的值,生成一个(可能为空的)列表(K3,V3)。

在map和reduce两个阶段之间还有一个极其重要的步骤:将mapper的结果输出给不同的reducer。这就是partitioner的工作。partitioner的通俗作用如下所示:

在map和reduce阶段之间,一个MapReduce应用必然从mapper任务得到输出结果,并把这些结果发布给reducer任务。该过程通常被称为洗牌(shuffle),因为在单节点上的mapper输出可能被送往分布在集群多个节点上的reducer。

8.MapReduce处理的基本原则之一是将输入数据分割成块。这些块可以在多台计算机上并行处理。在Hadoop的术语中,这些块被称为输入分片(Input Split)。每个分片应该足够小以实现更细粒度的并行。另一方面,每个分片也不能太小,否则启动与停止各个分片处理所需的开销将占去很大一部分执行时间。HDFS按块存储文件井分布在多台机器上。笼统而言,每个文件块为一个分片。由于不同的机器会存储不同的块,如果每个分片/块都由它所驻留的机器进行处理,就自动实现了并行。此外,由于HDFS在多个节点上复制数据块以实现可靠性,MapReduce可以选择任意一个包含分片/数据块副本的节点。Hadoop默认地将输入文件中的每一行视为一个记录,而键/值对分别为该行的字节偏移(key)和内容(value)。将文件化为分片时,在实际情况中,一个分片最终总是以一个文件块为大小,在HDFS中默认为128MB。最佳分片大小应该与块大小相同,因为它是确保可以存储在单个节点上的最大输入块的大小。如果分片跨越两个数据块,那么对于HDFS节点基本不可能同时存储这两个数据块,因此分片中的部分数据会通过网络传输到map任务运行的节点,效率更低。

通常而言,一个Map任务的运行时间在一分钟左右比较合适Reduce任务的数量应该是最大Reduce任务容量的0.95倍或是1.75倍。0.95倍时,如果一个Reduce任务失败,Hadoop可以很快找到一台空闲机器重新执行任务,当Reduce任务数量是容量的1.75倍时,执行速度快的机器可以获得更多的Reduce任务,可以使负载更加均衡,提高任务的处理速度。

Map任务将其输出写入本地硬盘,而非HDFS,因为map的输出是中间结果,由reduce任务处理后才产生最终输出结果,而且map任务完成后,该中间结果就可以删除,放在HDFS中备份并不值得。如果运行map任务的节点在将map中间结果传送给reduce任务之前失败,Hadoop将在另一个节点上重新运行这个map任务。Reduce的输出通常存储在HDFS中以实现可靠存储。对于reduce输出的每个HDFS块,第一个副本存储在本地节点上,其他副本处于可靠性存储在其他机架的节点上,因此,将reduce的输出写入HDFS确实需要占用网络带宽。

9.HBase是一个类似谷歌Bigtable的分布式数据库,这张表的索引是行关键字,列关键字和时间戳。同一张表中的每一行数据都可以有截然不同的列。HBase的写操作是锁行的。

10.HDFS的缺点如下:(1)不适合低延迟数据访问。它的设计初衷主要是为达到高的数据吞吐量而设计的,这会以高延迟为代价。这可以用HBase通过三层数据管理项目来尽可能弥补HDFS的不足。(2)无法高效存储大量小文件。(3)不支持多用户写入及任意修改文件。在HDFS的一个文件中只有一个写入者,而且写操作只能在文件末尾完成,即只能执行追加(append)操作

HDFS使用机架感知(rack-aware)策略来改进数据可靠性。大多数情况下,同一个机架内的带宽比不同机架两台机器间的带宽大。目前,HDFS将副本存放在不同机架上,副本系数是3,将一个副本存放在本地机架节点上,另一个副本放在另一机架的一个节点上,第三个副本放在与第二个副本相同机架的另外一个节点上,这种策略减少了机架间的数据传输,提高了写操作的效率。可以有效防止整个机架失效时数据丢失,并且允许读数据的时候充分利用多个机架带宽。但是,这种策略的写操作需要传输数据块到多个机架,这增加了写操作的成本

由于namenode将文件系统的元数据存储在内存中,因此该文件系统所能存储的文件总数受限于namenode的内存容量,根据经验,每个文件、目录和数据块的存储信息大约占150字节。namenode管理文件系统的命名空间,它维护着文件系统树及整棵树内所有的文件和目录。Datanode定期向namenode发送它们所存储的块的列表。如果没有namenode,文件系统将无法使用,如果运行namenode服务的机器损坏,文件系统上所有文件将会丢失,因此对namenode实现容错非常重要。

第一种容错机制就是备份文件系统元数据状态的文件,写入一个远程挂载的网络文件系统(NFS)。第二种方法是运行一个辅助namenode,定期合并编辑日志与命名空间镜像,以防止编辑日志过大。它会保存合并后的命名空间镜像的副本,并在namenode发生故障时启用。但是,辅助namenode保存的状态总是滞后于主namenode,所以在主namenode失效时,难免会丢失部分数据。这种情况下,一般把存储在NFS上的namenode元数据复制到辅助namenode并作为新的主namenode运行。

Namenode在内存中保存文件系统每个文件和数据块的引用关系,这意味着对于一个拥有大量文件的超大集群来说,内存将成为限制系统扩展的瓶颈。不过,联合HDFS允许系统通过添加namenode实现扩展,其中每个namenode管理文件系统命名空间中的一部分。在联合环境下,每个namenode维护一个命名空间分区(namespace volume),由命名空间元数据和一个数据块池(block pool)组成,数据块池包含该命名空间下文件的所有数据块。命名空间分区彼此独立,互相不能通信,其中一个namenode失效不会影响由其他namenode维护的命名空间可用性。而数据块池不进行切分,因此集群中的datanode需要注册到每个namenode,并存储来自多个数据块池中的数据块。

要从一个失效的namenode回复,系统管理员需要启动一个拥有文件系统元数据副本的新namenode,并配置datanode和客户端以便使用这个新的namenode,并配置datanode和客户端以便使用新namenode。在三个步骤之后,备份namenode才能重新提供服务:(1)将命名空间的镜像导入内存中;(2)重新编辑日志;(3)接收到足够多来自datanode的数据块报告并退出安全模式。对于一个拥有大量文件与数据块的集群,namenode的冷启动需要30分钟。

为了避免冷启动影响日常维护,Hadoop2增加了HDFS高可用性支持,设置了一对活动-备用(active-standby)namenode。当活动namenode失效,备用namenode会接管它的工作,不会有明显的中断感受。实现该功能有以下要求:(1)namenode之间需要通过共享存储共享编辑日志。(2)datanode需要同时向两个namenode发送数据块处理报告,因为数据块映射信息存储在namenode的内存中。(3)客户端需要特定机制处理namenode失效问题。(4)辅助namenode的角色被备用namenode所包含,备用namenode为活动的namenode命名空间设置周期性检查点。

共享存储有两种选择:(1)NFS过滤器或群体日志管理器(Quorum Journal Manager,QJM)。QJM以一组日志节点(journal node)的形式运行,每一次编辑必须写入多数日志节点,一般有三个。在故障切换与规避中,系统有一个称为故障转移控制器(failover controller)的新实体,管理将活动namenode转移为备用namenode的转换过程。每一个namenode运行着一个轻量级的故障转移控制器,工作就是通过心跳机制监控主namenode是否失效,并在失效时进行故障切换。同一时间QJM仅允许一个namenode向编辑日志中写入数据。

11.reduce()函数的输入参数类型必须匹配map()函数的输出类型。Job对象指定任务执行规范,不必明确指定jar文件的名称,在Job对象的setJarByClass()方法中传递一个类即可,Hadoop利用这个类查找包含它的jar文件。

Map函数的输入类型默认情况下和reduce函数是相同的,因此如果mapper产生出和reducer相同的类型时,不需要单独设置setMapOutputKeyClass()和setMapOutputValueClass()方法,否则需要通过这两个方法设置map函数的输出类型。

在mapper将输出发往reducer进行处理之前,中间还可以经过一个combiner函数,用于将mapper输出结果聚合,减少输入到reducer的值的数量。例如现在要求去年的最大温度值,几个mapper分别输出了各自节点上存储的去年的所有温度值,combiner先把每个mapper输出的温度值都求一个最大值,求出与mapper个数相同数量的几个最大值,再把这几个最大值传输到reducer求最终的温度最大值。这样可以避免传输大量数据到reducer,节省网络带宽,以免网速不够限制任务速度。但是combiner不能用于求平均等场合。

三、YARN

12.YARN(Yet Another Resource Negotiator)是Hadoop的集群资源管理系统,最初是为了改善MapReduce的实现,提供请求和使用集群资源的API,用户代码中用的是分布式计算框架提供的更高层API,这些API建立在YARN之上并向用户隐藏了资源管理细节,一些分布式计算应用例如MapReduce,Spark等作为YARN应用运行在集群计算层(YARN)和集群存储层(HDFS和HBase)上,如下所示:

还有一层应用,例如Pig,Hive和Crunch等是运行在Application层之上的处理框架,它们不直接和YARN打交道。YARN通过两类持续运行的守护进程提供自己的核心服务:(1)管理集群资源使用的资源管理器(resource manager);(2)运行在集群所有节点上且能够启动和监控容器(container)的节点管理器(node manager)。容器用于执行特定应用程序的进程。下图描述了YARN运行一个应用的过程:

(1)首先,客户端联系资源管理器,要求它运行一个appplication master进程。

(2)资源管理器找到一个能够在容器中启动application master的节点管理器。

(3)application master可能在所处的容器中简单地运行一个运算,并将结果返回给客户端,或是向资源管理器请求更多的容器。

(4)如果请求了更多的容器,则进行分布式运算。

从上图可以看到,YARN本身不会为应用各部分(客户端、master和进程)之间的通信提供任何手段,大多数重要的YARN应用使用例如Hadoop的RPC层的远程通信机制来向客户端传递状态更新和返回结果,但这些通信机制都专属于各应用。

当启动一个容器用于处理HDFS数据块(为了在MapReduce中运行一个map任务)时,应用会向以下几种节点申请容器:(1)存储该数据块三个副本的节点;(2)存储这些副本的机架中其他的某个节点。如果都申请失败,则申请集群中的任意节点。

在应用分类方面,MapReduce采取一个用户作业对应一个应用的方式,按照应用到用户运行的作业之间的映射关系对应用进行分类;Spark采用作业的每个工作流或每个用户对话对应一个应用的方式,这种方法比前一种效率更高,因为容器可以在作业之间重用,并且可以缓存作业之间的中间数据。

13.MapReduce和YARN的区别,以及MapReduce1各功能被YARN取代的关系如下所示:

MapReduce1中,两类守护进程控制着作业执行流程:一个jobtracker以及一个或多个tasktracker。jobtracker通过调度tasktracker上运行的任务来协调所有运行在系统上的作业。tasktracker在运行任务的同时将运行进度报告发送给jobtracker,jobtracker由此记录每项作业任务的整体进度情况。如果其中一个任务失败,jobtracker可以在另一个tasktracker节点上重新调度该任务。

在MapReduce1中,jobtracker同时负责作业调度(将任务与tasktracker匹配)和任务进度监控(跟踪任务、重启失败或迟缓的任务;记录任务流水,如维护计数器的计数)。相比之下,在YARN中这些职责由不同的实体负责,分别为资源管理器和application master(每个MapReduce作业一个)。jobtracker也负责存储已完成作业的作业历史,但是也可以运行一个历史服务器作为一个独立的守护进程取代jobtracker。在YARN中,与jobtracker记录历史作用等价的角色是时间轴服务器(timeline server),它主要用于存储应用历史。

14.YARN相对于MapReduce1的好处有以下几方面:

(1)可扩展性:YARN相比于MapReduce1可以在更大规模的集群上运行,当节点数达到4000,任务数达到40000时,MapReduce1的瓶颈来源于jobtracker必须同时管理作业和任务。YARN利用资源管理器和application master分离的架构特点克服了这个局限性,可以扩展到接近10000个节点和100000个任务。

(2)可用性:jobtracker内存中大量快速变化的复杂状态(例如,每个任务状态每几秒更新一次)使得改进jobtracker服务获得高可用性(High availability,HA)非常困难,即很难在服务守护进程失效时,将该守护进程的状态复制到另一个守护进程上继续提供服务。而YARN中jobtracker的职责在资源管理器和application master之间进行了划分,高可用性服务变为一个分而治之问题:先为资源管理器提供高可用性,再为YARN应用提供高可用性。

(3)利用率:MapReduce1中,每个tasktracker都配置有若干固定长度的slot,这些slot是静态分配的,在配置的时候就被划分为map slot和reduce slot。一个map slot仅能用于运行一个map任务,一个reduce slot仅能运行一个reduce任务。在YARN中,一个节点管理器管理一个资源池,而不是固定数目的slot。YARN上运行的MapReduce不会出现因为集群中只有map slot导致reduce任务只能等待的情况。而且,YARN中的资源是精细化管理的,一个应用能够按需请求资源,而不是请求一个不可变单位大小的slot,对有的任务slot太大浪费资源,对有的任务slot太小会导致失败

(4)多应用(Multitenancy)。YARN的最大优点在于向MapReduce以外的其他分布式应用,MapReduce只是YARN应用中的一个

15.YARN中有三种调度器可用:

(1)FIFO调度器(scheduler)。FIFO调度器将应用放置在一个队列中,按照先进先出的顺序运行应用。FIFO调度器的优点是,简单易懂,不需要任何配置,但是不适合共享集群。共享集群更适合使用容量调度器或公平调度器,而不会因为大应用在队列顶部导致下面的小应用一直等待无法运行。

(2)容量(capacity)调度器。一个独立的专门队列保证小作业一提交就可以启动,由于队列容量是为队列中的作业保留的,这种策略会以整个集群的利用率为代价。这意味着和FIFO调度器相比,大作业执行的时间要长

(3)公平(fair)调度器。使用该调度器时不需要预留一定量的资源,因为调度器会在所有运行的作业之间动态平衡资源。第一个大作业启动时,由于是唯一运行的作业,会获得集群中全部资源,当第二个小作业启动时,它被分配到集群的一半资源,不过第二个作业的启动到获得公平共享资源之间会有时间滞后,因为它必须等待第一个作业使用的容器用完并释放出资源;当小作业结束且不再申请资源后,大作业将回去再次使用全部集群资源。最终的效果是既得到了较高集群利用率,又能保证小作业及时完成。三种调度器的比较如下图:

16.容量调度器允许多个组织共享一个Hadoop集群,每个组织可以分配到全部集群资源的一部分,每个组织配有一个专门的队列,在一个队列内,使用FIFO调度策略对应用进行调度。但如上图所示,在这种调度器下,一般单个作业的资源不会超过队列容量。然而如果队列中有多个队列,并且队列资源不够用的时候,如果仍有可用的空闲集群资源,容量调度器可能会将空余的资源分配给队列中的作业,即使此时超出队列容量,称为弹性队列,这个可以通过yarn.scheduler.capacity.<queue-path>.user-limit-factor属性设置为大于1(默认值)来使一个作业使用超过其队列容量的资源。

正常操作时,容量调度器不会通过强行终止来抢占容器,因此如果一个队列一开始资源够用,随着需求增长资源开始不够用时,这个队列只能等着其他队列释放容器资源。缓解这种情况的方法是,为队列设置一个最大容量限制,这样每个队列就不会过多侵占其他队列的容量了,不过这样做会以牺牲队列弹性为代价。容量调度器的配置文件在<Hadoop安装目录>/etc/hadoop目录下。

17.公平调度器的使用由属性yarn.resourcemanager.scheduler.class的设置所决定。在原生Hadoop中默认使用容量调度器,在一些Hadoop发行版例如CDH中默认使用公平调度器。在原生Hadoop中如果要切换成公平调度器,需要将yarn-site.xml文件中的yarn.resourcemanager.scheduler.class设置为公平调度器的完整名字:org.apache.hadoop.yarn.server.resourcemanager.scheduler.fair.FairScheduler。在CDH中,依然可以通过capacity-scheduler.xml来配置(也可以通过设置属性yarn.scheduler.fair.allocation.file来指定某个路径下的配置文件名),如下所示:

没有进行公平调度器的配置的时候,默认工作策略是每个应用放置在一个以用户名命名的队列中,队列是在用户提交第一个应用时动态创建的。所有的队列都是root的子队列。每个队列(queue)都可以有不同的调度策略。队列的默认调度策略可以通过XML配置文件顶层元素的<defaultQueueSchedulingPolicy>标签进行设置,如果省略,默认使用公平调度,不过公平调度器支持在某个队列设置FIFO策略,以及Dominant Resource Fairness(DRF)策略。

在配置文件中,每个队列都可以有不同的调度策略,可以在某个<queue>标签下指定<schedulingPolicy>标签来配置。每个队列刻配置最大和最小资源数量,及最大可运行的应用数量。如果两个队列的资源都低于它们的公平共享额度,那么远低于最小资源数量的那个队列优先被分配资源。

<queuePlacementPolicy>标签包含一个规则列表,每条规则会被依次尝试直到匹配成功。Specified表示把应用放进指明的队列中,如果未指明,或指明的队列不存在,则规则不匹配继续尝试下一条规则,且不创建队列。primaryGroup规则会尝试把应用放在用户的组名命名的队列中,如果没有这样的队列则继续尝试下一个规则且不创建队列。Default是全没匹配时的最后规则,前面指定的规则都没匹配的时候就启用这条规则。除非明确定义队列,否则必要时会以用户名为队列名创建队列

18.在一个繁忙的集群中,当作业被提交给一个空队列时,作业不会立刻启动,直到集群上已经运行的作业释放了资源。为了使作业从提交到执行所需的时间可预测,公平调度器支持抢占(preemption)功能,即允许调度器终止那些占用资源超过了其公平共享份额的队列所在容器(线程),这些容器资源释放后可以分配给资源数量低于应得份额的队列。但是抢占功能会降低整个集群的效率,因为被终止的容器需要重新执行。

将yarn.scheduler.fair.preemption属性设置为true,可以启用抢占功能。两个相关的抢占超时设置分别为(1)最小共享(minimum share preemption timeout);(2)公平共享(fair share preemption timeout),两者设定时间均为秒级。默认两个超时参数都未设置,为了允许抢占容器需要至少设置其中一个参数。如果队列在minimum share preemption timeout指定时间内未获得被承诺的最小共享资源,调度器就会抢占其他容器。可以通过配置文件最上方的<defaultMinSharePreemptionTimeout>标签为所有队列设置默认的超时时间,还可以设置每个队列的<minSharePreemptionTimeout>标签为单个队列指定超时时间。同样,如果队列在fair share preemption timeout指定时间内获得的资源仍然低于其公平共享资源的一半,调度器会抢占其他容器。

19.在一个繁忙的集群上,如果一个应用请求某个节点,极有可能此时有其他容器正在该节点运行,此时如果等待一小段时间(不超过几秒),可以神奇地增加在所请求的节点上分配到一个容器的机会,从而可以提高集群的效率,这个特性称为延迟调度(delay scheduling,Application Master给Resource Manager提交资源申请的时候,会同时发送本地申请,机架申请和任意申请。然后,RM的匹配这些资源申请的时候,会先匹配本地申请,再匹配机架申请,最后才匹配任意申请。而延迟调度机制,就是调度器在匹配本地申请失败的时候,匹配机架申请或者任意申请成功的时候,允许略过这次的资源分配,直到达到延迟调度次数上限)。容量调度器和公平调度器都支持延迟调度。YARN中的每个节点管理器周期性地(默认一秒)向资源管理器发送心跳请求,心跳请求中携带了节点管理器中正运行的容器、新容器可用的资源等信息。这样对于一个想要运行一个容器的应用而言,每个心跳就是一个潜在的调度机会。

当使用延迟调度时,调度器会等待设定的最大数目的调度机会发生,然后才放松本地性限制,即在同一机架其他节点中分配一个容器。对于容量调度器,可以通过设置yarn.scheduler.capacity.node-locality-delay来配置延迟调度。设置为正整数表示调度器在放松节点限制、改为匹配同一机架上其他节点前,准备错过的调度机会数量。公平调度器使用集群规模的比例表示同样的意思。例如将yarn.scheduler.fair.locality.threshold.node设置为0.5,表示调度器在接受同一机架中其他节点之前,将一直等待直到集群中一半未保存对应文件块的节点(即3个存有文件块的datanode以外)已经通过Resource Manager给过调度机会(各节点发送心跳包给Resource Manager,心跳包中包含该节点剩余container信息,RM再发送container分配信息给对应AM)。

四、压缩

20.监测数据是否损坏的常见措施是,在数据第一次写入系统时计算校验和并在数据通过一个不可靠的通道进行传输时再次计算校验和。如果传输后计算的新校验和与原来写入时的校验和不匹配,就可以认为数据损坏。常见的错误检测码是CRC-32(32为循环冗余校验)。HDFS使用的校验和检测码是CRC-32C,会对写入的所有数据计算校验和,并在读取数据时验证校验和。dfs.bytes-per-checksum属性指定每多少字节计算一次校验和,默认情况为512字节。

datanode负责在收到数据后,存储数据与校验和之前对数据进行验证,在收到客户端的数据或复制其他datanode的数据时执行此操作。写入数据的客户端将数据及其校验和发送到一系列datanode组成的管线中,管线中最后一个datanode负责验证校验和。客户端从datanode读取数据时,也会验证校验和,将校验和与datanode中存储的校验和进行比较。每个datanode保存一个用于验证的校验和日志,因此可以得知每个数据块最后一次验证时间。客户端成功验证一个数据块后,会告诉该datanode更新日志。不只是客户端在读取数据块时会验证校验和,每个datanode也会在一个后台线程中运行一个DataBlockScanner,以定期验证存储在该datanode上的所有数据块,有助于解决硬盘的位损坏。

由于HDFS存储每个数据块的副本,因此可以通过数据副本来修复损坏数据块。基本思路为,客户端在读取数据块时,如果检测到错误,先向namenode报告已损坏的数据块以及正操作的这个datanode,再抛出ChecksumExcepiton异常。namenode将该数据副本标记为损坏,这样它不再将客户端请求发到这个datanode,或将该副本复制到另一个datanode。然后,namenode安排让该数据快的一个副本复制到出问题的datanode上。

21.Hadoop的LocalFileSystem类执行客户端的校验和验证。在写入一个名为filename的文件时,文件系统客户端会在包含各文件块校验和的目录下新建filename.crc隐藏文件,文件块的大小由属性file.bytes-per-checksum控制,默认为512字节。文件块的大小作为元数据存储在.crc文件中,因此即使文件块大小设置已经变化,依然可以正确读回文件。读取文件时验证校验和,如果检测到错误会报ChecksumException异常。

在底层文件系统本身支持校验和的时候,可以禁用Hadoop的校验和计算,可以使用RawLocalFileSystem类代替LocalFileSystem。要在一个应用中实现全局校验和验证,需要将fs.file.impl属性设置为org.apache.hadoop.fs.RawLocalFileSystem从而实现对文件URI重新映射。

22.文件压缩的好处:减少存储文件所需磁盘空间,并加速数据在网络和磁盘上的传输。这两大好处在处理海量数据时非常重要,与Hadoop结合使用的常见压缩方法如下所示:

压缩算法需要权衡空间和时间,压缩和解压缩速度越快,节省的空间越少。bzip2压缩能力强于gzip,但压缩速度更慢一点。bzip2的解压速度比压缩速度快,但仍比其他压缩格式慢一点。LZO、LZ4和Snappy均优化压缩速度,比gzip快一个数量级,但压缩效率稍逊一筹。Snappy和LZ4的解压缩速度比LZO高出很多。支持“切分(splitable)”表示可以搜索数据流的任意位置并进一步往下读取数据,可切分压缩格式尤其适合MapReduce

对于Hadoop来说,效率最高的两种压缩方式如下:(1)使用容器文件格式,例如顺序文件、Avro数据文件、ORCFiles或者Parquet文件,所有这些文件格式同时支持压缩和切分,通常最好与一个快速压缩工具配合使用,例如LZO,LZ4,或者Snappy。(2)使用支持切分的压缩格式,例如bzip2,尽管bzip2非常慢。或者使用通过索引实现切分的压缩格式,例如LZO。

23.Codec是压缩-解压算法的一种实现,在Hadoop中,一个对CompressionCodec接口的实现代表一个codec。例如GzipCodec包装了gzip的压缩和解压算法。下表列举了Hadoop实现的codec:

CompressionCodec包含两个函数,可以用于压缩和解压。如果要对输出数据流的数据进行压缩,可以用createOutputStream(OutputStream out)方法在底层数据流中对尚未压缩的数据新建一个CompressionOutputStream对象;同样,对输入数据流中读取的数据进行解压时,调用createInputStream(InputStream in)获取CompressionInputStream,通过该方法从底层数据流中读取解压后的数据。下方代码显示了如何用API压缩从标准输入中读取的数据并将其写到标准输出:

package HadoopIO;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.compress.CompressionCodec;
import org.apache.hadoop.io.compress.CompressionOutputStream;
import org.apache.hadoop.util.ReflectionUtils;

public class StreamCompressor {  //压缩从标准输入中读取的数据并将其写到标准输出
    public static void main(String[] args) throws Exception{
        String codecClassname=args[0];
        Class<?> codecClass=Class.forName(codecClassname);  //通过类名字符串获得类对象,用于装载类,要求JVM查找指定的类,并将类加载到内存中,JVM会执行该类的静态代码段
        Configuration conf=new Configuration();
        CompressionCodec codec=(CompressionCodec) ReflectionUtils.newInstance(codecClass,conf);  //使用ReflectionUtils新建codec实例

        CompressionOutputStream out=codec.createOutputStream(System.out);  //在底层数据流中对尚未压缩的数据新建一个CompressionOutputStream对象
        IOUtils.copyBytes(System.in,out,4096,false);  //从输入流复制数据,从输出流写入复制的数据,复制缓冲区大小为4096字节,复制结束后不关闭数据流,输出由CompressionOutputStream对象压缩
        out.finish();  //要求压缩方法完成压缩数据流的写操作,但不关闭数据流
    }
}

CompressionCodecFactory类提供了getCodec()方法,可以将文件的Path对象作为输入参数,根据文件的后缀名判断需要用哪种解压方法的codec,如下所示:

package HadoopIO;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.compress.CompressionCodec;
import org.apache.hadoop.io.compress.CompressionCodecFactory;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;

public class FileDecompressor {  //通过文件后缀名推断需要使用哪种解压codec
    public static void main(String[] args) throws Exception{
        String uri=args[0];
        Configuration conf=new Configuration();
        FileSystem fs= FileSystem.get(URI.create(uri),conf);

        Path inputPath=new Path(uri);
        CompressionCodecFactory factory=new CompressionCodecFactory(conf);  //CompressionCodecFactory提供一种将文件后缀名映射到一个CompressionCodec的方法
        CompressionCodec codec=factory.getCodec(inputPath);  //获取文件路径中的后缀名
        if(codec==null){
            System.err.println("No codec found for "+uri);
            System.exit(1);
        }

        String outputUri=CompressionCodecFactory.removeSuffix(uri,codec.getDefaultExtension());  //一旦找到对应的解压codec,就去除压缩文件后缀名形成输出文件名,getDefaultExtension()用于获得压缩文件的后缀名,例如“.bz2”

        InputStream in=null;
        OutputStream out=null;
        try{
            in=codec.createInputStream(fs.open(inputPath));
            out=fs.create(new Path(outputUri));
            IOUtils.copyBytes(in,out,conf);
        }finally{
            IOUtils.closeStream(in);
            IOUtils.closeStream(out);
        }
    }
}

为了提高性能,最好使用原生(native)类库来实现压缩和解压缩。例如与内置Java实现相比,使用原生gzip类库可以减少约一半的解压缩时间和约10%的压缩时间。如果使用的是原生代码库并且需要在应用中执行大量压缩和解压缩操作,可以考虑使用CodecPool,它支持反复使用压缩和解压,以分摊创建这些对象的开销,代码如下所示:

package HadoopIO;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.compress.CodecPool;
import org.apache.hadoop.io.compress.CompressionCodec;
import org.apache.hadoop.io.compress.CompressionOutputStream;
import org.apache.hadoop.io.compress.Compressor;
import org.apache.hadoop.util.ReflectionUtils;

//使用压缩池对从标准输入读取的数据进行压缩,然后将其写到标准输出
public class PooledStreamCompressor {  //使用CodecPool支持反复压缩和解压缩,适用于在应用中执行大量压缩和解压缩操作的场合,可以分摊创建这些对象的开销
    public static void main(String[] args) throws Exception{
        String codecClassname=args[0];
        Class<?> codecClass=Class.forName(codecClassname);  //通过类名字符串获得类对象,用于装载类,要求JVM查找指定的类,并将类加载到内存中,JVM会执行该类的静态代码段
        Configuration conf=new Configuration();
        CompressionCodec codec=(CompressionCodec) ReflectionUtils.newInstance(codecClass,conf);  //使用ReflectionUtils新建codec实例
        Compressor compressor=null;
        try{
            compressor= CodecPool.getCompressor(codec);
            CompressionOutputStream out=codec.createOutputStream(System.out,compressor);  //对于指定的CompressionCodec,从压缩池中获取一个Compressor实例
            IOUtils.copyBytes(System.in,out,4096,false);  //从输入流复制数据,从输出流写入复制的数据,复制缓冲区大小为4096字节,复制结束后不关闭数据流,输出由CompressionOutputStream对象压缩
            out.finish();  //要求压缩方法完成压缩数据流的写操作,但不关闭数据流
        }finally{
            CodecPool.returnCompressor(compressor);  //确保即使出现IOException异常,也可以使compressor可以返回压缩池中
        }
    }
}

24.输入文件是压缩格式的情况下,根据文件后缀名推断出相应的codec之后,MapReduce会在读取文件时自动解压文件。要想压缩MapReduce作业的输出,需要在mapred-site.xml中将mapreduce.output.fileoutputformat.compress属性设为true,将mapreduce.output.fileoutputformat.compress.codec属性设置为打算使用的压缩codec类名。另一种方法是在代码中设置FileOutputFormat,如下所示:

package HadoopIO;

import Temperature.MaxTemperatureMapper;
import Temperature.MaxTemperatureReducer;
import Temperature.MaxTemperatureWithCombiner;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.compress.GzipCodec;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class MaxTemperatureWithCompression {
    public static void main(String[] args) throws Exception {
        if(args.length!=2){
            System.err.println("Usage: MaxTemperatureWithCompression <input path> "+"<output path>");
            System.exit(-1);
        }

        Job job=new Job();  //Job对象指定作业执行规范,用于控制整个作业的运行
        job.setJarByClass(MaxTemperatureWithCombiner.class);  //不必明确指定jar文件的名称,在setJarByClass()方法中传递一个类即可,Hadoop利用这个类查找包含它的jar文件
        FileInputFormat.addInputPath(job,new Path(args[0]));  //定义输入数据的路径,多路径输入可以多次调用该方法
        FileOutputFormat.setOutputPath(job,new Path(args[1]));  //指定输出路径,指定的是reduce()函数输出文件的写入目录。运行作业前输出目录不应该存在,否则会报错并拒绝运行作业

        job.setOutputKeyClass(Text.class);  //设置reduce()函数输出的键类型
        job.setOutputValueClass(IntWritable.class);  //设置reduce()函数输出的值类型

        FileOutputFormat.setCompressOutput(job,true);  //代替mapred-site.xml中的设置
        FileOutputFormat.setOutputCompressorClass(job, GzipCodec.class);

        job.setMapperClass(MaxTemperatureMapper.class);  //指定要使用的map类
        job.setCombinerClass(MaxTemperatureReducer.class);  //指定要使用的combiner类
        job.setReducerClass(MaxTemperatureReducer.class);  //指定要使用的reduce类

        System.exit(job.waitForCompletion(true)?0:1);  //提交作业并等待执行完成,具有一个唯一的标识用于指示是否已生成详细输出,标识为true时作业把其进度信息写到控制台,将true或false转换成程序退出代码0或1
    }
}

如果为输出生成顺序文件(sequence file),可以设置mapreduce.output.fileoutputformat.compress.type属性来控制使用压缩的方式,默认为RECORD,即针对每条记录进行压缩。如果改为BLOCK,将针对一组记录进行压缩,这是推荐的压缩策略,因为压缩效率更高。SequenceFileOutputFormat类也有一个静态方法putCompressionType()可以方便地设置该属性。下表归纳了用于设置MapReduce作业输出压缩格式的配置属性:

虽然MapReduce应用读写的是未经压缩的数据,但如果对map阶段的中间输入通过LZO、LZ4或者Snappy等快速压缩方式进行压缩,可以获得性能提升,因为map任务输出需要写到磁盘并通过网络传输到reduce节点,这样需要传输的数据减少了。下面是在作业中启用map任务输出gzip压缩格式的代码:

五、序列化

25.在Hadoop中,系统中多个节点上进程间的通信是通过远程过程调用(Remote Procedure Call,RPC)实现的。RPC协议将消息序列化成二进制流后发送到远程节点,远程节点再将二进制流反序列化为原始信息Hadoop使用自己的序列化格式Writable,它紧凑速度快,但不容易用Java以外的语言进行扩展或使用。Writable接口定义了两个方法:一个将其状态写入DataOutput二进制流,一个从DataInput二进制流读取状态:

Hadoop使用IntWritable封装Java int类型。IntWritable实现原始的WritableComparable接口,这个接口继承自Writable和java.lang.Comparable接口:

对MapReduce来说,类型比较很重要,因为中间有个基于键的排序阶段。WritableComparator是对继承自WritableComparable类的RawComparator类的一个通用实现,RawComparator接口继承自Java Comparator,如下所示:

该接口允许其实现直接比较数据流中的记录,无需先把数据流反序列化为对象,避免了新建对象的额外开销。例如根据IntWritable接口实现的comparator实现原始compare()方法,该方法可以从每个字节数组b1和b2中读取给定起始位置s1和s2以及长度l1和l2的一个整数进而直接进行比较。

WritableComparator提供两个主要功能。(1)提供对原始compare()方法的一个默认实现,该方法能够反序列化将在流中进行比较的对象,并调用对象的compare()方法。(2)充当RawComparator实例的实现(已注册Writable的实现)。例如,为了获得IntWritable的comparator,可以如下调用:

这个comparator可以用于比较两个IntWritable对象:

或其序列化表示:

26.定长格式编码(如IntWritable,占4字节)很适合数值在整个值域空间中分布非常均匀的情况,例如精心设计的哈希函数。然而,大多数数值变量的分布都不均匀,一般变长格式(如VintWritable,占1~5字节,第一个字节表示正负)会更节省空间。变长编码的另一个优点是可以在VintWritable和VlongWritable转换,因为它们的编码实际上是一致的。

27.Java的String与Hadoop的Text虽然都表示字符串但是并不相同,不同点在于:

(1)String的长度是所含char的个数,但Text对象的长度却是包含的每个字符其UTF-8编码的字节数,对Text类的索引是根据字节编码后字节序列中的位置实现的,并非字符的个数位置。例如一个字符串“XXXXX”,String的字符长度为5,但是Text对象的长度会大于5,因为其中每个字符也许有英文、汉字或数字等,进行编码后字节数可能各不相同,对于第4个字符的索引,String是indexOf(3),但Text可能为find(6)。

正因为Text类中的字符串对每个字符的索引不能用每次加1来计算,迭代Text类的字符串比String复杂:将Text对象转换为java.nio.ByteBuffer对象,然后利用缓冲区对Text对象反复调用bytesToCodePoint()静态方法,该方法可以获取下一个字符的位置,并返回相应的int值,然后再从缓冲区读取下一个字符。当bytesToCodePoint()返回-1时表示遍历到末尾:

package HadoopIO;

import org.apache.hadoop.io.Text;

import java.nio.ByteBuffer;

public class TextIterator {  //遍历Text对象中的字符
    public static void main(String[] args){
        Text t=new Text("\u0041\u00DF\u6771\uD801\uDC00");

        ByteBuffer buf=ByteBuffer.wrap(t.getBytes(),0,t.getLength());  //将Text字符串放入缓冲区
        int cp;
        while(buf.hasRemaining() && (cp=Text.bytesToCodePoint(buf))!=-1){  //当缓冲区有数据且未遍历到最后一个字符时,通过bytesToCodePoint()方法获取下一个字符的位置,返回相应int值
            System.out.println(Integer.toHexString(cp));
        }
    }
}

(2)与String相比,Text对象实例的值是可变的,字符串实例可以被直接设置为另一个值并且依然使用同一个Text对象,而不是像String一样,看起来是赋值,其实是重新分配了一个String对象给新字符串值,并将引用指向新String对象:

28.BytesWritable是对二进制数组的封装,它的序列化格式为一个数据所含字节数量(占4字节),后接数据本身。例如,长度为2的字节数组包含3和5,序列化为一个4字节的整数00000002和该数组中的两个字节03和05,变为000000020305。NullWritable是Writable的特殊类型,它的序列化长度为0。它不从数据流中读写数据,而是充当占位符,例如在MapReduce中,如果不需要使用键或值的序列化地址,就可以将键或值声明为NullWritable,这样可以高效存储常量空值。可以通过NullWritable.get()方法获取到实例。

ObjectWritable是对Java基本类型(String,enum,Writable,null或这些类型组成的数组)的一个通用封装,在Hadoop RPC中用于对方法参数和返回类型进行封装和解封装。当一个字段中包含多个类型时,ObjectWritable很有用,例如SequenceFile的值中包含多个类型,可以将值类型声明为ObjectWritable,并将每个类型封装在一个ObjectWritable中。然而,如果需要封装的类型数量比较少并且提前知道具体是什么类型,那么每次序列化都写通用封装类型的名称会非常浪费空间,可以使用静态类型数组,并对序列化后类型的引用加入位置索引提高性能,就是GenericWritable

29.ArrayWritable和TwoDArrayWritable是对Writable的数组和二维数组的实现。这两者中所有元素必须是同一类的实例,如:

ArrayWritable writable=new ArrayWritable(Text.class);

MapWritable和SortedMapWritable分别实现了java.util.Map<Writable,Writable>和java.util.SortedMap<WritableComparable,Writable>。可以使用MapWritable类型,或在需要排序集合的时候使用SortedMapWritable类型来枚举集合中的元素,对集合的枚举类型可以使用EnumSetWritable。对于单类型的Writable列表,使用ArrayWritable足够,但如果需要把不同Writable类型存储在单个列表中,可以用GenericWritable将元素封装在一个ArrayWritable,或者可以借鉴MapWritable写一个通用的ListWritable。

30.定制一个自己的Writable实现的例子如下所示,用于表示一对字符串,同时为了提高速度,可以实现一个RawComparator,将原来先通过readFields()将数据流反序列化为对象,再通过compareTo方法比较,变为直接比较两个TextPair对象的序列化表示如下所示:

package HadoopIO;

import java.io.*;

import org.apache.hadoop.io.*;

public class TextPair implements WritableComparable<TextPair> {  //定制一个新的存储一对Text对象的Writable实现
    private Text first;
    private Text second;

    public TextPair(){
        set(new Text(),new Text());
    }

    public TextPair(String first,String second){
        set(new Text(first),new Text(second));
    }
    public TextPair(Text first,Text second){
        set(first,second);
    }

    public void set(Text first,Text second){
        this.first=first;
        this.second=second;
    }

    public Text getFirst(){
        return first;
    }
    public Text getSecond(){
        return second;
    }

    public void write(DataOutput out) throws IOException {  //将每个Text对象序列化到输出流中,因为继承了WritableComparable所以必须实现该方法
        first.write(out);
        second.write(out);
    }

    public void readFields(DataInput in) throws IOException {  //查看各个字段的值,对来自输入流的字节进行反序列化,因为继承了WritableComparable所以必须实现该方法
        first.readFields(in);
        second.readFields(in);
    }

    @Override
    public int hashCode() {  //MapReduce中的默认分区类HashPartitioner通常用hashCode()方法选择reduce分区,需要确保有个较好的哈希函数来保证每个reduce分区的大小相似
        return first.hashCode()*163+second.hashCode();
    }

    @Override
    public boolean equals(Object o) {
        if(o instanceof TextPair){
            TextPair tp=(TextPair)o;
            return first.equals(tp.first) && second.equals(tp.second);
        }
        return false;
    }

    @Override
    public String toString() {  //即使结合使用TextOutputFormat和定制的Writable,也需要自己重写toString()方法,TextOutputFormat对键和值调用toString()方法
        return first+"\t"+second;
    }

    public int compareTo(TextPair tp) {  //如果第一个字符相同,则按照第二个字符排序,因为继承了WritableComparable所以必须实现该方法
        int cmp=first.compareTo(tp.first);
        if(cmp!=0){
            return cmp;
        }
        return second.compareTo(tp.second);
    }

    public static class Comparator extends WritableComparator{  //前面的代码是先通过readFields()将数据流反序列化为对象,再通过compareTo方法比较,这里变为直接比较两个TextPair对象的序列化表示,提高速度
        private static final Text.Comparator TEXT_COMPARATOR=new Text.Comparator();
        public Comparator(){
            super(TextPair.class);
        }

        @Override
        public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) {
            try{
                int firstL1=WritableUtils.decodeVIntSize(b1[s1])+readVInt(b1,s1);  //计算字节流中第一个TextPair对象中第一个Text字段的长度,由ByteWritable字节数组开头表示字符个数的字节长度加上实际字符的个数组成,具体原因可看BytesWritable的说明
                int firstL2=WritableUtils.decodeVIntSize(b2[s2])+readVInt(b2,s2);  //计算字节流中第二个TextPair对象中第一个Text字段的长度,由ByteWritable字节数组开头表示字符个数的字节长度加上实际字符的个数组成,具体原因可看BytesWritable的说明
                int cmp=TEXT_COMPARATOR.compare(b1,s1,firstL1,b2,s2,firstL2);  //比较两个TextPair对象的第一个Text对象
                if(cmp!=0){
                    return cmp;
                }
                return TEXT_COMPARATOR.compare(b1,s1+firstL1,l1-firstL1,b2,s2+firstL2,l2-firstL2);  //如果两个TextPair对象的第一个Text对象比较结果相同,则比较两者的第二个Text对象
            }catch(IOException e){
                throw new IllegalArgumentException(e);
            }
        }
    }

    static{  //调用静态方法define()将Comparator注册到WritableComparator的comparators成员中,comparators是HashMap类型而且是static的,相当于告诉WritableComparator,当使用WritableComparator.get(TextPair.class)方法时,要返回自己注册的这个Comparator,然后就可以用comparator.compare()来进行比较,而不需要将要比较的字节流反序列化为对象,节省创建对象的所有开销
        WritableComparator.define(TextPair.class,new Comparator());
    }
}

六、文件格式数据结构

31.纯文本不适合记录二进制类型的数据,而Hadoop的SequenceFile类很合适,作为日志文件的存储格式时,可以自己选择键和值的类型。同时SequenceFile也可以作为小文件的容器,HDFS和MapReduce是针对大文件优化的,所以通过SequenceFile类型将多个小文件包装起来,打包成一个SequenceFile类,可以获得更高效率的存储和处理。通过createWriter()静态方法可以创建SequenceFile对象,并返回SequenceFile.Writer实例,该静态方法有多个重载版本,但都需要指定待写入的数据流(FSDataOutputStream或FileSystem和Path对象)、Configuration对象以及键和值的类型。可选参数包括压缩类型及其codec,Progressable回调函数用于通知写入的进度,以及在SequenceFile头文件中存储的Metadata实例。

存储在SequenceFile中的键和值不一定需要是Writable类型,只要能被Serialization序列化和反序列化,任何类型都可以。以下例子为将键值对写入一个SequenceFile:

package HadoopIO;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.SequenceFile;
import org.apache.hadoop.io.Text;

import java.io.IOException;
import java.net.URI;

public class SequenceFileWriteDemo {  //将键值对写入一个SequenceFile对象
    private static final String[] DATA={
            "One, two, buckle my shoe",
            "Three, four, shut the door",
            "Five, six, pick up sticks",
            "Seven, eight, lay them straight",
            "Nine, ten, a big fat hen"
    };

    public static void main(String[] args) throws IOException {
        String uri=args[0];
        Configuration conf=new Configuration();
        FileSystem fs=FileSystem.get(URI.create(uri),conf);
        Path path=new Path(uri);

        IntWritable key=new IntWritable();
        Text value=new Text();
        SequenceFile.Writer writer=null;
        try{
            writer=SequenceFile.createWriter(fs,conf,path,key.getClass(),value.getClass());  //创建SequenceFile对象,并返回SequenceFile.Writer实例
            for(int i=0;i<100;i++){
                key.set(100-i);
                value.set(DATA[i%DATA.length]);
                System.out.printf("[%s]\t%s\t%s\n",writer.getLength(),key,value);
                writer.append(key,value);  //在文件末尾附加键值对
            }
        }finally{
            IOUtils.closeStream(writer);  //关闭数据流
        }
    }
}

从头到尾读取顺序文件也类似,创建SequenceFile.Reader实例后反复调用next()方法迭代读取记录,如果使用的是Writable类型,通过键和值作为参数的next()方法可以将数据流中下一条键值对读入变量中,如public boolean next(Writable key, Writable val)。读取SequenceFile的例子如下所示:

package HadoopIO;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.SequenceFile;
import org.apache.hadoop.io.Writable;
import org.apache.hadoop.util.ReflectionUtils;

import java.io.IOException;
import java.net.URI;

public class SequenceFileReadDemo {  //读取包含Writable类型键值对的SequenceFile
    public static void main(String[] args) throws IOException {
        String uri=args[0];
        Configuration conf=new Configuration();  //根据编辑好的xml配置文件创建Configuration实例
        FileSystem fs=FileSystem.get(URI.create(uri),conf);  //通过给定的URI和配置权限确定要使用的文件系统
        Path path=new Path(uri);

        SequenceFile.Reader reader=null;
        try{
            reader=new SequenceFile.Reader(fs,path,conf);  //创建读取顺序文件的实例
            Writable key=(Writable)ReflectionUtils.newInstance(reader.getKeyClass(),conf);  //通过getKeyClass()发现SequenceFile中使用的键类型,然后通过ReflectionUtils对象生成键的实例
            Writable value=(Writable)ReflectionUtils.newInstance(reader.getValueClass(),conf);  //通过getKeyClass()发现SequenceFile中使用的值类型,然后通过ReflectionUtils对象生成值的实例
            long position=reader.getPosition();  //读取位置定位到开头
            while(reader.next(key,value)){  //next()方法迭代读取记录,如果键值对成功读取,返回true,如果已读到文件末尾返回false
                String syncSeen=reader.syncSeen()?"*":"";  //如果读到了同步点所在位置,就在显示所读取数据的第一列多打印一个星号
                System.out.printf("[%s%s]\t%s\t%s\n",position,syncSeen,key,value);
                position=reader.getPosition();  //beginning of next record
            }
        }finally{
            IOUtils.closeStream(reader);
        }
    }
}

部分输出如下所示:

该例子可以显示SequenceFile中同步点的位置信息(星号位置),同步点就是数据读取指针由于搜索等操作而跑到其他位置后,可以通过该同步点再一次与记录边界同步的一个位置。同步点是由SequenceFile.Writer记录的,在顺序文件写入过程中插入一个特殊项以便每隔几个记录便有一个同步标识,该特殊项很小只造成不到1%的存储开销,同步点始终位于记录的边界处。在顺序文件中搜索给定位置有两种方法:(1)可以通过seek()方法,例如reader.seek(359)。但如果指定的位置不是记录边界,调用next()方法会报错。(2)通过同步点查找记录边界。SequenceFile.Reader对象的sync(long position)方法可以将读取位置定位到position之后的下一个同步点,例如reader.sync(360)会定位到2021L的位置,并且同步到最近同步点之后可以用next()继续读取。当然SequenceFile.Writer对象也有一个sync()方法用于在数据流当前位置插入一个同步点。

32.加入同步点的顺序文件可以作为MapReduce的输入,因为SequenceFile允许切分,所以该文件的不同部分可以由独立的map任务单独处理。hadoop fs -text命令可以以文本形式显示顺序文件,如下所示:

MapReduce是对多个顺序文件进行排序或合并的最好方法,MapReduce本身是并行的,并且可以指定要使用多少个reducer(该数决定输出分区数,指定一个reducer就得到一个输出文件)。

33.SequenceFile由文件头和随后的一条至多条记录组成,如下所示:

顺序文件的前三个字节为SEQ(顺序文件代码),其后的一个字节表示顺序文件的版本号。文件头中包含其他字段例如键和值类的名称、数据压缩细节、用户定义元数据及同步标识。同步标识用于在读取文件时能够从任意位置识别记录边界。每个文件都有一个随机生成的同步标识,其值存储在文件头中。同步标识位于顺序文件中的两个记录之间,但并非每条记录末尾都有该标识,而是每隔一些记录有一个。如果没有启用压缩(默认),每条记录由记录长度(字节数,占4字节的整数)、键长度、键和值组成。记录压缩格式基本相同,只是值用文件头中定义的codec压缩,但键没有被压缩。

块压缩(block compression)是一次性压缩多条记录,因为可以利用记录间的相似性进行压缩,所以相比单条记录压缩方法,该方法压缩效率更高。可以不断向数据块中压缩记录,直到块的字节数不小于io.seqfile.compress.blocksize属性中设置的字节数,默认为1MB。每一个新块的开始处都需要插入同步标识,如下所示:

数据块的格式为:首先是一个指示数据块中记录数的字段,紧接着是4个压缩字段(键长度、键、值长度和值)。

34.MapFile是已经排过序的SequenceFile,它有索引所以可以按键查找,索引自身就是一个SequenceFile,包含了map中的一小部分键(默认情况下,是每隔128个键)。因为索引可以加载进内存,所以可以提供对主数据文件的快速查找,主数据文件则是另一个SequenceFile,包含了所有的map条目,这些条目都按照键的顺序进行了排序。

当使用MapFile.Writer进行写操作时,map条目必须顺序添加,否则会抛出IOException异常。MapFile在键值对的接口上有一些变种:(1)SetFile用于存储Writable键的集合,键必须按照排好的顺序添加。(2)ArrayFile的键是一个整型,用于表示数组元素的索引,值是一个Writable值。(3)BloomMapFile提供了get()方法的一个高性能实现,对稀疏文件很有用,该实现使用一个动态的布隆过滤器检测某个键是否在MapFile中,由于在内存中进行该测试很快,仅当键存在时,常规的get()方法才会被调用。

35.SequenceFile、MapFile和Avro数据文件都是面向行的格式,意味着每一行的值在文件中是连续存储的。在面向列的存储格式中,文件中的行或者Hive中的一张表被分割成行的分片,每个分片以列的形式存储,先存储第一列的值,再存储第二列的值,如下所示:

面向列的存储可以使一个查询跳过不必访问的列。在像顺序文件这样面向行的存储中,即使只需要读取某一列,存储在SequenceFile一条记录中的整个数据行都会被加载进内存,虽然延迟反序列化(lazy deserialization)策略只反序列化被访问的列字段能节省一定开销,但依然不能减少从磁盘读取整个一行数据的开销。面向列的存储格式适合只访问表中一小部分列的查询。相反,面向行的存储格式适合同时处理一行中很多列的情况。然而,由于必须在内存中缓存行的分片(row split)而不是单独的一行,面向列的存储格式需要更多的内存用于读写并且,如果Writer处理失败,当前文件无法恢复,所以面向列的格式不适合流的写操作。而面向行的存储格式例如SequenceFile和Avro,可以一直读取到writer失败后的最近同步点。

  • 5
    点赞
  • 47
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值