Hadoop Shuffle詳解

xaio7biancheng發表於2018-09-07

每個任務最重要的一個過程就Shuffle過程,這個過程會把所有的資料進行洗牌整理,排序,如果資料量大,將會非常的耗時。如圖1.1所示,是一個從map端輸出資料到合併成一個檔案的過程。

圖1.1  Map檔案輸出

從圖中可以看到Map端輸出的資料會被提交到一個記憶體緩衝區當中,當記憶體滿了後,會被Spill到HDFS中,當Map任務結束後,會把所有的臨時檔案合併到一個最終的檔案中,作為一個最終的Map輸出檔案。這個過程涉及到兩個過程都有排序操作,第一個是從KVBuffer到檔案Spill中,預設通過快速排序演算法進行排序。第二個是所有臨時檔案合併時,此時會有一次多路歸併排序的過程,使用歸併排序演算法。

1  Mapper的輸出緩衝區kvbuffer

Mapper任務執行完後的資料會通過MapOutputBuffer提交到一個kvbuffer緩衝區中,這個緩衝區的資料是專門儲存map端輸出資料的,它是一個環形緩衝區,大小可通過配置mapreduce.task.io.sort.mb來配置這個緩衝區的大小,預設是100MB。kvbuffer本質上是一個byte陣列,模擬的環形資料結構,環形緩衝區適用於寫入和讀取的內容保持在順序的情況下,要不然就不能均勻的向前推進。

雖然在Hadoop中,資料是要排序的,但是在Hadoop中有個非常良好的策略,就是不移動資料本身,而是為每個資料建立一個後設資料kvmeta,在排序的時候,直接對後設資料進行排序,然後順序讀寫後設資料即可。

kvbuffer邏輯結構如圖1.2所示。

圖1.2  kvbuffer結構

圖中長條矩形表示作為位元組陣列的緩衝區kvbuffer,其七點處的下標為0,終點處的下標為kvbuffer.length。注意,這是按環形緩衝區使用的,所以往裡寫入內容時一旦超過終點就又“翻折”到緩衝區的起點,反之亦然。

分隔點的位置可以在緩衝區的任何位置上,分隔點的位置確定後,資料(KV對)都放在分隔點的右側,並且向右伸展,而後設資料則放在它的左側,並且向左擴充套件。

寫入到緩衝區的每個KV對都有一組配套的後設資料指明其位置和長度。KV對長度是可變的,但後設資料的長度是固定的,都是16位元組,即4個整數。這樣,所有的後設資料合併在一起就是一個後設資料塊,相當於一個(倒立的)陣列,可以通過KV對的後設資料,再按照其後設資料的指引就可找到這個KV對的K和V,還可以知道這個KV對屬於哪個Partition。

其中後設資料的資料主要是構成如下:

//val offset in acct ,第一個整數是V值起點位元組的下標。

int VALSTART=0

//key offset in acct,第二個正式是K值起點位元組的下標。

int KEYSTART=0

//partition offset in acct,第三個整數是KV對所屬的Partition

int PARTITION=2

//length of value,第四個整數是V值的長度

int VALLEN=3   

如圖1.3所示,在有些情況,資料緩衝區在底部,自底向上伸展,後設資料則在頂部,自頂向下伸展;二者相互靠攏。

       

圖1.3  kvbuffer變數

其中上圖的引數,kvstart指向後設資料塊中的第一份後設資料,kvend指向後設資料塊的最後一份資料。kvindex指向下一份後設資料指向的位置。buffer index指向下一份(KV資料對)的寫入位置,buffer start是KV(資料對)開始的位置。kvindex,kvstart,kvend是整數型別的陣列下標。

2  Sort和Spill

當環形緩衝區kvbuffer滿了或者達到一定的閾值後,需要把緩衝區的資料寫入臨時檔案中,這個過程叫sortAndSpill。在原始碼中可以看到有個專門的Spill執行緒來負責這個工作,當有需要Spill操作的時候,執行緒會被喚醒,然後執行Spill,在Spill之前,會有一個sort階段,先把kvbuffer中的資料按照partition值和key兩個關鍵字升序排序,移動的只是索引資料,排序結果是kvmeta中資料按照partition為單位聚集在一起,同一partition內的按照key有序。

詳細的sortAndSpill程式碼如下:

private void sortAndSpill() throws IOException, ClassNotFoundException,
                                       InterruptedException {

      //approximate the length of the output file to be the length of the
      //buffer + header lengths for the partitions

      long size = (bufend >= bufstart ? bufend - bufstart : (bufvoid - bufend) + bufstart) +
                   partitions * APPROX_HEADER_LENGTH;

      FSDataOutputStream out = null;

      try {
        // create spill file
        final SpillRecord spillRec = new SpillRecord(partitions); //每個partiiton定義一個索引
        final Path filename =  mapOutputFile.getSpillFileForWrite(numSpills, size);
        out = rfs.create(filename);
        final int endPosition = (kvend > kvstart) ? kvend : kvoffsets.length + kvend;
        // 使用快速排序演算法
        sorter.sort(MapOutputBuffer.this, kvstart, endPosition, reporter);

        int spindex = kvstart;
        // Spill檔案的索引
        IndexRecord rec = new IndexRecord();
        InMemValBytes value = new InMemValBytes();

        for (int i = 0; i < partitions; ++i) {  // 迴圈訪問各個分割槽
          IFile.Writer<K, V> writer = null;
          try {
            long segmentStart = out.getPos();
            writer = new Writer<K, V>(job, out, keyClass, valClass, codec,
                                      spilledRecordsCounter);

            if (combinerRunner == null) {  //沒有定義combiner
              // spill directly
              DataInputBuffer key = new DataInputBuffer();
              while (spindex < endPosition &&
                  kvindices[kvoffsets[spindex % kvoffsets.length] + PARTITION] == i) {

                final int kvoff = kvoffsets[spindex % kvoffsets.length];
                getVBytesForOffset(kvoff, value);
                key.reset(kvbuffer, kvindices[kvoff + KEYSTART],
                          (kvindices[kvoff + VALSTART] - kvindices[kvoff + KEYSTART]));
                writer.append(key, value);
                ++spindex;

              }

            } else { //定義了combiner,使用combiner合併資料
              int spstart = spindex;
              while (spindex < endPosition &&
                       kvindices[kvoffsets[spindex % kvoffsets.length] + PARTITION] == i) {
                ++spindex;
              }

              // Note: we would like to avoid the combiner if we've fewer
              // than some threshold of records for a partition
              if (spstart != spindex) {
                combineCollector.setWriter(writer);
                RawKeyValueIterator kvIter = new MRResultIterator(spstart, spindex);
                combinerRunner.combine(kvIter, combineCollector);
              }

            }

            // close the writer
            writer.close();
            // record offsets
            rec.startOffset = segmentStart; //分割槽鍵值起始位置
            rec.rawLength = writer.getRawLength();//資料原始長度
            rec.partLength = writer.getCompressedLength();//資料壓縮後的長度
            spillRec.putIndex(rec, i);

            writer = null;

          } finally {
            if (null != writer) writer.close();
          }

        }

        // 處理spill檔案的索引,如果記憶體索引大小超過限制,則寫入到檔案中。

        if (totalIndexCacheMemory >= INDEX_CACHE_MEMORY_LIMIT) {
          // 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;
        }
        LOG.info("Finished spill " + numSpills);
        ++numSpills;

      } finally {

        if (out != null) out.close();

      }

    }

從程式碼中可以看到,看出再排序完成之後,迴圈訪問記憶體中的每個分割槽,如果沒有定義combine的話就直接把這個分割槽的鍵值對spill寫出到磁碟。spill是mapreduce的中間結果,儲存在資料節點的本地磁碟上,儲存路徑由以下引數指定:

1.core-site.xml:

hadoop.tmp.dir// hadoop臨時資料夾目錄

2.mapred-site.xml:

mapreduce.cluster.local.dir =${hadoop.tmp.dir}/mapred/local;

//預設的中間檔案存放路徑

在執行mapreduce任務的過程,我們可以通過這個路徑去檢視spill檔案的情況。

3  Mapper Merge

當Map任務執行完後,可能產生了很多的Spill檔案,這些檔案需要合併到一個檔案腫麼然後備份發給各個Reducer。如果kvbuffer緩衝區不為空,就執行一次沖刷操作,確保所有的資料已寫入檔案中,然後執行mergeParts()合併Spill檔案。merge合併操作也會帶有排序操作,將單個有序的spill檔案合併成最終的有序的檔案。merge多路歸併排序也是通過spill檔案的索引來操作的

圖1.4 就是map輸出到磁碟的過程,這些中間檔案(fiel.out,file.out.inde)將來是要等著Reducer取走的,不過並不是Reducer取走之後就刪除的,因為Reducer可能會執行失敗,在整個Job完成之後,ApplicationMaster通知Mapper可以刪除了才會將這些中間檔案刪掉.向硬碟中寫資料的時機。

圖1.4  spill檔案merge

4  Reduce Shuffle階段

在MapTask還未完成最終合併時,ReduceTask是沒有資料輸入的,即使此時ReduceTask程式已經建立,也只能睡眠等地啊有MapTask完成執行,從而可以從其所在節點獲取其輸出資料。如前所述,一個MapTask最終資料輸出是一個合併好的Spill檔案,可以通過該節點的Web地址,即所謂的MapOutputServerAddress加以訪問。

ReduceTask執行在YarnChild啟動的Java虛擬上。在Reduce Shuffle階段,分為兩個步驟,第一個copy,第二個Merge Sort。

(1).Copy階段

Reduce任務通過HTTP向各個Map任務拖取它所需要的資料。Map任務成功完成後,會通知ApplicationMaster狀態已經更新。所以,對於指定作業來說,ApplicationMaster能記錄Map輸出和NodeManager機器的對映關係。Reduce會定期向ApplicationMaster獲取Map的輸出位置,一旦拿到輸出位置,Reduce任務就會從此輸出對應的機器上上覆制輸出到本地,而不會等到所有的Map任務結束。

(2).Merge Sort

Copy過來的資料會先放入記憶體緩衝區中,如果記憶體緩衝區中能放得下這次資料的話就直接把資料寫到記憶體中,即記憶體到記憶體merge。Reduce要向每個Map去拖取資料,在記憶體中每個Map對應一塊資料,當記憶體快取區中儲存的Map資料佔用空間達到一定程度的時候,開始啟動記憶體中merge,把記憶體中的資料merge輸出到磁碟上一個檔案中,即記憶體到磁碟merge。

當屬於該reducer的map輸出全部拷貝完成,則會在reducer上生成多個檔案(如果拖取的所有map資料總量都沒有記憶體緩衝區,則資料就只存在於記憶體中),這時開始執行合併操作,即磁碟到磁碟merge,Map的輸出資料已經是有序的,Merge進行一次合併排序,所謂Reduce端的sort過程就是這個合併的過程。一般Reduce是一邊copy一邊sort,即copy和sort兩個階段是重疊而不是完全分開的。最終Reduce shuffle過程會輸出一個整體有序的資料塊。

詳細的流程過程如圖1.5所示。

圖1.5  reduce shuffle過程圖

相關文章