MapReduce原理及源码浅析(二)
回顾:
上一篇文章我们讨论了客户端提交MapReduce作业时是如何创建split清单以及MapTask如何读取split中的记录的。获取split实际上调用了FileInputFormat的getSplits()方法,split有四个重要的参数:
- Path
- offset
- length
- hosts和cachedhosts
split中的记录向map环节写这一动作是由LineRecordReader完成的,LineRecordReader类似于一种迭代器模式,getCurrentKey()返回当前记录相对于文件的偏移量;getCurrentValue()返回该记录。那么MapTask是如何调用LineRecordReader来获取数据呢?我们接下来分析MapTask的工作:
Map阶段的工作主要有三部分
- input
- map
- output
MyMapper代码:
public class MyMapper extends Mapper {
private static final IntWritable one = new IntWritable(1);
private Text word = new Text();
public void map(Object key, Text value, Context context) throws IOException, InterruptedException {
StringTokenizer itr = new StringTokenizer(value.toString());
while(itr.hasMoreTokens()){
word.set(itr.nextToken());
context.write(word,one);
}
}
}
Map阶段的一系列工作是由MapTask负责的,我们的MyMapper代码只不过是自定义了MapTask中runNewMapper()方法中使用的Mapper,从而实现我们的业务逻辑。进入MapTask的run()方法:
if (useNewApi) {
runNewMapper(job, splitMetaInfo, umbilical, reporter);
} else {
runOldMapper(job, splitMetaInfo, umbilical, reporter);
}
这里我们调用的是runNewMapper()方法
// make a mapper
org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE> mapper =
(org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE>)
ReflectionUtils.newInstance(taskContext.getMapperClass(), job);
org.apache.hadoop.mapreduce.RecordReader<INKEY,INVALUE> input =
new NewTrackingRecordReader<INKEY,INVALUE>(split, inputFormat, reporter, taskContext);
此方法里反射出了mapper和newTrackingRecordReader,后者调用的是LineRecordReader的getCurrentKey()等方法;同时拿到了MapTask类的私有类NewOutputCollector作为output
// get an output object
if (job.getNumReduceTasks() == 0) {
output =
new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
} else {
output = new NewOutputCollector(taskContext, job, umbilical, reporter);
}
runNewMapper()方法中的下面这段代码体现了整个mapTask阶段的工作,其中的input、mapper、output分别为前文所述的LineRecordReader、MyMapper、NewOutputCollector
try {
input.initialize(split, mapperContext);
mapper.run(mapperContext);
mapPhase.complete();
setPhase(TaskStatus.Phase.SORT);
statusUpdate(umbilical);
input.close();
input = null;
output.close(mapperContext);
output = null;
} finally {
closeQuietly(input);
closeQuietly(output, mapperContext);
}
mapper.run()调用了Mapper类的run(),run()又调用了Mapper的map()
/**
* Expert users can override this method for more complete control over the
* execution of the Mapper.
* @param context
* @throws IOException
*/
public void run(Context context) throws IOException, InterruptedException {
setup(context);
try {
while (context.nextKeyValue()) {
map(context.getCurrentKey(), context.getCurrentValue(), context);
}
} finally {
cleanup(context);
}
}
/**
* Called once for each key/value pair in the input split. Most applications
* should override this, but the default is the identity function.
*/
@SuppressWarnings("unchecked")
protected void map(KEYIN key, VALUEIN value,
Context context) throws IOException, InterruptedException {
context.write((KEYOUT) key, (VALUEOUT) value);
}
map()里调用的write()是TaskInputOutputContextImpl类的write()方法
/**
* Generate an output key/value pair.
*/
public void write(KEYOUT key, VALUEOUT value
) throws IOException, InterruptedException {
output.write(key, value);
}
这个output是个RecordWriter,子类实现是前文的NewOutputCollector,它的构造器和write()方法如下
NewOutputCollector(org.apache.hadoop.mapreduce.JobContext jobContext,
JobConf job,
TaskUmbilicalProtocol umbilical,
TaskReporter reporter
) throws IOException, ClassNotFoundException {
collector = createSortingCollector(job, reporter);
partitions = jobContext.getNumReduceTasks();
if (partitions > 1) {
partitioner = (org.apache.hadoop.mapreduce.Partitioner<K,V>)
ReflectionUtils.newInstance(jobContext.getPartitionerClass(), job);
} else {
partitioner = new org.apache.hadoop.mapreduce.Partitioner<K,V>() {
@Override
public int getPartition(K key, V value, int numPartitions) {
return partitions - 1;
}
};
}
}
@Override
public void write(K key, V value) throws IOException, InterruptedException {
collector.collect(key, value,
partitioner.getPartition(key, value, partitions));
}
这里面有两点需要注意:
- 分区器partitioner
- write()方法
Partitioner
先看else的情况,意味着partitions=1,也就是说reduceTask只有一个,此时getPartition()方法默认为返回0;当分区数大于一时,会调用getPartitionerClass()反射出我们在客户端自定义的Partitioner,如果用户未定义,默认取HashPartitioner
public Class<? extends Partitioner<?,?>> getPartitionerClass()
throws ClassNotFoundException {
return (Class<? extends Partitioner<?,?>>)
conf.getClass(PARTITIONER_CLASS_ATTR, HashPartitioner.class);
}
/** Partition keys by their {@link Object#hashCode()}. */
@InterfaceAudience.Public
@InterfaceStability.Stable
public class HashPartitioner<K, V> extends Partitioner<K, V> {
/** Use {@link Object#hashCode()} to partition. */
public int getPartition(K key, V value,
int numReduceTasks) {
return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
}
}
MapOutputBuffer
回到NewOutputCollector的write()方法,调用的是MapOutputCollector的collect()方法,子类实现也是MapTask的私有类MapOutputBuffer,关于MapOutputBuffer有几个重要的点:
- comparator
- combinerRunner
- init()方法
@InterfaceAudience.LimitedPrivate({"MapReduce"})
@InterfaceStability.Unstable
public static class MapOutputBuffer<K extends Object, V extends Object>
implements MapOutputCollector<K, V>, IndexedSortable {
private int partitions;
private JobConf job;
private TaskReporter reporter;
private Class<K> keyClass;
private Class<V> valClass;
private RawComparator<K> comparator;
private SerializationFactory serializationFactory;
private Serializer<K> keySerializer;
private Serializer<V> valSerializer;
private CombinerRunner<K,V> combinerRunner;
private CombineOutputCollector<K, V> combineCollector;
在讨论comparator和combinerRunner之前有必要复习一下MapTask的工作原理:
在MapTask从split中读取记录并生成键值对后,键值对在内存中需要经历二次排序(reduce拉取数据时排序是磁盘IO,比在内存中排序慢很多),第一次排序利用partitioner将key属于同一分区的键值对排在一起,如图中的<Hadoop,1>,<Java,1>,<Hadoop,1>和<MapReduce,1>,<Spark,1>;第二次排序,是根据key来排序。经过这样的二次排序后,reduceTask阶段shuffle的时候就不需要对整个map的输出进行随机读取,因为mapTask的输出不仅分区有序,而且每个分区内部也是有序的,shuffle的时候各个reducer一共只需要对mapTask的输出遍历一次就可以拉回所有数据。这里的排序就会用到我们的comparator,用的是JobConf类中的getOutputKeyComparator()方法返回了一个RawComparator
public RawComparator getOutputKeyComparator() {
Class<? extends RawComparator> theClass = getClass(
JobContext.KEY_COMPARATOR, null, RawComparator.class);
if (theClass != null)
return ReflectionUtils.newInstance(theClass, this);
return WritableComparator.get(getMapOutputKeyClass().asSubclass(WritableComparable.class), this);
}
方法中先用theClass属性取出用户配置的KEY_COMPARATOR,该comparator必须实现RawComparator接口,默认为null。如果有用户配置,就反射出来;若没有配置,就通过getMapOutputKeyClass()方法获取我们的key类型自己的comparator。
完成二次排序后,还可以有一个combine环节,它的作用是先对我们局部的map结果进行一次reduce,这个过程同样是在内存中进行的,我们可以将<Hadoop,1>,<Hadoop,1>reduce成<Hadoop,2>,当数据量很大时,reduce阶段的IO压力会得到极大缓解,是一个非常巧妙的设计。
combinerRunner的实现是NewCombinerRunner类
@InterfaceAudience.Private
@InterfaceStability.Unstable
protected static class NewCombinerRunner<K, V> extends CombinerRunner<K,V> {
private final Class<? extends org.apache.hadoop.mapreduce.Reducer<K,V,K,V>>
reducerClass;
private final org.apache.hadoop.mapreduce.TaskAttemptID taskId;
private final RawComparator<K> comparator;
private final Class<K> keyClass;
private final Class<V> valueClass;
private final org.apache.hadoop.mapreduce.OutputCommitter committer;
该类传入了一个参数reducerClass,后续的combine()方法调用的就是反射出来的reducer的run()方法
@Override
public void combine(RawKeyValueIterator iterator,
OutputCollector<K,V> collector
) throws IOException, InterruptedException,
ClassNotFoundException {
// make a reducer
org.apache.hadoop.mapreduce.Reducer<K,V,K,V> reducer =
(org.apache.hadoop.mapreduce.Reducer<K,V,K,V>)
ReflectionUtils.newInstance(reducerClass, job);
org.apache.hadoop.mapreduce.Reducer.Context
reducerContext = createReduceContext(reducer, job, taskId,
iterator, null, inputCounter,
new OutputConverter(collector),
committer,
reporter, comparator, keyClass,
valueClass);
reducer.run(reducerContext);
}
这个reducer就是我们在客户端配置的reducer类,恰恰说明,我们reduce阶段的任务在每一次map中先小范围地进行了一次。
最后再来看init()方法:
MapOutputBuffer的作用就是在map的键值对向磁盘写出之前将其存放并完成sort和combine,init()方法代码很长,这里只摘取部分代码并结合图示加以说明
final float spillper =
job.getFloat(JobContext.MAP_SORT_SPILL_PERCENT, (float)0.8);
final int sortmb = job.getInt(JobContext.IO_SORT_MB, 100);
sorter = ReflectionUtils.newInstance(job.getClass("map.sort.class",
QuickSort.class, IndexedSorter.class), job);
minSpillsForCombine = job.getInt(JobContext.MAP_COMBINE_MIN_SPILLS, 3);
spiller是用户配置的溢写阈值,默认为0.8,也就是说当我们的buffer的容量使用了80%时,开始向磁盘溢写,这一过程是加了锁的,buffer的相应部分不能发生其他读写,同时buffer的剩余20%并行地进行数据写入;sortmb是用户配置的排序时buffer的大小,默认为100mb;sorter取用户自定义的排序器,默认为快速排序;minSpillsForCombine为启用combine的最少map溢写数量,默认为3.
我们的buffer实际上是一个字节数组:
byte[] kvbuffer; // main output buffer
我们可以用下图表示kvbuffer的存储过程:
注意到一个问题,我们在溢写前要对键值对进行排序,排序意味着比较和位置互换,但是不同的键值对所占字节不相同,互换难以进行,但是注意到我们在kvbuffer的尾部存储了键值对的元数据,元数据固定存放了分区号、键起始位置、值起始位置和值长度4个整型数值,长度为固定的16个字节,因此我们对元数据进行排序就可以巧妙地避开刚刚的问题。
排序之后键值对向磁盘溢写,此时kvbuffer仍然有数据并行地写入,如果按照上图所示按照溢写之前的方向写入数据,那么很快键值对和元数据会“相撞”,若想继续写入数据就要从数组的边界写入,但是此时元数据中的四个数值无法确定键值对的位置,因此我们从buffer中的空白区域划出一条“赤道”(equator),从赤道开始向两侧写,这样哪怕写到了数组的边界我们可以自动地从另一边界继续写入,读取键值对的时候也不需要额外的参数,因此我们的kvbuffer实际上是一种环形结构,我们称其为“环形缓冲区”,如下图。
spiller值的设定是一个调优点,如果设计得当,数据并行写入达到1-spiller的比例时,spillThread恰好完成溢写并释放锁。溢写操作是由MapOutputBuffer维护的BlockingBuffer完成的,是一个简单的磁盘IO操作
final BlockingBuffer bb = new BlockingBuffer();
...
bb.write(b0, 0, 0);
至此,Map阶段的三个部分input、map、output也已经介绍完了。
小结
- Input:LineRecordReader从split中读取记录,每一条记录调用一次map()方法
- Map:从LineRecordReader的value中提取出键值对,并进行二次排序和combine
- Output:使用环形缓冲区为排序提供场所,写出的数据为<K,V,P>
下一篇文章我们讨论ReduceTask的工作原理,敬请期待~