Hadoop源代碼分析(完整圖文版) part 3

Hadoop源代碼分析(三五)

除了對外提供的接口,NameNode上還有一系列的線程,不斷檢查系統的狀態,下面是這些線程的功能分析。

在NameNode中,定義瞭如下線程:

  Daemon hbthread = null;   // HeartbeatMonitor thread

  public Daemon lmthread = null;   // LeaseMonitor thread

  Daemon smmthread = null;  // SafeModeMonitor thread
  public Daemon replthread = null;  // Replication thread 

  private Daemon dnthread = null;

PendingReplicationBlocks中也有一個線程:

  Daemon timerThread = null;

NameNode內嵌的HTTP服務器中自然也有線程,這塊我們就不分析啦。

  HttpServer infoServer;

心跳線程用於對DataNode的心態進行檢查,以間隔heartbeatRecheckInterval運行heartbeatCheck方法。如果在一定時間內沒收到DataNode的心跳信息,我們就認爲該節點已經死掉,調用removeDatanode(前面分析過)將DataNode標記爲無效。

租約lmthread用於檢查租約的硬超時,如果租約硬超時,調用前面分析過的internalReleaseLease,釋放租約。

smmthread運行的SafeModeMonitor我們前面已經分析過了。

replthread運行ReplicationMonitor,這個線程會定期調用computeDatanodeWork和processPendingReplications。

computeDatanodeWork會執行computeDatanodeWork或computeInvalidateWork。computeDatanodeWork從neededReplications中掃描,取出需要複製的項,然後:

l           檢查文件不存在或者處於構造狀態;如果是,從隊列中刪除複製項,退出對複製項的處理(接着處理下一個);

l           得到當前數據塊副本數並選擇複製的源DataNode,如果空,退出對複製項的處理;

l           再次檢查副本數(很可能有DataNode從故障中恢復),如果發現不需要複製,從隊列中刪除複製項,退出對複製項的處理;

l           選擇複製的目標,如果目標空,退出對複製項的處理;

l           將複製的信息(數據塊和目標DataNode)加入到源目標DataNode中;在目標DataNode中記錄複製請求;

l           從隊列中將複製項移動到pendingReplications。

可見,這個方法執行後,複製項從neededReplications挪到pendingReplications中。DataNode在某次心跳的應答中,可以拿到相應的信息,執行復制操作。

computeInvalidateWork當然是用於刪除無效的數據塊。它的主要工作在invalidateWorkForOneNode中完成。和上面computeDatanodeWork類似,不過它的處理更簡單,將recentInvalidateSets的數據通過DatanodeDescriptor.addBlocksToBeInvalidated挪到DataNode中。

dnthread執行的是DecommissionedMonitor,它的run方法週期調用decommissionedDatanodeCheck,再到checkDecommissionStateInternal,定期將完成Decommission任務的DataNode狀態從DECOMMISSION_INPROGRESS改爲DECOMMISSIONED。

PendingReplicationMonitor中的線程用於對處在等待複製狀態的數據塊進行檢查。如果發現長時間該數據塊沒被複制,那麼會將它挪到timedOutItems中。請參考PendingReplicationBlocks的討論。

infoServer的相關線程我們就不分析了,它們都用於處理HTTP請求。

上面已經總結了NameNode上的一些爲特殊任務啓動的線程,除了這些線程,NameNode上還運行着RPC服務器的相關線程,具體可以看前面章節。

在我們開始分析Secondary NameNode前,我們給出了以NameNode上一些狀態轉移圖,大家可以通過這個圖,更好理解NameNode。

NameNode: 

                   

DataNode:


文件:



Block,比較複雜:


 

 

上面的圖不是很嚴格,只是用於幫助大家理解NameNode對Block複雜的處理過程。

稍微說明一下,“Block in inited DataNode”表明這個數據塊在一個剛初始化的DataNode上。“Block in INodeFile”是數據塊屬於某個文件,“Block in INodeFileUnderConstruction” 表明這數據塊屬於一個正在構建的文件,當然,處於這個狀態的Block可能因爲租約恢復而轉移到“Block in Recover”。右上方描述了需要複製的數據塊的狀態,UnderReplicatedBlocks和PendingReplicationBlocks的區別在於Block是否被插入到某一個DatanodeDescriptor中。Corrupt和Invalidate的就好理解啦。

Hadoop源代碼分析(三六)

轉戰進入Secondary NameNode,前面的分析我們有事也把它稱爲從NameNode,從NameNode在HDFS裏是個小配角。

跟Secondary NameNode有關係的類不是很多,如下圖:


 

首先要討論的是NameNode和Secondary NameNode間的通信。NameNode上實現了接口NamenodeProtocol(如下圖),就是用於NameNode和Secondary NameNode間的命令通信。

 

NameNode和Secondary NameNode間數據的通信,使用的是HTTP協議,HTTP的容器用的是jetty,TransferFsImage是文件傳輸的輔助類。


 

GetImageServlet的doGet方法目前支持取FSImage(getimage),取日誌(getedit)和存FSImage(putimage)。例如:

http://localhost:50070/getimage?getimage

可以獲取FSImage。

http://localhost:50070/getimage?getedit

可以獲取日誌文件。

保存FSImage需要更多的參數,它的流程很好玩,Secondary NameNode發送一個HTTP請求到NameNode,啓動NameNode上一個HTTP客戶端到Secondary NameNode上去下載FSImage,下載需要的一些信息,都放在從NameNode的HTTP請求中。

我們先來考察Secondary NameNode持久化保存的信息:

[hadoop@localhost namesecondary]$ ls –R
.:
current  image  in_use.lock  previous.checkpoint

./current:
edits  fsimage  fstime  VERSION

./image:
fsimage

./previous.checkpoint:
edits  fsimage  fstime  VERSION

in_use.lock的用法和前面NameNode,DataNode的是一樣的。對比NameNode保存的信息,我們可以發現Secondary NameNode上保存多了一個previous.checkpoint。CheckpointStorage就是應用於Secondary NameNode的存儲類,它繼承自FSImage,只添加了很少的方法。

previous.checkpoint目錄保存了上一個checkpoint的信息(current裏的永遠是最新的),臨時目錄用於創建新checkpoint,成功後,老的checkpoint保存在previous.checkpoint目錄中。狀態圖如下(基類FSImage用的是黑色):



至於上面目錄下文件的內容,和FSImage是一樣的。

CheckpointStorage除了上面圖中的startCheckpoint和endCheckpoint方法(上圖給出了正常流程),還有:

    void recoverCreate(Collection<File> dataDirs,

                       Collection<File> editsDirs) throws IOException

和FSImage.coverTransitionRead類似,用於分析現有目錄,創建目錄(如果不存在)並從可能的錯誤中恢復。

    private void doMerge(CheckpointSignature sig) throws IOException

doMerge被類SecondaryNameNode的同名方法調用,我們後面再分析。

Hadoop源代碼分析(三七)

Secondary NameNode的成員變量很少,主要的有:

  private CheckpointStorage checkpointImage;
Secondary NameNode使用的Storage

  private NamenodeProtocol namenode;
和NameNode通信的接口

  private HttpServer infoServer;
傳輸文件用的HTTP服務器

main方法是Secondary NameNode的入口,它最終啓動線程,執行SecondaryNameNode的run。啓動前的對SecondaryNameNode的構造過程也很簡單,主要是創建和NameNode通信的接口和啓動HTTP服務器。

SecondaryNameNode的run方法每隔一段時間執行doCheckpoint(),從NameNode的主要工作都在這一個方法裏。這個方法,總的來說,會從NameNode上取下FSImage和日誌,然後再本地合併,再上傳回NameNode。這個過程結束後,從NameNode上保持了NameNode上持久化信息的一個備份,同時,NameNode上已經完成合併到FSImage的日誌可以拋棄,一箭雙鵰。

具體的的流程是:

1:調用startCheckpoint,爲接下來的工作準備空間。startCheckpoint會在內部做一系列的檢查,然後調用CheckpointStorage的startCheckpoint方法,創建目錄。

2:調用namenode的rollEditLog方法,開始一次新的檢查點過程。調用會返回一個CheckpointSignature(檢查點簽名),在上傳合併完的FSImage時,會使用這個簽名。

Namenode的rollEditLog方法最終調用的是FSImage的同名方法,前面提到過這個方法,作用是關閉往edits上寫的日誌,打開日誌到edits.new。明顯,在Secondary NameNode下載fsimage和日誌的時候,對命名空間的修改,將保持在edits.new的日誌中。

注意,如果FSImage這個時候的狀態(看下面的狀態機,前面出現過一次)不是出於CheckpointStates.ROLLED_EDITS,將拋異常結束這個過程。

3:通過downloadCheckpointFiles下載fsimage和日誌,並設置本地檢查點狀態爲CheckpointStates.UPLOAD_DONE。

4:合併日誌的內容到fsimage中。過程很簡單,CheckpointStorage利用繼承自FSImage的loadFSImage加載fsimage,loadFSEdits應用日誌,然後通過saveFSImage保存。很明顯,現在保存在硬盤上的fsimage是合併日誌的內容以後的文件。

5:使用putFSImage上傳合併日誌後的fsimage(讓NameNode通過HTTP到從NameNode取文件)。這個過程中,NameNode會:

調用NameNode的FSImage.validateCheckpointUpload,檢查現在的狀態;
利用HTTP,從Secondary NameNode獲取新的fsimage;
更新結束後設置新狀態。

6:調用NameNode的rollFsImage,最終調用FSImage的rollFsImage方法,前面我們已經分析過了。

7:調用本地endCheckpoint方法,結束一次doCheckpoint流程。

其實前面在分析FSImage的時候,我們在不瞭解Secondary NameNode的情況下,分析了很多和Checkpoint相關的方法,現在我們終於可以有一個比較統一的瞭解了,下面給出NameNode和Secondary NameNode的存儲系統在這個流程中的狀態轉移圖,方便大家理解。


 

圖中右側的狀態轉移圖:


 

文件系統上的目錄的變化(三六中出現):

 

Hadoop源代碼分析(三八)

我們可以開始從系統的外部來了解HDFS了,DFSClient提供了連接到HDFS系統並執行文件操作的基本功能。DFSClient也是個大傢伙,我們先分析它的一些內部類。我們先看LeaseChecker。租約是客戶端對文件寫操作時需要獲取的一個憑證,前面分析NameNode時,已經瞭解了租約,INodeFileUnderConstruction的關係,INodeFileUnderConstruction只有在文件寫的時候存在。客戶端的租約管理很簡單,包括了增加的put和刪除的remove方法,run方法會定期執行,並通過ClientProtocl的renewLease,自動延長租約。

 

接下來我們來分析內部爲文件讀引入的類。


InputStream是系統的虛類,提供了3個read方法,一個skip(跳過數據)方法,一個available方法(目前流中可讀的字節數),一個close方法和幾個在輸入流中做標記的方法(mark:標記,reset:回到標記點和markSupported:能力查詢)。

FSInputStream也是一個虛類,它將接口Seekable和PositionedReadable混插到類中。Seekable提供了可以在流中定位的能力(seek,getPos和seekToNewSource),而PositionedReadable提高了從某個位置開始讀的方法(一個read方法和兩個readFully方法)。

FSInputChecker在FSInputStream的基礎上,加入了HDFS中需要的校驗功能。校驗在readChecksumChunk中實現,並在內部的read1方法中調用。所有的read調用,最終都是使用read1讀數據並做校驗。如果校驗出錯,拋出異常ChecksumException。

有了支持校驗功能的輸入流,就可以開始構建基於Block的輸入流了。我們先回顧前面提到的讀數據塊的請求協議:

 

 

然後我們來分析一下創建BlockReader需要的參數,newBlockReader最複雜的請求如下:

   public static BlockReader newBlockReader( Socket sock, String file,

                                       long blockId, 

                                       long genStamp,

                                       long startOffset, long len,

                                       int bufferSize, boolean verifyChecksum,

                                       String clientName)

                                       throws IOException

其中,sock爲到DataNode的socket連接,file是文件名(只是用於日誌輸出),其它的參數含義都很清楚,和協議基本是一一對應的。該方法會和DataNode進行對話,發送上面的讀數據塊的請求,處理應答並構造BlockReader對象(BlockReader的構造函數基本上只有賦值操作)。

BlockReader的readChunk用於處理DataNode送過來的數據,格式前面我們已經討論過了,如下圖。


 

讀數據用的read,會調用父類FSInputChecker的read,最後調用readChunk,如下:


 

read如果發現讀到正確的校驗碼,則用過checksumOk方法,向DataNode發送成功應達。

BlockReader的主要流程就介紹完了,接下來分析DFSInputStream,它封裝了DFSClient讀文件內容的功能。在它的內部,不但要處理和NameNode的通信,同時通過BlockReader,處理和DataNode的交互。

DFSInputStream記錄Block的成員變量是:

    private LocatedBlocks locatedBlocks = null;

它不但保持了文件對應的Block序列,還保持了管理Block的DataNode的信息,是DFSInputStream中最重要的成員變量。DFSInputStream的構造函數,通過類內部的openInfo方法,獲取這個變量的值。openInfo間接調用了NameNode的getBlockLocations,獲取LocatedBlocks。

DFSInputStream中處理數據塊位置的還有下面一些函數:

    synchronized List<LocatedBlock> getAllBlocks() throws IOException

    private LocatedBlock getBlockAt(long offset) throws IOException

    private synchronized List<LocatedBlock> getBlockRange(long offset,

                                                          long length)

    private synchronized DatanodeInfo blockSeekTo(long target) throws IOException

它們的功能都很清楚,需要注意的是他們處理過程中可能會調用再次調用NameNode的getBlockLocations,使得流程比較複雜。blockSeekTo還會創建對應的BlockReader對象,它被幾個重要的方法調用(如下圖)。在打開到DataNode之前,blockSeekTo會調用chooseDataNode,選擇一個現在活着的DataNode。


 

通過上面的分析,我們已經知道了在什麼時候會連接NameNode,什麼時候會打開到DataNode的連接。下面我們來看讀數據。read方法定義如下:

    public int read(long position, byte[] buffer, int offset, int length)

該方法會從流的position位置開始,讀取最多length個byte到buffer中offset開始的空間中。參數檢測完以後,通過getBlockRange獲取要讀取的數據塊對應的block範圍,然後,利用fetchBlockByteRange方法,讀取需要的數據。

fetchBlockByteRange從某一個數據塊中讀取一段數據,定義如下:

    private void fetchBlockByteRange(LocatedBlock block, long start,

                                     long end, byte[] buf, int offset)

由於讀取的內容都在一個數據塊內部,這個方法會創建BlockReader,然後利用BlockReader的readAll方法,讀取數據。讀的過程中如果發生校驗錯,那麼,還會通過reportBadBlocks,向NameNode報告校驗錯。

另一個讀方法是:

    public synchronized int read(byte buf[], int off, int len) throws IOException

它在流的當前位置(可以通過seek方法調整)讀取數據。首先它會判斷當前流的位置,如果已經越過了對象現在的blockReader能讀取的範圍(當上次read讀到數據塊的尾部時,會發生這中情況),那麼通過blockSeekTo打開到下一個數據塊的blockReader。然後,read在當前的這個數據塊中通過readBuffer讀數據。主要,這個read方法只在一塊數據塊中讀取數據,就是說,如果還有空間可以存放數據但已經到了數據塊的尾部,它不會打開到下一個數據塊的BlockReader繼續讀,而是返回,返回值包含了以讀取數據的長度。

DFSDataInputStream是一個Wrapper(DFSInputStream),我們就不討論了。

Hadoop源代碼分析(三九)

接下來當然是分析輸出流了。

處於繼承體系的最上方是OutputStream,它實現了Closeable(方法close)和Flushable(方法flush)接口,提供了3個不同形式的write方法,這些方法的含義都很清楚。接下來的是FSOutputSummer,它引入了HDFS寫數據時需要的計算校驗和的功能。FSOutputSummer的write方法會調用write1,write1中計算校驗和並將用戶輸入的數據拷貝到對象的緩衝區中,緩衝區滿了以後會調用flushBuffer,flushBuffer最終調用還是虛方法的writeChunk,這個時候,緩衝區對應的校驗和緩衝區對的內容都已經準備好了。通過這個類,HDFS可以把一個流轉換成爲DataNode數據接口上的包格式(前面我們討論過這個包的格式,如下)。

  

 

DFSOutputStream繼承自FSOutputSummer,是一個非常複雜的類,它包含了幾個內部類。我們先分析Packet,其實它對應了上面的數據包,有了上面的圖,這個類就很好理解了,它的成員變量和上面數據塊包含的信息基本一一對應。構造函數需要的參數有pktSize,包的大小,chunksPerPkt,chunk的數目(chunk是一個校驗單元)和該包在Block中的偏移量offsetInBlock。writeData和writeChecksum用於往緩衝區裏寫數據/校驗和。getBuffer用戶獲得整個包,包括包頭和數據。

DataStreamer和ResponseProcessor用於寫包/讀應答,和我們前面討論DataNode的Pipe寫時類似,客戶端寫數據也需要兩個線程,下圖擴展了我們在討論DataNode處理寫時的示意圖,包含了客戶端:


 

DataStreamer啓動後進入一個循環,在沒有錯誤和關閉標記爲false的情況下,該循環首先調用processDatanodeError,處理可能的IO錯誤,這個過程比較複雜,我們在後面再討論。

接着DataStreamer會在dataQueue(數據隊列)上等待,直到有數據出現在隊列上。DataStreamer獲取一個數據包,然後判斷到DataNode的連接是否是打開的,如果不是,通過DFSOutputStream.nextBlockOutputStream打開到DataNode的連接,並啓動ResponseProcessor線程。

DataNode的連接準備好以後,DataStreamer獲取數據包緩衝區,然後將數據包從dataQueue隊列挪到ackQueue隊列,最後通過blockStream,寫數據。如果數據包是最後一個,那麼,DataStreamer將會寫一個長度域爲0的包,指示DataNode數據傳輸結束。

DataStreamer的循環在最後一個數據包寫出去以後,會等待直到ackQueue隊列爲空(表明所有的應答已經被接收),然後做清理動作(包括關閉socket連接,ResponseProcessor線程等),退出線程。

ResponseProcessor相對來說比較簡單,就是等待來自DataNode的應答。如果是成功的應答,則刪除在ackQueue的包,如果有錯誤,那麼,記錄出錯的DataNode,並設置標誌位。

Hadoop源代碼分析(四零)

有了上面的基礎,我們可以來解剖DFSOutputStream了。先看構造函數:

    private DFSOutputStream(String src, long blockSize, Progressable progress,

        int bytesPerChecksum) throws IOException

 

    DFSOutputStream(String src, FsPermission masked, boolean overwrite,

        short replication, long blockSize, Progressable progress,

        int buffersize, int bytesPerChecksum) throws IOException

 

    DFSOutputStream(String src, int buffersize, Progressable progress,

        LocatedBlock lastBlock, FileStatus stat,

        int bytesPerChecksum) throws IOException {

這些構造函數的參數主要有:文件名src;進度回調函數progress(預留接口,目前未使用);數據塊大小blockSize;Block副本數replication;每個校驗chunk的大小bytesPerChecksum;文件權限masked;是否覆蓋原文件標記overwrite;文件狀態信息stat;文件的最後一個Block信息lastBlock;buffersize(?未見引用)。

後面兩個構造函數會調用第一個構造函數,這個函數會調用父類的構造函數,並設置對象的src,blockSize,progress和checksum屬性。

第二個構造函數會調用namenode.create方法,在文件空間中建立文件,並啓動DataStreamer,它被DFSClient的create方法調用。第三個構造函數被DFSClient的append方法調用,顯然,這種情況比價複雜,文件擁有一些數據塊,添加數據往往添加在最後的數據塊上。同時,append方法調用時,Client已經知道了最後一個Block的信息和文件的一些信息,如FileStatus中包含的Block大小,文件權限位等等。結合這些信息,構造函數需要計算並設置一些對象成員變量的值,並試圖從可能的錯誤中恢復(調用processDatanodeError),最後啓動DataStreamer。

我們先看正常流程,前面已經分析過,通過FSOutputSummer,HDFS客戶端能將流轉換成package,這個包是通過writeChunk,發送出去的,下面是它們的調用關係。


 

在檢查完一系列的狀態以後,writeChunk先等待,直到dataQueue中未發送的包小於門限值。如果現在沒有可用的Packet對象,則創建一個Packet對象,往Packet中寫數據,包括校驗值和數據。如果數據包被寫滿,那麼,將它放入發送隊列dataQueue中。writeChunk的過程比較簡單,這裏的寫入,也只是把數據寫到本地隊列,等待DataStreamer發送,沒有實際寫到DataNode上。

createBlockOutputStream用於建立到第一個DataNode的連接,它的聲明如下:

private boolean createBlockOutputStream(DatanodeInfo[] nodes, String client,

                    boolean recoveryFlag)

nodes是所有接收數據的DataNode列表,client就是客戶端名稱,recoveryFlag指示是否是爲錯誤恢復建立的連接。createBlockOutputStream很簡單,打開到第一個DataNode的連接,然後發送下面格式的數據包,並等待來自DataNode的Ack。如果出錯,記錄出錯的DataNode在nodes中的位置,設置errorIndex並返回false。

 

當recoveryFlag指示爲真時,意味着這次寫是一次恢復操作,對於DataNode來說,這意味着爲寫準備的臨時文件(在tmp目錄中)可能已經存在,需要進行一些特殊處理,具體請看FSDataset的實現。

當Client寫數據需要一個新的Block的時候,可以調用nextBlockOutputStream方法。

    private DatanodeInfo[] nextBlockOutputStream(String client) throws IOException

這個方法的實現很簡單,首先調用locateFollowingBlock(包含了重試和出錯處理),通過namenode.addBlock獲取一個新的數據塊,返回的是DatanodeInfo列表,有了這個列表,就可以建立寫數據的pipe了。下一個大動作就是調用上面的createBlockOutputStream,建立到DataNode的連接了。

有了上面的準備,我們來分析processDatanodeError,它的主要流程是:

l           參數檢查;

l           關閉可能還打開着的blockStream和blockReplyStream;

l           將未收到應答的數據塊(在ackQueue中)挪到dataQueue中;

l           循環執行:

1.      計算目前還活着的DataNode列表;

2.      選擇一個主DataNode,通過DataNode RPC的recoverBlock方法啓動它上面的恢復過程;

3.      處理可能的出錯;

4.      處理恢復後Block可能的變化(如Stamp變化);

5.      調用createBlockOutputStream到DataNode的連接。

l           啓動ResponseProcessor。

這個過程涉及了DataNode上的recoverBlock方法和createBlockOutputStream中可能的Block恢復,是一個相當耗資源的方法,當系統出錯的概率比較小,而且數據塊上能恢復的數據很多(平均32M),還是值得這樣做的。

寫的流程就分析到着,接下來我們來看流的關閉,這個過程也涉及了一系列的方法,它們的調用關係如下:


 

flushInternal會一直等待到發送隊列(包括可能的currentPacket)和應答隊列都爲空,這意味着數據都被DataNode順利接收。

sync作用和UNIX的sync類似,將寫入數據持久化。它首先調用父類的flushBuffer方法,將可能還沒拷貝到DFSOutputStream的數據拷貝回來,然後調用flushInternal,等待所有的數據都寫完。然後調用namenode.fsync,持久化命名空間上的數據。

closeInternal比較複雜一點,它首先調用父類的flushBuffer方法,將可能還沒拷貝到DFSOutputStream的數據拷貝回來,然後調用flushInternal,等待所有的數據都寫完。接着結束兩個工作線程,關閉socket,最後調用amenode.complete,通知NameNode結束一次寫操作。close方法先調用closeInternal,然後再本地的leasechecker中移除對應的信息。

Hadoop源代碼分析(四一)

前面分析的DFSClient內部類,佔據了這個類的實現部分的2/3,我們來看剩下部分。

DFSClient的成員變量不多,而且大部分是系統的缺省配置參數,其中比較重要的是到NameNode的RPC客戶端:

  public final ClientProtocol namenode;

  final private ClientProtocol rpcNamenode;

它們的差別是namenode在rpcNamenode的基礎上,增加了失敗重試功能。DFSClient中提供可各種構造它們的static函數,createClientDatanodeProtocolProxy用於生成到DataNode的RPC客戶端。

DFSClient的構造函數也比價簡單,就是初始化成員變量,close用於關閉DFSClient。

下面的功能,DFSClient只是簡單地調用NameNode的對應方法(加一些簡單的檢查),就不羅嗦了:

setReplication/rename/delete/exists(通過getFileInfo的返回值是否爲空判斷)/listPaths/getFileInfo/setPermission/setOwner/getDiskStatus/totalRawCapacity/totalRawUsed/datanodeReport/setSafeMode/refreshNodes/metaSave/finalizeUpgrade/mkdirs/getContentSummary/setQuota/setTimes

DFSClient提供了各種create方法,它們最後都是構造一個OutputStream,並將文件名和生成的OutputStream加到leasechecker,完成創建動作。

append操作是通過namenode.append,獲取最後的Block信息,然後構造一個OutputStream,並將文件名和生成的OutputStream加到leasechecker,完成append動作。

getFileChecksum用於獲取文件的校驗信息,它在得到數據塊的位置信息後利用DataNode提供的OP_BLOCK_CHECKSUM操作,獲取需要的數據,並綜合起來。過程簡單,方法主要是在處理OP_BLOCK_CHECKSUM需要交換的數據包。

DFSClient內部還有一些其它的輔助方法,都比較簡單,就不再分析了。

Hadoop源代碼分析(MapReduce概論)

大家都熟悉文件系統,在對HDFS進行分析前,我們並沒有花很多的時間去介紹HDFS的背景,畢竟大家對文件系統的還是有一定的理解的,而且也有很好的文檔。在分析Hadoop的MapReduce部分前,我們還是先了解系統是如何工作的,然後再進入我們的分析部分。下面的圖來自http://horicky.blogspot.com/2008/11/hadoop-mapreduce-implementation.html,是我看到的講MapReduce最好的圖。



 

以Hadoop帶的wordcount爲例子(下面是啓動行):

hadoop jar hadoop-0.19.0-examples.jar wordcount /usr/input /usr/output

用戶提交一個任務以後,該任務由JobTracker協調,先執行Map階段(圖中M1,M2和M3),然後執行Reduce階段(圖中R1和R2)。Map階段和Reduce階段動作都受TaskTracker監控,並運行在獨立於TaskTracker的Java虛擬機中。

我們的輸入和輸出都是HDFS上的目錄(如上圖所示)。輸入由InputFormat接口描述,它的實現如ASCII文件,JDBC數據庫等,分別處理對於的數據源,並提供了數據的一些特徵。通過InputFormat實現,可以獲取InputSplit接口的實現,這個實現用於對數據進行劃分(圖中的splite1到splite5,就是劃分以後的結果),同時從InputFormat也可以獲取RecordReader接口的實現,並從輸入中生成<k,v>對。有了<k,v>,就可以開始做map操作了。

map操作通過context.collect(最終通過OutputCollector. collect)將結果寫到context中。當Mapper的輸出被收集後,它們會被Partitioner類以指定的方式區分地寫出到輸出文件裏。我們可以爲Mapper提供Combiner,在Mapper輸出它的<k,v>時,鍵值對不會被馬上寫到輸出裏,他們會被收集在list裏(一個key值一個list),當寫入一定數量的鍵值對時,這部分緩衝會被Combiner中進行合併,然後再輸出到Partitioner中(圖中M1的黃顏色部分對應着Combiner和Partitioner)。

Map的動作做完以後,進入Reduce階段。這個階段分3個步驟:混洗(Shuffle),排序(sort)和reduce。

混洗階段,Hadoop的MapReduce框架會根據Map結果中的key,將相關的結果傳輸到某一個Reducer上(多個Mapper產生的同一個key的中間結果分佈在不同的機器上,這一步結束後,他們傳輸都到了處理這個key的Reducer的機器上)。這個步驟中的文件傳輸使用了HTTP協議。

排序和混洗是一塊進行的,這個階段將來自不同Mapper具有相同key值的<key,value>對合併到一起。

Reduce階段,上面通過Shuffle和sort後得到的<key, (list of values)>會送到Reducer. reduce方法中處理,輸出的結果通過OutputFormat,輸出到DFS中。

Hadoop源代碼分析MapTask

接下來我們來分析Task的兩個子類,MapTask和ReduceTask。MapTask的相關類圖如下:


 

MapTask其實不是很複雜,複雜的是支持MapTask工作的一些輔助類。MapTask的成員變量少,只有split和splitClass。我們知道,Map的輸入是split,是原始數據的一個切分,這個切分由org.apache.hadoop.mapred.InputSplit的子類具體描述(前面我們是通過org.apache.hadoop.mapreduce.InputSplit介紹了InputSplit,它們對外的API是一樣的)。splitClass是InputSplit子類的類名,通過它,我們可以利用Java的反射機制,創建出InputSplit子類。而split是一個BytesWritable,它是InputSplit子類串行化以後的結果,再通過InputSplit子類的readFields方法,我們可以回覆出對應的InputSplit對象。

MapTask最重要的方法是run。run方法相當簡單,配置完系統的TaskReporter後,就根據情況執行runJobCleanupTask,runJobSetupTask,runTaskCleanupTask或執行Mapper。由於MapReduce現在有兩套API,MapTask需要支持這兩套API,使得MapTask執行Mapper分爲runNewMapper和runOldMapper,run*Mapper後,MapTask會調用父類的done方法。

接下來我們來分析runOldMapper,最開始部分是構造Mapper處理的InputSplit,更新Task的配置,然後就開始創建Mapper的RecordReader,rawIn是原始輸入,然後分正常(使用TrackedRecordReader,後面討論)和跳過部分記錄(使用SkippingRecordReader,後面討論)兩種情況,構造對應的真正輸入in。

跳過部分記錄是Map的一種出錯恢復策略,我們知道,MapReduce處理的數據集合非常大,而有些任務對一部分出錯的數據不進行處理,對結果的影響很小(如大數據集合的一些統計量),那麼,一小部分的數據出錯導致已處理的大量結果無效,是得不償失的,跳過這部分記錄,成了Mapper的一種選擇。

Mapper的輸出,是通過MapOutputCollector進行的,也分兩種情況,如果沒有Reducer,那麼,用DirectMapOutputCollector(後面討論),否則,用MapOutputBuffer(後面討論)。

構造完Mapper的輸入輸出,通過構造配置文件中配置的MapRunnable,就可以執行Mapper了。目前系統有兩個MapRunnable:MapRunner和MultithreadedMapRunner,如下圖。


 

原有API在這塊的處理上和新API有很大的不一樣。接口MapRunnable是原有API中Mapper的執行器,run方法就是用於執行用戶的Mapper。MapRunner是單線程執行器,相當簡單,首先,當MapTask調用:

       MapRunnable<INKEY,INVALUE,OUTKEY,OUTVALUE> runner =

           ReflectionUtils.newInstance(job.getMapRunnerClass(), job);

MapRunner的configure會在newInstance的最後被調用,configure執行的過程中,對應的Mapper會通過反射機制構造出來。

MapRunner的run方法,會先創建對應的key,value對象,然後,對InputSplit的每一對<key,value>,調用Mapper的map方法,循環結束後,Mapper對應的清理方法會被調用。我們需要注意,key,value對象在run方法中是被重複使用的,就是說,每次傳入Mapper的map方法的key,value都是同一個對象,只不過是裏面的內容變了,對象並沒有變。如果你需要保留key,value的內容,需要實現clone機制,克隆出對象的一個新備份。

相對於新API的多線程執行器,老API的MultithreadedMapRunner就比較複雜了,總體來說,就是通過阻塞隊列配合Java的多線程執行器,將<key,value>分發到多個線程中去處理。需要注意的是,在這個過程中,這些線程共享一個Mapper實例,如果Mapper有共享的資源,需要有一定的保護機制。

runNewMapper用於執行新版本的Mapper,比runOldMapper稍微複雜,我們就不再討論了。

Hadoop源代碼分析(MapTask輔助類 I

MapTask的輔助類主要針對Mapper的輸入和輸出。首先我們來看MapTask中用的的Mapper輸入,在類圖中,這部分位於右上角。

MapTask.TrackedRecordReader是一個Wrapper,在原有輸入RecordReader的基礎上,添加了收集上報統計數據的功能。

MapTask.SkippingRecordReader也是一個Wrapper,它在MapTask.TrackedRecordReader的基礎上,添加了忽略部分輸入的功能。在分析MapTask.SkippingRecordReader之前,我們先看一下類SortedRanges和它相關的類。


類SortedRanges.Ranges表示了一個範圍,以開始位置和範圍長度(這樣的話就可以表示長度爲0的範圍)來表示一個範圍,並提供了一系列的範圍操作方法。注意,方法getEndIndex得到的右端點並不包含在範圍內(應理解爲開區間)。SortedRanges包含了一系列不重疊的範圍,爲了保證包含的範圍不重疊,在add方法和remove方法上需要做一些處理,保證不重疊的約束。SkipRangeIterator是訪問SortedRanges包含的Ranges的迭代器。

MapTask.SkippingRecordReader的實現很簡單,因爲要忽略的輸入都保持在SortedRanges.Ranges,只需要在next方法中,判斷目前範圍時候落在SortedRanges.Ranges中,如果是,忽略,並將忽略的記錄寫文件(可配置)

NewTrackingRecordReader和NewOutputCollector被新API使用,我們不分析。

MapTask的輸出輔助類都繼承自MapOutputCollector,它只是在OutputCollector的基礎上添加了close和flush方法。

DirectMapOutputCollector用在Reducer的數目爲0,就是不需要Reduce階段的時候。它是直接通過

out = job.getOutputFormat().getRecordWriter(fs, job, finalName, reporter);

得到對應的RecordWriter,collect直接到RecordWriter上。

如果Mapper後續有reduce任務,系統會使用MapOutputBuffer做爲輸出,這是個比較複雜的類,有1k行左右的代碼。

我們知道,Mapper是通過OutputCollector將Map的結果輸出,輸出的量很大,Hadoop的機制是通過一個circle buffer 收集Mapper的輸出, 到了io.sort.mb * percent量的時候,就spill到disk,如下圖。圖中出現了兩個數組和一個緩衝區,kvindices保持了記錄所屬的(Reduce)分區,key在緩衝區開始的位置和value在緩衝區開始的位置,通過kvindices,我們可以在緩衝區中找到對應的記錄。kvoffets用於在緩衝區滿的時候對kvindices的partition進行排序,排完序的結果將輸出到輸出到本地磁盤上,其中索引(kvindices)保持在spill{spill號}.out.index中,數據保存在spill{spill號}.out中。


 

當Mapper任務結束後,有可能會出現多個spill文件,這些文件會做一個歸併排序,形成Mapper的一個輸出(spill.out和spill.out.index),如下圖:



 

這個輸出是按partition排序的,這樣的話,Mapper的輸出被分段,Reducer要獲取的就是spill.out中的一段。(注意,內存和硬盤上的索引結構不一樣)

(感謝彭帥的Hadoop Map Stage流程分析 http://www.cnblogs.com/OnlyXP/archive/2009/05/25/1488811.html)

Hadoop源代碼分析(MapTask輔助類,II

有了上面Mapper輸出的內存存儲結構和硬盤存儲結構討論,我們來仔細分析MapOutputBuffer的流程。

首先是成員變量。最先初始化的是作業配置job和統計功能reporter。通過配置,MapOutputBuffer可以獲取本地文件系統(localFs和rfs),Reducer的數目和Partitioner。

SpillRecord是文件spill.out{spill號}.index在內存中的對應抽象(內存數據和文件數據就差最後的校驗和),該文件保持了一系列的IndexRecord,如下圖:


IndexRecord有3個字段,分別是startOffset:記錄偏移量,rawLength:初始長度,partLength:實際長度(可能有壓縮)。SpillRecord保持了一系列的IndexRecord,並提供方法用於添加記錄(沒有刪除記錄的操作,因爲不需要),獲取記錄,寫文件,讀文件(通過構造函數)。

接下來是一些和輸出緩存區kvbuffer,緩存區記錄索引kvindices和緩存區記錄索引排序工作數組kvoffsets相關的處理,下面的圖有助於說明這段代碼。


 

這部分依賴於3個配置參數,io.sort.spill.percent是kvbuffer,kvindices和kvoffsets的總大小(以M爲單位,缺省是100,就是100M,這一部分是MapOutputBuffer中佔用存儲最多的)。io.sort.record.percent是kvindices和kvoffsets佔用的空間比例(缺省是0.05)。前面的分析我們已經知道kvindices和kvoffsets,如果記錄數是N的話,它佔用的空間是4N*4bytes,根據這個關係和io.sort.record.percent的值,我們可以計算出kvindices和kvoffsets最多能有多少個記錄,並分配相應的空間。參數io.sort.spill.percent指示當輸出緩衝區或kvindices和kvoffsets記錄數量到達對應的佔用率的時候,會啓動spill,將內存緩衝區的記錄存放到硬盤上,softBufferLimit和softRecordLimit爲對應的字節數。

值對<key, value>輸出到緩衝區是通過Serializer串行化的,這部分的初始化跟在上面輸出緩存後面。接下來是一些計數器和可能的數據壓縮處理器的初始化,可能的Combiner和combiner工作的一些配置。

最後是啓動spillThread,該Thread會檢查內存中的輸出緩存區,在滿足一定條件的時候將緩衝區中的內容spill到硬盤上。這是一個標準的生產者-消費者模型,MapTask的collect方法是生產者,spillThread是消費者,它們之間同步是通過spillLock(ReentrantLock)和spillLock上的兩個條件變量(spillDone和spillReady)完成的。

先看生產者,MapOutputBuffer.collect的主要流程是:

l           報告進度和參數檢測(<K, V>符合Mapper的輸出約定);

l           spillLock.lock(),進入臨界區;

l           如果達到spill條件,設置變量並通過spillReady.signal(),通知spillThread;並等待spill結束(通過spillDone.await()等待);

l           spillLock.unlock();

l           輸出key,value並更新kvindices和kvoffsets(注意,方法collect是synchronized,key和value各自輸出,它們也會佔用連續的輸出緩衝區);

kvstart,kvend和kvindex三個變量在判斷是否需要spill和spill是否結束的過程中很重要,kvstart是有效記錄開始的下標,kvindex是下一個可做記錄的位置,kvend的作用比較特殊,它在一般情況下kvstart==kvend,但開始spill的時候它會被賦值爲kvindex的值,spill結束時,它的值會被賦給kvstart,這時候kvstart==kvend。這就是說,如果kvstart不等於kvend,系統正在spill,否則,kvstart==kvend,系統處於普通工作狀態。其實在代碼中,我們可以看到很多kvstart==kvend的判斷。

下面我們分情況,討論kvstart,kvend和kvindex的配合。初始化的時候,它們都被賦值0。


 

下圖給出了一個沒有spill的記錄添加過程:


 

注意kvindex和kvnext的關係,取模實現了循環緩衝區

如果在添加記錄的過程中,出現spill(多種條件),那麼,主要的過程如下:


 

首先還是計算kvnext,主要,這個時候kvend==kvstart(圖中沒有畫出來)。如果spill條件滿足,那麼,kvindex的值會賦給kvend(這是kvend不等於kvstart),從kvstart和kvend的大小關係,我們可以知道記錄位於數組的那一部分(左邊是kvstart<kvend的情況,右邊是另外的情況)。Spill結束的時候,kvend值會被賦給kvstart, kvend==kvstart又重新滿足,同時,我們可以發現kvindex在這個過程中沒有變化,新的記錄還是寫在kvindex指向的位置,然後,kvindex=kvnect,kvindex移到下一個可用位置。

大家體會一下上面的過程,特別是kvstart,kvend和kvindex的配合,其實,<key,value>對輸出使用的緩衝區,也有類似的過程。

Collect在處理<key,value>輸出時,會處理一個MapBufferTooSmallException,這是value的串行化結果太大,不能一次放入緩衝區的指示,這種情況下我們需要調用spillSingleRecord,特殊處理。

Hadoop源代碼分析(MapTask輔助類,III

接下來討論的是key,value的輸出,這部分比較複雜,不過有了前面kvstart,kvend和kvindex配合的分析,有利於我們理解這部分的代碼。

輸出緩衝區中,和kvstart,kvend和kvindex對應的是bufstart,bufend和bufmark。這部分還涉及到變量bufvoid,用於表明實際使用的緩衝區結尾(見後面BlockingBuffer.reset分析),和變量bufmark,用於標記記錄的結尾。這部分代碼需要bufmark,是因爲key或value的輸出是變長的,(前面元信息記錄大小是常量,就不需要這樣的變量)。

最好的情況是緩衝區沒有翻轉和value串行化結果很小,如下圖:


 

先對key串行化,然後對value做串行化,臨時變量keystart,valstart和valend分別記錄了key結果的開始位置,value結果的開始位置和value結果的結束位置。

串行化過程中,往緩衝區寫是最終調用了Buffer.write方法,我們後面再分析。

如果key串行化後出現bufindex < keystart,那麼會調用BlockingBuffer的reset方法。原因是在spill的過程中需要對<key,value>排序,這種情況下,傳遞給RawComparator的必須是連續的二進制緩衝區,通過BlockingBuffer.reset方法,解決這個問題。下圖解釋瞭如何解決這個問題:



 

當發現key的串行化結果出現不連續的情況時,我們會把bufvoid設置爲bufmark,見緩衝區開始部分往後挪,然後將原來位於bufmark到bufvoid出的結果,拷到緩衝區開始處,這樣的話,key串行化的結果就連續存放在緩衝區的最開始處。

上面的調整有一個條件,就是bufstart前面的緩衝區能夠放下整個key串行化的結果,如果不能,處理的方式是將bufindex置0,然後調用BlockingBuffer內部的out的write方法直接輸出,這實際調用了Buffer.write方法,會啓動spill過程,最終我們會成功寫入key串行化的結果。

下面我們看write方法。key,value串行化過程中,往緩衝區寫數據是最終調用了Buffer.write方法,又是一個複雜的方法。

l           do-while循環,直到我們有足夠的空間可以寫數據(包括緩衝區和kvindices和kvoffsets)

u      首先我們計算緩衝區連續寫是否寫滿標誌buffull和緩衝區非連續情況下有足夠寫空間標誌wrap(這個實在拗口),見下面的討論;條件(buffull && !wrap)用於判斷目前有沒有足夠的寫空間;

u      在spill沒啓動的情況下(kvstart == kvend),分兩種情況,如果數組中有記錄(kvend != kvindex),那麼,根據需要(目前輸出空間不足或記錄數達到spill條件)啓動spill過程;否則,如果空間還是不夠(buffull && !wrap),表明這個記錄非常大,以至於我們的內存緩衝區不能容下這麼大的數據量,拋MapBufferTooSmallException異常;

u      如果空間不足同時spill在運行,等待spillDone;

l           寫數據,注意,如果buffull,則寫數據會不連續,則寫滿剩餘緩衝區,然後設置bufindex=0,並從bufindex處接着寫。否則,就是從bufindex處開始寫。

下圖給出了緩衝區連續寫是否寫滿標誌buffull和緩衝區非連續情況下有足夠寫空間標誌wrap計算的幾種可能:


 

情況1和情況2中,buffull判斷爲從bufindex到bufvoid是否有足夠的空間容納寫的內容,wrap是圖中白顏色部分的空間是否比輸入大,如果是,wrap爲true;情況3和情況4中,buffull判斷bufindex到bufstart的空間是否滿足條件,而wrap肯定是false。明顯,條件(buffull && !wrap)滿足時,目前的空間不夠一次寫。

接下來我們來看spillSingleRecord,只是用於寫放不進內存緩衝區的<key,value>對。過程很流水,首先是創建SpillRecord記錄,輸出文件和IndexRecord記錄,然後循環,構造SpillRecord並在恰當的時候輸出記錄(如下圖),最後輸出spill{n}.index文件。

 

前面我們提過spillThread,在這個系統中它是消費者,這個消費者相當簡單,需要spill時調用函數sortAndSpill,進行spill。sortAndSpill和spillSingleRecord類似,函數的開始也是創建SpillRecord記錄,輸出文件和IndexRecord記錄,然後,需要在kvoffsets上做排序,排完序後順序訪問kvoffsets,也就是按partition順序訪問記錄。

按partition循環處理排完序的數組,如果沒有combiner,則直接輸出記錄,否則,調用combineAndSpill,先做combin然後輸出。循環的最後記錄IndexRecord到SpillRecord。

sortAndSpill最後是輸出spill{n}.index文件。

combineAndSpill比價簡單,我們就不分析了。

BlockingBuffer中最後要分析的方法是flush方法。調用flush方法,意味着Mapper的結果都已經collect了,需要對緩衝區做一些最後的清理,併合並spill{n}文件產生最後的輸出。

緩衝區處理部分很簡單,先等待可能的spill過程完成,然後判斷緩衝區是否爲空,如果不是,則調用sortAndSpill,做最後的spill,然後結束spill線程。

flush合併spill{n}文件是通過mergeParts方法。如果Mapper最後只有一個spill{n}文件,簡單修改該文件的文件名就可以。如果Mapper沒有任何輸出,那麼我們需要創建啞輸出(dummy files)。如果spill{n}文件多於1個,那麼按partition循環處理所有文件,將處於處理partition的記錄輸出。處理partition的過程中可能還會再次調用combineAndSpill,最記錄再做一次combination,其中還涉及到工具類Merger,我們就不再深入研究了。

Hadoop源代碼分析(Task的內部類和輔助類)

從前面的圖中,我們可以發現Task有很多內部類,並擁有大量類成員變量,這些類配合Task完成相關的工作,如下圖。


 

MapOutputFile管理着Mapper的輸出文件,它提供了一系列get方法,用於獲取Mapper需要的各種文件,這些文件都存放在一個目錄下面。

我們假設傳入MapOutputFile的JobID爲job_200707121733_0003,TaskID爲task_200707121733_0003_m_000005。MapOutputFile的根爲
{mapred.local.dir}/taskTracker/jobcache/{jobid}/{taskid}/output

在下面的討論中,我們把上面的路徑記爲{MapOutputFileRoot}

以上面JogID和TaskID爲例,我們有:
{mapred.local.dir}/taskTracker/jobcache/job_200707121733_0003/task_200707121733_0003_m_000005/output

需要注意的是,{mapred.local.dir}可以包含一系列的路徑,那麼,Hadoop會在這些根路徑下找一個滿足要求的目錄,建立所需的文件。MapOutputFile的方法有兩種,結尾帶ForWrite和不帶ForWrite,帶ForWrite用於創建文件,它需要一個文件大小作爲參數,用於檢查磁盤空間。不帶ForWrite用於獲取以建立的文件。

getOutputFile:文件名爲{MapOutputFileRoot}/file.out; 

getOutputIndexFile:文件名爲{MapOutputFileRoot}/file.out.index

getSpillFile:文件名爲{MapOutputFileRoot}/spill{spillNumber}.out

getSpillIndexFile:文件名爲{MapOutputFileRoot}/spill{spillNumber}.out.index

以上四個方法用於Task子類MapTask中;

getInputFile:文件名爲{MapOutputFileRoot}/map_{mapId}.out

用於ReduceTask中。我們到使用到他們的地方再介紹相應的應用場景。

 

介紹完臨時文件管理以後,我們來看Task.CombineOutputCollector,它繼承自org.apache.hadoop.mapred.OutputCollector,很簡單,只是一個OutputCollector到IFile.Writer的Adapter,活都讓IFile.Writer幹了。

 

ValuesIterator用於從RawKeyValueIterator(Key,Value都是DataInputBuffer,ValuesIterator要求該輸入已經排序)中獲取符合RawComparator<KEY> comparator的值的迭代器。它在Task中有一個簡單子類,CombineValuesIterator。

 

Task.TaskReporter用於向JobTracker提交計數器報告和狀態報告,它實現了計數器報告Reporter和狀態報告StatusReporter。爲了不影響主線程的工作,TaskReporter有一個獨立的線程,該線程通過TaskUmbilicalProtocol接口,利用Hadoop的RPC機制,向JobTracker報告Task執行情況。

 

FileSystemStatisticUpdater用於記錄對文件系統的對/寫操作字節數,是個簡單的工具類。

Hadoop源代碼分析(mapreduce.lib.partition/reduce/output

Map的結果,會通過partition分發到Reducer上,Reducer做完Reduce操作後,通過OutputFormat,進行輸出,下面我們就來分析參與這個過程的類。


 

Mapper的結果,可能送到可能的Combiner做合併,Combiner在系統中並沒有自己的基類,而是用Reducer作爲Combiner的基類,他們對外的功能是一樣的,只是使用的位置和使用時的上下文不太一樣而已。

Mapper最終處理的結果對<key, value>,是需要送到Reducer去合併的,合併的時候,有相同key的鍵/值對會送到同一個Reducer那,哪個key到哪個Reducer的分配過程,是由Partitioner規定的,它只有一個方法,輸入是Map的結果對<key, value>和Reducer的數目,輸出則是分配的Reducer(整數編號)。系統缺省的Partitioner是HashPartitioner,它以key的Hash值對Reducer的數目取模,得到對應的Reducer。

Reducer是所有用戶定製Reducer類的基類,和Mapper類似,它也有setup,reduce,cleanup和run方法,其中setup和cleanup含義和Mapper相同,reduce是真正合並Mapper結果的地方,它的輸入是key和這個key對應的所有value的一個迭代器,同時還包括Reducer的上下文。系統中定義了兩個非常簡單的Reducer,IntSumReducer和LongSumReducer,分別用於對整形/長整型的value求和。

Reduce的結果,通過Reducer.Context的方法collect輸出到文件中,和輸入類似,Hadoop引入了OutputFormat。OutputFormat依賴兩個輔助接口:RecordWriter和OutputCommitter,來處理輸出。RecordWriter提供了write方法,用於輸出<key, value>和close方法,用於關閉對應的輸出。OutputCommitter提供了一系列方法,用戶通過實現這些方法,可以定製OutputFormat生存期某些階段需要的特殊操作。我們在TaskInputOutputContext中討論過這些方法(明顯,TaskInputOutputContext是OutputFormat和Reducer間的橋樑)。

OutputFormat和RecordWriter分別對應着InputFormat和RecordReader,系統提供了空輸出NullOutputFormat(什麼結果都不輸出,NullOutputFormat.RecordWriter只是示例,系統中沒有定義),LazyOutputFormat(沒在類圖中出現,不分析),FilterOutputFormat(不分析)和基於文件FileOutputFormat的SequenceFileOutputFormat和TextOutputFormat輸出。

基於文件的輸出FileOutputFormat利用了一些配置項配合工作,包括mapred.output.compress:是否壓縮;mapred.output.compression.codec:壓縮方法;mapred.output.dir:輸出路徑;mapred.work.output.dir:輸出工作路徑。FileOutputFormat還依賴於FileOutputCommitter,通過FileOutputCommitter提供一些和Job,Task相關的臨時文件管理功能。如FileOutputCommitter的setupJob,會在輸出路徑下創建一個名爲_temporary的臨時目錄,cleanupJob則會刪除這個目錄。

SequenceFileOutputFormat輸出和TextOutputFormat輸出分別對應輸入的SequenceFileInputFormat和TextInputFormat,我們就不再詳細分析啦。

Hadoop源代碼分析(IFile

Mapper的輸出,在發送到Reducer前是存放在本地文件系統的,IFile提供了對Mapper輸出的管理。我們已經知道,Mapper的輸出是<Key,Value>對,IFile以記錄<key-len, value-len, key, value>的形式存放了這些數據。爲了保存鍵值對的邊界,很自然IFile需要保存key-len和value-len。

和IFile相關的類圖如下:


其中,文件流形式的輸入和輸出是由IFIleInputStream和IFIleOutputStream抽象。以記錄形式的讀/寫操作由IFile.Reader/IFile.Writer提供,IFile.InMemoryReader用於讀取存在於內存中的IFile文件格式數據。

我們以輸出爲例,來分析這部分的實現。首先是下圖的和序列化反序列化相關的Serialization/Deserializer,這部分的code是在包org.apache.hadoop.io.serializer。序列化由Serializer抽象,通過Serializer的實現,用戶可以利用serialize方法把對象序列化到通過open方法打開的輸出流裏。Deserializer提供的是相反的過程,對應的方法是deserialize。hadoop.io.serializer中還實現了配合工作的Serialization和對應的工廠SerializationFactory。兩個具體的實現是WritableSerialization和JavaSerialization,分別對應了Writeble的序列化反序列化和Java本身帶的序列化反序列化。



 

有了Serializer/Deserializer,我們來分析IFile.Writer。Writer的構造函數是:

    public Writer(Configuration conf, FSDataOutputStream out, 

        Class<K> keyClass, Class<V> valueClass,

        CompressionCodec codec, Counters.Counter writesCounter)

conf,配置參數,out是Writer的輸出,keyClass 和valueClass 是輸出的Kay,Value的class屬性,codec是對輸出進行壓縮的方法,參數writesCounter用於對輸出字節數進行統計的Counters.Counter。通過這些參數,我們可以構造我們使用的支持壓縮功能的輸出流(類成員out,類成員rawOut保存了構造函數傳入的out),相關的計數器,還有就是Kay,Value的Serializer方法。

Writer最主要的方法是append方法(居然不是write方法,呵呵),有兩種形式:

public void append(K key, V value) throws IOException {

public void append(DataInputBuffer key, DataInputBuffer value)

append(K key, V value)的主要過程是檢查參數,然後將key和value序列化到DataOutputBuffer中,並獲取序列化後的長度,最後把長度(2個)和DataOutputBuffer中的結果寫到輸出,並復位DataOutputBuffer和計數。append(DataInputBuffer key, DataInputBuffer value)處理過程也比較類似,就不再分析了。

close方法中需要注意的是,我們需要標記文件尾,或者是流結束。目前是通過寫2個值爲EOF_MARKER的長度來做標記。

IFileOutputStream是用於配合Writer的輸出流,它會在IFiles的最後添加校驗數據。當Writer調用IFileOutputStream的write操作時,IFileOutputStream計算並保持校驗和,流被close的時候,校驗結果會寫到對應文件的文件尾。實際上存放在磁盤上的文件是一系列的<key-len, value-len, key, value>記錄和校驗結果。

 Reader的相關過程,我們就不再分析了。

Hadoop源代碼分析(*IDs類和*Context類)

我們開始來分析Hadoop MapReduce的內部的運行機制。用戶向Hadoop提交Job(作業),作業在JobTracker對象的控制下執行。Job被分解成爲Task(任務),分發到集羣中,在TaskTracker的控制下運行。Task包括MapTask和ReduceTask,是MapReduce的Map操作和Reduce操作執行的地方。這中任務分佈的方法比較類似於HDFS中NameNode和DataNode的分工,NameNode對應的是JobTracker,DataNode對應的是TaskTracker。JobTracker,TaskTracker和MapReduce的客戶端通過RPC通信,具體可以參考HDFS部分的分析。

我們先來分析一些輔助類,首先是和ID有關的類,ID的繼承樹如下:


 

這張圖可以看出現在Hadoop的org.apache.hadoop.mapred向org.apache.hadoop.mapreduce遷移帶來的一些問題,其中灰色是標註爲@Deprecated的。ID攜帶一個整型,實現了WritableComparable接口,這表明它可以比較,而且可以被Hadoop的io機制串行化/解串行化(必須實現compareTo/readFields/write方法)。JobID是系統分配給作業的唯一標識符,它的toString結果是job_<jobtrackerID>_<jobNumber>。例子:job_200707121733_0003表明這是jobtracker 200707121733(利用jobtracker的開始時間作爲ID)的第3號作業。

作業分成任務執行,任務號TaskID包含了它所屬的作業ID,同時也有任務ID,同時還保持了這是否是一個Map任務(成員變量isMap)。任務號的字符串表示爲task_<jobtrackerID>_<jobNumber>_[m|r]_<taskNumber>,如task_200707121733_0003_m_000005表示作業200707121733_0003的000005號任務,改任務是一個Map任務。

一個任務有可能有多個執行(錯誤恢復/消除Stragglers等),所以必須區分任務的多個執行,這是通過類TaskAttemptID來完成,它在任務號的基礎上添加了嘗試號。一個任務嘗試號的例子是attempt_200707121733_0003_m_000005_0,它是任務task_200707121733_0003_m_000005的第0號嘗試。

JVMId用於管理任務執行過程中的Java虛擬機,我們後面再討論。

爲了使Job和Task工作,Hadoop提供了一系列的上下文,這些上下文保存了Job和Task工作的信息。


 

處於繼承樹的最上方是org.apache.hadoop.mapreduce.JobContext,前面我們已經介紹過了,它提供了Job的一些只讀屬性,兩個成員變量,一個保存了JobID,另一個類型爲JobConf,JobContext中除了JobID外,其它的信息都保持在JobConf中。它定義瞭如下配置項:

l           mapreduce.inputformat.class:InputFormat的實現

l           mapreduce.map.class:Mapper的實現

l           mapreduce.combine.class: Reducer的實現

l           mapreduce.reduce.class:Reducer的實現

l           mapreduce.outputformat.class: OutputFormat的實現

l           mapreduce.partitioner.class: Partitioner的實現

同時,它提供方法,使得通過類名,利用Java反射提供的Class.forName方法,獲得類對應的Class。org.apache.hadoop.mapred的JobContext對象比org.apache.hadoop.mapreduce.JobContext多了成員變量progress,用於獲取進度信息,它類型爲JobConf成員job指向mapreduce.JobContext對應的成員,沒有添加任何新功能。

JobConf繼承自Configuration,保持了MapReduce執行需要的一些配置信息,它管理着46個配置參數,包括上面mapreduce配置項對應的老版本形式,如mapreduce.map.class 對應mapred.mapper.class。這些配置項我們在使用到它們的時候再介紹。

org.apache.hadoop.mapreduce.JobContext的子類Job前面也已經介紹了,後面在討論系統的動態行爲時,再回來看它。

TaskAttemptContext用於任務的執行,它引入了標識任務執行的TaskAttemptID和任務狀態status,並提供新的訪問接口。org.apache.hadoop.mapred的TaskAttemptContext繼承自mapreduce的對應版本,只是增加了記錄進度的progress。

TaskInputOutputContext和它的子類都在包org.apache.hadoop.mapreduce中,前面已經分析過了,我們就不再羅嗦。

Hadoop源代碼分析(包hadoop.mapred中的MapReduce接口)

前面已經完成了對org.apache.hadoop.mapreduce的分析,這個包提供了Hadoop MapReduce部分的應用API,用於用戶實現自己的MapReduce應用。但這些接口是給未來的MapReduce應用的,目前MapReduce框架還是使用老系統(參考補丁HADOOP-1230)。下面我們來分析org.apache.hadoop.mapred,首先還是從mapred的MapReduce框架開始分析,下面的類圖(灰色部分爲標記爲@Deprecated的類/接口):


 

我們把包mapreduce的類圖附在下面,對比一下,我們就會發現,org.apache.hadoop.mapred中的MapReduce API相對來說很簡單,主要是少了和Context相關的類,那麼,好多在mapreduce中通過context來完成的工作,就需要通過參數來傳遞,如Map中的輸出,老版本是:

      output.collect(key, result);  // output’s type is: OutputCollector

新版本是:

      context.write(key, result);  // output’s type is: Context

它們分別使用OutputCollector和Mapper.Context來輸出map的結果,顯然,原有OutputCollector的新API中就不再需要。總體來說,老版本的API比較簡單,MapReduce過程中關鍵的對象都有,但可擴展性不是很強。同時,老版中提供的輔助類也很多,我們前面分析的FileOutputFormat,也有對應的實現,我們就不再討論了。


Hadoop源代碼分析(包mapreduce.lib.input

接下來我們按照MapReduce過程中數據流動的順序,來分解org.apache.hadoop.mapreduce.lib.*的相關內容,並介紹對應的基類的功能。首先是input部分,它實現了MapReduce的數據輸入部分。類圖如下:


 

類圖的右上角是InputFormat,它描述了一個MapReduce Job的輸入,通過InputFormat,Hadoop可以:

l           檢查MapReduce輸入數據的正確性;

l           將輸入數據切分爲邏輯塊InputSplit,這些塊會分配給Mapper;

l           提供一個RecordReader實現,Mapper用該實現從InputSplit中讀取輸入的<K,V>對。

在org.apache.hadoop.mapreduce.lib.input中,Hadoop爲所有基於文件的InputFormat提供了一個虛基類FileInputFormat。下面幾個參數可以用於配置FileInputFormat:

l           mapred.input.pathFilter.class:輸入文件過濾器,通過過濾器的文件纔會加入InputFormat;

l           mapred.min.split.size:最小的劃分大小;

l           mapred.max.split.size:最大的劃分大小;

l           mapred.input.dir:輸入路徑,用逗號做分割。

類中比較重要的方法有:

  protected List<FileStatus> listStatus(Configuration job)

遞歸獲取輸入數據目錄中的所有文件(包括文件信息),輸入的job是系統運行的配置Configuration,包含了上面我們提到的參數。

  public List<InputSplit> getSplits(JobContext context)

將輸入劃分爲InputSplit,包含兩個循環,第一個循環處理所有的文件,對於每一個文件,根據輸入的劃分最大/最小值,循環得到文件上的劃分。注意,劃分不會跨越文件。

FileInputFormat沒有實現InputFormat的createRecordReader方法。

FileInputFormat有兩個子類,SequenceFileInputFormat是Hadoop定義的一種二進制形式存放的鍵/值文件(參考http://hadoop.apache.org/core/docs/current/api/org/apache/hadoop/io/SequenceFile.html),它有自己定義的文件佈局。由於它有特殊的擴展名,所以SequenceFileInputFormat重載了listStatus,同時,它實現了createRecordReader,返回一個SequenceFileRecordReader對象。TextInputFormat處理的是文本文件,createRecordReader返回的是LineRecordReader的實例。這兩個類都沒有重載FileInputFormat的getSplits方法,那麼,在他們對於的RecordReader中,必須考慮FileInputFormat對輸入的劃分方式。

FileInputFormat的getSplits,返回的是FileSplit。這是一個很簡單的類,包含的屬性(文件名,起始偏移量,劃分的長度和可能的目標機器)已經足以說明這個類的功能。

RecordReader用於在劃分中讀取<Key,Value>對。RecordReader有五個虛方法,分別是:

l           initialize:初始化,輸入參數包括該Reader工作的數據劃分InputSplit和Job的上下文context;

l           nextKey:得到輸入的下一個Key,如果數據劃分已經沒有新的記錄,返回空;

l           nextValue:得到Key對應的Value,必須在調用nextKey後調用;

l           getProgress:得到現在的進度;

l           close,來自java.io的Closeable接口,用於清理RecordReader。

我們以LineRecordReader爲例,來分析RecordReader的構成。前面我們已經分析過FileInputFormat對文件的劃分了,劃分完的Split包括了文件名,起始偏移量,劃分的長度。由於文件是文本文件,LineRecordReader的初始化方法initialize會創建一個基於行的讀取對象LineReader(定義在org.apache.hadoop.util中,我們就不分析啦),然後跳過輸入的最開始的部分(只在Split的起始偏移量不爲0的情況下進行,這時最開始的部分可能是上一個Split的最後一行的一部分)。nextKey的處理很簡單,它使用當前的偏移量作爲Key,nextValue當然就是偏移量開始的那一行了(如果行很長,可能出現截斷)。進度getProgress和close都很簡單。

 

Hadoop源代碼分析(包mapreduce.lib.map

Hadoop的MapReduce框架中,Map動作通過Mapper類來抽象。一般來說,我們會實現自己特殊的Mapper,並註冊到系統中,執行時,我們的Mapper會被MapReduce框架調用。Mapper類很簡單,包括一個內部類和四個方法,靜態結構圖如下:


 

內部類Context繼承自MapContext,並沒有引入任何新的方法。

Mapper的四個方法是setup,map,cleanup和run。其中,setup和cleanup用於管理Mapper生命週期中的資源,setup在完成Mapper構造,即將開始執行map動作前調用,cleanup則在所有的map動作完成後被調用。方法map用於對一次輸入的key/value對進行map動作。run方法執行了上面描述的過程,它調用setup,讓後迭代所有的key/value對,進行map,最後調用cleanup。

org.apache.hadoop.mapreduce.lib.map中實現了Mapper的三個子類,分別是InverseMapper(將輸入<key, value> map爲輸出<value, key>),MultithreadedMapper(多線程執行map方法)和TokenCounterMapper(對輸入的value分解爲token並計數)。其中最複雜的是MultithreadedMapper,我們就以它爲例,來分析Mapper的實現。

MultithreadedMapper會啓動多個線程執行另一個Mapper的map方法,它會啓動mapred.map.multithreadedrunner.threads(配置項)個線程執行Mapper:mapred.map.multithreadedrunner.class(配置項)。MultithreadedMapper重寫了基類Mapper的run方法,啓動N個線程(對應的類爲MapRunner)執行mapred.map.multithreadedrunner.class(我們稱爲目標Mapper)的run方法(就是說,目標Mapper的setup和cleanup會被執行多次)。目標Mapper共享同一份InputSplit,這就意味着,對InputSplit的數據讀必須線程安全。爲此,MultithreadedMapper引入了內部類SubMapRecordReader,SubMapRecordWriter,SubMapStatusReporter,分別繼承自RecordReader,RecordWriter和StatusReporter,它們通過互斥訪問MultithreadedMapper的Mapper.Context,實現了對同一份InputSplit的線程安全訪問,爲Mapper提供所需的Context。這些類的實現方法都很簡單。

Hadoop源代碼分析(包org.apache.hadoop.mapreduce

有了前一節的分析,我們來看一下具體的接口,它們都處於包org.apache.hadoop.mapreduce中。


 

上面的圖中,類可以分爲4種。右上角的是從Writeable繼承的,和Counter(還有CounterGroup和Counters,也在這個包中,並沒有出現在上面的圖裏)和ID相關的類,它們保持MapReduce過程中需要的一些計數器和標識;中間大部分是和Context相關的*Context類,它爲Mapper和Reducer提供了相關的上下文;關於Map和Reduce,對應的類是Mapper,Reducer和描述他們的Job(在Hadoop 中一次計算任務稱之爲一個job,下面的分析中,中文爲“作業”,相應的task我們稱爲“任務”);圖中其他類是配合Mapper和Reduce工作的一些輔助類。

如果你熟悉HTTPServlet, 那就能很輕鬆地理解Hadoop採用的結構,把整個Hadoop看作是容器,那麼Mapper和Reduce就是容器裏的組件,*Context保存了組件的一些配置信息,同時也是和容器通信的機制。

和ID相關的類我們就不再討論了。我們先看JobContext,它位於*Context繼承樹的最上方,爲Job提供一些只讀的信息,如Job的ID,名稱等。下面的信息是MapReduce過程中一些較關鍵的定製信息:

(來自http://www.ibm.com/developerworks/cn/opensource/os-cn-hadoop2/index.html):

參數

作用

缺省值

其它實現

InputFormat 

將輸入的數據集切割成小數據集 InputSplits, 每一個 InputSplit 將由一個 Mapper 負責處理。此外 InputFormat 中還提供一個 RecordReader 的實現, 將一個 InputSplit 解析成 <key,value> 對提供給 map 函數。

TextInputFormat
(針對文本文件,按行將文本文件切割成 InputSplits, 並用 LineRecordReader 將 InputSplit 解析成 <key,value> 對,key 是行在文件中的位置,value 是文件中的一行) 

SequenceFileInputFormat 

OutputFormat 

提供一個 RecordWriter 的實現,負責輸出最終結果

TextOutputFormat
(用 LineRecordWriter 將最終結果寫成純文件文件,每個 <key,value> 對一行,key 和 value 之間用 tab 分隔) 

SequenceFileOutputFormat

OutputKeyClass 

輸出的最終結果中 key 的類型

LongWritable 

 

OutputValueClass 

輸出的最終結果中 value 的類型

Text 

 

MapperClass 

Mapper 類,實現 map 函數,完成輸入的 <key,value> 到中間結果的映射

IdentityMapper
(將輸入的 <key,value> 原封不動的輸出爲中間結果) 

LongSumReducer,
LogRegexMapper,
InverseMapper 

CombinerClass 

實現 combine 函數,將中間結果中的重複 key 做合併

null
(不對中間結果中的重複 key 做合併) 

 

ReducerClass 

Reducer 類,實現 reduce 函數,對中間結果做合併,形成最終結果

IdentityReducer
(將中間結果直接輸出爲最終結果) 

AccumulatingReducer, LongSumReducer 

InputPath 

設定 job 的輸入目錄, job 運行時會處理輸入目錄下的所有文件

null 

 

OutputPath 

設定 job 的輸出目錄,job 的最終結果會寫入輸出目錄下

null 

 

MapOutputKeyClass 

設定 map 函數輸出的中間結果中 key 的類型

如果用戶沒有設定的話,使用 OutputKeyClass 

 

MapOutputValueClass 

設定 map 函數輸出的中間結果中 value 的類型

如果用戶沒有設定的話,使用 OutputValuesClass 

 

OutputKeyComparator 

對結果中的 key 進行排序時的使用的比較器

WritableComparable 

 

PartitionerClass 

對中間結果的 key 排序後,用此 Partition 函數將其劃分爲R份,每份由一個 Reducer 負責處理。

HashPartitioner
(使用 Hash 函數做 partition) 

KeyFieldBasedPartitioner PipesPartitioner 

Job繼承自JobContext,提供了一系列的set方法,用於設置Job的一些屬性(Job更新屬性,JobContext讀屬性),同時,Job還提供了一些對Job進行控制的方法,如下:

l           mapProgress:map的進度(0—1.0);

l           reduceProgress:reduce的進度(0—1.0);

l           isComplete:作業是否已經完成;

l           isSuccessful:作業是否成功;

l           killJob:結束一個在運行中的作業;

l           getTaskCompletionEvents:得到任務完成的應答(成功/失敗);

l           killTask:結束某一個任務;

 

發佈了61 篇原創文章 · 獲贊 291 · 訪問量 48萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章