参考: JeffreyZhou的博客园
- 《Hadoop权威指南》第四版
0 Map/Reduce大致流程
- 输入(input): 将输入数据分成一个个split,并将spilt进一步拆成<key,value>形式;
- 映射(map):根据输入的<key,value>进行处理,输出list<key,value>;
- 合并(combiner):合并(单个节点上)中间相同的key值;
- 分区(partition):将<key,value>分成N分,分别送到下一环节;
- 化简(reduce):将中间结果合并,得到最终结果;
- 输出(output):指定输出最终结果格式。
本博文重点在于将map/reduce中的各个小环节进行理解和应用,属于细节方面,而不是讲解MR的大致运行流程,这类知识应该作为前提了解,本文在此放置一个整体的运行例图,方便对比本博文的章节内容。
图片来源:图解mapreduce原理和执行过程
2.1 Java Map/Reduce
在明白MR程序的工作原理之后,要知道一个MR作业,包括三点:
- 输入数据
- MR程序
- Job配置信息
上篇博文大概讲解了输入数据的分片、格式等知识,这篇来讲讲MR过程,后面再将配置信息。
2.2 map处理
map函数由Mapper类来表示,如果不指定map函数,则系统自动指定一个Null,这意味着将输入的<key,value>对,不做任何修改,直接送到下一个环节。要自定义map函数,就继承Mapper类,复写其map函数。如wordcount程序中的这句:
public static class TokenizerMapper
extends Mapper<Object, Text, Text, IntWritable>
throws IOException, InterruptedException
{
public void map(Object key, Text value, Context context)
throws IOException, InterruptedException
{
// your map code
context.write(key, value);
}
}
Mapper是一个泛型类型,有四个形参,分别指定map函数的输入键、输入值、输出键和输出值的类型。可以看到,这几个形参基本都是java中没见过的类型,但是text 和 int都比较熟悉,没错,这是Hadoop自身提供的一套基本类型,它可以优化网络序列化传输,至于这个是什么后面再将,总之就是进行优化了,更适合Hadoop的使用。常见对应关系如下:
Hadoop | Java |
---|---|
Text | String |
IntWritable | Integer |
。。。 | 依次类似 |
在程序中,使用下列语句指定map处理类:
job.setMapperClass(TokenizerMapper.class))
2.3 reduce处理
这一块如图所示,接受上一环节的输出,进行处理,并形成最终的输出结果,上一环节可能是Partitioner的输出,也可能就是map的输出,这个要视情况来决定是使用低配方案还是顶配,待会儿分析中间这几个环节,先来看看哼哈二将的哈大头。同样,要自定义reduce函数,就继承Reducer类,复写reduce函数:
public static class IntSumReducer
extends Reducer<Text,IntWritable,Text,IntWritable> {
public void reduce(Text key, Iterable<IntWritable> values,Context context)
throws IOException, InterruptedException {
// your reduce code
context.write(key, result);
}
}
reduce和map很像,同样的四个形参,可以想到的是,这里的输入参数类型应该是和map(或者说上一个任意环节)的输出类型一样的,不然暗号都不一样,怎么可能成功接头。所以,这是编程中要注意到的一点。
在程序中,使用下列语句指定map处理类:
job.setReducerClass(IntSumReducer.class))
2.4 map和reduce之间的纠缠
-
map任务将其输出写入本地磁盘,而非HDFS
map的输出是中间结果,该中间结果由reduce任务处理后才产生最终输出结果,而且一旦作业(job)完成,map的输出结果就可以删除。 -
默认只有一个reduce任务
默认情况下,所有Map任务的输出,排序后需通过网络传输发送到运行reduce任务的节点,数据在reduce端合并,然后才能由我们自定义的reduce函数处理,此时的输出存储到HDFS中。 -
太多map和多个reduce任务
当然,一般都是设置多个reduce任务,不然人家map群挑一个reduce,那对reduce多不公平,但reduce,你看她名字就知道了,化简,肯定是比map少,这才能显示出其地位。
那么,在多对多的情况下,怎么分配呢,这就是partiton的作用了,分好区,划地封侯,保证领土完整,互不侵犯。 -
map的输出传送到reduce的输入
前面说了,map具有数据本地优势化,但reduce没有啊,在集群中,带宽应该算是最重要的资源了,没办法,要致富,先修路,没出路,再大的能耐也没辙。那么为了减少map输出产生的数据传送,我们可以现在map本地进行一下“reduce”,没错,就是本地化的reduce。这里似乎有点乱,只要记住一点,combiner属于优化方案,每个combiner只作用于一个map上面,而reduce的作用在所有map上面。combiner函数是为了减少mapper和reducer之间的数据传输量,是否使用还需要斟酌一下。 -
上面的逻辑看起来比较简单,但真实运行情况要比这个复杂的多,简单的做一下对比就知道了:
WordCount举例 | 真实情况 |
---|---|
一个任务 | 多个任务并发 |
现有的小规模输入数据,txt格式 | 海量,不同数据来源,多种格式,实时 |
几个mapper,一个reducer | 好多reducer,超多mapper |
无软、硬件故障 | 硬件故障是一种常态 |
2.5 Combiner函数和Partitioner函数
- 前面讲了,combiner就是本地化的reducer,自定义:
public static class MyCombiner
extends Reducer<Text,IntWritable,Text,IntWritable> {
public void reduce(Text key, Iterable<IntWritable> values,Context context)
throws IOException, InterruptedException {
context.write(key, new IntWritable(1));
}
}
在程序中,使用下列语句指定map处理类:
job.setCombinerClass(IntSumReducer.class);
job.setReducerClass(IntSumReducer.class)
值得注意的一点是:combiner可以不止一次的调用。。。如果至少存在3个溢出文件,则combiner就会在输出文件写到磁盘之再次运行。
- Partitioner决定着Map节点的输出将被分区到哪个Reduce节点。而默认的Partitioner是HashPartitioner,它根据每条数据记录的主健值进行Hash操作,获得一个非负整数的Hash码,然后用当前作业的Reduce节点数取模运算,这个取模运算只是为了平均reduce的处理能力,咱可以自定义。有N个结点的话,就会平均分配置到N个节点上,一个隔一个依次。大多情况下这个平均分配是够用了,但也会有一些特殊情况,以后补充。
自定义:
public static class MyPartitioner extends HashPartitioner<K,V> {
public void getPartition(K key, V value,int numReduceTasks) {
super.getPartition(key,value,numReduceTasks);
}
}
2.6 到底什么是shuffle
简单说,就是将map输出作为输入,传给reducer的过程,成为shuffle。它属于不断被优化和改进的代码库的一部分,
此处划重点,优化!!!
也就是说,按照上面分析的理想状态,不就是map后输出,然后经过combiner,再将所有的输出集合到partitioner进行划分,到不同的reducer进行处理,输出到HDFS不就行啦。
但是,再强大的理论框架,也挡不住现实的残酷。(以下3点没有查到具体的依据,都是自己的想法):
(1)首先,直接map输出如果直接写入磁盘,一般机械硬盘读写很慢,这严重影响效率,理所当然想到利用内存(缓冲区),但问题又来了,内容的容量远远小于硬盘容量,经过内存就可能产生很多小文件(spill to disk溢出文件),但最终一个map的输出文件就只有一个,这时,就需要将多个溢出文件合并(merge on disk)。;
(2)既然在这过程中利用了内存,内存速度快,那么在这转手的过程中,还可以顺便做一些其它的优化措施,比如,sort
和combiner
。排序是MR的默认行为,再进行一下combine,就可以减少spill文件。
(3)对照下图,似乎还有一个partitions没有解释,因为上述两点,有一个前提条件:只有一个map和一个reducer。前面讲了,对于多个reducer,要将map分区送给不同的reducer,那么在哪决定分给哪个reducer呢?其实在写磁盘之前,线程首先根据数据最终要传的reducer把数据划分成相应的分区(partition)。在每个分区中,后台线程再按键进行内存中排序,再运行combiner(如果有的话)。
上面是按照优化的目的,来解释shuffle中各个环节的意义,已经将整个过程讲了一遍,接下来就将这个过程分为map端和reduce端来说一下。
下面这段的参考来源: MapReduce原理分析记录
2.6.1 map端
map函数开始输出时,并不是简单地将它写到磁盘,这个过程比想象中更复杂,它利用缓冲的方式写到内存,并出于效率的考虑进行预排序。
内存缓冲区默认大小是100MB,一旦缓冲内容达到阈值(默认为0.8,即80%),一个后台线程便开始把内容溢出(spill)到磁盘,在这过程中,map输出继续写到缓冲区,互不影响。
在任务完成之前,溢出文件被合并成一个已分区且已排序的输出文件。最终生成的文件存放在Task Tracker(即DataNode)够得着的某个本地目录内。
关于Namenode、Datanode、Jobtracker、Tasktracker区别见本系列另外一篇文章:Hadoop学习中的一些概念区分
2.6.2 reduce端
每个reduce task不断地通过RPC从Job Tracker(即namenode)那里获取map task是否完成的信息,如果reduce得到通知,获知某台Task Tracker上的map task执行完成,就可以开始启动shuffle的后半段过程了。
reduce task在执行前,不断拉取当前job里,每个map的最终结果,然后对从不同地方拉取过来的数据不断的做merge,最终也形成一个文件,最为reduce task的输入。
- copy。每个map任务的完成时间可能不同,因此在每个map任务完成时,reduce任务就开始复制其输出,从而能够并行取得map输出,默认是5个copy线程。
- Merge。这和map阶段的merge动作一样,至少合并的是不同map端的数值。
- “最终文件”,merge后生成的最终文件,可能存在于磁盘,也可能存在于内存中,默认是在磁盘中。
关于MR流程的一些补充
1. map任务个数
- 等于输入文件被划分成的分块数(每个split对应一个map过程);
- 如果文件在HDFS中,这又取决于输入文件的大小以及文件块的大小(即Block size)等于输入文件被划分成的分块数(每个split对应一个map过程)。
- 对于大多数作业来说,一个合理的split大小趋向于HDFS的Block size,默认是128MB。
- 原因:
这就是map的数据本地化的优势,即著名的“计算向数据靠近”的思想。因为集群的带宽资源很宝贵,Hadoop在存储有输入数据的节点上运行map任务,可以获得最佳性能。
(1)如果分片过大,跨越了两个数据块,那么对于任意一个HDFS节点,基本都不可能同时存储这两个数据块(根据HDFS的存储特点),就需要通过网络传输数据;
(2)如果分片太小,那么关键时间反而会卡在管理分片和构建map任务的总时间,而不是计算本身,有点“得不偿失”。
2. reduce任务个数
从上面可以看到,我们并没有设置map任务的数量,因为这是根据Block size来自动划分的。
但reduce任务的数量是单独指定的,默认情况下,只有一个reducer,这当然不行,选择reducer的数量是个技术活,由于并行化程度提高,增加reducer的数量能缩短reduce过程,然而,太多的话,小文件将会更多,这又不够优化。
按照《Hadoop权威指南》中一条经验法则:
目标reducer保持在每个运行5分钟左右,且产生至少一个HDFS块(大小)的输出比较合适。
3. MR过程中的数据格式问题
在上一篇博文中,我们先从外层了解了整个MR作业的输入和输出,把MR作业当作一个黑盒子,先知道进去啥东西,出来啥结果。然后这一篇博文打开了这个黑盒子,了解了中间过程的大体框架,那么,在输入数据进入这个黑盒子以后,到输出之前,不管数据内容,先想想这中间的数据格式及形成又是如何变化的呢?
其实在前面提到过,不管是啥格式,至少这个环节的输出格式,得和下一环节的输入格式一致吧?不然口径都不一样,怎么对接?
上面只提到了setMapperClass()
和setReducerClass()
来指定要用的map类型和reduce类型,这两个类中定义了接收的输入数据类型和最终的输出数据类型。但其实可以用下面两个函数控制reduce函数的输出类型,并且必须和reduce类产生的相匹配:
setOutputKeyClass()
setOutputValueClass()
map函数的输出类型默认情况下和reduce函数是相同的,但是,如果不同,则通过以下方法来设置map函数的输出类型:
setMapOutputKeyClass()
setMapOutputValueClass()
4. WordCount示例程序
这一大套讲下来,自己都有点迷糊了,放一个程序代码,来捋一捋思路,清醒一下:
// Map过程
public static class TokenizerMapper extends Mapper<Object, Text, Text, IntWritable>{
private final static IntWritable one = new IntWritable(1);
private Text word = new Text();
public void map(Object key, Text value, Context context) throws IOException, InterruptedException {
// key 是这一行数据的起始偏移量,value是这一行的文本内容
System.out.println("key=" +key.toString());
System.out.println("Value=" + value.toString());
// 切分单词,可以使用split()方法
StringTokenizer itr = new StringTokenizer(value.toString());
//遍历单词数组,输出key-value的格式
while (itr.hasMoreTokens()) {
word.set(itr.nextToken());
context.write(word, one);
}
}
}
// Reduce过程
public static class IntSumReducer extends Reducer<Text,IntWritable,Text,IntWritable> {
private IntWritable result = new IntWritable();
public void reduce(Text key, Iterable<IntWritable> values,Context context)
throws IOException, InterruptedException {
int sum = 0;
for (IntWritable val : values) {
sum += val.get();
}
result.set(sum);
context.write(key, result);
}
}
// 主程序
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration(); // 配置文件
// System.out.println("url:" + conf.get("fs.default.name")); // deprecated
System.out.println("url:" + conf.get("fs.defaultFS"));
String[] otherArgs = new GenericOptionsParser(conf, args).getRemainingArgs();
if (otherArgs.length != 2) {
System.err.println("Usage: wordcount <in> <out>");
System.exit(2);
}
// 获取一个作业
// Job job = new Job(conf, "word count"); // deprecated
Job job = Job.getInstance(conf,"wordcount"); // 用job的静态方法
// 设置job所用的那些类(class)文件在哪个jar包
job.setJarByClass(WordCount.class);
// 设置所用的map reduce类
job.setMapperClass(TokenizerMapper.class);
job.setCombinerClass(IntSumReducer.class); // 对每个节点的map输出进行combine
job.setReducerClass(IntSumReducer.class); // 对所有节点的输出进行reduce
// 设置数据输出类型
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(IntWritable.class);
// 指定要处理的输入、输出路径,
//此处为输入参数
FileInputFormat.addInputPath(job, new Path(otherArgs[0]));
FileOutputFormat.setOutputPath(job, new Path(otherArgs[1]));
//此处为固定文件目录
// FileInputFormat.addInputPath(job, "input");
// FileOutputFormat.setOutputPath(job, "output");
// 将job提交给集群运行
System.exit(job.waitForCompletion(true) ? 0 : 1);
}