4.3 Map類
創建Map類和map函數,map函數是org.apache.hadoop.mapreduce.Mapper類中的定義的,當處理每一個鍵值對的時候,都要調用一次map方法,用戶需要覆寫此方法。此外還有setup方法和cleanup方法。map方法是當map任務開始運行的時候調用一次,cleanup方法是整個map任務結束的時候運行一次。
4.3.1 Map介紹
Mapper類是一個泛型類,帶有4個參數(輸入的鍵,輸入的值,輸出的鍵,輸出的值)。在這裏輸入的鍵爲Object(默認是行),輸入的值爲Text(hadoop中的String類型),輸出的鍵爲Text(關鍵字)和輸出的值爲IntWritable(hadoop中的int類型)。以上所有hadoop數據類型和java的數據類型都很相像,除了它們是針對網絡序列化而做的特殊優化。
MapReduce中的類似於IntWritable的類型還有如下幾種:
BooleanWritable:標準布爾型數值、ByteWritable:單字節數值、DoubleWritable:雙字節數值、FloatWritable:浮點數、IntWritable:整型數、LongWritable:長整型數、Text:使用UTF8格式存儲的文本(類似java中的String)、NullWritable:當<key, value>中的key或value爲空時使用。
這些都是實現了WritableComparable接口:
Map任務是一類將輸入記錄集轉換爲中間格式記錄集的獨立任務。 Mapper類中的map方法將輸入鍵值對(key/value pair)映射到一組中間格式的鍵值對集合。這種轉換的中間格式記錄集不需要與輸入記錄集的類型一致。一個給定的輸入鍵值對可以映射成0個或多個輸出鍵值對。
1 StringTokenizer itr = new StringTokenizer(value.toString()); 2 while (itr.hasMoreTokens()) { 3 word.set(itr.nextToken()); 4 context.write(word, one); 5 }
這裏將輸入的行進行解析分割之後,利用Context的write方法進行保存。而Context是實現了MapContext接口的一個抽象內部類。此處把解析出的每個單詞作爲key,將整形1作爲對應的value,表示此單詞出現了一次。map就是一個分的過程,reduce就是合的過程。Map任務的個數和前面的split的數目對應,作爲map函數的輸入。Map任務的具體執行見下一小節。
4.3.2 Map任務分析
Map任務被提交到Yarn後,被ApplicationMaster啓動,任務的形式是YarnChild進程,在其中會執行MapTask的run方法。無論是MapTask還是ReduceTask都是繼承的Task這個抽象類。
run方法的執行步驟有:
Step1:
判斷是否有Reduce任務,如果沒有的話,Map任務結束,就整個提交的作業結束;如果有的話,當Map任務完成的時候設置當前進度爲66.7%,Sort完成的時候設置進度爲33.3%。
Step2:
啓動TaskReporter線程,用於更新當前的狀態。
Step3:
初始化任務,設置任務的當前狀態爲RUNNING,設置輸出目錄等。
Step4:
判斷當前是否是jobCleanup任務、jobSetup任務、taskCleanup任務及相應的處理。
Step5:
調用runNewMapper方法,執行具體的map。
Step6:
作業完成之後,調用done方法,進行任務的清理、計數器更新、任務狀態更新等。
4.3.3 runNewMapper分析
下面我們來看看這個runNewMapper方法。代碼如下:
1 private <INKEY,INVALUE,OUTKEY,OUTVALUE> 2 void runNewMapper(final JobConf job, 3 final TaskSplitIndex splitIndex, 4 final TaskUmbilicalProtocol umbilical, 5 TaskReporter reporter 6 ) throws IOException, ClassNotFoundException, 7 InterruptedException { 8 // make a task context so we can get the classes 9 org.apache.hadoop.mapreduce.TaskAttemptContext taskContext = new org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl(job, getTaskID(), reporter); 10 11 // make a mapper org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE> mapper = (org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE>) 12 ReflectionUtils.newInstance(taskContext.getMapperClass(), job); 13 14 // make the input format org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE> inputFormat = (org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE>) 16 ReflectionUtils.newInstance(taskContext.getInputFormatClass(), job); 18 // rebuild the input split 19 org.apache.hadoop.mapreduce.InputSplit split = null;20 21 split = getSplitDetails(new path(splitIndex.getSplitLocation()), splitIndex.getStartOffset()); 24 25 LOG.info("Processing split: " + split); 26 org.apache.hadoop.mapreduce.RecordReader<INKEY,INVALUE> input = new NewTrackingRecordReader<INKEY,INVALUE> (split, inputFormat, reporter, taskContext); 27 28 job.setBoolean(JobContext.SKIP_RECORDS, isSkipping()); 29 org.apache.hadoop.mapreduce.RecordWriter output = null; 30 31 // get an output object 32 if (job.getNumReduceTasks() == 0) { 33 output = new NewDirectOutputCollector(taskContext, job, umbilical, reporter); 34 } else { 35 output = new NewOutputCollector(taskContext, job, umbilical, reporter); 36 } 37 38 org.apache.hadoop.mapreduce.MapContext<INKEY, INVALUE, OUTKEY, OUTVALUE> mapContext = new MapContextImpl<INKEY, INVALUE, OUTKEY, OUTVALUE>(job, getTaskID(), input, output, committer, reporter, split); 39 org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE>.Context mapperContext = new WrappedMapper<INKEY, INVALUE, OUTKEY, OUTVALUE>().getMapContext(mapContext); 40 41 try { 42 input.initialize(split, mapperContext); 43 mapper.run(mapperContext); 44 mapPhase.complete(); 45 setPhase(TaskStatus.Phase.SORT); 46 statusUpdate(umbilical); 47 input.close(); 48 input = null; 49 output.close(mapperContext); 50 output = null; 51 } finally { 52 closeQuietly(input); 53 closeQuietly(output, mapperContext); 54 } 55 }
此方法的主要執行流程是:
Step1:
獲取配置信息類對象TaskAttemptContextImpl、自己開發的Mapper的實例mapper、用戶指定的InputFormat對象 (默認是TextInputFormat)、任務對應的分片信息split。
其中TaskAttemptContextImpl類實現TaskAttemptContext接口,而TaskAttemptContext接口又繼承於JobContext和Progressable接口,但是相對於JobContext增加了一些有關task的信息。通過TaskAttemptContextImpl對象可以獲得很多與任務執行相關的類,比如用戶定義的Mapper類,InputFormat類等。
Step2:
根據inputFormat構建一個NewTrackingRecordReader對象,這個對象中的RecordReader<K,V> real是LineRecordReader,用於讀取分片中的內容,傳遞給Mapper的map方法做處理的。
Step3:
然後創建org.apache.hadoop.mapreduce.RecordWriter對象,作爲任務的輸出,如果沒有reducer,就設置此RecordWriter對象爲NewDirectOutputCollector(taskContext, job, umbilical, reporter)直接輸出到HDFS上;如果有reducer,就設置此RecordWriter對象爲NewOutputCollector(taskContext, job, umbilical, reporter)作爲輸出。
NewOutputCollector是有reducer的作業的map的輸出。這個類的主要包含的對象是MapOutputCollector<K,V> collector,是利用反射工具構造出來的:
1 ReflectionUtils.newInstance(job.getClass(JobContext.MAP_OUTPUT_COLLECTOR_CLASS_ATTR, MapOutputBuffer.class, MapOutputCollector.class), job);
如果Reduce的個數大於1,則實例化org.apache.hadoop.mapreduce.Partitioner<K,V> (默認是HashPartitioner.class),用來對mapper的輸出數據進行分區,即數據要彙總到哪個reducer上,NewOutputCollector的write方法會調用collector.collect(key, value,partitioner.getPartition(key, value, partitions));否則設置分區個數爲0。
Step4:
打開輸入文件(構建一個LineReader對象,在這實現文件內容的具體讀)並且將文件指針指向文件頭。由LineRecordReader的initialize方法完成。
實際上讀文件內容的是類中的LineReader對象in,該對象在initialize方法中進行了初始化,會根據輸入文件的文件類型(壓縮或不壓縮)傳入相應輸入流對象。LineReader會從輸入流對象中通過:
in.readLine(new Text(), 0, maxBytesToConsume(start));
方法每次讀取一行放入Text對象str中,並返回讀取數據的長度。
LineRecordReader.nextKeyValue()方法會設置兩個對象key和value,key是一個偏移量指的是當前這行數據在輸入文件中的偏移量(注意這個偏移量可不是對應單個分片內的偏移量,而是針對整個文中的偏移量),value是通過LineReader的對象in讀取的一行內容:
1 in.readLine(value, maxLineLength, Math.max(maxBytesToConsume(pos), maxLineLength));
如果沒有數據可讀了,這個方法會返回false,否則true。
另外,getCurrentKey()和getCurrentValue()是獲取當前的key和value,調用這倆方法之前需要先調用nextKeyValue()爲key和value賦新值,否則會重複。
這樣就跟org.apache.hadoop.mapreduce.Mapper中的run方法關聯起來了。
Step5:
執行org.apache.hadoop.mapreduce.Mapper的run方法。
1 public void run(Context context) throws IOException, InterruptedException { 3 setup(context); 5 try { 7 while (context.nextKeyValue()) { 9 map(context.getCurrentKey(), context.getCurrentValue(), context); 11 } 13 } finally { 15 cleanup(context); 17 } 19 }
Step5.1:
首先會執行setup方法,用於設定用戶自定義的一些參數等,方便在下面的操作步驟中讀取。參數是設置在Context中的。此對象的初始化在MapTask類中的runNewMapper方法中:
1 org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE>.Context 3 mapperContext = new WrappedMapper<INKEY, INVALUE, OUTKEY, OUTVALUE>().getMapContext(mapContext);
會將LineRecordReader的實例對象和NewOutputCollector的實例對象傳進去,下面的nextKeyValue()、getCurrentValue()、getCurrentKey()會調用reader的相應方法,從而實現了Mapper.run方法中的nextKeyValue()不斷獲取key和value。
Step5.2:
循環中的map方法就是用戶自定的map。map方法邏輯處理完之後,最後都會有context.write(K,V)方法用來將計算數據輸出。此write方法最後調用的是NewOutputCollector.write方法,write方法會調用MapOutputBuffer.collect(key, value,partitioner.getPartition(key, value, partitions))方法,用於彙報進度、序列化數據並將其緩存等,主要是裏面還有個Spill的過程,下一小節會詳細介紹。
Step5.3:
當讀完數據之後,會調用cleanup方法來做一些清理工作,這點我們同樣可以利用,我們可以根據自己的需要重寫cleanup方法。
Step6:
最後是輸出流的關閉output.close(mapperContext),該方法會執行MapOutputBuffer.flush()操作會將剩餘的數據也通過sortAndSpill()方法寫入本地文件,並在最後調用mergeParts()方法合併所有spill文件。sortAndSpill方法在4.3.4小節中會介紹。
4.3.4 Spill分析
Spill的漢語意思是溢出,spill處理就是溢出寫。怎麼個溢出法呢?Spill過程包括輸出、排序、溢寫、合併等步驟,有點複雜,如圖所示:
每個Map任務不斷地以<key, value>對的形式把數據輸出到在內存中構造的一個環形數據結構中。使用環形數據結構是爲了更有效地使用內存空間,在內存中放置儘可能多的數據。
這個數據結構其實就是個字節數組,叫kvbuffer,這裏面不只有<key, value>數據,還放置了一些索引數據,並且給放置索引數據的區域起了一個kvmeta的別名。
kvbuffer = new byte[maxMemUsage]; bufvoid = kvbuffer.length; kvmeta = ByteBuffer.wrap(kvbuffer).order(ByteOrder.nativeOrder()).asIntBuffer(); setEquator(0); bufstart = bufend = bufindex = equator; kvstart = kvend = kvindex;
kvmeta是對記錄Record<key, value>在kvbuffer中的索引,是個四元組,包括:value的起始位置、key的起始位置、partition值、value的長度,佔用四個Int長度,kvmeta的存放指針kvindex每次都是向下跳四步,然後再向上一個坑一個坑地填充四元組的數據。比如kvindex初始位置是-4,當第一個<key, value>寫完之後,(kvindex+0)的位置存放value的起始位置、(kvindex+1)的位置存放key的起始位置、(kindex+2)的位置存放partition的值、(kvindex+3)的位置存放value的長度,然後kvindex跳到-8位置,等第二個<key, value>和索引寫完之後,kvindex跳到-32位置。
<key, value>數據區域和索引數據區域在kvbuffer中是相鄰不重疊的兩個區域,用一個分界點來劃分兩者,而分割點是變化的,每次Spill之後都會更新一次。初始的分界點是0,<key, value>數據的存儲方向是向上增長,索引數據的存儲方向是向下增長,如圖所示:
其中,kvbuffer的大小maxMemUsage的默認是100M。涉及到的變量有點多:
(1)kvstart是有效記錄開始的下標;
(2)kvindex是下一個可做記錄的位置;
(3)kvend在開始Spill的時候它會被賦值爲kvindex的值,Spill結束時,它的值會被賦給kvstart,這時候kvstart==kvend。這就是說,如果kvstart不等於kvend,系統正在spill,否則,kvstart==kvend,系統處於普通工作狀態;
(4)bufvoid,用於表明實際使用的緩衝區結尾;
(5)bufmark,用於標記記錄的結尾;
(6)bufindex初始值爲0,一個Int型的key寫完之後,bufindex增長爲4,一個Int型的value寫完之後,bufindex增長爲8
在kvindex和bufindex之間(包括equator節點)的那一坨數據就是未被Spill的數據。如果這部分數據所佔用的空間大於等於Spill的指定百分比(默認是80%),則開始調用startSpill方法進行溢寫。對應的方法爲:
1 private void startSpill() { 2 3 assert !spillInProgress; 4 5 kvend = (kvindex + NMETA) % kvmeta.capacity(); 6 7 bufend = bufmark; 8 9 spillInProgress = true; 10 11 LOG.info("Spilling map output"); 12 13 LOG.info("bufstart = " + bufstart + "; bufend = " + bufmark + 14 15 "; bufvoid = " + bufvoid); 16 17 LOG.info("kvstart = " + kvstart + "(" + (kvstart * 4) + 18 19 "); kvend = " + kvend + "(" + (kvend * 4) + 20 21 "); length = " + (distanceTo(kvend, kvstart, 22 23 kvmeta.capacity()) + 1) + "/" + maxRec); 24 25 spillReady.signal(); 26 27 }
這裏會觸發信號量,使得在MapTask類的init方法中正在等待的SpillThread線程繼續運行。
1 while (true) { 3 spillDone.signal(); 5 while (!spillInProgress) { 7 spillReady.await(); 9 } 10 11 try { 13 spillLock.unlock(); 15 sortAndSpill(); 17 } catch (Throwable t) { 19 sortSpillException = t; 21 } finally { 23 spillLock.lock(); 25 if (bufend < bufstart) { 27 bufvoid = kvbuffer.length; 29 } 30 31 kvstart = kvend; 33 bufstart = bufend; 35 spillInProgress = false; 37 } 39 }
繼續調用sortAndSpill方法,此方法負責將buf中的數據刷到磁盤。主要是根據排過序的kvmeta把每個partition的<key, value>數據寫到文件中,一個partition對應的數據搞完之後順序地搞下個partition,直到把所有的partition遍歷完(partiton的個數就是reduce的個數)。
Step1:
先計算寫入文件的大小;
1 final long size = (bufend >= bufstart 3 ? bufend - bufstart 5 : (bufvoid - bufend) + bufstart) + 7 partitions * APPROX_HEADER_LENGTH;
Step2:
然後獲取寫到本地(非HDFS)文件的文件名,會有一個編號,例如output/spill2.out;命名格式對應的代碼爲:
1 return lDirAlloc.getLocalPathForWrite(MRJobConfig.OUTPUT + "/spill" 2 3 + spillNumber + ".out", size, getConf());
Step3:
使用快排對緩衝區kvbuffe中區間[bufstart,bufend)內的數據進行排序,先按分區編號partition進行升序,然後按照key進行升序。這樣經過排序後,數據以分區爲單位聚集在一起,且同一分區內所有數據按照key有序;
Step4:
構建一個IFile.Writer對象將輸出流傳進去,輸出到指定的文件當中,這個對象支持行級的壓縮。
1 writer = new Writer<K, V>(job, out, keyClass, valClass, codec, spilledRecordsCounter);
如果用戶設置了Combiner(實際上是一個Reducer),則寫入文件之前會對每個分區中的數據進行一次聚集操作,通過combinerRunner.combine(kvIter, combineCollector)實現,進而會執行reducer.run方法,只不過輸出和正常的reducer不一樣而已,這裏最終會調用IFile.Writer的append方法實現本地文件的寫入。
Step5:
將元數據信息寫到內存索引數據結構SpillRecord中。如果內存中索引大於1MB,則寫到文件名類似於output/spill2.out.index的文件中,“2”就是當前Spill的次數。
1 if (totalIndexCacheMemory >= indexCacheMemoryLimit) { 2 3 // create spill index file 4 5 Path indexFilename = 6 7 mapOutputFile.getSpillIndexFileForWrite(numSpills, partitions 8 9 * MAP_OUTPUT_INDEX_RECORD_LENGTH); 10 11 spillRec.writeToFile(indexFilename, job); 12 13 } else { 14 15 indexCacheList.add(spillRec); 16 17 totalIndexCacheMemory += 18 19 spillRec.size() * MAP_OUTPUT_INDEX_RECORD_LENGTH; 20 21 }
index文件中不光存儲了索引數據,還存儲了crc32的校驗數據。index文件不一定在磁盤上創建,如果內存(默認1M空間)中能放得下就放在內存中。
out文件、index文件和partition數據文件的對應關係爲:
索引文件的信息主要包括partition的元數據的偏移量、大小、壓縮後的大小等。
Step6:
Spill結束的時候,會調用resetSpill方法進行重置。
1 private void resetSpill() { 2 3 final int e = equator; 4 5 bufstart = bufend = e; 6 7 final int aligned = e - (e % METASIZE); 8 9 // set start/end to point to first meta record 10 11 // Cast one of the operands to long to avoid integer overflow 12 13 kvstart = kvend = (int) 14 15 (((long)aligned - METASIZE + kvbuffer.length) % kvbuffer.length) / 4; 16 17 LOG.info("(RESET) equator " + e + " kv " + kvstart + "(" + 18 19 (kvstart * 4) + ")" + " kvi " + kvindex + "(" + (kvindex * 4) + ")"); 20 21 }
也就是取kvbuffer中剩餘空間的中間位置,用這個位置設置爲新的分界點。
4.3.5 合併
Map任務如果輸出數據量很大,可能會進行好幾次Spill,out文件和Index文件會產生很多,分佈在不同的磁盤上。這時候就需要merge操作把這些文件進行合併。
Merge會從所有的本地目錄上掃描得到Index文件,然後把索引信息存儲在一個列表裏,最後根據列表來創建一個叫file.out的文件和一個叫file.out.Index的文件用來存儲最終的輸出和索引。
每個artition都應一個段列表,記錄所有的Spill文件中對應的這個partition那段數據的文件名、起始位置、長度等等。所以首先會對artition對應的所有的segment進行合併,合併成一個segment。當這個partition對應很多個segment時,會分批地進行合併,類似於堆排序。最終的索引數據仍然輸出到Index文件中。對應mergeParts方法。
4.3.6 相關配置選項
Map的東西大概的就這麼多。主要是讀取數據然後寫入內存緩衝區,緩存區滿足條件就會快排,並設置partition,然後Spill到本地文件和索引文件;如果有combiner,Spill之前也會做一次聚集操作,等數據跑完會通過歸併合併所有spill文件和索引文件,如果有combiner,合併之前在滿足條件後會做一次綜合的聚集操作。map階段的結果都會存儲在本地中(如果有reducer的話),非HDFS。
在上面的分析,包括過程的梳理中,主要涉及到以下幾種配置選項:
mapreduce.job.map.output.collector.class,默認爲MapTask.MapOutputBuffer;
mapreduce.map.sort.spill.percent配置內存開始溢寫的百分比值,默認爲0.8;
mapreduce.task.io.sort.mb配置內存bufer的大小,默認是100mb;
map.sort.class配置排序實現類,默認爲QuickSort,快速排序;
mapreduce.map.output.compress.codec配置map的輸出的壓縮處理程序;
mapreduce.map.output.compress配置map輸出是否啓用壓縮,默認爲false