HDFS寫過程分析

EddieJ發表於2019-04-01

簡單之美 | HDFS 寫檔案過程分析

HDFS 是一個分散式檔案系統,在 HDFS 上寫檔案的過程與我們平時使用的單機檔案系統非常不同,從巨集觀上來看,在 HDFS 檔案系統上建立並寫一個檔案,流程如下圖(來自《Hadoop:The Definitive Guide》一書)所示:

AsDDFU.png

具體過程描述如下:

  1. Client 呼叫 DistributedFileSystem 物件的 create 方法,建立一個檔案輸出流(FSDataOutputStream)物件
  2. 通過 DistributedFileSystem 物件與 Hadoop 叢集的 NameNode 進行一次 RPC 遠端呼叫,在 HDFS 的 Namespace 中建立一個檔案條目(Entry),該條目沒有任何的 Block
  3. 通過 FSDataOutputStream 物件,向 DataNode 寫入資料,資料首先被寫入 FSDataOutputStream 物件內部的 Buffer 中,然後資料被分割成一個個 Packet 資料包
  4. 以 Packet 最小單位,基於 Socket 連線傳送到按特定演算法選擇的 HDFS 叢集中一組 DataNode(正常是 3 個,可能大於等於 1)中的一個節點上,在這組 DataNode 組成的 Pipeline 上依次傳輸 Packet
  5. 這組 DataNode 組成的 Pipeline 反方向上,傳送 ack,最終由 Pipeline 中第一個 DataNode 節點將 Pipeline ack 傳送給 Client
  6. 完成向檔案寫入資料,Client 在檔案輸出流(FSDataOutputStream)物件上呼叫 close 方法,關閉流
  7. 呼叫 DistributedFileSystem 物件的 complete 方法,通知 NameNode 檔案寫入成功

下面程式碼使用 Hadoop 的 API 來實現向 HDFS 的檔案寫入資料,同樣也包括建立一個檔案和寫資料兩個主要過程,程式碼如下所示:

     static String[] contents = new String[] {
          "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
          "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
          "dddddddddddddddddddddddddddddddd",
          "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
     };
    
     public static void main(String[] args) {
          String file = "hdfs://h1:8020/data/test/test.log";
        Path path = new Path(file);
        Configuration conf = new Configuration();
        FileSystem fs = null;
        FSDataOutputStream output = null;
        try {
               fs = path.getFileSystem(conf);
               output = fs.create(path); // 建立檔案
               for(String line : contents) { // 寫入資料
                    output.write(line.getBytes("UTF-8"));
                    output.flush();
               }
          } catch (IOException e) {
               e.printStackTrace();
          } finally {
               try {
                    output.close();
               } catch (IOException e) {
                    e.printStackTrace();
               }
          }
     }
複製程式碼

結合上面的示例程式碼,我們先從 fs.create(path); 開始,可以看到 FileSystem 的實現 DistributedFileSystem 中給出了最終返回 FSDataOutputStream 物件的抽象邏輯,程式碼如下所示:

  public FSDataOutputStream create(Path f, FsPermission permission,
    boolean overwrite,
    int bufferSize, short replication, long blockSize,
    Progressable progress) throws IOException {

    statistics.incrementWriteOps(1);
    return new FSDataOutputStream
       (dfs.create(getPathName(f), permission, overwrite, true, replication, blockSize, progress, bufferSize), statistics);
  }
複製程式碼

上面,DFSClient dfs 的 create 方法中建立了一個 OutputStream 物件,在 DFSClient 的 create 方法:

  public OutputStream create(String src,
                             FsPermission permission,
                             boolean overwrite,
                             boolean createParent,
                             short replication,
                             long blockSize,
                             Progressable progress,
                             int buffersize
                             ) throws IOException {
   ... ...
}
複製程式碼

建立了一個 DFSOutputStream 物件,如下所示:

    final DFSOutputStream result = new DFSOutputStream(src, masked,
        overwrite, createParent, replication, blockSize, progress, buffersize,
        conf.getInt("io.bytes.per.checksum", 512));
複製程式碼

下面,我們從 DFSOutputStream 類開始,說明其內部實現原理。

DFSOutputStream 內部原理

開啟一個 DFSOutputStream 流,Client 會寫資料到流內部的一個緩衝區中,然後資料被分解成多個 Packet,每個 Packet 大小為 64k 位元組,每個 Packet 又由一組 chunk 和這組 chunk 對應的 checksum 資料組成,預設 chunk 大小為 512 位元組,每個 checksum 是對 512 位元組資料計算的校驗和資料。 當 Client 寫入的位元組流資料達到一個 Packet 的長度,這個 Packet 會被構建出來,然後會被放到佇列 dataQueue 中,接著 DataStreamer 執行緒會不斷地從 dataQueue 佇列中取出 Packet,傳送到複製 Pipeline 中的第一個 DataNode 上,並將該 Packet 從 dataQueue 佇列中移到 ackQueue 佇列中。ResponseProcessor 執行緒接收從 Datanode 傳送過來的 ack,如果是一個成功的 ack,表示複製 Pipeline 中的所有 Datanode 都已經接收到這個 Packet,ResponseProcessor 執行緒將 packet 從佇列 ackQueue 中刪除。 在傳送過程中,如果發生錯誤,所有未完成的 Packet 都會從 ackQueue 佇列中移除掉,然後重新建立一個新的 Pipeline,排除掉出錯的那些 DataNode 節點,接著 DataStreamer 執行緒繼續從 dataQueue 佇列中傳送 Packet。 下面是 DFSOutputStream 的結構及其原理,如圖所示:

AsD2O1.png

我們從下面 3 個方面來描述內部流程:

  • 建立 Packet

Client 寫資料時,會將位元組流資料快取到內部的緩衝區中,當長度滿足一個 Chunk 大小(512B)時,便會建立一個 Packet 物件,然後向該 Packet 物件中寫 Chunk Checksum 校驗和資料,以及實際資料塊 Chunk Data,校驗和資料是基於實際資料塊計算得到的。每次滿足一個 Chunk 大小時,都會向 Packet 中寫上述資料內容,直到達到一個 Packet 物件大小(64K),就會將該 Packet 物件放入到 dataQueue 佇列中,等待 DataStreamer 執行緒取出併傳送到 DataNode 節點。

  • 傳送 Packet

DataStreamer 執行緒從 dataQueue 佇列中取出 Packet 物件,放到 ackQueue 佇列中,然後向 DataNode 節點傳送這個 Packet 物件所對應的資料。

  • 接收 ack

傳送一個 Packet 資料包以後,會有一個用來接收 ack 的 ResponseProcessor 執行緒,如果收到成功的 ack,則表示一個 Packet 傳送成功。如果成功,則 ResponseProcessor 執行緒會將 ackQueue 佇列中對應的 Packet 刪除。

DFSOutputStream 初始化

首先看一下,DFSOutputStream 的初始化過程,構造方法如下所示:

    DFSOutputStream(String src, FsPermission masked, boolean overwrite,
        boolean createParent, short replication, long blockSize, Progressable progress,
        int buffersize, int bytesPerChecksum) throws IOException {
      this(src, blockSize, progress, bytesPerChecksum, replication);

      computePacketChunkSize(writePacketSize, bytesPerChecksum); // 預設 writePacketSize=64*1024(即64K),bytesPerChecksum=512(沒512個位元組計算一個校驗和),

      try {
        if (createParent) { // createParent為true表示,如果待建立的檔案的父級目錄不存在,則自動建立
          namenode.create(src, masked, clientName, overwrite, replication, blockSize);
        } else {
          namenode.create(src, masked, clientName, overwrite, false, replication, blockSize);
        }
      } catch(RemoteException re) {
        throw re.unwrapRemoteException(AccessControlException.class,
                                       FileAlreadyExistsException.class,
                                       FileNotFoundException.class,
                                       NSQuotaExceededException.class,
                                       DSQuotaExceededException.class);
      }
      streamer.start(); // 啟動一個DataStreamer執行緒,用來將寫入的位元組流打包成packet,然後傳送到對應的Datanode節點上
    }
上面computePacketChunkSize方法計算了一個packet的相關引數,我們結合程式碼來檢視,如下所示:
      int chunkSize = csize + checksum.getChecksumSize();
      int n = DataNode.PKT_HEADER_LEN + SIZE_OF_INTEGER;
      chunksPerPacket = Math.max((psize - n + chunkSize-1)/chunkSize, 1);
      packetSize = n + chunkSize*chunksPerPacket;
複製程式碼

我們用預設的引數值替換上面的引數,得到:

      int chunkSize = 512 + 4;
      int n = 21 + 4;
      chunksPerPacket = Math.max((64*1024 - 25 + 516-1)/516, 1);  // 127
      packetSize = 25 + 516*127;
複製程式碼

上面對應的引數,說明如下表所示:

引數名稱 引數值 引數含義
chunkSize 512+4=516 每個 chunk 的位元組數(資料 + 校驗和)
csize 512 每個 chunk 資料的位元組數
psize 64*1024 每個 packet 的最大位元組數(不包含 header)
DataNode.PKT_HEADER_LEN 21 每個 packet 的 header 的位元組數
chunksPerPacket 127 組成每個 packet 的 chunk 的個數
packetSize 25+516*127=65557 每個 packet 的位元組數(一個 header + 一組 chunk)

在計算好一個 packet 相關的引數以後,呼叫 create 方法與 Namenode 進行 RPC 請求,請求建立檔案:

        if (createParent) { // createParent為true表示,如果待建立的檔案的父級目錄不存在,則自動建立
          namenode.create(src, masked, clientName, overwrite, replication, blockSize);
        } else {
          namenode.create(src, masked, clientName, overwrite, false, replication, blockSize);
        }
複製程式碼

遠端呼叫上面方法,會在 FSNamesystem 中建立對應的檔案路徑,並初始化與該建立的檔案相關的一些資訊,如租約(向 Datanode 節點寫資料的憑據)。檔案在 FSNamesystem 中建立成功,就要初始化並啟動一個 DataStreamer 執行緒,用來向 Datanode 寫資料,後面我們詳細說明具體處理邏輯。

Packet 結構與定義

Client 向 HDFS 寫資料,資料會被組裝成 Packet,然後傳送到 Datanode 節點。Packet 分為兩類,一類是實際資料包,另一類是 heatbeat 包。一個 Packet 資料包的組成結構,如圖所示:

AsDIYD.png

上圖中,一個 Packet 是由 Header 和 Data 兩部分組成,其中 Header 部分包含了一個 Packet 的概要屬性資訊,如下表所示:

欄位名稱 欄位型別 欄位長度 欄位含義
pktLen int 4 4 + dataLen + checksumLen
offsetInBlock long 8 Packet 在 Block 中偏移量
seqNo long 8 Packet 序列號,在同一個 Block 唯一
lastPacketInBlock boolean 1 是否是一個 Block 的最後一個 Packet
dataLen int 4 dataPos – dataStart,不包含 Header 和 Checksum 的長度

Data 部分是一個 Packet 的實際資料部分,主要包括一個 4 位元組校驗和(Checksum)與一個 Chunk 部分,Chunk 部分最大為 512 位元組。 在構建一個 Packet 的過程中,首先將位元組流資料寫入一個 buffer 緩衝區中,也就是從偏移量為 25 的位置(checksumStart)開始寫 Packet 資料的 Chunk Checksum 部分,從偏移量為 533 的位置(dataStart)開始寫 Packet 資料的 Chunk Data 部分,直到一個 Packet 建立完成為止。如果一個 Packet 的大小未能達到最大長度,也就是上圖對應的緩衝區中,Chunk Checksum 與 Chunk Data 之間還保留了一段未被寫過的緩衝區位置,這種情況說明,已經在寫一個檔案的最後一個 Block 的最後一個 Packet。在傳送這個 Packet 之前,會檢查 Chunksum 與 Chunk Data 之間的緩衝區是否為空白緩衝區(gap),如果有則將 Chunk Data 部分向前移動,使得 Chunk Data 1 與 Chunk Checksum N 相鄰,然後才會被髮送到 DataNode 節點。 我們看一下 Packet 對應的 Packet 類定義,定義瞭如下一些欄位:

      ByteBuffer buffer;           // only one of buf and buffer is non-null
      byte[]  buf;
      long    seqno;               // sequencenumber of buffer in block
      long    offsetInBlock;       // 該packet在block中的偏移量
      boolean lastPacketInBlock;   // is this the last packet in block?
      int     numChunks;           // number of chunks currently in packet
      int     maxChunks;           // 一個packet中包含的chunk的個數
      int     dataStart;
      int     dataPos;
      int     checksumStart;
      int     checksumPos; 
複製程式碼

Packet 類有一個預設的沒有引數的構造方法,它是用來做 heatbeat 的,如下所示:

      Packet() {
        this.lastPacketInBlock = false;
        this.numChunks = 0;
        this.offsetInBlock = 0;
        this.seqno = HEART_BEAT_SEQNO; // 值為-1

        buffer = null;
        int packetSize = DataNode.PKT_HEADER_LEN + SIZE_OF_INTEGER; // 21+4=25
        buf = new byte[packetSize];

        checksumStart = dataStart = packetSize;
        checksumPos = checksumStart;
        dataPos = dataStart;
        maxChunks = 0;
      }
複製程式碼

通過程式碼可以看到,一個 heatbeat 的內容,實際上只有一個長度為 25 位元組的 header 資料。通過 this.seqno = HEART_BEAT_SEQNO; 的值可以判斷一個 packet 是否是 heatbeat 包,如果 seqno 為 - 1 表示這是一個 heatbeat 包。

Client 傳送 Packet 資料

可以 DFSClient 類中看到,傳送一個 Packet 之前,首先需要向選定的 DataNode 傳送一個 Header 資料包,表明要向 DataNode 寫資料,該 Header 的資料結構,如圖所示:

AsDqOI.png

上圖顯示的是 Client 傳送 Packet 到第一個 DataNode 節點的 Header 資料結構,主要包括待傳送的 Packet 所在的 Block(先向 NameNode 分配 Block ID 等資訊)的相關資訊、Pipeline 中另外 2 個 DataNode 的資訊、訪問令牌(Access Token)和校驗和資訊,Header 中各個欄位及其型別,詳見下表:

欄位名稱 欄位型別 欄位長度 欄位含義
Transfer Version short 2 Client 與 DataNode 之間資料傳輸版本號,由常量 DataTransferProtocol.DATA_TRANSFER_VERSION 定義,值為 17
OP int 4 操作型別,由常量 DataTransferProtocol.OP_WRITE_BLOCK 定義,值為 80
blkId long 8 Block 的 ID 值,由 NameNode 分配
GS long 8 時間戳(Generation Stamp),NameNode 分配 blkId 的時候生成的時間戳
DNCnt int 4 DataNode 複製 Pipeline 中 DataNode 節點的數量
Recovery Flag boolean 1 Recover 標誌
Client Text Client 主機的名稱,在使用 Text 進行序列化的時候,實際包含長度 len 與主機名稱字串 ClientHost
srcNode boolean 1 是否傳送 src node 的資訊,預設值為 false,不傳送 src node 的資訊
nonSrcDNCnt int 4 由 Client 寫的該 Header 資料,該數不包含 Pipeline 中第一個節點(即為 DNCnt-1)
DN2 DatanodeInfo DataNode 資訊,包括 StorageID、InfoPort、IpcPort、capacity、DfsUsed、remaining、LastUpdate、XceiverCount、Location、HostName、AdminState
DN3 DatanodeInfo DataNode 資訊,包括 StorageID、InfoPort、IpcPort、capacity、DfsUsed、remaining、LastUpdate、XceiverCount、Location、HostName、AdminState
Access Token Token 訪問令牌資訊,包括 IdentifierLength、Identifier、PwdLength、Pwd、KindLength、Kind、ServiceLength、Service
CheckSum Header DataChecksum 1+4 校驗和 Header 資訊,包括 type、bytesPerChecksum

Header 資料包傳送成功,Client 會收到一個成功響應碼(DataTransferProtocol.OP_STATUS_SUCCESS = 0),接著將 Packet 資料傳送到 Pipeline 中第一個 DataNode 上,如下所示:

           Packet one = null;
          one = dataQueue.getFirst(); // regular data packet
          ByteBuffer buf = one.getBuffer();
          // write out data to remote datanode
          blockStream.write(buf.array(), buf.position(), buf.remaining());

          if (one.lastPacketInBlock) { // 如果是Block中的最後一個Packet,還要寫入一個0標識該Block已經寫入完成
              blockStream.writeInt(0); // indicate end-of-block
          }
複製程式碼

否則,如果失敗,則會與 NameNode 進行 RPC 呼叫,刪除該 Block,並把該 Pipeline 中第一個 DataNode 加入到 excludedNodes 列表中,程式碼如下所示:

        if (!success) {
          LOG.info("Abandoning " + block);
          namenode.abandonBlock(block, src, clientName);

          if (errorIndex < nodes.length) {
            LOG.info("Excluding datanode " + nodes[errorIndex]);
            excludedNodes.add(nodes[errorIndex]);
          }

          // Connection failed.  Let's wait a little bit and retry
          retry = true;
        }
複製程式碼

DataNode 端服務元件

資料最終會傳送到 DataNode 節點上,在一個 DataNode 上,資料在各個元件之間流動,流程如下圖所示:

Asr9pQ.png

DataNode 服務中建立一個後臺執行緒 DataXceiverServer,它是一個 SocketServer,用來接收來自 Client(或者 DataNode Pipeline 中的非最後一個 DataNode 節點)的寫資料請求,然後在 DataXceiverServer 中將連線過來的 Socket 直接派發給一個獨立的後臺執行緒 DataXceiver 進行處理。所以,Client 寫資料時連線一個 DataNode Pipeline 的結構,實際流程如圖所示:

Asrkmq.png

每個 DataNode 服務中的 DataXceiver 後臺執行緒接收到來自前一個節點(Client/DataNode)的 Socket 連線,首先讀取 Header 資料:

    Block block = new Block(in.readLong(), dataXceiverServer.estimateBlockSize, in.readLong());
    LOG.info("Receiving " + block + " src: " + remoteAddress + " dest: " + localAddress);
    int pipelineSize = in.readInt(); // num of datanodes in entire pipeline
    boolean isRecovery = in.readBoolean(); // is this part of recovery?
    String client = Text.readString(in); // working on behalf of this client
    boolean hasSrcDataNode = in.readBoolean(); // is src node info present
    if (hasSrcDataNode) {
      srcDataNode = new DatanodeInfo();
      srcDataNode.readFields(in);
    }
    int numTargets = in.readInt();
    if (numTargets < 0) {
      throw new IOException("Mislabelled incoming datastream.");
    }
    DatanodeInfo targets[] = new DatanodeInfo[numTargets];
    for (int i = 0; i < targets.length; i++) {
      DatanodeInfo tmp = new DatanodeInfo();
      tmp.readFields(in);
      targets[i] = tmp;
    }
    Token<BlockTokenIdentifier> accessToken = new Token<BlockTokenIdentifier>();
    accessToken.readFields(in);
複製程式碼

上面程式碼中,讀取 Header 的資料,與前一個 Client/DataNode 寫入 Header 欄位的順序相對應,不再累述。在完成讀取 Header 資料後,當前 DataNode 會首先將 Header 資料再傳送到 Pipeline 中下一個 DataNode 結點,當然該 DataNode 肯定不是 Pipeline 中最後一個 DataNode 節點。接著,該 DataNode 會接收來自前一個 Client/DataNode 節點傳送的 Packet 資料,接收 Packet 資料的邏輯實際上在 BlockReceiver 中完成,包括將來自前一個 Client/DataNode 節點傳送的 Packet 資料寫入本地磁碟。在 BlockReceiver 中,首先會將接收到的 Packet 資料傳送寫入到 Pipeline 中下一個 DataNode 節點,然後再將接收到的資料寫入到本地磁碟的 Block 檔案中。

DataNode 持久化 Packet 資料

在 DataNode 節點的 BlockReceiver 中進行 Packet 資料的持久化,一個 Packet 是一個 Block 中一個資料分組,我們首先看一下,一個 Block 在持久化到磁碟上的物理儲存結構,如下圖所示:

AsrmhF.png

每個 Block 檔案(如上圖中 blk_1084013198 檔案)都對應一個 meta 檔案(如上圖中 blk_1084013198_10273532.meta 檔案),Block 檔案是一個一個 Chunk 的二進位制資料(每個 Chunk 的大小是 512 位元組),而 meta 檔案是與每一個 Chunk 對應的 Checksum 資料,是序列化形式儲存。

寫檔案過程中 Client/DataNode 與 NameNode 進行 RPC 呼叫

Client 在 HDFS 檔案系統中寫檔案過程中,會發生多次與 NameNode 節點進行 RPC 呼叫來完成寫資料相關操作,主要是在如下時機進行 RPC 呼叫:

  • 寫檔案開始時建立檔案:Client 呼叫 create 在 NameNode 節點的 Namespace 中建立一個標識該檔案的條目
  • 在 Client 連線 Pipeline 中第一個 DataNode 節點之前,Client 呼叫 addBlock 分配一個 Block(blkId+DataNode 列表 + 租約)
  • 如果與 Pipeline 中第一個 DataNode 節點連線失敗,Client 呼叫 abandonBlock 放棄一個已經分配的 Block
  • 一個 Block 已經寫入到 DataNode 節點磁碟,Client 呼叫 fsync 讓 NameNode 持久化 Block 的位置資訊資料
  • 檔案寫完以後,Client 呼叫 complete 方法通知 NameNode 寫入檔案成功
  • DataNode 節點接收到併成功持久化一個 Block 的資料後,DataNode 呼叫 blockReceived 方法通知 NameNode 已經接收到 Block

具體 RPC 呼叫的詳細過程,可以參考原始碼。

相關文章