mapreduce源码分析

561 阅读7分钟

mapreduce

Mapreduce是什么?

是一个分布式计算框架,支持少量代码实现海量数据并发处理程序。多个服务器同时进行计算,极大的提高了计算效率。

Mapreduce优点:

良好的扩展性:可以通过简单的增加机器扩展计算能力 容错性:其中一台机器挂掉了,上面的计算任务自动转移到另一个节点运行

Mapreduce缺点:

MapReduce无法在毫秒或者秒级时间内返回结果。 后一个应用程序的输入为前一个的输出,MapReduce的输入输出都会写入到磁盘,会造成大量的磁盘IO,会导致性能底下。

一、map过程

一个mapper节点可能有多个文件

  • 文件会被切分为三个block,(hadoop2.0以后默认每个datanode的block大小为128M),每个split与maptask是一一对应的关系。split的数量由InputSplitFormat类的getSplits 决定

1.1 启动map

//默认使用新版mapper
public void run{
     ···········//省略,此处进行选择新旧版api,以新版api为例
    if (useNewApi) {
      runNewMapper(job, splitMetaInfo, umbilical, reporter);
    } else {
      runOldMapper(job, splitMetaInfo, umbilical, reporter);
    }
}

运行一个新的mapper时候,mapTask会做什么?

void runNewMapper(final JobConf job,final TaskSplitIndex splitIndex,final TaskUmbilicalProtocol umbilical,
TaskReporter reporter) throws IOException, ClassNotFoundException,InterruptedException {
  // 创建taskContext 用于从conf配置文件中获取classes
  // 使用反射设置mapper.class
  ReflectionUtils.newInstance(taskContext.getMapperClass(), job); 设置mapper
  // 设置InputFormat.class
  ReflectionUtils.newInstance(taskContext.getInputFormatClass(), job);
  // 设置inputsplit.class
  split = getSplitDetails(new Path(splitIndex.getSplitLocation()),splitIndex.getStartOffset());
  // 将输入转换为需要的 <k,v>结构,作为mapper的输入
	org.apache.hadoop.mapreduce.RecordReader<INKEY,INVALUE> input =
	new NewTrackingRecordReader<INKEY,INVALUE>(split, inputFormat, reporter, taskContext);
	// 判断reduce是否为0,为0则将map输出落hdfs,否则将输出暂存本地磁盘进一步处理传给reducer
    if (job.getNumReduceTasks() == 0) {
      output = 
        new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
    } else {
      output = new NewOutputCollector(taskContext, job, umbilical, reporter);
    }
    ·········
    
  // 运行mapper函数
  //依次执行 setup->map->cleanup
  mapper.run(mapperContext);

我们来看看单个mapper运行时分哪些步骤?

public void run(Context context) throws IOException, InterruptedException {
  //setup主要进行资源的集中创建,根据实际解析需要重写
    setup(context);
    try {
    	//通过调用RecordReader 不断获取下一行数据并设置为CurrentKey/CurrentValue,作为map的<KeyIn,ValueIn>调用至map处理
      while (context.nextKeyValue()) { 
  			// 我们需要重写处理逻辑解析函数map
        map(context.getCurrentKey(), context.getCurrentValue(), context);
      }} finally {
  //cleanup做资源的清理工作
      cleanup(context);
      }}}
       
//每行数据都会调用一次map函数,我们最终需要把处理完的结果写入到map中,作为KEYOUT/VALUEOUT
  protected void map(KEYIN key, VALUEIN value, 
                     Context context) throws IOException, InterruptedException {
     //这里写我们的处理逻辑,最终要写到context中
    context.write((KEYOUT) key, (VALUEOUT) value);  
  }
  

2. shuffle

2.1 partition:

  • 要求:将具有同一key的数据放置于同一节点上(超级key?)

  • 使用哈希进行分区来实现同key同区(partition的数目等于reducer) 但是不同key也可在一partition

2.2 spill

  • 环形缓冲区写满默认的80%数据后,触发一次溢写过程,此时同时存在两个过程:
    1. 此80%的数据由内存写入磁盘
    2. 剩余的20%空间继续写入数据到内存
  • 当剩余的20%空间也写满&&磁盘数据未写完时,只能等待剩余数据写入磁盘,不可在进行写入操作。
  • 当数据全部落入磁盘,本轮spill完成,开始新一轮的写入,溢出。 环形缓冲区结构:

/ 输出结果,进入collect函数,获取分区信息,将数据写入内存缓冲区,写满后进行flush到磁盘
// 环形缓冲区
// start spill if the thread is not running and the soft limit has been
// 当写达到缓冲区阈值后进行写磁盘->临时文件 ,此时进行分partition排序 ,快排序先比较partition,在比较值
// 排序完成后,生成一堆小文件
sortAndSpill();   
//此时在按着堆排序和多路归并,生成一个大文件,大文件中按partition排列 ,每个partition区域内有序。

2.3 merge

每个maptask可能产生多个溢写文件 ->多路归并(小顶堆维护最小值) -->mapout

  1. sort :
    1. 按key排序,排序方式可自定义
  2. combine:
    1. 可以看为一次小的reduce,避免过多过大的文件参与到reduce过程中,先使用小计算进行合并
    2. 非默认过程,需要手动设置job.setCombinerClass(myCombine.class
    3. 计算规则与reduce一致,但是不是每种作业都可以做combine操作的,比如wordcount可以,(可以理解为计算顺序不影响结果的计算就可以进行combine?)
  3. 要求reduce的输入输出类型都一样,因为combine本质上就是reduce操作,不能影响后续的reduce输入输出
  4. combine操作后不会影响计算结果,像求和就不会影响

进行merge的准备工作:mergeParts,包装segments


//  索引列表:记录每条记录的开始位置,长度,所属的partition,后面的merge通过这个来找到属于每个partition的记录
public class IndexRecord {
  public long startOffset;
  public long rawLength;
  public long partLength;
  ······
 }

// 将多个文件进行合并的函数
MapOutputCollector.mergeParts()
{
// 在partition次(多路归并+堆排序)中将内部有序的多个小文件合并为一个按partition划分生成一个具有起始值,长度,以及索引的大文件,与此同时原文件将被删除
 for (int parts = 0; parts < partitions; parts++) {
// 1. 获取该partition的小文件们
      for(int i = 0; i < numSpills; i++) {
        filename[i] = mapOutputFile.getSpillFile(i);
        finalOutFileSize += rfs.getFileStatus(filename[i]).getLen();
      }
// 2. 将索引到的属于此partition的记录包装为segment对象,每个segment均为关于某个partition的有序记录,获取到的多个segment为所有文件关于此partition的记录
       for(int i = 0; i < numSpills; i++) {
       //从索引列表中查询这个partition对应的所有索引信息
            IndexRecord indexRecord = indexCacheList.get(i).getIndex(parts);
        //将此条记录包装为segment对象,后续操作均为segment
            Segment<K,V> s =
              new Segment<K,V>(job, rfs, filename[i], indexRecord.startOffset,
                               indexRecord.partLength, codec, true);     
            segmentList.add(i, s);
            if (LOG.isDebugEnabled()) {
              LOG.debug("MapId=" + mapId + " Reducer=" + parts +
                  "Spill =" + i + "(" + indexRecord.startOffset + "," +
                  indexRecord.rawLength + ", " + indexRecord.partLength + ")");
            }
          } 
// 3. 进行merge操作 kvIter 为可迭代对象,不断移动堆进行排序,每个可迭代对象都是一个个segment(内部有序记录),需要用最小堆维护最小值(不断获取多个segment的的最小一条记录),进行多路归并排序
       RawKeyValueIterator kvIter = Merger.merge(job, rfs·······segmentList, mergeFactor)
// 4. 将排好序的文件写入到磁盘 
      long segmentStart = finalOut.getPos();
      Writer<K, V> writer = new Writer<K, V>(job, finalOut, keyClass, valClass, codec,
                               spilledRecordsCounter);
          if (combinerRunner == null || numSpills < minSpillsForCombine) {
            Merger.writeFile(kvIter, writer, reporter, job);
          } else {  // 有combine则进行
            combineCollector.setWriter(writer);
            combinerRunner.combine(kvIter, combineCollector);
          }
 // 5.记录此次partition归并排序后写入文件的位置,数据长度,压缩后的数据长度,多个partition在一个文件中据此区分起始
     			rec.startOffset = segmentStart;
          rec.rawLength = writer.getRawLength();
          rec.partLength = writer.getCompressedLength();
          spillRec.putIndex(rec, parts);
        }
        spillRec.writeToFile(finalIndexFile, job);
        finalOut.close();
        for(int i = 0; i < numSpills; i++) {  //删除小文件
          rfs.delete(filename[i],true);
        }
        

我们接下来看看merge操作主要过程

RawKeyValueIterator merge(Class<K> keyClass, Class<V> valueClass,
                                     int factor, int inMem, Path tmpDir,
                                     Counters.Counter readsCounter,
                                     Counters.Counter writesCounter,
                                     Progress mergePhase)
  // 每个map排序生成一堆小文件,每个文件按partition 划分出一个个segment (此处应有图)
  // 我们需要将这些有序segment进行用多路归并排序。
  // 多路归并排序使用多个文件的最小值,使用小顶堆方式构建最小值
  do {
  //每次merge 默认100个segment
  initialize(segmentsToMerge.size());
  clear();
  for (Segment<K, V> segment : segmentsToMerge) {
  //将segment加入小顶堆中,维护最小segment队列
  put(segment);  
  }
  Writer<K, V> writer = 
  new Writer<K, V>(conf, fs, outputFile, keyClass, valueClass, codec,
  writesCounter);
  // 将排好序的segment,使用writer进行写文件
  writeFile(this, writer, reporter, conf);  
  
  //排序好的segments
  Segment<K, V> tempSegment = new Segment<K, V>(conf, fs, outputFile, codec, false);

  // Insert new merged segment into the sorted list
  // 将新排好序的segment 插入到排序好的segment list中 二分查找
  int pos = Collections.binarySearch(segments, tempSegment,segmentComparator);
	}while(true)

3.reduce

3.1 copy

主要是去每个map的输出文件中copy自己partition部分的数据

  • 当map任务完成超过一定百分比后,触发了reduce进程,通过http请求map端数据到内存中处理,与map端内存缓冲区类似,溢写文件过程类似
//将map输出文件从每个map中复制自己的分片数据,保存到内存或磁盘
 public RawKeyValueIterator run() throws IOException, InterruptedException {
    // Scale the maximum events we fetch per RPC call to mitigate OOM issues
    // on the ApplicationMaster when a thundering herd of reducers fetch events
    int eventsPerReducer = Math.max(MIN_EVENTS_TO_FETCH,
        MAX_RPC_OUTSTANDING_EVENTS / jobConf.getNumReduceTasks());
    int maxEventsToFetch = Math.min(MAX_EVENTS_TO_FETCH, eventsPerReducer);

     // 启动获取map完成情况的线程以及获取map输出的线程
		
    // Start the map-completion events fetcher thread
    final EventFetcher<K,V> eventFetcher = 
      new EventFetcher<K,V>(reduceId, umbilical, scheduler, this,
          maxEventsToFetch);
    eventFetcher.start();
    
    // Start the map-output fetcher threads
    final int numFetchers = jobConf.getInt(MRJobConfig.SHUFFLE_PARALLEL_COPIES, 5);
    Fetcher<K,V>[] fetchers = new Fetcher[numFetchers];
    for (int i=0; i < numFetchers; ++i) {
      fetchers[i] = new Fetcher<K,V>(jobConf, reduceId, scheduler, merger, 
                                     reporter, metrics, this, 
                                     reduceTask.getShuffleSecret());
      fetchers[i].start();
    }

//等待线程完成

3.2 merge

  • reduce的merge输入是每个map的输出分片的一个合并过的溢出文件
  • map的merge输入的是每个spill操作输出的spill_file
  • 对多个map输出的分片文件在进行多路归并过程。

有如下三种merge方式,按顺序分别为 1)内存到磁盘2)内存到内存 3)磁盘到磁盘 默认第二种不开启,三种merge均为归并排序,与map阶段的merge相同,迭代器维护小顶堆,同上

image.png

3.3 3.reduce

随着merge不断移动小顶堆输出迭代对象,输入到reduce函数中进行处理。这里写我们的reduce逻辑

细节

  1. 环形缓冲区的好处:
  • 不需要申请新的内存空间,环形指针,循环利用
  • 不存在gc问题

2.整个过程都有哪些排序查找算法

  • 快排,在spill阶段
  • 堆排序和多路归并排序,这两个放在一起是因为总是一起使用,在map中的merge和reduce中的merge,需要将多个小文件进行多路归并,这个过程使用小顶堆来维护最小值,不断移动小顶堆来保证最小值归并:
    • 堆排序,构建segment的最小值
    • 多路归并排序,在进行合并多个的有序小文件为一个文件的过程,对segment的排序
  • 二分查找,将segment加入有序的segmentlist,使用二分查找