Hadoop学习笔记 - 06MapReduce MapTask源码解析

474 阅读9分钟

写在前面: 本文主要介绍了MapTask的工作流程,即:input-map-output

MapTask input流程

源码分析

首先看input的入口,即MapTaskrun方法

  public void run(final JobConf job, final TaskUmbilicalProtocol umbilical)
    throws IOException, ClassNotFoundException, InterruptedException {
    this.umbilical = umbilical;

    if (isMapTask()) {
      // If there are no reducers then there won't be any sort. Hence the map 
      // phase will govern the entire attempt's progress.
      // 如果没有reducer,则所有的资源都给map方法
      if (conf.getNumReduceTasks() == 0) { // 不需要排序
        mapPhase = getProgress().addPhase("map", 1.0f);
      } else {
        // If there are reducers then the entire attempt's progress will be 
        // split between the map phase (67%) and the sort phase (33%).
        // 如果有reducer,则分66.7%的资源给map方法,33.3%的资源给排序
        mapPhase = getProgress().addPhase("map", 0.667f);
        sortPhase  = getProgress().addPhase("sort", 0.333f);
      }
    }
    TaskReporter reporter = startReporter(umbilical);
 
    boolean useNewApi = job.getUseNewMapper();
    // 进行上下文初始化,以及确定map输出的outputFormat格式类。
    initialize(job, getJobID(), reporter, useNewApi);

    // check if it is a cleanupJobTask
    if (jobCleanup) {
      runJobCleanupTask(umbilical, reporter);
      return;
    }
    if (jobSetup) {
      runJobSetupTask(umbilical, reporter);
      return;
    }
    if (taskCleanup) {
      runTaskCleanupTask(umbilical, reporter);
      return;
    }

    if (useNewApi) {
      // hadoop2.x默认使用新的API启动Mapper
      runNewMapper(job, splitMetaInfo, umbilical, reporter);
    } else {
      runOldMapper(job, splitMetaInfo, umbilical, reporter);
    }
    done(umbilical, reporter);
  }

run方法中会先判断后续是否需要reduce,若不需要则所有资源都用于map流程,若需要则分配33.3%的资源用于partitionkey的排序。接着initialize方法进行上下文初始化,以及确定map输出的OutputFormat格式类。最终调用runNewMapper方法。

  private <INKEY,INVALUE,OUTKEY,OUTVALUE>
  void runNewMapper(final JobConf job,
                    final TaskSplitIndex splitIndex,
                    final TaskUmbilicalProtocol umbilical,
                    TaskReporter reporter
                    ) throws IOException, ClassNotFoundException,
                             InterruptedException {
    // make a task context so we can get the classes
    // 构造TaskAttemptContext的对象taskContext
    org.apache.hadoop.mapreduce.TaskAttemptContext taskContext =
      new org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl(job, 
                                                                  getTaskID(),
                                                                  reporter);
    // make a mapper
    // 通过taskContext,经过反射机制拿到job任务传递过来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);
    // make the input format
    // 通过taskContext可以构造从job传入的输入map的格式化类的对象。
    org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE> inputFormat =
      (org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE>)
        ReflectionUtils.newInstance(taskContext.getInputFormatClass(), job);
    // rebuild the input split
    // 创建这个MapTask需要使用到的split对象。
    // 这个方法内部是会根据当前这个mapTask传入的split对应的文件块地址,去hdfs中获取这个文件,
    // 然后调用seek方法跳转到这个split对应的读取文件块的offset坐标。
    org.apache.hadoop.mapreduce.InputSplit split = null;
    split = getSplitDetails(new Path(splitIndex.getSplitLocation()),
        splitIndex.getStartOffset());
    LOG.info("Processing split: " + split);

    // 构造RecordReader,也就是从split切片中读取一条条记录,格式化成RecordReader传给map处理
    org.apache.hadoop.mapreduce.RecordReader<INKEY,INVALUE> input =
      new NewTrackingRecordReader<INKEY,INVALUE>
        (split, inputFormat, reporter, taskContext);
    
    job.setBoolean(JobContext.SKIP_RECORDS, isSkipping());
    org.apache.hadoop.mapreduce.RecordWriter output = null;
    
    // get an output object
    if (job.getNumReduceTasks() == 0) {
      output = 
        new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
    } else {
      // NewOutputCollector对象确定了排序器,分区器
      output = new NewOutputCollector(taskContext, job, umbilical, reporter);
    }

    org.apache.hadoop.mapreduce.MapContext<INKEY, INVALUE, OUTKEY, OUTVALUE> 
    mapContext = 
      new MapContextImpl<INKEY, INVALUE, OUTKEY, OUTVALUE>(job, getTaskID(), 
          input, output, 
          committer, 
          reporter, split);

    org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE>.Context 
        mapperContext = 
          new WrappedMapper<INKEY, INVALUE, OUTKEY, OUTVALUE>().getMapContext(
              mapContext);

    try { // 核心方法一般在try里面
      input.initialize(split, mapperContext);
      // mapper就是我们自己的mapper类
      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);
    }
  }

该方法首先构造TaskAttemptContext的对象taskContext,再通过taskContext构造从job传入的输入map的格式化类的对象。然后通过getSplitDetails方法创建split对象,这个方法会根据MapTask传入的split对应的block locationHDFS中获取这个文件,然后在inFile.seek(offset);中跳转到这个split对应blockoffset坐标。

接着会构造RecordReader,也就是从split切片中读取一条条记录,格式化成RecordReader传给map处理。可以看到NewTrackingRecordReader的构造方法中,this.real = inputFormat.createRecordReader(split, taskContext);RecordReader real就是map任务读取split使用的对象,也是写出map处理结果所使用的对象。

由于inputFormat对象默认为TextInputFormat类,所以返回的是LineRecordReader对象。进入该对象的initialize方法看一看:

  public void initialize(InputSplit genericSplit,
                         TaskAttemptContext context) throws IOException {
    FileSplit split = (FileSplit) genericSplit;
    Configuration job = context.getConfiguration();
    this.maxLineLength = job.getInt(MAX_LINE_LENGTH, Integer.MAX_VALUE);
    start = split.getStart();
    end = start + split.getLength();
    final Path file = split.getPath();

    // open the file and seek to the start of the split
    final FutureDataInputStreamBuilder builder =
        file.getFileSystem(job).openFile(file);
    FutureIOSupport.propagateOptions(builder, job,
        MRJobConfig.INPUT_FILE_OPTION_PREFIX,
        MRJobConfig.INPUT_FILE_MANDATORY_PREFIX);
    fileIn = FutureIOSupport.awaitFuture(builder.build()); // 面向文件的输入流
    
    CompressionCodec codec = new CompressionCodecFactory(job).getCodec(file);
    if (null!=codec) {
      isCompressedInput = true;
      decompressor = CodecPool.getDecompressor(codec);
      if (codec instanceof SplittableCompressionCodec) {
        final SplitCompressionInputStream cIn =
          ((SplittableCompressionCodec)codec).createInputStream(
            fileIn, decompressor, start, end,
            SplittableCompressionCodec.READ_MODE.BYBLOCK);
        in = new CompressedSplitLineReader(cIn, job,
            this.recordDelimiterBytes);
        start = cIn.getAdjustedStart();
        end = cIn.getAdjustedEnd();
        filePosition = cIn;
      } else {
        if (start != 0) {
          // So we have a split that is only part of a file stored using
          // a Compression codec that cannot be split.
          throw new IOException("Cannot seek in " +
              codec.getClass().getSimpleName() + " compressed stream");
        }

        in = new SplitLineReader(codec.createInputStream(fileIn,
            decompressor), job, this.recordDelimiterBytes);
        filePosition = fileIn;
      }
    } else {
      fileIn.seek(start); // 每个map不可能读全文,需要从具体某个offset开始读取
      in = new UncompressedSplitLineReader(
          fileIn, job, this.recordDelimiterBytes, split.getLength());
      filePosition = fileIn;
    }
    // If this is not the first split, we always throw away first record
    // because we always (except the last split) read one extra line in
    // 确认split开始读取的位置
    if (start != 0) {
      start += in.readLine(new Text(), 0, maxBytesToConsume(start));
    }
    this.pos = start;
  }

开始start被赋值为第一个split第一行数据对应blockoffset,并且fileIn作为面向文件的输入流会通过seek方法从start开始读。然后该方法最有意思一段出现了:

if (start != 0) {
      start += in.readLine(new Text(), 0, maxBytesToConsume(start));
    }

由于HDFS用于划分文件的block是通过字节来划分的,一行数据可能会被分割在两个block中,map计算肯定需要读取完整的一行。所以这时候会去判断start是否不为0,也就是判断该split是否是第一个split。如果不是则丢弃第一行,并把起始的offsetstart更新为该split的第二行。简单来说,除了第一个split,都会丢弃第一行数据,除了最后一个split,都会多读取一行数据。这样可以弥补HDFSblock切割数据的问题。

LineRecordReader初始化完成后,执行mapper.run(mapperContext),也就是我们自己写的Mapper方法。

  public void run(Context context) throws IOException, InterruptedException {
    setup(context);
    try {
      // 最终调用LineRecordReader的nextKeyValue方法
      // 1 读取数据中的一条记录,并对key,value赋值 2 返回一个布尔值给调用者,声明是否还有数据。

      while (context.nextKeyValue()) {
        map(context.getCurrentKey(), context.getCurrentValue(), context);
      }
    } finally {
      cleanup(context);
    }
  }

这里的contextMapContextImplcontext.nextKeyValue()方法相当于LineRecordReadernextKeyValue方法。该方法读取数据中的一条记录,对keyvalue赋值,并返回是否还有数据。然后通过getCurrentKeygetCurrentValue分别获取keyvalue

MapTask output流程

源码分析

继续看MapTask中的run方法。

    if (job.getNumReduceTasks() == 0) {
      output = 
        new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
    } else {
      // NewOutputCollector对象确定了排序器,分区器
      output = new NewOutputCollector(taskContext, job, umbilical, reporter);
    }

这里查看需要分区排序的情况,进入NewOutputCollector构造方法:

    NewOutputCollector(org.apache.hadoop.mapreduce.JobContext jobContext,
                       JobConf job,
                       TaskUmbilicalProtocol umbilical,
                       TaskReporter reporter
                       ) throws IOException, ClassNotFoundException {
      // buffer缓冲区相关逻辑
      collector = createSortingCollector(job, reporter);
      partitions = jobContext.getNumReduceTasks(); // 有多少reduce任务就有多少分区
      //如果reduceTask大于1,就通过反射机制获取分区器,可以自定义分区器!
      //如果只有一个reduceTask,那就直接返回0,因为只会被那一个ReduceTask拉取。
      if (partitions > 1) {
        partitioner = (org.apache.hadoop.mapreduce.Partitioner<K,V>)
          ReflectionUtils.newInstance(jobContext.getPartitionerClass(), job);
      } else { // partitions == 1 的情况
        partitioner = new org.apache.hadoop.mapreduce.Partitioner<K,V>() {
          @Override
          public int getPartition(K key, V value, int numPartitions) {
            return partitions - 1; // 返回0
          }
        };
      }
    }

NewOutputCollector对象确定排序器collector和分区器partitioner。首先查看创建排序器的方法createSortingCollector,该方法中默认给定MapOutputBuffer作为排序器。我们查看关键方法,初始化排序器collector.init(context)

    public void init(MapOutputCollector.Context context
                    ) throws IOException, ClassNotFoundException {
      job = context.getJobConf();
      reporter = context.getReporter();
      mapTask = context.getMapTask();
      mapOutputFile = mapTask.getMapOutputFile();
      sortPhase = mapTask.getSortPhase();
      spilledRecordsCounter = reporter.getCounter(TaskCounter.SPILLED_RECORDS);
      partitions = job.getNumReduceTasks();
      rfs = ((LocalFileSystem)FileSystem.getLocal(job)).getRaw();

      //sanity checks
      // 当这个排序缓冲区空间被map输出记录占用一定比例的时候,发生排序并溢写到磁盘
      final float spillper =
        job.getFloat(JobContext.MAP_SORT_SPILL_PERCENT, (float)0.8); // 溢写的默认值,80%而不是100%是为了放置map往buffer写的时候阻塞
      // 当前缓冲区的大小
      final int sortmb = job.getInt(MRJobConfig.IO_SORT_MB,
          MRJobConfig.DEFAULT_IO_SORT_MB);
      indexCacheMemoryLimit = job.getInt(JobContext.INDEX_CACHE_MEMORY_LIMIT,
                                         INDEX_CACHE_MEMORY_LIMIT_DEFAULT);
      if (spillper > (float)1.0 || spillper <= (float)0.0) {
        throw new IOException("Invalid \"" + JobContext.MAP_SORT_SPILL_PERCENT +
            "\": " + spillper);
      }
      if ((sortmb & 0x7FF) != sortmb) {
        throw new IOException(
            "Invalid \"" + JobContext.IO_SORT_MB + "\": " + sortmb);
      }
      // 排序器,默认快排
      sorter = ReflectionUtils.newInstance(job.getClass(
                   MRJobConfig.MAP_SORT_CLASS, QuickSort.class,
                   IndexedSorter.class), job);
      // buffers and accounting
      int maxMemUsage = sortmb << 20;
      maxMemUsage -= maxMemUsage % METASIZE;
      kvbuffer = new byte[maxMemUsage];
      bufvoid = kvbuffer.length;
      kvmeta = ByteBuffer.wrap(kvbuffer)
         .order(ByteOrder.nativeOrder())
         .asIntBuffer();
      setEquator(0);
      bufstart = bufend = bufindex = equator;
      kvstart = kvend = kvindex;

      maxRec = kvmeta.capacity() / NMETA;
      softLimit = (int)(kvbuffer.length * spillper);
      bufferRemaining = softLimit;
      LOG.info(JobContext.IO_SORT_MB + ": " + sortmb);
      LOG.info("soft limit at " + softLimit);
      LOG.info("bufstart = " + bufstart + "; bufvoid = " + bufvoid);
      LOG.info("kvstart = " + kvstart + "; length = " + maxRec);

      // k/v serialization
      // 比较器
      comparator = job.getOutputKeyComparator();
      keyClass = (Class<K>)job.getMapOutputKeyClass();
      valClass = (Class<V>)job.getMapOutputValueClass();
      serializationFactory = new SerializationFactory(job);
      keySerializer = serializationFactory.getSerializer(keyClass);
      keySerializer.open(bb);
      valSerializer = serializationFactory.getSerializer(valClass);
      valSerializer.open(bb);

      // output counters
      mapOutputByteCounter = reporter.getCounter(TaskCounter.MAP_OUTPUT_BYTES);
      mapOutputRecordCounter =
        reporter.getCounter(TaskCounter.MAP_OUTPUT_RECORDS);
      fileOutputByteCounter = reporter
          .getCounter(TaskCounter.MAP_OUTPUT_MATERIALIZED_BYTES);

      // compression
      if (job.getCompressMapOutput()) {
        Class<? extends CompressionCodec> codecClass =
          job.getMapOutputCompressorClass(DefaultCodec.class);
        codec = ReflectionUtils.newInstance(codecClass, job);
      } else {
        codec = null;
      }

      // combiner
      // 如果map阶段重复key很多没有必要,可以做一次小的reduce进行一次压缩
      final Counters.Counter combineInputCounter =
        reporter.getCounter(TaskCounter.COMBINE_INPUT_RECORDS);
      combinerRunner = CombinerRunner.create(job, getTaskID(), 
                                             combineInputCounter,
                                             reporter, null);
      if (combinerRunner != null) {
        final Counters.Counter combineOutputCounter =
          reporter.getCounter(TaskCounter.COMBINE_OUTPUT_RECORDS);
        combineCollector= new CombineOutputCollector<K,V>(combineOutputCounter, reporter, job);
      } else {
        combineCollector = null;
      }
      spillInProgress = false;
      minSpillsForCombine = job.getInt(JobContext.MAP_COMBINE_MIN_SPILLS, 3);
      spillThread.setDaemon(true);
      spillThread.setName("SpillThread");
      spillLock.lock();
      try {
        spillThread.start();
        while (!spillThreadRunning) {
          spillDone.await();
        }
      } catch (InterruptedException e) {
        throw new IOException("Spill thread failed to initialize", e);
      } finally {
        spillLock.unlock();
      }
      if (sortSpillException != null) {
        throw new IOException("Spill thread failed to initialize",
            sortSpillException);
      }
    }

浏览一下该方法,有几个关键点:

  • spillper:当缓冲区被map的输出占用到一定比例时,则发生分区、排序并溢写到磁盘。默认80%;
  • sortmb:缓冲区大小;
  • sorter:发生排序时使用的排序器,默认快排;
  • comparator:排序器所需的比较器,优先使用用户自定义的排序比较器,默认使用key自身的比较器;
  • combinerRunner:默认不合并,需要手动改设置。用于map阶段对重复的key进行reduce操作,合并次数默认为3;
  • SpillThread:该线程中的sortAndSpill方法实现了排序和溢写。

再回到Mapper中。run方法调用了map方法。

  protected void map(KEYIN key, VALUEIN value, 
                     Context context) throws IOException, InterruptedException {
    context.write((KEYOUT) key, (VALUEOUT) value);
  }

这里的contextMapContextImpl,进入write方法,发现MapContextImpl中没有该方法,则向他的父类寻找。随后在TaskInputOutputContextImpl中发现了该实现:

  public void write(KEYOUT key, VALUEOUT value
                    ) throws IOException, InterruptedException {
    output.write(key, value);
  }

这里的output就是上文提到的NewOutputCollector,可以看到它的write方法如下:

    public void write(K key, V value) throws IOException, InterruptedException {
      // map进入buffer时,参数为k v p
      collector.collect(key, value,
                        partitioner.getPartition(key, value, partitions));
    }

partitions是通过getPartition方法获取的:

  public int getPartition(K key, V value,
                          int numReduceTasks) {
    // key.hashCode() & Integer.MAX_VALUE得到一个非负整数
    // % numReduceTasks 取模 相同的key会进入同一个分区
    return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
  }

最终将key value partitionsmap存入了buffer中。

图解MapOutputBuffer流程

buffer本质是线性的字节数组,存储key value后还需要相应的索引才能方便查询。关于索引,它固定16并且包含一下内容:

  • Ppartition
  • KSkeybuffer中的起始位置
  • VSvaluebuffer中的起始位置,也可以计算出key结束的位置
  • VLvalue的长度,也可以计算出value的结束位置

在Hadoop 1.x的版本中,索引是存放在一个独立的字节数组中的。试想这样一种情况,key value非常小,但是数量较多,在buffer中占用的空间较小。与此同时存放索引的空间缺满了,这样不得不进行溢写,从而浪费了buffer的空间。

hadoop1.x buffer存储结构.png

到了Hadoop 2.x后,key value和索引存放在同一个字节数组中,且key value按序从左向右存储,索引按需从右向左存储。这样就解决了buffer空间浪费的问题。

hadoop2.x buffer存储结构.png

假设现在是默认情况,buffer的占用空间已达到80%,会将当前key value和索引占用的空间锁住,然后启动SpillThread线程将80%的数据进行快速排序,同时map向剩余空间写数据。此时的排序是二次排序,先通过索引中的P进行排序,再在相同的P中通过key排序,最终达到分区有序以及分区内的key有序。

需要注意的是,排序涉及到内存数据的移动,由于key value的大小不一致,数据移动会很复杂。所以这里移动的只是索引,因为索引大小是固定的。最终,溢写时只要按照排序的索引,写入磁盘文件中的数据就是有序的。

接下来需要关注溢写的同时map如何向buffer中写数据。

溢写的同时写数据.png

在空闲的内存中进行一次分割,分割线左侧追加索引,分割线右边追加key value,这样就可以将buffer看作是环形缓冲区。

combiner TODO 待完善

combiner当于提前对数据进行reduce操作,该操作发生在buffer排序后以及溢写前。

map最终会把一些出来的小文件合并成一个大文件,避免小文件碎片化导致reduce在拉取数据时造成随机读写。