Mapreduce執行過程分析(基於Hadoop2.4)——(二)

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接口又繼承於JobContextProgressable接口,但是相對於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

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