MapReduce原理及源码浅析(二)

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的工作原理,敬请期待~

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值