剖析Mapeduce程序
MpaReduce程序通过操作键/值对来处理数据,一般形式为map:(K1,V1)->list<K2,V2>reduce:(K2,list(V2))->list<K3,V3>
上面是这个数据流的一个相当普通的表现,并无特别之处。而在本节,我们将学习更多的细节,涉及一个典型MapReduce程序的每个阶段。下图显示了这个完整过程的高阶试图,我们将逐步遍历这个流程来进一步剖析每一个组成部分。
注意,输入数据被分配到不同节点之后,节点间通信的唯一时间是在“洗牌”阶段。这个通信约束对可扩展性有极大的帮助。
Hadoop数据类型
尽管我们的许多讨论之提键和值,但还是得注意它们的类型。MapReduce框架并不允许它们是任意的类。例如,虽然我们可以并且的确经常把某些键与值称为整数、字符串等,但它们实际上并不是Interger、String等那些标准的Java类。这是因为为了让键值对可以再集群上移动,MapReduce框架提供了一种序列化键值对的方法。因此,只有那些支持这种序列化的类能够在这个框架中充当键或者值。
更具体而言,实现Writable接口的类可以是值,而实现WritableComparable
Hadoop带有一些预定义的类用于实现WritableComparable,包括面向所有基本数据类型的封装类,如下图所示。
键和值所采用的数据类型可以超出hadoop自身所支持的基本类型。你可以自定义数据类型,只要它实现了WritableComparable
import java.io.DataInput; |
这个Edge类实现了Writable接口中的readFileds()和write()方法。它们与Java中的DataInput和DataOutput类一起用于类中内容的串行化。而Comparable接口中实现的是compareto()方法。如果被调用的Edge小于等于或者大于给定的Edge,这个方法会分别返回-1,0,1。
利用现在定义的数据类型接口,我们可以开始数据流处理过程的第一阶段,即上图的mapper。
Mapper
一个类要作为mapper,需要继承MapReduceBase基类并实现Mapper接口。并不奇怪,mapper和reducer的基类均为MapReduceBase类。它包含类的构造和结构方法。
void configure(JobConfjob)。该函数提取XML配置文件或者应用程序类中的参数,在数据处理之前调用该函数。
void close()。作为map任务结束前的最后一个操作,该函数完成所有的结尾工作,如关闭数据库连接、打卡文件等。
Mapper接口负责数据处理阶段。它采用的形式为Mappervoid map(K1 key,V value,OutputCollector<K2, V2> output,Reporter reporter) throws IOException
该函数处理一个给定键值对(K1,V1),生成一个键值对(K2,V2)的列表(该列表也可以为空)。OutputCollector接收这个映射过程的输出,Reporter可提供对mapper相关附加信息的记录,形成任务进度。
hadoop提供了一些有用的mapper实现,如下图
MapReduce,顾名思义在map之后的主要数据流操作是reduce。
Reducer
reducer的实现和mapper一样必须首先在MapReduce基类上扩展,允许配置和清理。此外,它还必须实现Reudcer接口使其具有如下单一的方法:void reduce(K2 key,Iterator<V2>values,OutputCollector<K3, V3> output,Reporter reporter) throws IOException
当reducer任务接收来自各个mapper的输出是,它按照键值对中的键对输入数据进行排序,并将相同键的值归并。然后调研reduce()函数,并通过迭代处理那些与指定键相关联的值,生成一个(可能为空)列表(K3,V3)。OutputCollector接收reduce阶段的输出,并写入输出文件。Reporter可提供对reducer相关附加信息的记录,形成任务进度。
在下图中列出了Hadoop提供的一些基本reducer实现。
Partitioner;重定向Mapper输出
初次使用MapReduced的程序员通常有一个误解,以为仅需使用一个reducer。毕竟,采用单一的reducer可以在处理之前对所有的数据进行排序——谁不喜欢排序的数据呢?但是,这样理解MapReduce是有问题的,它忽略了并行计算的好处。用一个reducer,我们的计算“云”就被降级成“雨点”了。
但是,当使用多个reducer时,我们就需要采取一些办法来确定mapper应该把键值对输出给谁。默认的作法是对键进行散列来确定reducer。Hadoop通过HashPartionner类强制执行这个策略。但有时HashPartitioner会让你出错。让我们回到Edge类
假设你使用Edge类来分析航班信息来决定从各个机场离港的乘客数目。这些数据可以为:
(San Francisco,Los Angeles) Chuck Lam
(San Francisco,Dallas) James Warren
如果你使用HashPartitioner,这两行可以被送到不同的reducer。离港的乘客数目被处理两次并且两次都是错误的。
如何为你的应用量身定制partitioner呢?在这种情况下,我们希望具有相同离港地的所有edge被送往相同的reducer。这也很容易做到,只要对edge类的departtureNode成员进行散列就可以了:public class EdgePartionner implements Partitioner<Edge, Writeable> {
@Override
public void configure(JobConf arg0) {
// TODO Auto-generated method stub
}
@Override
public int getPartition(Edge arg0, Writeable arg1, int arg2) {
// TODO Auto-generated method stub
return 0;
}
}
一个定制的partitioner只需实现configure()和getParttition()两个函数。前者将Hadoop对作业的配置应用在partitioner上,而后者返回一个介于0和reduce任务之间的整数,指向键值对将要发送到的reducer。
在map和reduce阶段之间,一个MapReducer应用必然从mapper任务得到输出结果,并把这些结果发布给reducer任务。该过程通常被称为洗牌,因为在单节点上的mapper输出可能被送往分布在集群多个节点上的reducer。
Combiner:本地reduce
在许多mapreduce应用场景中,我们不妨在分发mapper结果之前做一下“本地Reduce”。再考虑一下wordcout的例子。如果作业处理文件中单词“the”出现了574次,存储并洗牌一次(“the”,574)键值对比许多次(“the”,1)更为高效。这种处理步骤被称为合并。
预定义mapper和Reducer类单词计数,如下是修改后的wordcount2例程public class WordCount2
{
public static void main(String[] args)
{
JobClient client = new JobClient();
@SuppressWarnings("deprecation")
JobConf conf = new JobConf(WordCount2.class);
FileInputFormat.addInputPath(conf, new Path(args[0]));
FileOutputFormat.setOutputPath(conf, new Path(args[1]));
conf.setOutputKeyClass(Text.class);
conf.setOutputValueClass(LongWritable.class);
conf.setMapperClass(TokenCountMapper.class);
conf.setCombinerClass(LongSumReducer.class);
conf.setReducerClass(LongSumReducer.class);
client.setConf(conf);
try
{
JobClient.runJob(conf);
}
catch (Exception e)
{
e.printStackTrace();
}
}
因为我们使用了Hadoop预定义的类TokenCountMapper和LongSumReducer,MapReduce程序撰写就变得非常容易。虽然Hadoop也支持生成更复杂程序,但我们强调的是,Hadoop允许你通过最小的代码量快速生成实用的程序。
读和写
为了易于分布式处理,MapReduce对所处理的数据做了一定的假设,但它也具有一定的灵活性,可支持多种数据格式。
输入数据通常驻留在较大的文件中,通常几十或数百GB,甚至更大。MapReduce处理的基本原则是将输入数据分割成块。这些块可以在多台计算机上并行处理,在Hadoop的术语中,这些块被称为输入分片。每个分片应该足够小以实现更细粒度的并行。另一方面,每个分片也不能太小,否则启动与停止各个分片处理所需的开销将占去很大一部分执行时间。
并行处理切分输入数据的原则解释了其背后hadoop通用文件系统的一些设计决策。例如hadoop的文件系统提供了FSDataInputStream类用于读取文件,而未采用Java中java.io.DataInputStream。FSDataInputStream扩展了DataInputStream以支持随机读,MapReduce需要这个特性,因为一台机器可能被指派从输入文件的中间开始处理一个分片。如果没有随机访问,则需要从头开始一直读取到分片的位置,效率就会非常低。你还可以看到HDFS为了存储MapReduce并行切分和处理的数据所做的设计。HDFS按块存储文件并分布在多台机器上。笼统而言,每个文件块分为一个分片。由于不同的机器会存储不同的块,如果每个分片/块都由它所驻留的机器进行处理,就自动实现了并行。此外,由于HDFS在多个节点上复制数据块以实现可靠性,MapReduce可以选择任意一个包含分片/数据块副本的节点。
注意,输入分片是一种记录的逻辑划分,而HDFS数据块是对输入数据的物理分割。当它们一致时,效率会非常高。但在实际应用中从未达到完全一致。记录可能会跨过数据块的边界。Hadoop确保全部记录都被处理。处理特定分片的计算节点会从一个数据块中获取记录的一个片段,该数据块可能不是该记录的“主”数据块,而会存放在远端。为获取一个记录片段所需的通信成本是微不足道的,因为它相对而言很少发生。
InputFormat
Hadoop分割与读取输入文件的方式被定义在InputFormat接口的一个实现中。TextInputFormat是InputFormat的默认实现,我们一直暗自使用至今的正式这种数据格式。当你想要一次获取一行内容而输入数据又没有确定的键值时,这种数据格式通常会非常有用。从TextInputFormat返回的键为每行的字节偏移量,而我们尚未看到任何的程序使用这个键用于数据处理。
1.常用的INPUTFORMAT类
在下图中列出了InputFormat的其他常用实现,并简要描述了每个实现传递给mapper的键值对。
KeyValueTextInputFormat在更结构化的输入文件中使用,由一个预定义的字符,通常为制表符(\t),将每行(记录)的键与值分开。
回想一下,我们在以前的mapper中曾使用LongWritable和Text分别作为键(key)和值(value)的类型。在TextInputFormat中,因为值为用数字表示的偏移量,所以LongWritable和Text分别作为键(key)和值(Value)的类型。而当使用KeyValueTextInputFormat时,无论是键和值都为Text类型,你必须改变Mapper的实现以及map()方法来适应这个新的键(key)类型。
输入到MapReduce作业的数据未必都是些外部数据。实际上,一个MapReduce作业的输入常常是其他一些MapReduce的输出。默认的输出格式与KeyValueTextInputFormat能够读取的数据格式保持一致(即记录中的每行均为一个由制表符分隔的键和值)。不过,Hadoop提供了更加有效的二进制压缩文件格式,称为序列文件。这个序列文件为Hadoop处理做了优化,当链接多个MapReduce作业时,它是首选格式。读取序列文件的InputFormat类为SequenceFileInputFormat。
序列文件的键和值的对象类型可由用户来定义。输出和输入类型必须匹配,Mapper实现和map()方法均须采用正确的输入类型。
2.生成一个定制的InputFormat-InputSplit和RecordReader
有时你会期望采用与标准InputFormat类不同的方式读取输入数据。这时你必须编写自定义的InputFormat类。让我们看看要做那些事。InputFormat是一个仅包含两个方法的接口。
public interface InputFormat<K, V> |
这两个方法总结了InputFormat需执行的两个功能:
确定所有用于输入数据的文件,并将之分割为输入分片。每个map任务分配一个分片。
提供一个对象(RecordReader),循环提取给定分片中的记录,并解析每个记录为预定义类型的键与值。
谁又希望去考虑如何将文件划分为分片呢?在创建自己的InputFormat类时,你最好从负责文件分割的FileInputFormat类的子类。FileInputFormat实现了getSplits()方法,不过保留了getRecordReader()抽象让子类填写。FileInputFormat中实现了getSplits()把输入数据粗略的划分为一组分片,分片数目在numSplits中限定,且每个分片的大小必须大于mapred.min.split.size个字节,但小于文件系统的块。在实际情况中,一个分片最终总是以一个块为大小,在HDFS中默认为64MB。
FileInputFormat有一定数量的protected方法,子类可以通过覆盖改变其行为,其中一个就是isSplitable(FileSystem fs,Path filename)方法。它检查你是否可以讲给定文件分片。默认实现总是返回true,因此所有大于一个分块的文件都要分片。有时你可能想要一个文件为其自身的分块,这时你就可以覆盖isSplitable()来返回false。例如,一些文件压缩方案并不支持分割。(你不能从文件的中间开始读数据。)一些数据处理操作,如文件转换,需要把每个文件视为一个原子记录,也不能将之分片。
在使用FileInputFormat时,你关注于定制RecordReader,它负责吧一个输入分片解析为记录。再把每个记录解析为一个键值对。我们看一下这个接口的样子。public interface RecordReader<K, V> {
boolean next(K key, V value) throws IOException;
K createKey();
V createValue();
long getPos() throws IOException;
public void close() throws IOException;
float getProgress() throws IOException;
}
我们不用自己写RecordReader,还是利用Hadoop所提供的类。例如,LineRecordReader实现RecordReader
我们给出自定义InputFormat类的一个用例,它按照特定类型读取记录,而不使用普通的Text类型。例如,我们以前使用的KeyValueTextInputFormat,是从数据文件中读取以制表符分隔的时间戳和URI数据。这个类最终把时间戳和URL都作为Text类型。在这个例子中,我们创建一个TimerUrlTextInputFormat,它完成相同的工作,但把URL作为URLWritable类型。如前面提到的,我们通过扩展FileInputFormat生成我们的InputFormat类,并实现一个factory方法来返回RecordReader。public class TimerUrlTextInputFormat extends FileInputFormat<Text, URLWritable> {
@SuppressWarnings("deprecation")
@Override
public RecordReader<Text, URLWritable> getRecordReader(InputSplit input,
JobConf job, Reporter reporter) throws IOException {
// TODO Auto-generated method stub
return new TimerUrlLineRecordReader(job, (FileSplit) input);
}
}
我们的URLWritable类非常简单:
public class URLWritable implements Writable
{
protected URL url;
public URLWritable()
{
}
public URLWritable(URL url)
{
this.url = url;
}
@Override
public void readFields(DataInput in) throws IOException
{
url = new URL(in.readUTF());
}
@Override
public void write(DataOutput out) throws IOException
{
out.writeUTF(url.toString());
}
public void set(String s) throws MalformedURLException
{
url = new URL(s);
}
除了类的构建函数之外,TimerUrlRecordReader会在RecordReader接口中实现6种方法。它主要是在KeyValueTextInputFormat之外的一个封装,但把记录的值从Text类型转为URLWritableclass TimerUrlLineRecordReader implements RecordReader<Text, URLWritable>
{
private KeyValueLineRecordReader lineReader;
private Text lineKey, lineValue;
public TimerUrlLineRecordReader(JobConf job, FileSplit split)
throws IOException
{
lineReader = new KeyValueLineRecordReader(job, split);
lineKey = lineReader.createKey();
lineValue = lineReader.createValue();
}
@Override
public void close() throws IOException
{
lineReader.close();
}
@Override
public Text createKey()
{
// TODO Auto-generated method stub
return new Text("");
}
@Override
public URLWritable createValue()
{
// TODO Auto-generated method stub
return new URLWritable();
}
@Override
public long getPos() throws IOException
{
// TODO Auto-generated method stub
return lineReader.getPos();
}
@Override
public float getProgress() throws IOException
{
// TODO Auto-generated method stub
return lineReader.getProgress();
}
@Override
public boolean next(Text key, URLWritable value) throws IOException
{
if (!lineReader.next(lineKey, lineValue))
{
return false;
}
key.set(lineKey);
value.set(lineValue.toString());
return true;
}
}
}
TimerUrlLineRecordReader类生成一个KeyValueLineRecordReader对象,并直接把getPos()、getProgress()以及close()方法调用传递给它。而next()方法将lineValue Text对象转换为URLWritable类型
OutputFormat
当MapReduce输出数据到文件时,使用的是OutputFormate类,它与InputFormat类相似。因为每个reducer仅需将它的输出写入自己的文件中,输出无需分片。输出文件放在一个公用目录中通,通常命名为part-nnnn,这里nnnn是reducer的分区ID。RecordWriter对象输出结果进行格式化,而RecordReader对输入格式进行解析。
如下图所示,为Hadoop主要的OutputFormat类
默认的OutputFormat是TextOutputFormat,将每个记录写为一行文本。每个记录的键和值通过tostring()被转换为字符串(string),并以制作符(\t)分隔。分隔符可以在mapred.textoutputformat.separator属性中修改。
TextOutputFormat采用可被KeyValueTextInputFormat识别的格式输出数据。如果把键的类型设为NullWritable,它也可以采用被TextInputFormat识别的输出格式。在这种情况下,在键值对中没有键,也没有分隔符。如果想完全禁止输出,应该使用NullOutPutFormat。如果让reducer采用自己的方式输出,并且不需要Hadoop写任何附加的文件,可以限制Hadoop的输出。
最后,SequeceFileOutFormat以序列文件格式输出数据,使其可以通过SequeceFileInputFormat来读取。它有助于通过中间数据结果将MapReduce作业串接起来。