MapReduce 的Types 和 Formats

MapReduce有一種簡單的數據處理模型:map和reduce的輸入和輸出都是key-value鍵值對。下面來看下各種格式的數據在該模型中的使用。

MapReduce Types

Hadoop MapReduce的map函數和reduce函數一般具有以下形式:

map:(K1, V1)   → list (K2, V2)

reduce:(K2, list(V2))   → list(K3, V3)

一般,map的輸入的key和value的類型(K1, V1)和其輸出的類型(K2, V2)是不相同的,但是reduce的輸入必須要map的輸出類型相匹配,雖然可能和reduce的輸出類型不同(K3, V3)。JAVA API的鏡像如下:

public class Mapper<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  public class Context extends MapContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
    // ...
  }
  protected void map(KEYIN key, VALUEIN value, Context context) throws IOException, InterruptedException {
    // ...
  }
}
public class Reducer<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  public class Context extends ReducerContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
    // ...
  }
  protected void reduce(KEYIN key, Iterable<VALUEIN> values, Context context) throws IOException, InterruptedException {
    // ...
  }
}
Context對象是用於發出鍵值對的,是通過其write方法發出的。

如果有combiner函數,因爲它具有和reduce函數同樣的形式(因爲它實現了Reducer),除了它的輸出類型爲中間的key-value類型(K2, V2),所以它的輸出可以傳遞給reduce函數:

map: (K1, V1) → list(K2, V2)
combiner: (K2, list(V2)) → list(K2, V2)
reduce: (K2, list(V2)) → list(K3, V3)

一般combiner函數和reduce函數是一樣的,K2和K3一樣、V2和V3一樣。

partition函數的操作對象爲中間的key和value類型(K2, V2),並且會返回一個分區索引。事實上,分區是僅由key決定的(與value無關):

partition: (K2, V2) → integer

或在JAVA中:

public abstract class Partitioner<KEY, VALUE> {
  public abstract int getPartition(KEY key, VALUE value, int numPartitions);
}
輸入類型(input type)是由輸入格式(input format)設置的,其它的類型可以通過顯式調用Job的方法來設置(在老的API中是JobConf),如果沒有顯式設置,則中間的類型默認爲LongWritable和Text。所以,如果K2和K3類型相同,就不需要調用setMapOutputKeyClass()方法,因爲其類型會有setOutputKeyClass().方法設置。同樣,如果V2和V3類型相同,僅調用setOutputValueClass()方法即可。

用這些方法設置中間和最終的輸出類型看起來有些奇怪,爲什麼輸出類型不能由mapper和reducer來確定?原因是Java泛型的限制:類型擦除,即在運行時,類型信息並不總是存在的,所以,Hadoop纔不得不顯式設置輸出類型。這也意味着,可以爲MapReduce Job配置一個不兼容了類型,因爲在編譯時是不檢查改配置的。類型衝突是在job執行期間檢測的,所以最好先運行小數據量的Job測試,來排除和修復類型不匹配的問題。

默認的輸入格式爲TextInputFormat,它的Key的類型爲LongWritable,Value類型爲Text。

mapper默認爲Mapper 類,它輸出的類型的和輸入類型相同:

public class Mapper<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  protected void map(KEYIN key, VALUEIN value, Context context) throws IOException, InterruptedException {
    context.write((KEYOUT) key, (VALUEOUT) value);
  }
}
Map是一個泛型,它可以處理任何類型的key和value。

默認的partitioner爲HashPartitioner,它會計算每個key的hash值,來決定每個key屬於哪個分區。每個分區有一個reduce task處理,所以job的分區數和reduce task是相等的:

public class HashPartitioner<K, V> extends Partitioner<K, V> {
  public int getPartition(K key, V value, int numReduceTasks) {
    return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
  }
}
Key的hash 值(即MD5值,可能爲負數)通過與最大的整數Integer.MAX_VALUE(即0111111111111111)的按位與運算被轉變爲一個非負整數,然後和分區的個數求餘,就可以得出key所屬分區的索引,計算公式爲:(key.hashCode() & Integer.MAX_VALUE) % numReduceTasks; 。

注:這個簡單算法得到的結果可能不均勻,因爲key畢竟不會那麼線性連續,這時候可以自己寫個測試類,計算出最優的hash算法。 假設有一個好的hash算法,那麼所有的records將會被均勻的分配給reduce task,這樣同一個key的所有記錄就會被同一個reduce task處理。

由於reducer的數量和輸入切片的數量是相等的,而輸入切片的數量又是有block的大小決定的,所以並不需要設置reducer的數量。

如果選擇reducer的數量?
大多數Job都會將reducer的數量設置爲一個較大的數字,否則,job將會變得很慢,因爲所有的中間數據都流入了一個reducer。
增加reducer的數量將縮短reduce階段的運行時間,因爲得到了更多的並行。但是如果增加的太多,將會有許多的小文件產生,這是次優。通常的做法是每個reducer運行5分鐘左右,併產生至少一個塊大小的輸出。

reducer默認爲Reducer類,也是一個泛型,它簡單的將所有的輸入寫到輸出中:

public class Reducer<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
  protected void reduce(KEYIN key, Iterable<VALUEIN> values, Context context) throws IOException, InterruptedException {
    for (VALUEIN value: values) {
      context.write((KEYOUT) key, (VALUEOUT) value);
    }
  }
}
大多數的MapReduce程序,不會始終使用用一種key或value類型,所以,你需要配置聲明你要使用的類型。

Records在發送給reducer之前會被排序,key按數字順序排序,它們從多個input文件交叉的合併到一個輸出文件。

默認的輸出格式(output format)爲TextOutputFormat,它輸出所有的records。

Input Formats

Input Splits and Records

每個輸入切片就是被單個map處理的輸入塊,每個map處理一個split,每個split又被拆分爲多個記錄(record),然後,map依次處理每個record(一個鍵值對)。切片(split)和記錄(record)都是一個邏輯概念:沒有什麼需要它們綁定到文件。在一個數據庫的環境中,一個split可能來自表中的若干行,一個record對應表中的一行。

輸入切片是由InputSplit類表示(位於org.apache.hadoop.mapreduce包下):

public abstract class InputSplit {
  public abstract long getLength() throws IOException, InterruptedException;
  public abstract String[] getLocations() throws IOException, InterruptedException;
}
InputSplit有一個以字節爲單位的長度和一組存儲位置(即主機名),值得注意的是split並不包含輸入數據,只是對數據的一個引用。存儲位置是爲了讓MapReduce儘可能將map task放置到離split 數據最近的地方。長度大小是爲了排序split使最大的優先被處理,儘可能的減少job的運行時間(這是一個貪婪近似算法的實例)。

作爲MapReduce應用的開發者,不需要直接處理InputSplit,因爲它是由InputFormat創建的(InputFormat負責創建輸入切片和將切片分割爲record)。InputSplit的接口如下:

public abstract class InputFormat<K, V> {
  public abstract List<InputSplit> getSplits(JobContext context) throws IOException, InterruptedException;
  public abstract RecordReader<K, V> createRecordReader(InputSplit split, TaskAttemptContext context) throws IOException, InterruptedException;
}
客戶端通過調用getSplits()方法來爲運行的job計算切片,然後將它們發送個application master,application master根據切片(InputSplit)的存儲位置來調度map task,map task 將切片傳遞個InputFormat的createRecordReader()方法創建切片的RecordReader對象,RecordReader就像是一個迭代器(iterator)遍歷所有的record,每個record被傳遞給map函數以產生一個鍵值對。下面看下Mapper的run()方法:

public void run(Context context) throws IOException, InterruptedException {
  setup(context);
  while (context.nextKeyValue()) {
    map(context.getCurrentKey(), context.getCurrentValue(), context);
  }
  cleanup(context);
}

在運行setup()方法之後,將循環調用Context的nextKeyValue()方法(實際是委託RecordReader的同名方法),來爲mapper產生key和value對象,然後通過context的getCurrentKey()方法和getCurrentValue()方法從RecordReader中獲取當前的key和value值,並傳遞給map()方法。當RecordReader讀取到最後,nextKeyValue()方法將返回false。然後運行cleanup()方法,完成。

下圖爲InputFormat的層次結構:


輸入切片和HDFS塊大小的關係:

通常,FileInputFormats的record並不完全匹配HDFS塊的大小。例如,TextInputFormat的records是行,往往會超出HDFS塊的邊界,這並不影響程序的運行——超出的行不會被丟棄或折斷,當這意味着儘可能使數據本地化的map將會執行一些遠程讀取的操作。

如下圖例子,一個文件被拆分爲多個行,行的邊界並沒有和HDFS塊的邊界對應。Split爲邏輯record的邊界(該例爲行),可以看到第一個split包含了5行,儘管它跨越了第一和第二個塊,第二個split從第6行開始:



Output Formats
Hadoop的數據輸出格式和輸入格式是對應的,下圖爲Output Formats的層次結構:








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