MapReduce中shuffle和排序(轉)

我覺得這篇博客寫的很好,hadoop權威指南沒有講清楚的、沒

看懂的這個上面都講的很詳細,收藏一下!點擊打開原文

 

MapReduce簡介

在Hadoop MapReduce中,框架會確保reduce收到的輸入數據是根據key排序過的。數據從Mapper輸出到Reducer接收,是一個很複雜的過程,框架處理了所有問題,並提供了很多配置項及擴展點。一個MapReduce的大致數據流如下圖:

這裏寫圖片描述

更詳細的MapReduce介紹參考Hadoop MapReduce原理與實例

Mapper的輸出排序、然後傳送到Reducer的過程,稱爲shuffle。本文詳細地解析shuffle過程,深入理解這個過程對於MapReduce調優至關重要,某種程度上說,shuffle過程是MapReduce的核心內容。

Mapper端

當map函數通過context.write()開始輸出數據時,不是單純地將數據寫入到磁盤。爲了性能,map輸出的數據會寫入到緩衝區,並進行預排序的一些工作,整個過程如下圖:

這裏寫圖片描述

環形Buffer數據結構

每一個map任務有一個環形Buffer,map將輸出寫入到這個Buffer。環形Buffer是內存中的一種首尾相連的數據結構,專門用來存儲Key-Value格式的數據:

這裏寫圖片描述

Hadoop中,環形緩衝其實就是一個字節數組:

// MapTask.java
private byte[] kvbuffer;  // main output buffer

kvbuffer = new byte[maxMemUsage - recordCapacity]; 

kvbuffer包含數據區和索引區(20190817更新:數據區是存放數據的,索引區是爲了你存放進入的數據還能原樣的拿出來,保證數據不出錯!當面試官問你爲啥要設計環形的數據結構,它的目的是什麼,原理是什麼。我覺得應屆生把本文涉及到的地方說清楚,應該面試官都會滿意了),這兩個區是相鄰不重疊的區域,用一個分界點來標識。分界點不是永恆不變的,每次Spill之後都會更新一次。初始分界點爲0,數據存儲方向爲向上增長,索引存儲方向向下:

這裏寫圖片描述

bufferindex一直往上增長,例如最初爲0,寫入一個int類型的key之後變爲4,寫入一個int類型的value之後變成8。

索引是對key-value在kvbuffer中的索引,是個四元組,佔用四個Int長度,包括:

  • value的起始位置
  • key的起始位置
  • partition值
  • value的長度
private static final int VALSTART = 0;    // val offset in acct
private static final int KEYSTART = 1;    // key offset in acct
private static final int PARTITION = 2;   // partition offset in acct
private static final int VALLEN = 3;      // length of value
private static final int NMETA = 4;       // num meta ints
private static final int METASIZE = NMETA * 4; // size in bytes
 // write accounting info
kvmeta.put(kvindex + PARTITION, partition);
kvmeta.put(kvindex + KEYSTART, keystart);
kvmeta.put(kvindex + VALSTART, valstart);
kvmeta.put(kvindex + VALLEN, distanceTo(valstart, valend));

kvmeta的存放指針kvindex每次都是向下跳四個“格子”,然後再向上一個格子一個格子地填充四元組的數據。比如kvindex初始位置是-4,當第一個key-value寫完之後,(kvindex+0)的位置存放value的起始位置、(kvindex+1)的位置存放key的起始位置、(kvindex+2)的位置存放partition的值、(kvindex+3)的位置存放value的長度,然後kvindex跳到-8位置。

緩衝區的大小默認爲100M,但是可以通過mapreduce.task.io.sort.mb這個屬性來配置。

Spill

map將輸出不斷寫入到這個緩衝區中,當緩衝區使用量達到一定比例之後,一個後臺線程開始把緩衝區的數據寫入磁盤,這個寫入的過程叫spill。開始spill的Buffer比例默認爲0.80,可以通過mapreduce.map.sort.spill.percent配置。在後臺線程寫入的同時,map繼續將輸出寫入這個環形緩衝,如果緩衝池寫滿了,map會阻塞直到spill過程完成,而不會覆蓋緩衝池中的已有的數據。

在寫入之前,後臺線程把數據按照他們將送往的reducer進行劃分,通過調用PartitionergetPartition()方法就能知道該輸出要送往哪個Reducer。默認的Partitioner使用Hash算法來分區,即通過key.hashCode() mode R來計算,R爲Reducer的個數。getPartition返回Partition事實上是個整數,例如有10個Reducer,則返回0-9的整數,每個Reducer會對應到一個Partition。map輸出的鍵值對,與partition一起存在緩衝中(即前面提到的kvmeta中)。假設作業有2個reduce任務,則數據在內存中被劃分爲reduce1和reduce2:

這裏寫圖片描述

並且針對每部分數據,使用快速排序算法(QuickSort)對key排序。

如果設置了Combiner,則在排序的結果上運行combine。

排序後的數據被寫入到mapreduce.cluster.local.dir配置的目錄中的其中一個,使用round robin fashion的方式輪流。注意寫入的是本地文件目錄,而不是HDFS。Spill文件名像sipll0.out,spill1.out等。

不同Partition的數據都放在同一個文件,通過索引來區分partition的邊界和起始位置。索引是一個三元組結構,包括起始位置、數據長度、壓縮後的數據長度,對應IndexRecord類:

public class IndexRecord {
  public long startOffset;
  public long rawLength;
  public long partLength;

  public IndexRecord() { }

  public IndexRecord(long startOffset, long rawLength, long partLength) {
    this.startOffset = startOffset;
    this.rawLength = rawLength;
    this.partLength = partLength;
  }
}

每個mapper也有對應的一個索引環形Buffer,默認爲1KB,可以通過mapreduce.task.index.cache.limit.bytes來配置,索引如果足夠小則存在內存中,如果內存放不下,需要寫入磁盤。 
Spill文件索引名稱類似這樣 spill110.out.index, spill111.out.index。

Spill文件的索引事實上是 org.apache.hadoop.mapred.SpillRecord的一個數組,每個Map任務(源碼中的MapTask.java類)維護一個這樣的列表:

final ArrayList<SpillRecord> indexCacheList = new ArrayList<SpillRecord>();
  • 1

創建一個SpillRecord時,會分配(Number_Of_Reducers * 24)Bytes緩衝:

public SpillRecord(int numPartitions) {
  buf = ByteBuffer.allocate(
      numPartitions * MapTask.MAP_OUTPUT_INDEX_RECORD_LENGTH);
  entries = buf.asLongBuffer();
}

numPartitions是Partition的個數,其實也就是Reducer的個數:

public static final int MAP_OUTPUT_INDEX_RECORD_LENGTH = 24;

// ---

partitions = jobContext.getNumReduceTasks();
final SpillRecord spillRec = new SpillRecord(partitions);

默認的索引緩衝爲1KB,即1024*1024 Bytes,假設有2個Reducer,則每個Spill文件的索引大小爲2*24=48 Bytes,當Spill文件超過21845.3時,索引文件就需要寫入磁盤。

索引及spill文件如下圖示意:

這裏寫圖片描述

Spill的過程至少需要運行一次,因爲Mapper的輸出結果必須要寫入磁盤,供Reducer進一步處理。

合併Spill文件

在整個map任務中,一旦緩衝達到設定的閾值,就會觸發spill操作,寫入spill文件到磁盤,因此最後可能有多個spill文件。在map任務結束之前,這些文件會根據情況合併到一個大的分區的、排序的文件中,排序是在內存排序的基礎上進行全局排序。下圖是合併過程的簡單示意:

這裏寫圖片描述

相對應的索引文件也會被合併,以便在Reducer請求對應Partition的數據的時候能夠快速讀取。

另外,如果spill文件數量大於mapreduce.map.combiner.minspills配置的數,則在合併文件寫入之前,會再次運行combiner。如果spill文件數量太少,運行combiner的收益可能小於調用的代價。

mapreduce.task.io.sort.factor屬性配置每次最多合併多少個文件,默認爲10,即一次最多合併10個spill文件。最後,多輪合併之後,所有的輸出文件被合併爲唯一一個大文件,以及相應的索引文件(可能只在內存中存在)。

壓縮

在數據量大的時候,對map輸出進行壓縮通常是個好主意。要啓用壓縮,將mapreduce.map.output.compress設爲true,並使用mapreduce.map.output.compress.codec設置使用的壓縮算法。

通過HTTP暴露輸出結果

map輸出數據完成之後,通過運行一個HTTP Server暴露出來,供reduce端獲取。用來相應reduce數據請求的線程數量可以配置,默認情況下爲機器內核數量的兩倍,如需自己配置,通過mapreduce.shuffle.max.threads屬性來配置,注意該配置是針對NodeManager配置的,而不是每個作業配置。

同時,Map任務完成後,也會通知Application Master,以便Reducer能夠及時來拉取數據。

通過緩衝、劃分(partition)、排序、combiner、合併、壓縮等過程之後,map端的工作就算完畢:

這裏寫圖片描述

Reducer端

各個map任務運行完之後,輸出寫入運行任務的機器磁盤中。Reducer需要從各map任務中提取自己的那一部分數據(對應的partition)。每個map任務的完成時間可能是不一樣的,reduce任務在map任務結束之後會盡快取走輸出結果,這個階段叫copy。 
Reducer是如何知道要去哪些機器去數據呢?一旦map任務完成之後,就會通過常規心跳通知應用程序的Application Master。reduce的一個線程會週期性地向master詢問,直到提取完所有數據(如何知道提取完?)。

數據被reduce提走之後,map機器不會立刻刪除數據,這是爲了預防reduce任務失敗需要重做。因此map輸出數據是在整個作業完成之後才被刪除掉的。

reduce維護幾個copier線程,並行地從map任務機器提取數據。默認情況下有5個copy線程,可以通過mapreduce.reduce.shuffle.parallelcopies配置。

如果map輸出的數據足夠小,則會被拷貝到reduce任務的JVM內存中。mapreduce.reduce.shuffle.input.buffer.percent配置JVM堆內存的多少比例可以用於存放map任務的輸出結果。如果數據太大容不下,則被拷貝到reduce的機器磁盤上。

內存中合併

當緩衝中數據達到配置的閾值時,這些數據在內存中被合併、寫入機器磁盤。閾值有2種配置方式:

  • 配置內存比例: 前面提到reduce JVM堆內存的一部分用於存放來自map任務的輸入,在這基礎之上配置一個開始合併數據的比例。假設用於存放map輸出的內存爲500M,mapreduce.reduce.shuffle.merger.percent配置爲0.80,則當內存中的數據達到400M的時候,會觸發合併寫入。
  • 配置map輸出數量: 通過mapreduce.reduce.merge.inmem.threshold配置。

在合併的過程中,會對被合併的文件做全局的排序。如果作業配置了Combiner,則會運行combine函數,減少寫入磁盤的數據量。

Copy過程中磁盤合併

在copy過來的數據不斷寫入磁盤的過程中,一個後臺線程會把這些文件合併爲更大的、有序的文件。如果map的輸出結果進行了壓縮,則在合併過程中,需要在內存中解壓後才能給進行合併。這裏的合併只是爲了減少最終合併的工作量,也就是在map輸出還在拷貝時,就開始進行一部分合並工作。合併的過程一樣會進行全局排序。

最終磁盤中合併

當所有map輸出都拷貝完畢之後,所有數據被最後合併成一個排序的文件,作爲reduce任務的輸入。這個合併過程是一輪一輪進行的,最後一輪的合併結果直接推送給reduce作爲輸入,節省了磁盤操作的一個來回。最後(所以map輸出都拷貝到reduce之後)進行合併的map輸出可能來自合併後寫入磁盤的文件,也可能來及內存緩衝,在最後寫入內存的map輸出可能沒有達到閾值觸發合併,所以還留在內存中。

每一輪合併並不一定合併平均數量的文件數,指導原則是使用整個合併過程中寫入磁盤的數據量最小,爲了達到這個目的,則需要最終的一輪合併中合併儘可能多的數據,因爲最後一輪的數據直接作爲reduce的輸入,無需寫入磁盤再讀出。因此我們讓最終的一輪合併的文件數達到最大,即合併因子的值,通過mapreduce.task.io.sort.factor來配置。

假設現在有50個map輸出文件,合併因子配置爲10,則需要5輪的合併。最終的一輪確保合併10個文件,其中包括4個來自前4輪的合併結果,因此原始的50箇中,再留出6個給最終一輪。所以最後的5輪合併可能情況如下:

這裏寫圖片描述

前4輪合併後的數據都是寫入到磁盤中的,注意到最後的2格顏色不一樣,是爲了標明這些數據可能直接來自於內存。

MemToMem合併

除了內存中合併和磁盤中合併外,Hadoop還定義了一種MemToMem合併,這種合併將內存中的map輸出合併,然後再寫入內存。這種合併默認關閉,可以通過reduce.merge.memtomem.enabled打開,當map輸出文件達到reduce.merge.memtomem.threshold時,觸發這種合併。

最後一次合併後傳遞給reduce方法

合併後的文件作爲輸入傳遞給Reducer,Reducer針對每個key及其排序的數據調用reduce函數。產生的reduce輸出一般寫入到HDFS,reduce輸出的文件第一個副本寫入到當前運行reduce的機器,其他副本選址原則按照常規的HDFS數據寫入原則來進行,詳細信息請參考這裏

通過從map機器提取結果,合併,combine之後,傳遞給reduce完成最後工作,整個過程也就差不多完成。最後再感受一下下面這張圖:

這裏寫圖片描述

性能調優

如果能夠根據情況對shuffle過程進行調優,對於提供MapReduce性能很有幫助。相關的參數配置列在後面的表格中。

一個通用的原則是給shuffle過程分配儘可能大的內存,當然你需要確保map和reduce有足夠的內存來運行業務邏輯。因此在實現Mapper和Reducer時,應該儘量減少內存的使用,例如避免在Map中不斷地疊加。

運行map和reduce任務的JVM,內存通過mapred.child.java.opts屬性來設置,儘可能設大內存。容器的內存大小通過mapreduce.map.memory.mbmapreduce.reduce.memory.mb來設置,默認都是1024M。

map優化

在map端,避免寫入多個spill文件可能達到最好的性能,一個spill文件是最好的。通過估計map的輸出大小,設置合理的mapreduce.task.io.sort.*屬性,使得spill文件數量最小。例如儘可能調大mapreduce.task.io.sort.mb

map端相關的屬性如下表:

屬性名 值類型 默認值 說明
mapreduce.task.io.sort.mb int 100 用於map輸出排序的內存大小
mapreduce.map.sort.spill.percent float 0.80 開始spill的緩衝池閾值
mapreduce.task.io.sort.factor int 10 合併文件數最大值,與reduce共用
mapreduce.map.combine.minspills int 3 運行combiner的最低spill文件數
mapreduce.map.out.compress boolean false 輸出是否壓縮
mapreduce.map.out.compress 類名 DefaultCodec 壓縮算法
mapreduce.shuffle.max.threads int 0 服務於reduce提取結果的線程數量

reduce優化

在reduce端,如果能夠讓所有數據都保存在內存中,可以達到最佳的性能。通常情況下,內存都保留給reduce函數,但是如果reduce函數對內存需求不是很高,將mapreduce.reduce.merge.inmem.threshold(觸發合併的map輸出文件數)設爲0,mapreduce.reduce.input.buffer.percent(用於保存map輸出文件的堆內存比例)設爲1.0,可以達到很好的性能提升。在2008年的TB級別數據排序性能測試中,Hadoop就是通過將reduce的中間數據都保存在內存中勝利的。

reduce端相關屬性:

屬性名 值類型 默認值 說明
mapreduce.reduce.shuffle.parallelcopies int 5 提取map輸出的copier線程數
mapreduce.reduce.shuffle.maxfetchfailures int 10 提取map輸出最大嘗試次數,超出後報錯
mapreduce.task.io.sort.factor int 10 合併文件數最大值,與map共用
mapreduce.reduce.shuffle.input.buffer.percent float 0.70 copy階段用於保存map輸出的堆內存比例
mapreduce.reduce.shuffle.merge.percent float 0.66 開始spill的緩衝池比例閾值
mapreduce.reduce.shuffle.inmem.threshold int 1000 開始spill的map輸出文件數閾值,小於等於0表示沒有閾值,此時只由緩衝池比例來控制
mapreduce.reduce.input.buffer.percent float 0.0 reduce函數開始運行時,內存中的map輸出所佔的堆內存比例不得高於這個值,默認情況內存都用於reduce函數,也就是map輸出都寫入到磁盤

通用優化

Hadoop默認使用4KB作爲緩衝,這個算是很小的,可以通過io.file.buffer.size來調高緩衝池大小。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章