HDFS讀檔案過程分析:讀取檔案的Block資料

簡單之美發表於2014-11-01

我們可以從java.io.InputStream類中看到,抽象出一個read方法,用來讀取已經開啟的InputStream例項中的位元組,每次呼叫read方法,會讀取一個位元組資料,該方法抽象定義,如下所示:

public abstract int read() throws IOException;

Hadoop的DFSClient.DFSInputStream類實現了該抽象邏輯,如果我們清楚瞭如何從HDFS中讀取一個檔案的一個block的一個位元組的原理,更加抽象的頂層只需要迭代即可獲取到該檔案的全部資料。

HDFS讀檔案過程分析:獲取檔案對應的Block列表中,我們已經獲取到一個檔案對應的Block列表資訊,開啟一個檔案,接下來就要讀取實際的物理塊資料,我們從下面的幾個方面來詳細說明讀取資料的過程。

Client從Datanode讀取檔案的一個位元組

下面,我們通過分析DFSClient.DFSInputStream中實現的程式碼,讀取HDFS上檔案的內容。首先從下面的方法開始:

@Override
public synchronized int read() throws IOException {
  int ret = read( oneByteBuf, 0, 1 );
  return ( ret <= 0 ) ? -1 : (oneByteBuf[0] & 0xff);
}

上面呼叫read(oneByteBuf, 0, 1)讀取一個位元組到單位元組緩衝區oneByteBuf中,具體實現見如下方法:

@Override
public synchronized int read(byte buf[], int off, int len) throws IOException {
  checkOpen(); // 檢查Client是否正在執行
  if (closed) {
    throw new IOException("Stream closed");
  }
  failures = 0;

  if (pos < getFileLength()) { // getFileLength()獲取檔案所包含的總位元組數,pos表示讀取當前檔案的第(pos+1)個位元組
    int retries = 2;
    while (retries > 0) {
      try {
        if (pos > blockEnd) { // blockEnd表示檔案的長度(位元組數)
          currentNode = blockSeekTo(pos); // 找到第pos個位元組資料所在的Datanode(實際根據該位元組資料所在的block後設資料來定位)
        }
        int realLen = (int) Math.min((long) len, (blockEnd - pos + 1L));
        int result = readBuffer(buf, off, realLen); // 讀取一個位元組到緩衝區中

        if (result >= 0) {
          pos += result; // 每成功讀取result個位元組,pos增加result
        } else {
          // got a EOS from reader though we expect more data on it.
          throw new IOException("Unexpected EOS from the reader");
        }
        if (stats != null && result != -1) {
          stats.incrementBytesRead(result);
        }
        return result;
      } catch (ChecksumException ce) {
        throw ce;           
      } catch (IOException e) {
        if (retries == 1) {
          LOG.warn("DFS Read: " + StringUtils.stringifyException(e));
        }
        blockEnd = -1;
        if (currentNode != null) { addToDeadNodes(currentNode); }
        if (--retries == 0) {
          throw e;
        }
      }
    }
  }
  return -1;
}

讀取檔案資料的一個位元組,具體過程如下:

  1. 檢查流物件是否處於開啟狀態(前面已經獲取到檔案對應的block列表的後設資料,並開啟一個InputStream物件)
  2. 從檔案的第一個block開始讀取,首先需要找到第一個block對應的資料塊所在的Datanode,可以從快取的block列表中查詢到(如果查詢不到,則會與Namenode進行一次RPC通訊請求獲取到)
  3. 開啟一個到該讀取的block所在Datanode節點的流,準備讀取block資料
  4. 建立了到Datanode的連線後,讀取一個位元組資料到位元組緩衝區中,返回讀取的位元組數(1個位元組)

在讀取的過程中,以位元組為單位,通過判斷某個偏移位置的位元組屬於哪個block(根據block後設資料所限定的位元組偏移範圍),在根據這個block去定位某一個Datanode節點,這樣就可連續地讀取一個檔案的全部資料(組成檔案的、連續的多個block資料塊)。

查詢待讀取的一個位元組所在的Datanode節點

上面public synchronized int read(byte buf[], int off, int len) throws IOException方法,呼叫了blockSeekTo方法來獲取,檔案某個位元組索引位置的資料所在的Datanode節點。其實,很容易就能想到,想要獲取到資料所在的Datanode節點,一定是從block後設資料中計算得到,然後根據Client快取的block對映列表,找到block對應的Datanode列表,我們看一下blockSeekTo方法的程式碼實現:

private synchronized DatanodeInfo blockSeekTo(long target) throws IOException {
  ... ...

  DatanodeInfo chosenNode = null;
  int refetchToken = 1; // only need to get a new access token once
  while (true) {
    LocatedBlock targetBlock = getBlockAt(target, true); // 獲取位元組偏移位置為target的位元組資料所在的block後設資料物件
    assert (target==this.pos) : "Wrong postion " + pos + " expect " + target;
    long offsetIntoBlock = target - targetBlock.getStartOffset();

    DNAddrPair retval = chooseDataNode(targetBlock); // 選擇一個Datanode去讀取資料
    chosenNode = retval.info;
    InetSocketAddress targetAddr = retval.addr;

    // 先嚐試從本地讀取資料,如果資料不在本地,則正常去讀取遠端的Datanode節點
    Block blk = targetBlock.getBlock();
    Token<BlockTokenIdentifier> accessToken = targetBlock.getBlockToken();
    if (shouldTryShortCircuitRead(targetAddr)) {
      try {
        blockReader = getLocalBlockReader(conf, src, blk, accessToken,
            chosenNode, DFSClient.this.socketTimeout, offsetIntoBlock); // 建立一個用來讀取本地資料的BlockReader物件
        return chosenNode;
      } catch (AccessControlException ex) {
        LOG.warn("Short circuit access failed ", ex);
        //Disable short circuit reads
        shortCircuitLocalReads = false;
      } catch (IOException ex) {
        if (refetchToken > 0 && tokenRefetchNeeded(ex, targetAddr)) {
          /* Get a new access token and retry. */
          refetchToken--;
          fetchBlockAt(target);
          continue;
        } else {
          LOG.info("Failed to read " + targetBlock.getBlock()
              + " on local machine" + StringUtils.stringifyException(ex));
          LOG.info("Try reading via the datanode on " + targetAddr);
        }
      }
    }

    // 本地讀取失敗,按照更一般的方式去讀取遠端的Datanode節點來獲取資料
    try {
      s = socketFactory.createSocket();
      LOG.debug("Connecting to " + targetAddr);
      NetUtils.connect(s, targetAddr, getRandomLocalInterfaceAddr(), socketTimeout);
      s.setSoTimeout(socketTimeout);
      blockReader = RemoteBlockReader.newBlockReader(s, src, blk.getBlockId(),
          accessToken,
          blk.getGenerationStamp(),
          offsetIntoBlock, blk.getNumBytes() - offsetIntoBlock,
          buffersize, verifyChecksum, clientName); // 建立一個遠端的BlockReader物件
      return chosenNode;
    } catch (IOException ex) {
      if (refetchToken > 0 && tokenRefetchNeeded(ex, targetAddr)) {
        refetchToken--;
        fetchBlockAt(target);
      } else {
        LOG.warn("Failed to connect to " + targetAddr
            + ", add to deadNodes and continue" + ex);
        if (LOG.isDebugEnabled()) {
          LOG.debug("Connection failure", ex);
        }
        // Put chosen node into dead list, continue
        addToDeadNodes(chosenNode); // 讀取失敗,會將選擇的Datanode加入到Client的dead node列表,為下次讀取選擇合適的Datanode讀取檔案資料提供參考後設資料資訊
      }
      if (s != null) {
        try {
          s.close();
        } catch (IOException iex) { }                       
      }
      s = null;
    }
  }
}

上面程式碼中,主要包括如下幾個要點:

  • 選擇合適的Datanode節點,提高讀取效率

在讀取檔案的時候,首先會從Namenode獲取檔案對應的block列表後設資料,返回的block列表是按照Datanode的網路拓撲結構進行排序過的(本地節點優先,其次是同一機架節點),而且,Client還維護了一個dead node列表,只要此時bock對應的Datanode列表中節點不出現在dead node列表中就會被返回,用來作為讀取資料的Datanode節點。

  • 如果Client為叢集Datanode節點,嘗試從本地讀取block

通過呼叫chooseDataNode方法返回一個Datanode結點,通過判斷,如果該節點地址是本地地址,並且該節點上對應的block後設資料資訊的狀態不是正在建立的狀態,則滿足從本地讀取資料塊的條件,然後會建立一個LocalBlockReader物件,直接從本地讀取。在建立LocalBlockReader物件的過程中,會先從快取中查詢一個本地Datanode相關的LocalDatanodeInfo物件,該物件定義了與從本地Datanode讀取資料的重要資訊,以及快取了待讀取block對應的本地路徑資訊,可以從LocalDatanodeInfo類定義的屬性來說明:

private ClientDatanodeProtocol proxy = null;
private final Map<Block, BlockLocalPathInfo> cache;

如果快取中存在待讀取的block的相關資訊,可以直接進行讀取;否則,會建立一個proxy物件,以及計算待讀取block的路徑資訊BlockLocalPathInfo,最後再加入到快取,為後續可能的讀取加速。我們看一下如果沒有從快取中找到LocalDatanodeInfo資訊(尤其是BlockLocalPathInfo),則會執行如下邏輯:

// make RPC to local datanode to find local pathnames of blocks
pathinfo = proxy.getBlockLocalPathInfo(blk, token);

上面proxy為ClientDatanodeProtocol型別,Client與Datanode進行RPC通訊的協議,RPC呼叫getBlockLocalPathInfo獲取block對應的本地路徑資訊,可以在Datanode類中檢視具體實現,如下所示:

BlockLocalPathInfo info = data.getBlockLocalPathInfo(block);

Datanode呼叫FSDataset(實現介面FSDatasetInterface)的getBlockLocalPathInfo,如下所示:

@Override //FSDatasetInterface
public BlockLocalPathInfo getBlockLocalPathInfo(Block block)
    throws IOException {
  File datafile = getBlockFile(block); // 獲取本地block在本地Datanode檔案系統中的檔案路徑
  File metafile = getMetaFile(datafile, block);  // 獲取本地block在本地Datanode檔案系統中的後設資料的檔案路徑
  BlockLocalPathInfo info = new BlockLocalPathInfo(block, datafile.getAbsolutePath(), metafile.getAbsolutePath());
  return info;
}

接著可以直接去讀取該block檔案(如果需要檢查校驗和檔案,會讀取block的後設資料檔案metafile):

... // BlockReaderLocal類的newBlockReader靜態方法
// get a local file system
File blkfile = new File(pathinfo.getBlockPath());
dataIn = new FileInputStream(blkfile);

if (!skipChecksum) { // 如果檢查block的校驗和
  // get the metadata file
  File metafile = new File(pathinfo.getMetaPath());
  checksumIn = new FileInputStream(metafile);

  // read and handle the common header here. For now just a version
  BlockMetadataHeader header = BlockMetadataHeader.readHeader(new DataInputStream(checksumIn));
  short version = header.getVersion();
  if (version != FSDataset.METADATA_VERSION) {
    LOG.warn("Wrong version (" + version + ") for metadata file for " + blk + " ignoring ...");
  }
  DataChecksum checksum = header.getChecksum();
  localBlockReader = new BlockReaderLocal(conf, file, blk, token, startOffset, length, pathinfo, checksum, true, dataIn, checksumIn);
} else {
  localBlockReader = new BlockReaderLocal(conf, file, blk, token, startOffset, length, pathinfo, dataIn);
}

在上面程式碼中,返回了BlockLocalPathInfo,但是很可能在這個過程中block被刪除了,在刪除block的時候,Namenode會排程指派該Datanode刪除該block,恰好在這個時間間隔內block對應的BlockLocalPathInfo資訊已經失效(檔案已經被刪除),所以上面這段程式碼再try中會丟擲異常,並在catch中捕獲到IO異常,會從快取中再清除掉失效的block到BlockLocalPathInfo的對映資訊。

  • 如果Client非叢集Datanode節點,遠端讀取block

如果Client不是Datanode本地節點,則只能跨網路節點遠端讀取,首先建立Socket連線:

s = socketFactory.createSocket();
LOG.debug("Connecting to " + targetAddr);
NetUtils.connect(s, targetAddr, getRandomLocalInterfaceAddr(), socketTimeout);
s.setSoTimeout(socketTimeout);

建立Client到目標Datanode(targetAddr)的連線,然後同樣也是建立一個遠端BlockReader物件RemoteBlockReader來輔助讀取block資料。建立RemoteBlockReader過程中,首先向目標Datanode傳送RPC請求:

// in and out will be closed when sock is closed (by the caller)
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(NetUtils.getOutputStream(sock,HdfsConstants.WRITE_TIMEOUT)));

//write the header.
out.writeShort( DataTransferProtocol.DATA_TRANSFER_VERSION ); // Client與Datanode之間傳輸資料的版本號
out.write( DataTransferProtocol.OP_READ_BLOCK ); // 傳輸操作型別:讀取block
out.writeLong( blockId ); // block ID
out.writeLong( genStamp ); // 時間戳資訊
out.writeLong( startOffset ); // block起始偏移量
out.writeLong( len ); // block長度
Text.writeString(out, clientName); // 客戶端標識
accessToken.write(out); 
out.flush();

然後獲取到DataInputStream物件來讀取Datanode的響應資訊:

DataInputStream in = new DataInputStream(
    new BufferedInputStream(NetUtils.getInputStream(sock), bufferSize));

最後,返回一個物件RemoteBlockReader:

return new RemoteBlockReader(file, blockId, in, checksum, verifyChecksum, startOffset, firstChunkOffset, sock);

藉助BlockReader來讀取block位元組

我們再回到blockSeekTo方法中,待讀取block所在的Datanode資訊、BlockReader資訊都已經具備,接著就可以從包含輸入流(InputStream)物件的BlockReader中讀取資料塊中一個位元組資料:

int result = readBuffer(buf, off, realLen);

將block資料中一個位元組讀取到buf中,如下所示:

private synchronized int readBuffer(byte buf[], int off, int len) throws IOException {
  IOException ioe;
  boolean retryCurrentNode = true;

  while (true) {
    // retry as many times as seekToNewSource allows.
    try {
      return blockReader.read(buf, off, len); // 呼叫blockReader的read方法讀取位元組資料到buf中
    } catch ( ChecksumException ce ) {
      LOG.warn("Found Checksum error for " + currentBlock + " from " + currentNode.getName() + " at " + ce.getPos());         
      reportChecksumFailure(src, currentBlock, currentNode);
      ioe = ce;
      retryCurrentNode = false; // 只嘗試讀取當前選擇的Datanode一次,失敗的話就會被加入到Client的dead node列表中
    } catch ( IOException e ) {
      if (!retryCurrentNode) {
        LOG.warn("Exception while reading from " + currentBlock + " of " + src + " from " + currentNode + ": " + StringUtils.stringifyException(e));
      }
      ioe = e;
    }
    boolean sourceFound = false;
    if (retryCurrentNode) {
      /* possibly retry the same node so that transient errors don't
       * result in application level failures (e.g. Datanode could have
       * closed the connection because the client is idle for too long).
       */
      sourceFound = seekToBlockSource(pos);
    } else {
      addToDeadNodes(currentNode); // 加入到Client的dead node列表中
      sourceFound = seekToNewSource(pos); // 從當前選擇的Datanode上讀取資料失敗,會再次選擇一個Datanode,這裡seekToNewSource方法內部呼叫了blockSeekTo方法去選擇一個Datanode
    }
    if (!sourceFound) {
      throw ioe;
    }
    retryCurrentNode = false;
  }
}

通過BlockReaderLocal或者RemoteBlockReader來讀取block資料,邏輯非常類似,主要是控制讀取位元組的偏移量,記錄偏移量的狀態資訊,詳細可以檢視它們的原始碼。

DataNode節點處理讀檔案Block請求

我們可以在DataNode端看一下,如何處理一個讀取Block的請求。如果Client與DataNode不是同一個節點,則為遠端讀取檔案Block,首先Client需要傳送一個請求頭資訊,程式碼如下所示:

//write the header.
out.writeShort( DataTransferProtocol.DATA_TRANSFER_VERSION ); // Client與Datanode之間傳輸資料的版本號
out.write( DataTransferProtocol.OP_READ_BLOCK ); // 傳輸操作型別:讀取block
out.writeLong( blockId ); // block ID
out.writeLong( genStamp ); // 時間戳資訊
out.writeLong( startOffset ); // block起始偏移量
out.writeLong( len ); // block長度
Text.writeString(out, clientName); // 客戶端標識
accessToken.write(out); 
out.flush();

DataNode節點端通過驗證資料傳輸版本號(DataTransferProtocol.DATA_TRANSFER_VERSION)一致以後,會判斷傳輸操作型別,如果是讀操作DataTransferProtocol.OP_READ_BLOCK,則會通過Client建立的Socket來建立一個OutputStream物件,然後通過BlockSender向Client傳送Block資料,程式碼如下所示:

try {
  blockSender = new BlockSender(block, startOffset, length, true, true, false, datanode, clientTraceFmt); // 建立BlockSender物件
} catch(IOException e) {
  out.writeShort(DataTransferProtocol.OP_STATUS_ERROR);
  throw e;
}

out.writeShort(DataTransferProtocol.OP_STATUS_SUCCESS); // 回覆一個響應Header資訊:成功狀態
long read = blockSender.sendBlock(out, baseStream, null); // 傳送請求的Block資料

相關文章