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的層次結構: