Hadoop中Map端shuffle過程及源碼解析

分析的源碼是基於Hadoop2.6.0。
官網上面的MapReduce過程
這裏寫圖片描述
Map端shuffle的過程:
在執行每個map task時,無論map方法中執行什麼邏輯,最終都是要把輸出寫到磁盤上。如果沒有reduce階段,則直接輸出到hdfs上,如果有有reduce作業,則每個map方法的輸出在寫磁盤前線在內存中緩存。每個map task都有一個環狀的內存緩衝區,存儲着map的輸出結果,在每次當緩衝區快滿的時候由一個獨立的線程將緩衝區的數據以一個溢出文件的方式存放到磁盤,當整個map task結束後再對磁盤中這個map task產生的所有溢出文件做合併,被合併成已分區且已排序的輸出文件。然後等待reduce task來拉數據。
二、 流程描述

1、 在child進程調用到runNewMapper時,會設置output爲NewOutputCollector,來負責map的輸出。
2、 在map方法的最後,不管經過什麼邏輯的map處理,最終一般都要調用到TaskInputOutputContext的write方法,進而調用到設置的output即NewOutputCollector的write方法。
3、NewOutputCollector其實只是對MapOutputBuffer的一個封裝,其write方法調用的是MapOutputBuffer的collect方法。
4、MapOutputBuffer的collect方法中把key和value序列化後存儲在一個環形緩存中,如果緩存滿了則會調用startspill方法設置信號量,使得一個獨立的線程SpillThread可以對緩存中的數據進行處理。
5、SpillThread線程的run方法中調用sortAndSpill方法對緩存中的數據進行排序後寫溢出文件。
6、當map輸出完成後,會調用output的close方法。
7、在close方法中調用flush方法,對剩餘的緩存進行處理,最後調用mergeParts方法,將前面過程的多個溢出文件合併爲一個。
這裏寫圖片描述

對上面流程進行解釋:
1、對第一步介紹:
MapTask中的run方法會根據新舊api選擇執行的Mapper函數。


    if (useNewApi) {
      runNewMapper(job, splitMetaInfo, umbilical, reporter);
    } else {
      runOldMapper(job, splitMetaInfo, umbilical, reporter);
    }

在runNewMapper方法的執行過程如下:

 void runNewMapper( job, splitIndex,umbilical,TaskReporter reporter){
 // 生成一個Mapper
        mapper =  ReflectionUtils.newInstance(taskContext.getMapperClass(), job);
        // 根據splitIndex構建一個split
        split = getSplitDetails(new Path(splitIndex.getSplitLocation()),splitIndex.getStartOffset());
        //生成RecordReader類型的input
input = new NewTrackingRecordReader(split, inputFormat, reporter, taskContext);
        // 根據Reducer數目對輸出output賦值
        if (job.getNumReduceTasks() == 0) {
            output = new NewDirectOutputCollector(taskContext, job, umbilical,
                    reporter);
        } else {
            output = new NewOutputCollector(taskContext, job, umbilical,
                    reporter);
        }
        //對mapContent進行賦值
    mapContext = new MapContextImpl(job, getTaskID(), input, output, committer, reporter, split);
     //mapContent進行包裝
     mapperContext = new WrappedMapper<INKEY, INVALUE, OUTKEY, OUTVALUE>()
                .getMapContext(mapContext);

        try {
            //根據分片內容,對mapperContext進行初始化
            input.initialize(split, mapperContext);
            //運行mapper的run方法
            mapper.run(mapperContext);
            input.close();
            //關閉output
            output.close(mapperContext);
        } finally {
            closeQuietly(input);
            closeQuietly(output, mapperContext);
        }
}

2、對第二步進行介紹:
Mapper類中的map方法中的Content繼承了MapContext,MapContext繼承了TaskInputOutputContext。
而在runNewMapper方法中的mapperContext變量,實現了對out的封裝。而out是NewOutputCollector類型變量。不管經過什麼邏輯的map處理,最終一般都要調用到TaskInputOutputContext的write方法,進而調用到設置的output即NewOutputCollector的write方法
3、對第三步進行介紹:
NewOutputCollector實現了對MapOutputCollector的封裝

private class NewOutputCollector<K, V> extendsRecordWriter<K, V> {
        private final MapOutputCollector<K, V> collector;
        NewOutputCollector(JobContext jobContext,
                JobConf job, TaskUmbilicalProtocol umbilical,
                TaskReporter reporter)  {
            collector = createSortingCollector(job, reporter);//對collector進行賦值,變成MapOutputBuffer類型
        }
       //寫操作
        @Override
        public void write(K key, V value)  {
            collector.collect(key, value,
                    partitioner.getPartition(key, value, partitions));
        }
      //關閉操作
        @Override
        public void close(TaskAttemptContext context) throws IOException,
                InterruptedException {
            try {
                collector.flush();
            } catch (ClassNotFoundException cnf) {
                throw new IOException("can't find class ", cnf);
            }
            collector.close();
        }
    }

其中MapOutputBuffer實現了MapOutputCollector接口。所以NewOutputCollector中的write方法調用的是MapOutputBuffer的collect方法。

什麼是MapOutputBuffer
MapOutputBuffer是一個用來暫時存儲map輸出的緩衝區,它的緩衝區大小是有限的,當寫入的數據超過緩衝區的設定的閥值時,需要將緩衝區的數據溢出寫入到磁盤,這個過程稱之爲spill,spill的動作會通過Condition通知給SpillThread,由SpillThread完成具體的處理過程。MR採用了循環緩衝區,做到數據在spill的同時,仍然可以向剩餘空間繼續寫入數據 。
這裏寫圖片描述

緩衝區之間的關係,從上圖即可一目瞭然。 kvoffsets作爲一級索引,是用來表示每個k,v在kvindices中的位置。當對kvbuffer中的某一個分區的KeyValue序列進行排序時,排序結果只需要將kvoffsets中對應的索引項進行交換即可,保證kvoffsets中索引的順序其實就想記錄的KeyValue的真實順序。換句話說,我們要對一堆對象進行排序,實際上只要記錄他們索引的順序即可,原始記錄保持不動,而kvoffsets就是一堆整數的序列,交換起來快得多。 kvindices中的內容爲:分區號、Key和Value在kvbuffer中的位置。通過解析這個數組,就可以得到某個分區的所有KV的位置。之所以需要按照分區號提取,是因爲Map的輸出結果需要分爲多份,分別送到不同的Reduce任務,否則還需要對key進行計算纔得到分區號。 kvbuffer存儲了實際的k,v。k,v的大小不向索引區一樣明確的是一對佔一個int,可能會出現尾部的一個key被拆分兩部分,一步存在尾部,一部分存在頭部,但是key爲保證有序會交給RawComparator進行比較,而comparator對傳入的key是需要有連續的,那麼由此可以引出key在尾部剩餘空間存不下時,如何處理。處理方法是,當尾部存不下,先存尾部,剩餘的存頭部,同時在copy key存到接下來的位置,但是當頭部開始,存不下一個完整的key,會付出溢出flush到磁盤。當碰到整個buffer都存儲不下key,那麼會拋出異常
MapBufferTooSmallException表示buffer太小容納不小.

MapOutputBuffer初始化分析

    // 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);

comparator是key之間用於比較的類,在沒有設置的情況下,默認是key所屬類裏面的一個子類,這個子類繼承自WritableComparator。以Text作爲key爲例,就是class Comparator extends WritableComparator。Map處理的輸入並不排序,會對處理完畢後的結果進行排序,此時就會用到該比較器。
serializationFactory,序列化工廠類,其功能是從配置文件中讀取序列化類的集合。Map處理的輸出是Key,Value集合,需要進行序列化才能寫到緩存以及文件中。
keySerializervalSerializer這兩個序列化對象,通過序列化工廠類中獲取到的。序列化和反序列化的操作基本類似,都是打開一個流,將輸出寫入流中或者從流中讀取數據。
keySerializer.open(bb)valSerializer.open(bb)打開的是流,但不是文件流,而是BlockingBuffer,也就是說後續調用serialize輸出key/value的時候,都是先寫入到Buffer中。這裏又涉及一個變量bb
其定義是:BlockingBuffer bb = new BlockingBuffer()。使用BlockingBuffer的意義在於將序列化後的Key或Value送入BlockingBuffer。BlockingBuffer內部又引入一個類:Buffer。Buffer實際上最終也封裝了一個字節緩衝區byte[] kvbuffer,Map之後的結果暫時都會存入kvbuffer這個緩存區,等到要慢的時候再刷寫到磁盤,Buffer這個類的作用就是對kvbuffer進行封裝,比如在其write方法中存在以下代碼:

 public synchronized void write(byte b[], int off, int len)
  {
          spillLock.lock();
          try {
           do {
 .......
               } while (buffull && !wrap);
           } finally {
           spillLock.unlock();
          }
        if (buffull) {
           final int gaplen = bufvoid - bufindex;
           System.arraycopy(b, off, kvbuffer, bufindex, gaplen);
           len -= gaplen;
           off += gaplen;
          bufindex = 0;
         }
        System.arraycopy(b, off, kvbuffer, bufindex, len);
         bufindex += len;
       }
 }

上面的 System.arraycopy 就是將要寫入的b(序列化後的數據)寫入到 kvbuffer中。因此,Buffer相當於封裝了kvbuffer,實現環形緩衝區等功能,BlockingBuffer則繼續對此進行封裝,使其支持內部Key的比較功能。
那麼,上面write這個方法又是什麼時候調用的呢?實際上就是MapOutputBuffer的collect方法中,會對KeyValue進行序列化,在序列化方法中,會進行寫入:

 public void serialize(Writable w) throws IOException {
       w.write(dataOut);
     }

此處的dataout就是前面 keySerializer.open(bb)這一 方法中傳進來的,也就是BlockingBuffer(又封裝了Buffer):

 public void open(OutputStream out) {
       if (out instanceof DataOutputStream) {
         dataOut = (DataOutputStream) out;
       } else {
         dataOut = new DataOutputStream(out);
       }
     }

因此,當執行序列化方法serialize的時候,會調用Buffer的write方法,最終將數據寫入byte[] kvbuffer。

collect分析
這裏分析的collect是MapOutputBuffer中的collect方法,在用戶層的map方法內調用collector.collect最終會一層層調用到MapOutputBuffer.collect。
collect的代碼我們分爲兩部分來看,一部分是根據索引區來檢查是否需要觸發spill,另外一部分是操作buffer並更新索引區的記錄。
第一部分代碼如下

if (bufferRemaining <= 0) {
        //  開始spill,如果溢寫線程沒有在運行並且內存已滿
        spillLock.lock();
        try {
          do {
            if (!spillInProgress) {
              final int kvbidx = 4 * kvindex;
              final int kvbend = 4 * kvend;
              final int bUsed = distanceTo(kvbidx, bufindex);
              final boolean bufsoftlimit = bUsed >= softLimit;
              if ((kvbend + METASIZE) % kvbuffer.length !=
                  equator - (equator % METASIZE)) {
                //  溢寫完成,修改空間參數
                resetSpill();
                bufferRemaining = Math.min(
                    distanceTo(bufindex, kvbidx) - 2 * METASIZE,
                    softLimit - bUsed) - METASIZE;
                continue;
              } else if (bufsoftlimit && kvindex != kvend) {//開始溢出
                startSpill();
                final int avgRec = (int)
                  (mapOutputByteCounter.getCounter() /
                  mapOutputRecordCounter.getCounter());
                final int distkvi = distanceTo(bufindex, kvbidx);
                final int newPos = (bufindex +
                  Math.max(2 * METASIZE - 1,
                          Math.min(distkvi / 2,
                                   distkvi / (METASIZE + avgRec) * METASIZE)))
                  % kvbuffer.length;
                setEquator(newPos);
                bufmark = bufindex = newPos;
                final int serBound = 4 * kvend;
                bufferRemaining = Math.min(
                    distanceTo(bufend, newPos),
                    Math.min(
                      distanceTo(newPos, serBound),
                      softLimit)) - 2 * METASIZE;
              }
            }
          } while (false);
        } finally {
          spillLock.unlock();
        }
      }

其中的resetSpill()是處理有key存在尾部存了一部分,頭部存了一部分的情況。由於key的比較函數需要的是一個連續的key,因此需要對key進行特殊處理。
startSpill() 是爲了激活spill

    private void startSpill() {
      assert !spillInProgress;
      kvend = (kvindex + NMETA) % kvmeta.capacity();
      bufend = bufmark;
      spillInProgress = true;
      spillReady.signal();//激活spill
    }

第二部分代碼。

 try {
        // 步驟1:序列化K
        int keystart = bufindex;
        keySerializer.serialize(key);
        if (bufindex < keystart) {
          // 對k進行轉換調整,使其連續
          bb.shiftBufferedKey();
          keystart = 0;
        }
        // 步驟2:序列化value,並標記一個完整k,v的結束位置
        final int valstart = bufindex;
        valSerializer.serialize(value);
        bb.write(b0, 0, 0);
        //record必須被標記在前面的寫之後,因爲這條record的元數據(二級索引)記錄還沒有被寫。
        int valend = bb.markRecord();

        mapOutputRecordCounter.increment(1);
        mapOutputByteCounter.increment(
            distanceTo(keystart, valend, bufvoid));

        // 步驟三:更新索引信息
        kvmeta.put(kvindex + PARTITION, partition);
        kvmeta.put(kvindex + KEYSTART, keystart);
        kvmeta.put(kvindex + VALSTART, valstart);
        kvmeta.put(kvindex + VALLEN, distanceTo(valstart, valend));
        // advance kvindex
        kvindex = (kvindex - NMETA + kvmeta.capacity()) % kvmeta.capacity();
      } catch (MapBufferTooSmallException e) {//MapBufferTooSmall異常
        LOG.info("Record too large for in-memory buffer: " + e.getMessage());
        spillSingleRecord(key, value, partition);
        mapOutputRecordCounter.increment(1);
        return;
}}

SpillThreadrun方法。

   public void run() {
       //臨界區
        spillLock.lock();
        spillThreadRunning = true;
        try {
          while (true) {
            spillDone.signal();
            //表示沒有要spill的記錄
            while (!spillInProgress) {
              spillReady.await();
            }
            try {
              spillLock.unlock();
              //執行操作
              sortAndSpill();
            } catch (Throwable t) {
              sortSpillException = t;
            } finally {
              spillLock.lock();
              if (bufend < bufstart) {
                bufvoid = kvbuffer.length;
              }
              kvstart = kvend;
              bufstart = bufend;
              spillInProgress = false;
            }
          }
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
        } finally {
          spillLock.unlock();
          spillThreadRunning = false;
        }
      }
    }

SpillThread線程的run方法中調用sortAndSpill把緩存中的輸出寫到格式爲+ ‘/spill’ + spillNumber + ‘.out’的spill文件中。索引(kvindices)保持在spill{spill號}.out.index中,數據保存在spill{spill號}.out中 創建SpillRecord記錄,輸出文件和IndexRecord記錄,然後,需要在kvoffsets上做排序,排完序後順序訪問kvoffsets,也就是按partition順序訪問記錄。按partition循環處理排完序的數組,如果沒有combiner,則直接輸出記錄,否則,調用combineAndSpill,先做combin然後輸出。循環的最後記錄IndexRecord到SpillRecord。

sortAndSpill 函數

private void sortAndSpill() throws IOException, ClassNotFoundException,InterruptedException {
               ........
            try {
                sorter.sort(MapOutputBuffer.this, mstart, mend, reporter);
                .......
                for (int i = 0; i < partitions; ++i) {
                    IFile.Writer<K, V> writer = null;
                    try {
                        //判斷有沒有combiner函數
                        if (combinerRunner == null) {
                            .......
                                writer.append(key, value);//鍵值寫到溢出文件
                            .......
                            }
                        } else {
                            .......
                                combinerRunner.combine(kvIter, combineCollector);
                            .......

                        }

                        // 給SpillRecord賦值
                        rec.startOffset = segmentStart;// 起始位置
                        rec.rawLength = writer.getRawLength()
                                + CryptoUtils.cryptoPadding(job);// 原始長度
                        rec.partLength = writer.getCompressedLength()
                                + CryptoUtils.cryptoPadding(job);// 壓縮後長度
                        spillRec.putIndex(rec, i);

                    } finally {
                        if (null != writer)
                            writer.close();
                    }
                }
                //當indexCacheList的累積到一定的長度時,才溢寫到文件中
                if (totalIndexCacheMemory >= indexCacheMemoryLimit) {
                    // create spill index file
                    Path indexFilename = mapOutputFile
                            .getSpillIndexFileForWrite(numSpills, partitions
                                    * MAP_OUTPUT_INDEX_RECORD_LENGTH);
                    spillRec.writeToFile(indexFilename, job);
                } else {
                    indexCacheList.add(spillRec);
                    totalIndexCacheMemory += spillRec.size()
                            * MAP_OUTPUT_INDEX_RECORD_LENGTH;
                }
            } finally {
                if (out != null)
                    out.close();
            }
        }

MapOutputBuffercompare方法和swap方法

MapOutputBuffer實現了IndexedSortable接口,從接口命名上就可以猜想到,這個排序不是移動數據,而是移動數據的索引。在這裏要排序的其實是kvindices對象,通過移動其記錄在kvoffets上的索引來實現。 如圖,表示了寫磁盤前Sort的效果。kvindices保持了記錄所屬的(Reduce)分區,key在緩衝區開始的位置和value在緩衝區開始的位置,通過kvindices,我們可以在緩衝區中找到對應的記錄。kvoffets用於在緩衝區滿的時候對kvindices的partition進行排序,排完序的結果將輸出到輸出到本地磁盤上,其中索引(kvindices)保持在spill{spill號}.out.index中,數據保存在spill{spill號}.out中。通過觀察MapOutputBuffer的compare知道,先是在partition上排序,然後是在key上排序。
這裏寫圖片描述

 public int compare(final int mi, final int mj) {
      final int kvi = offsetFor(mi % maxRec);
      final int kvj = offsetFor(mj % maxRec);
      final int kvip = kvmeta.get(kvi + PARTITION);
      final int kvjp = kvmeta.get(kvj + PARTITION);
      // 按照分區排序
      if (kvip != kvjp) {
        return kvip - kvjp;
      }
      // 按照key排序
      return comparator.compare(kvbuffer,
          kvmeta.get(kvi + KEYSTART),
          kvmeta.get(kvi + VALSTART) - kvmeta.get(kvi + KEYSTART),
          kvbuffer,
          kvmeta.get(kvj + KEYSTART),
          kvmeta.get(kvj + VALSTART) - kvmeta.get(kvj + KEYSTART));
    }

flush分析

我們看看flush是在哪個時間段調用的,在文章開始處說到runOldMapper處理的時候,有提到,代碼如下:

runner.run(in, new OldOutputCollector(collector, conf), reporter);
collector.flush()

Mapper的結果都已經被collect了,需要對緩衝區做一些最後的清理,調用flush方法,合併spill{n}文件產生最後的輸出。先等待可能的spill過程完成,然後判斷緩衝區是否爲空,如果不是,則調用sortAndSpill,做最後的spill,然後結束spill線程.

public void flush() throws IOException, ClassNotFoundException,
           InterruptedException {
      spillLock.lock();
      try {
        while (spillInProgress) {
          reporter.progress();
          spillDone.await();//寫進程等待
        }
        checkSpillException();

        final int kvbend = 4 * kvend;
        if ((kvbend + METASIZE) % kvbuffer.length !=
            equator - (equator % METASIZE)) {
          // 溢寫完成
          resetSpill();
        }
        if (kvindex != kvend) {//緩衝區還有數據沒有刷出去,則觸發spill
          kvend = (kvindex + NMETA) % kvmeta.capacity();
          bufend = bufmark;
          sortAndSpill();
        }
      } catch (InterruptedException e) {
        throw new IOException("Interrupted while waiting for the writer", e);
      } finally {
        spillLock.unlock();
      }
      assert !spillLock.isHeldByCurrentThread();
      // 停止spill線程
      try {
        spillThread.interrupt();
        spillThread.join();
      } catch (InterruptedException e) {
        throw new IOException("Spill failed", e);
      }
      // release sort buffer before the merge
      kvbuffer = null;
      //合併之前輸出的spill.1.out...spill.n.out爲file.out
      mergeParts();
      Path outputPath = mapOutputFile.getOutputFile();
      fileOutputByteCounter.increment(rfs.getFileStatus(outputPath).getLen());
    }

MapTask.MapOutputBuffermergeParts()方法.

從不同溢寫文件中讀取出來的,然後再把這些值加起來。因爲merge是將多個溢寫文件合併到一個文件,所以可能也有相同的key存在,在這個過程中如果配置設置過Combiner,也會使用Combiner來合併相同的key。?mapreduce讓每個map只輸出一個文件,並且爲這個文件提供一個索引文件,以記錄每個reduce對應數據的偏移量。

private void mergeParts() throws IOException, InterruptedException,
                ClassNotFoundException {
        ..........
            {
                IndexRecord rec = new IndexRecord();
                final SpillRecord spillRec = new SpillRecord(partitions);
                for (int parts = 0; parts < partitions; parts++) {//循環分區
                    //  創建需要merge的分片
                    List<Segment<K, V>> segmentList = new ArrayList<Segment<K, V>>(
                            numSpills);
                    for (int i = 0; i < numSpills; i++) {//循環共多少個split
                        IndexRecord indexRecord = indexCacheList.get(i)
                                .getIndex(parts);

                        Segment<K, V> s = new Segment<K, V>(job, rfs,
                                filename[i], indexRecord.startOffset,
                                indexRecord.partLength, codec, true);
                        segmentList.add(i, s);
                        }
                    }

                 ........
                    // 合併操作
                    @SuppressWarnings("unchecked")
                    RawKeyValueIterator kvIter = Merger.merge(job, rfs,
                            keyClass, valClass, codec, segmentList,
                            mergeFactor, new Path(mapId.toString()),
                            job.getOutputKeyComparator(), reporter,
                            sortSegments, null, spilledRecordsCounter,
                            sortPhase.phase(), TaskType.MAP);

                    //  將merge後的文件寫入到磁盤中 ,如果有combiner,要進行後寫入
                    long segmentStart = finalOut.getPos();
                    FSDataOutputStream finalPartitionOut = CryptoUtils
                            .wrapIfNecessary(job, finalOut);
                    Writer<K, V> writer = new Writer<K, V>(job,
                            finalPartitionOut, keyClass, valClass, codec,
                            spilledRecordsCounter);
                    if (combinerRunner == null
                            || numSpills < minSpillsForCombine) {
                        Merger.writeFile(kvIter, writer, reporter, job);
                    } else {
                        combineCollector.setWriter(writer);
                        combinerRunner.combine(kvIter, combineCollector);
                    }

                    // SpillRecord賦值
                    rec.startOffset = segmentStart;
                    rec.rawLength = writer.getRawLength()
                            + CryptoUtils.cryptoPadding(job);
                    rec.partLength = writer.getCompressedLength()
                            + CryptoUtils.cryptoPadding(job);
                    spillRec.putIndex(rec, parts);
                }
                spillRec.writeToFile(finalIndexFile, job);
                finalOut.close();
                for (int i = 0; i < numSpills; i++) {
                    rfs.delete(filename[i], true);
                }
            }
        }

合併前後index文件和spill文件的結構圖

merge最終生成一個spill.out和spill.out.index文件
從前面的分析指導,多個partition的都在一個輸出文件中,但是按照partition排序的。即把maper輸出按照partition分段了。一個partition對應一個reducer,因此一個reducer只要獲取一段即可。

參考文章
http://www.cnblogs.com/jirimutu01/p/4553678.html
http://www.it165.net/pro/html/201402/9903.html
http://blog.csdn.net/gjt19910817/article/details/37843135

發佈了18 篇原創文章 · 獲贊 4 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章