原始碼|HDFS之DataNode:寫資料塊(1)

monkeysayhi發表於2018-02-05

作為分散式檔案系統,HDFS擅於處理大檔案的讀/寫。這得益於“檔案元資訊與檔案資料分離,檔案資料分塊儲存”的思想:namenode管理檔案元資訊,datanode管理分塊的檔案資料。

HDFS 2.x進一步將資料塊儲存服務抽象為blockpool,不過寫資料塊過程與1.x大同小異。本文假設副本系數1(即寫資料塊只涉及1個客戶端+1個datanode),未發生任何異常,分析datanode寫資料塊的過程。

原始碼版本:Apache Hadoop 2.6.0

可參考猴子追原始碼時的速記打斷點,親自debug一遍。

副本系數1,即只需要一個datanode構成最小的管道,與更常見的管道寫相比,可以認為“無管道”。後續再寫兩篇文章分別分析管道寫無異常、管道寫有異常兩種情況。

開始之前

總覽

參考原始碼|HDFS之DataNode:啟動過程,我們大體瞭解了datanode上有哪些重要的工作執行緒。其中,與寫資料塊過程聯絡最緊密的是DataXceiverServer與BPServiceActor。

參考HDFS-1.x、2.x的RPC介面,客戶端與資料節點間主要通過流介面DataTransferProtocol完成資料塊的讀/寫。DataTransferProtocol用於整個管道中的客戶端、資料節點間的流式通訊,其中,DataTransferProtocol#writeBlock()負責完成寫資料塊的工作:

  public void writeBlock(final ExtendedBlock blk,
      final StorageType storageType, 
      final Token<BlockTokenIdentifier> blockToken,
      final String clientName,
      final DatanodeInfo[] targets,
      final StorageType[] targetStorageTypes, 
      final DatanodeInfo source,
      final BlockConstructionStage stage,
      final int pipelineSize,
      final long minBytesRcvd,
      final long maxBytesRcvd,
      final long latestGenerationStamp,
      final DataChecksum requestedChecksum,
      final CachingStrategy cachingStrategy,
      final boolean allowLazyPersist) throws IOException;
複製程式碼

文章的組織結構

  1. 如果只涉及單個分支的分析,則放在同一節。
  2. 如果涉及多個分支的分析,則在下一級分多個節,每節討論一個分支。
  3. 多執行緒的分析同多分支。
  4. 每一個分支和執行緒的組織結構遵循規則1-3。

DataXceiverServer執行緒

注意,DataTransferProtocol並不是一個RPC協議,因此,常見通過的尋找DataTransferProtocol介面的實現類來確定“客戶端呼叫的遠端方法”是站不住腳。不過依然可以按照這個思路倒追,看實現類究竟是如何被建立,與誰通訊,來驗證是否找到了正確的實現類。

依靠debug,猴子從DataXceiver類反向追到了DataXceiverServer類。這裡從DataXceiverServer類開始,正向講解。

DataXceiverServer執行緒在DataNode#runDatanodeDaemon()方法中啟動。

DataXceiverServer#run():

  public void run() {
    Peer peer = null;
    while (datanode.shouldRun && !datanode.shutdownForUpgrade) {
      try {
        peer = peerServer.accept();

        ...// 檢查DataXceiver執行緒的數量,超過最大限制就丟擲IOE

        // 啟動一個新的DataXceiver執行緒
        new Daemon(datanode.threadGroup,
            DataXceiver.create(peer, datanode, this))
            .start();
      } catch (SocketTimeoutException ignored) {
        // wake up to see if should continue to run
      } catch (AsynchronousCloseException ace) {
        // another thread closed our listener socket - that's expected during shutdown,
        // but not in other circumstances
        if (datanode.shouldRun && !datanode.shutdownForUpgrade) {
          LOG.warn(datanode.getDisplayName() + ":DataXceiverServer: ", ace);
        }
      } catch (IOException ie) {
        ...// 清理
      } catch (OutOfMemoryError ie) {
        ...// 清理並sleep 30s
      } catch (Throwable te) {
        // 其他異常就關閉datanode
        LOG.error(datanode.getDisplayName()
            + ":DataXceiverServer: Exiting due to: ", te);
        datanode.shouldRun = false;
      }
    }

    ...// 關閉peerServer並清理所有peers
  }
複製程式碼

DataXceiverServer執行緒是一個典型的Tcp Socket Server。客戶端每來一個TCP請求,如果datanode上的DataXceiver執行緒數量還沒超過限制,就啟動一個新的DataXceiver執行緒。

預設的最大DataXceiver執行緒數量為4096,通過dfs.datanode.max.transfer.threads設定。

主流程:DataXceiver執行緒

DataXceiver#run():

  public void run() {
    int opsProcessed = 0;
    Op op = null;

    try {
      ...// 一些初始化
      
      // 使用一個迴圈,以允許客戶端傳送新的操作請求時重用TCP連線
      do {
        updateCurrentThreadName("Waiting for operation #" + (opsProcessed + 1));

        try {
          ...// 超時設定
          op = readOp();
        } catch (InterruptedIOException ignored) {
          // Time out while we wait for client rpc
          break;
        } catch (IOException err) {
          ...// 此處的優化使得正常處理完一個操作後,一定會丟擲EOFException或ClosedChannelException,可以退出迴圈
          ...// 如果是其他異常,則說明出現錯誤,重新丟擲以退出迴圈
        }

        ...// 超時設定

        opStartTime = now();
        processOp(op);
        ++opsProcessed;
      } while ((peer != null) &&
          (!peer.isClosed() && dnConf.socketKeepaliveTimeout > 0));
    } catch (Throwable t) {
      ...// 異常處理
    } finally {
      ...// 資源清理,包括開啟的檔案、socket等
    }
  }
複製程式碼

此處的優化不多講。

DataXceiver#readOp()繼承自Receiver類:從客戶端發來的socket中讀取op碼,判斷客戶端要進行何種操作操作。寫資料塊使用的op碼為80,返回的列舉變數op = Op.WRITE_BLOCK

DataXceiver#processOp()也繼承自Receiver類:

  protected final void processOp(Op op) throws IOException {
    switch(op) {
    case READ_BLOCK:
      opReadBlock();
      break;
    case WRITE_BLOCK:
      opWriteBlock(in);
      break;
    ...// 其他case
    default:
      throw new IOException("Unknown op " + op + " in data stream");
    }
  }
  
  ...
  
  private void opWriteBlock(DataInputStream in) throws IOException {
    final OpWriteBlockProto proto = OpWriteBlockProto.parseFrom(vintPrefixed(in));
    final DatanodeInfo[] targets = PBHelper.convert(proto.getTargetsList());
    TraceScope traceScope = continueTraceSpan(proto.getHeader(),
        proto.getClass().getSimpleName());
    try {
      writeBlock(PBHelper.convert(proto.getHeader().getBaseHeader().getBlock()),
          PBHelper.convertStorageType(proto.getStorageType()),
          PBHelper.convert(proto.getHeader().getBaseHeader().getToken()),
          proto.getHeader().getClientName(),
          targets,
          PBHelper.convertStorageTypes(proto.getTargetStorageTypesList(), targets.length),
          PBHelper.convert(proto.getSource()),
          fromProto(proto.getStage()),
          proto.getPipelineSize(),
          proto.getMinBytesRcvd(), proto.getMaxBytesRcvd(),
          proto.getLatestGenerationStamp(),
          fromProto(proto.getRequestedChecksum()),
          (proto.hasCachingStrategy() ?
              getCachingStrategy(proto.getCachingStrategy()) :
            CachingStrategy.newDefaultStrategy()),
            (proto.hasAllowLazyPersist() ? proto.getAllowLazyPersist() : false));
     } finally {
      if (traceScope != null) traceScope.close();
     }
  }
複製程式碼

HDFS 2.x相對於1.x的另一項改進,在流式介面中也大幅替換為使用protobuf,不再是裸TCP分析位元組流了。

Receiver類實現了DataTransferProtocol介面,但沒有實現DataTransferProtocol#writeBlock()。多型特性告訴我們,這裡會呼叫DataXceiver#writeBlock()。

終於回到了DataXceiver#writeBlock():

  public void writeBlock(final ExtendedBlock block,
      final StorageType storageType, 
      final Token<BlockTokenIdentifier> blockToken,
      final String clientname,
      final DatanodeInfo[] targets,
      final StorageType[] targetStorageTypes, 
      final DatanodeInfo srcDataNode,
      final BlockConstructionStage stage,
      final int pipelineSize,
      final long minBytesRcvd,
      final long maxBytesRcvd,
      final long latestGenerationStamp,
      DataChecksum requestedChecksum,
      CachingStrategy cachingStrategy,
      final boolean allowLazyPersist) throws IOException {
    ...// 檢查,設定引數等

    ...// 構建向上遊節點或客戶端回覆的輸出流(此處即為客戶端)

    ...// 略
    
    try {
      if (isDatanode || 
          stage != BlockConstructionStage.PIPELINE_CLOSE_RECOVERY) {
        // 建立BlockReceiver,準備接收資料塊
        blockReceiver = new BlockReceiver(block, storageType, in,
            peer.getRemoteAddressString(),
            peer.getLocalAddressString(),
            stage, latestGenerationStamp, minBytesRcvd, maxBytesRcvd,
            clientname, srcDataNode, datanode, requestedChecksum,
            cachingStrategy, allowLazyPersist);

        storageUuid = blockReceiver.getStorageUuid();
      } else {
        ...// 管道錯誤恢復相關
      }

      ...// 下游節點的處理。一個datanode是沒有下游節點的。
      
      // 傳送的第一個packet是空的,只用於建立管道。這裡立即返回ack表示管道是否建立成功
      // 由於該datanode沒有下游節點,則執行到此處,表示管道已經建立成功
      if (isClient && !isTransfer) {
        if (LOG.isDebugEnabled() || mirrorInStatus != SUCCESS) {
          LOG.info("Datanode " + targets.length +
                   " forwarding connect ack to upstream firstbadlink is " +
                   firstBadLink);
        }
        BlockOpResponseProto.newBuilder()
          .setStatus(mirrorInStatus)
          .setFirstBadLink(firstBadLink)
          .build()
          .writeDelimitedTo(replyOut);
        replyOut.flush();
      }

      // 接收資料塊(也負責傳送到下游,不過此處沒有下游節點)
      if (blockReceiver != null) {
        String mirrorAddr = (mirrorSock == null) ? null : mirrorNode;
        blockReceiver.receiveBlock(mirrorOut, mirrorIn, replyOut,
            mirrorAddr, null, targets, false);

        ...// 資料塊複製相關
      }

      ...// 資料塊恢復相關
      
      ...// 資料塊複製相關
      
    } catch (IOException ioe) {
      LOG.info("opWriteBlock " + block + " received exception " + ioe);
      throw ioe;
    } finally {
      ...// 清理資源
    }

    ...// 更新metrics
  }
複製程式碼

特別說明幾個引數:

  • stage:表示資料塊構建的狀態。此處為BlockConstructionStage.PIPELINE_SETUP_CREATE
  • isDatanode:表示寫資料塊請求是否由資料節點發起。如果寫請求中clientname為空,就說明是由資料節點發起(如資料塊複製等由資料節點發起)。此處為false。
  • isClient:表示寫資料塊請求是否由客戶端發起,此值一定與isDatanode相反。此處為true。
  • isTransfers:表示寫資料塊請求是否為資料塊複製。如果stage為BlockConstructionStage.TRANSFER_RBWBlockConstructionStage.TRANSFER_FINALIZED,則表示為了資料塊複製。此處為false。

下面討論“準備接收資料塊”和“接收資料塊”兩個過程。

準備接收資料塊:BlockReceiver.<init>()

BlockReceiver.<init>()

  BlockReceiver(final ExtendedBlock block, final StorageType storageType,
      final DataInputStream in,
      final String inAddr, final String myAddr,
      final BlockConstructionStage stage, 
      final long newGs, final long minBytesRcvd, final long maxBytesRcvd, 
      final String clientname, final DatanodeInfo srcDataNode,
      final DataNode datanode, DataChecksum requestedChecksum,
      CachingStrategy cachingStrategy,
      final boolean allowLazyPersist) throws IOException {
    try{
      ...// 檢查,設定引數等

      // 開啟檔案,準備接收資料塊
      if (isDatanode) { // 資料塊複製和資料塊移動是由資料節點發起的,這是在tmp目錄下建立block檔案
        replicaInfo = datanode.data.createTemporary(storageType, block);
      } else {
        switch (stage) {
        // 對於客戶端發起的寫資料請求(只考慮create,不考慮append),在rbw目錄下建立資料塊(block檔案、meta檔案,資料塊處於RBW狀態)
        case PIPELINE_SETUP_CREATE:
          replicaInfo = datanode.data.createRbw(storageType, block, allowLazyPersist);
          datanode.notifyNamenodeReceivingBlock(
              block, replicaInfo.getStorageUuid());
          break;
        ...// 其他case
        default: throw new IOException("Unsupported stage " + stage + 
              " while receiving block " + block + " from " + inAddr);
        }
      }
      ...// 略
      
      // 對於資料塊複製、資料塊移動、客戶端建立資料塊,本質上都在建立新的block檔案。對於這些情況,isCreate為true
      final boolean isCreate = isDatanode || isTransfer 
          || stage == BlockConstructionStage.PIPELINE_SETUP_CREATE;
      streams = replicaInfo.createStreams(isCreate, requestedChecksum);
      assert streams != null : "null streams!";

      ...// 計算meta檔案的檔案頭
      // 如果需要建立新的block檔案,也就需要同時建立新的meta檔案,並寫入檔案頭
      if (isCreate) {
        BlockMetadataHeader.writeHeader(checksumOut, diskChecksum);
      } 
    } catch (ReplicaAlreadyExistsException bae) {
      throw bae;
    } catch (ReplicaNotFoundException bne) {
      throw bne;
    } catch(IOException ioe) {
      ...// IOE通常涉及檔案等資源,因此要額外清理資源
    }
  }
複製程式碼

儘管上述程式碼的註釋加了不少,但建立block的場景比較簡單,只需要記住在rbw目錄下建立block檔案和meta檔案即可。

在rbw目錄下建立資料塊後,還要通過DataNode#notifyNamenodeReceivingBlock()向namenode彙報正在接收的資料塊。該方法僅僅將資料塊放入緩衝區中,由BPServiceActor執行緒非同步彙報。

此處不展開,後面會介紹一個相似的方法DataNode#notifyNamenodeReceivedBlock()。

接收資料塊:BlockReceiver#receiveBlock()

BlockReceiver#receiveBlock():

  void receiveBlock(
      DataOutputStream mirrOut, // output to next datanode
      DataInputStream mirrIn,   // input from next datanode
      DataOutputStream replyOut,  // output to previous datanode
      String mirrAddr, DataTransferThrottler throttlerArg,
      DatanodeInfo[] downstreams,
      boolean isReplaceBlock) throws IOException {
    ...// 引數設定

    try {
      // 如果是客戶端發起的寫請求(此處即為資料塊create),則啟動PacketResponder傳送ack
      if (isClient && !isTransfer) {
        responder = new Daemon(datanode.threadGroup, 
            new PacketResponder(replyOut, mirrIn, downstreams));
        responder.start(); // start thread to processes responses
      }

      // 同步接收packet,寫block檔案和meta檔案
      while (receivePacket() >= 0) {}

      // 此時,節點已接收了所有packet,可以等待傳送完所有ack後關閉responder
      if (responder != null) {
        ((PacketResponder)responder.getRunnable()).close();
        responderClosed = true;
      }

      ...// 資料塊複製相關

    } catch (IOException ioe) {
      if (datanode.isRestarting()) {
        LOG.info("Shutting down for restart (" + block + ").");
      } else {
        LOG.info("Exception for " + block, ioe);
        throw ioe;
      }
    } finally {
      ...// 清理
    }
  }
複製程式碼

同步接收packet:BlockReceiver#receivePacket()

先看BlockReceiver#receivePacket()。

嚴格來說,BlockReceiver#receivePacket()負責接收上游的packet,並繼續向下遊節點管道寫

  private int receivePacket() throws IOException {
    // read the next packet
    packetReceiver.receiveNextPacket(in);

    PacketHeader header = packetReceiver.getHeader();
    ...// 略

    ...// 檢查packet頭

    long offsetInBlock = header.getOffsetInBlock();
    long seqno = header.getSeqno();
    boolean lastPacketInBlock = header.isLastPacketInBlock();
    final int len = header.getDataLen();
    boolean syncBlock = header.getSyncBlock();

    ...// 略
    
    // 如果不需要立即持久化也不需要校驗收到的資料,則可以立即委託PacketResponder執行緒返回 SUCCESS 的ack,然後再進行校驗和持久化
    if (responder != null && !syncBlock && !shouldVerifyChecksum()) {
      ((PacketResponder) responder.getRunnable()).enqueue(seqno,
          lastPacketInBlock, offsetInBlock, Status.SUCCESS);
    }

    ...// 管道寫相關
    
    ByteBuffer dataBuf = packetReceiver.getDataSlice();
    ByteBuffer checksumBuf = packetReceiver.getChecksumSlice();
    
    if (lastPacketInBlock || len == 0) {    // 收到空packet可能是表示心跳或資料塊傳送
      // 這兩種情況都可以嘗試把之前的資料刷到磁碟
      if (syncBlock) {
        flushOrSync(true);
      }
    } else {    // 否則,需要持久化packet
      final int checksumLen = diskChecksum.getChecksumSize(len);
      final int checksumReceivedLen = checksumBuf.capacity();

      ...// 如果是管道中的最後一個節點,則持久化之前,要先對收到的packet做一次校驗(使用packet本身的校驗機制)
      ...// 如果校驗錯誤,則委託PacketResponder執行緒返回 ERROR_CHECKSUM 的ack

      final boolean shouldNotWriteChecksum = checksumReceivedLen == 0
          && streams.isTransientStorage();
      try {
        long onDiskLen = replicaInfo.getBytesOnDisk();
        if (onDiskLen<offsetInBlock) {
          ...// 如果校驗塊不完整,需要載入並調整舊的meta檔案內容,供後續重新計算crc

          // 寫block檔案
          int startByteToDisk = (int)(onDiskLen-firstByteInBlock) 
              + dataBuf.arrayOffset() + dataBuf.position();
          int numBytesToDisk = (int)(offsetInBlock-onDiskLen);
          out.write(dataBuf.array(), startByteToDisk, numBytesToDisk);
          
          // 寫meta檔案
          final byte[] lastCrc;
          if (shouldNotWriteChecksum) {
            lastCrc = null;
          } else if (partialCrc != null) {  // 如果是校驗塊不完整(之前收到過一部分)
            ...// 重新計算crc
            ...// 更新lastCrc
            checksumOut.write(buf);
            partialCrc = null;
          } else { // 如果校驗塊完整
            ...// 更新lastCrc
            checksumOut.write(checksumBuf.array(), offset, checksumLen);
          }

          ...//略
        }
      } catch (IOException iex) {
        datanode.checkDiskErrorAsync();
        throw iex;
      }
    }

    // 相反的,如果需要立即持久化或需要校驗收到的資料,則現在已經完成了持久化和校驗,可以委託PacketResponder執行緒返回 SUCCESS 的ack
    // if sync was requested, put in queue for pending acks here
    // (after the fsync finished)
    if (responder != null && (syncBlock || shouldVerifyChecksum())) {
      ((PacketResponder) responder.getRunnable()).enqueue(seqno,
          lastPacketInBlock, offsetInBlock, Status.SUCCESS);
    }

    ...// 如果超過了響應時間,還要主動傳送一個IN_PROGRESS的ack,防止超時

    ...// 節流器相關
    
    // 當整個資料塊都傳送完成之前,客戶端會可能會傳送有資料的packet,也因為維持心跳或表示結束寫資料塊傳送空packet
    // 因此,當標誌位lastPacketInBlock為true時,不能返回0,要返回一個負值,以區分未到達最後一個packet之前的情況
    return lastPacketInBlock?-1:len;
  }
  
  ...
  
  private boolean shouldVerifyChecksum() {
    // 對於客戶端寫,只有管道中的最後一個節點滿足`mirrorOut == null`
    return (mirrorOut == null || isDatanode || needsChecksumTranslation);
  }
複製程式碼

BlockReceiver#shouldVerifyChecksum()主要與管道寫有關,本文只有一個datanode,則一定滿足mirrorOut == null

上述程式碼看起來長,主要工作只有四項:

  1. 接收packet
  2. 校驗packet
  3. 持久化packet
  4. 委託PacketResponder執行緒傳送ack

BlockReceiver#receivePacket() + PacketResponder執行緒 + PacketResponder#ackQueue構成一個生產者消費者模型。生產和消費的物件是ack,BlockReceiver#receivePacket()是生產者,PacketResponder執行緒是消費者。

掃一眼PacketResponder#enqueue():

    void enqueue(final long seqno, final boolean lastPacketInBlock,
        final long offsetInBlock, final Status ackStatus) {
      final Packet p = new Packet(seqno, lastPacketInBlock, offsetInBlock,
          System.nanoTime(), ackStatus);
      if(LOG.isDebugEnabled()) {
        LOG.debug(myString + ": enqueue " + p);
      }
      synchronized(ackQueue) {
        if (running) {
          ackQueue.addLast(p);
          ackQueue.notifyAll();
        }
      }
    }
複製程式碼

ackQueue是一個執行緒不安全的LinkedList。

關於如何利用執行緒不安全的容器實現生產者消費者模型可參考Java實現生產者-消費者模型中的實現三。

非同步傳送ack:PacketResponder執行緒

與BlockReceiver#receivePacket()相對,PacketResponder執行緒負責接收下游節點的ack,並繼續向上遊管道響應

PacketResponder#run():

    public void run() {
      boolean lastPacketInBlock = false;
      final long startTime = ClientTraceLog.isInfoEnabled() ? System.nanoTime() : 0;
      while (isRunning() && !lastPacketInBlock) {
        long totalAckTimeNanos = 0;
        boolean isInterrupted = false;
        try {
          Packet pkt = null;
          long expected = -2;
          PipelineAck ack = new PipelineAck();
          long seqno = PipelineAck.UNKOWN_SEQNO;
          long ackRecvNanoTime = 0;
          try {
            // 如果當前節點不是管道的最後一個節點,且下游節點正常,則從下游讀取ack
            if (type != PacketResponderType.LAST_IN_PIPELINE && !mirrorError) {
              ack.readFields(downstreamIn);
              ...// 統計相關
              ...// OOB相關(暫時忽略)
              seqno = ack.getSeqno();
            }
            // 如果從下游節點收到了正常的 ack,或當前節點是管道的最後一個節點,則需要從佇列中消費pkt(即BlockReceiver#receivePacket()放入的ack)
            if (seqno != PipelineAck.UNKOWN_SEQNO
                || type == PacketResponderType.LAST_IN_PIPELINE) {
              pkt = waitForAckHead(seqno);
              if (!isRunning()) {
                break;
              }
              // 管道寫用seqno控制packet的順序:當且僅當下游正確接收的序號與當前節點正確處理完的序號相等時,當前節點才認為該序號的packet已正確接收;上游同理
              expected = pkt.seqno;
              if (type == PacketResponderType.HAS_DOWNSTREAM_IN_PIPELINE
                  && seqno != expected) {
                throw new IOException(myString + "seqno: expected=" + expected
                    + ", received=" + seqno);
              }
              ...// 統計相關
              lastPacketInBlock = pkt.lastPacketInBlock;
            }
          } catch (InterruptedException ine) {
            ...// 異常處理
          } catch (IOException ioe) {
            ...// 異常處理
          }

          ...// 中斷退出

          // 如果是最後一個packet,將block的狀態轉換為FINALIZED,並關閉BlockReceiver
          if (lastPacketInBlock) {
            finalizeBlock(startTime);
          }

          // 此時,必然滿足 ack.seqno == pkt.seqno,構造新的 ack 傳送給上游
          sendAckUpstream(ack, expected, totalAckTimeNanos,
              (pkt != null ? pkt.offsetInBlock : 0), 
              (pkt != null ? pkt.ackStatus : Status.SUCCESS));
          // 已經處理完隊頭元素,出隊
          // 只有一種情況下滿足pkt == null:PacketResponder#isRunning()返回false,即PacketResponder執行緒正在關閉。此時無論佇列中是否有元素,都不需要出隊了
          if (pkt != null) {
            removeAckHead();
          }
        } catch (IOException e) {
          ...// 異常處理
        } catch (Throwable e) {
          ...// 異常處理
        }
      }
      LOG.info(myString + " terminating");
    }
複製程式碼

總結起來,PacketResponder執行緒的核心工作如下:

  1. 接收下游節點的ack
  2. 比較ack.seqno與當前隊頭的pkt.seqno
  3. 如果相等,則向上遊傳送pkt
  4. 如果是最後一個packet,將block的狀態轉換為FINALIZED

一不小心把管道響應的邏輯也分析了。。。

掃一眼PacketResponder執行緒使用的出隊和檢視對頭的方法:

    // 檢視隊頭
    Packet waitForAckHead(long seqno) throws InterruptedException {
      synchronized(ackQueue) {
        while (isRunning() && ackQueue.size() == 0) {
          if (LOG.isDebugEnabled()) {
            LOG.debug(myString + ": seqno=" + seqno +
                      " waiting for local datanode to finish write.");
          }
          ackQueue.wait();
        }
        return isRunning() ? ackQueue.getFirst() : null;
      }
    }
    
    ...
    
    // 出隊
    private void removeAckHead() {
      synchronized(ackQueue) {
        ackQueue.removeFirst();
        ackQueue.notifyAll();
      }
    }
複製程式碼

隊尾入隊,隊頭出隊。

  • 每次檢視對頭後,如果發現佇列非空,則只要不出隊,則佇列後續狀態一定是非空的,且隊頭元素不變。
  • 檢視隊頭後的第一次出隊,彈出的一定是剛才檢視隊頭看到的元素。

需要看下PacketResponder#finalizeBlock():

    private void finalizeBlock(long startTime) throws IOException {
      // 關閉BlockReceiver,並清理資源
      BlockReceiver.this.close();
      ...// log
      block.setNumBytes(replicaInfo.getNumBytes());
      // datanode上的資料塊關閉委託給FsDatasetImpl#finalizeBlock()
      datanode.data.finalizeBlock(block);
      // namenode上的資料塊關閉委託給Datanode#closeBlock()
      datanode.closeBlock(
          block, DataNode.EMPTY_DEL_HINT, replicaInfo.getStorageUuid());
      ...// log
    }
複製程式碼

datanode角度的資料塊關閉:FsDatasetImpl#finalizeBlock()

FsDatasetImpl#finalizeBlock():

  public synchronized void finalizeBlock(ExtendedBlock b) throws IOException {
    if (Thread.interrupted()) {
      // Don't allow data modifications from interrupted threads
      throw new IOException("Cannot finalize block from Interrupted Thread");
    }
    ReplicaInfo replicaInfo = getReplicaInfo(b);
    if (replicaInfo.getState() == ReplicaState.FINALIZED) {
      // this is legal, when recovery happens on a file that has
      // been opened for append but never modified
      return;
    }
    finalizeReplica(b.getBlockPoolId(), replicaInfo);
  }
  
  ...
  
  private synchronized FinalizedReplica finalizeReplica(String bpid,
      ReplicaInfo replicaInfo) throws IOException {
    FinalizedReplica newReplicaInfo = null;
    if (replicaInfo.getState() == ReplicaState.RUR &&
       ((ReplicaUnderRecovery)replicaInfo).getOriginalReplica().getState() == 
         ReplicaState.FINALIZED) {  // 資料塊恢復相關(略)
      newReplicaInfo = (FinalizedReplica)
             ((ReplicaUnderRecovery)replicaInfo).getOriginalReplica();
    } else {
      FsVolumeImpl v = (FsVolumeImpl)replicaInfo.getVolume();
      // 回憶BlockReceiver.<init>()的分析,我們建立的block處於RBW狀態,block檔案位於rbw目錄(當然,實際上位於哪裡也無所謂,原因見後)
      File f = replicaInfo.getBlockFile();
      if (v == null) {
        throw new IOException("No volume for temporary file " + f + 
            " for block " + replicaInfo);
      }

      // 在卷FsVolumeImpl上進行block檔案與meta檔案的狀態轉換
      File dest = v.addFinalizedBlock(
          bpid, replicaInfo, f, replicaInfo.getBytesReserved());
      // 該副本即代表最終的資料塊副本,處於FINALIZED狀態
      newReplicaInfo = new FinalizedReplica(replicaInfo, v, dest.getParentFile());

      ...// 略
    }
    volumeMap.add(bpid, newReplicaInfo);

    return newReplicaInfo;
  }
複製程式碼

FsVolumeImpl#addFinalizedBlock():

  File addFinalizedBlock(String bpid, Block b,
                         File f, long bytesReservedForRbw)
      throws IOException {
    releaseReservedSpace(bytesReservedForRbw);
    return getBlockPoolSlice(bpid).addBlock(b, f);
  }
複製程式碼

還記得datanode啟動過程中分析的FsVolumeImpl與BlockPoolSlice的關係嗎?此處將操作繼續委託給BlockPoolSlice#addBlock():

可知,BlockPoolSlice僅管理處於FINALIZED的資料塊

  File addBlock(Block b, File f) throws IOException {
    File blockDir = DatanodeUtil.idToBlockDir(finalizedDir, b.getBlockId());
    if (!blockDir.exists()) {
      if (!blockDir.mkdirs()) {
        throw new IOException("Failed to mkdirs " + blockDir);
      }
    }
    File blockFile = FsDatasetImpl.moveBlockFiles(b, f, blockDir);
    ...// 統計相關
    return blockFile;
  }
複製程式碼

BlockPoolSlice反向藉助FsDatasetImpl提供的靜態方法FsDatasetImpl.moveBlockFiles():

  static File moveBlockFiles(Block b, File srcfile, File destdir)
      throws IOException {
    final File dstfile = new File(destdir, b.getBlockName());
    final File srcmeta = FsDatasetUtil.getMetaFile(srcfile, b.getGenerationStamp());
    final File dstmeta = FsDatasetUtil.getMetaFile(dstfile, b.getGenerationStamp());
    try {
      NativeIO.renameTo(srcmeta, dstmeta);
    } catch (IOException e) {
      throw new IOException("Failed to move meta file for " + b
          + " from " + srcmeta + " to " + dstmeta, e);
    }
    try {
      NativeIO.renameTo(srcfile, dstfile);
    } catch (IOException e) {
      throw new IOException("Failed to move block file for " + b
          + " from " + srcfile + " to " + dstfile.getAbsolutePath(), e);
    }
    ...// 日誌
    return dstfile;
  }
複製程式碼

直接將block檔案和meta檔案從原目錄(rbw目錄,對應RBW狀態)移動到finalized目錄(對應FINALIZED狀態)。

至此,datanode上的寫資料塊已經完成。

不過,namenode上的元資訊還沒有更新,因此,還要向namenode彙報收到了資料塊。

  • 執行緒安全由FsDatasetImpl#finalizeReplica()保證
  • 整個FsDatasetImpl#finalizeReplica()的流程中,都不關係資料塊的原位置,狀態轉換邏輯本身保證了其正確性。

namenode角度的資料塊關閉:Datanode#closeBlock()

Datanode#closeBlock():

  void closeBlock(ExtendedBlock block, String delHint, String storageUuid) {
    metrics.incrBlocksWritten();
    BPOfferService bpos = blockPoolManager.get(block.getBlockPoolId());
    if(bpos != null) {
      // 向namenode彙報已收到的資料塊
      bpos.notifyNamenodeReceivedBlock(block, delHint, storageUuid);
    } else {
      LOG.warn("Cannot find BPOfferService for reporting block received for bpid="
          + block.getBlockPoolId());
    }
    // 將新資料塊新增到blockScanner的掃描範圍中(暫不討論)
    FsVolumeSpi volume = getFSDataset().getVolume(block);
    if (blockScanner != null && !volume.isTransientStorage()) {
      blockScanner.addBlock(block);
    }
  }
複製程式碼

BPOfferService#notifyNamenodeReceivedBlock():

  void notifyNamenodeReceivedBlock(
      ExtendedBlock block, String delHint, String storageUuid) {
    checkBlock(block);
    // 收到資料塊(增加)與刪除資料塊(減少)是一起彙報的,都構造為ReceivedDeletedBlockInfo
    ReceivedDeletedBlockInfo bInfo = new ReceivedDeletedBlockInfo(
        block.getLocalBlock(),
        ReceivedDeletedBlockInfo.BlockStatus.RECEIVED_BLOCK,
        delHint);

    // 每個BPServiceActor都要向自己負責的namenode傳送報告
    for (BPServiceActor actor : bpServices) {
      actor.notifyNamenodeBlock(bInfo, storageUuid, true);
    }
  }
複製程式碼

BPServiceActor#notifyNamenodeBlock():

  void notifyNamenodeBlock(ReceivedDeletedBlockInfo bInfo,
      String storageUuid, boolean now) {
    synchronized (pendingIncrementalBRperStorage) {
      // 更新pendingIncrementalBRperStorage
      addPendingReplicationBlockInfo(
          bInfo, dn.getFSDataset().getStorage(storageUuid));
      // sendImmediateIBR是一個volatile變數,控制是否立即傳送BlockReport(BR)
      sendImmediateIBR = true;
      // 傳入的now為true,接下來將喚醒阻塞在pendingIncrementalBRperStorage上的所有執行緒
      if (now) {
        pendingIncrementalBRperStorage.notifyAll();
      }
    }
  }
複製程式碼

該方法的核心是pendingIncrementalBRperStorage,它維護了兩次彙報之間收到、刪除的資料塊pendingIncrementalBRperStorage是一個緩衝區,此處將收到的資料塊放入緩衝區後即認為通知完成(當然,不一定成功);由其他執行緒讀取緩衝區,非同步向namenode彙報

猴子看的原始碼比較少,但這種緩衝區的設計思想在HDFS和Yarn中非常常見。緩衝區實現瞭解耦,解耦不僅能提高可擴充套件性,還能在緩衝區兩端使用不同的處理速度、處理規模。如pendingIncrementalBRperStorage,生產者不定期、零散放入的資料塊,消費者就可以定期、批量的對資料塊進行處理。而保障一定及時性的前提下,批量彙報減輕了RPC的壓力。

利用IDE,很容易得知,只有負責向各namenode傳送心跳的BPServiceActor執行緒阻塞在pendingIncrementalBRperStorage上。後文將分析該執行緒如何進行實際的彙報。

PacketResponder#close()

根據對BlockReceiver#receivePacket()與PacketResponder執行緒的分析,節點已接收所有packet時,ack可能還沒有傳送完。

因此,需要呼叫PacketResponder#close(),等待傳送完所有ack後關閉responder:

    public void close() {
      synchronized(ackQueue) {
        // ackQueue非空就說明ack還沒有傳送完成
        while (isRunning() && ackQueue.size() != 0) {
          try {
            ackQueue.wait();
          } catch (InterruptedException e) {
            running = false;
            Thread.currentThread().interrupt();
          }
        }
        if(LOG.isDebugEnabled()) {
          LOG.debug(myString + ": closing");
        }
        // notify阻塞在PacketResponder#waitForAckHead()方法上的PacketResponder執行緒,使其檢測到關閉條件
        running = false;
        ackQueue.notifyAll();
      }

      // ???
      synchronized(this) {
        running = false;
        notifyAll();
      }
    }
複製程式碼

猴子沒明白19-22行的synchronized語句塊有什麼用,,,求解釋。

BPServiceActor執行緒

根據前文,接下來需要分析BPServiceActor執行緒如何讀取pendingIncrementalBRperStorage緩衝區,進行實際的彙報。

在BPServiceActor#offerService()中呼叫了pendingIncrementalBRperStorage#wait()。由於涉及阻塞、喚醒等操作,無法按照正常流程分析,這裡從執行緒被喚醒的位置開始分析:

        // 如果目前不需要彙報,則wait一段時間
        long waitTime = dnConf.heartBeatInterval - 
        (Time.now() - lastHeartbeat);
        synchronized(pendingIncrementalBRperStorage) {
          if (waitTime > 0 && !sendImmediateIBR) {
            try {
              // BPServiceActor執行緒從此處醒來,然後退出synchronized塊
              pendingIncrementalBRperStorage.wait(waitTime);
            } catch (InterruptedException ie) {
              LOG.warn("BPOfferService for " + this + " interrupted");
            }
          }
        } // synchronized
複製程式碼

可能有讀者閱讀過猴子的條件佇列大法好:使用wait、notify和notifyAll的正確姿勢,認為此處if(){wait}的寫法姿勢不正確。讀者可再複習一下該文的“version2:過早喚醒”部分,結合HDFS的心跳機制,思考一下為什麼此處的寫法沒有問題。更甚,此處恰恰應當這麼寫。

如果目前不需要彙報,則BPServiceActor執行緒會wait一段時間,正式這段wait的時間,讓BPServiceActor#notifyNamenodeBlock()的喚醒產生了意義。

BPServiceActor執行緒喚醒後,醒來後,繼續心跳迴圈:

    while (shouldRun()) {
      try {
        final long startTime = now();
        if (startTime - lastHeartbeat >= dnConf.heartBeatInterval) {
複製程式碼

假設還到達心跳傳送間隔,則不執行if語句塊。

此時,在BPServiceActor#notifyNamenodeBlock()方法中修改的volatile變數sendImmediateIBR就派上了用場:

        // 檢測到sendImmediateIBR為true,則立即彙報已收到和已刪除的資料塊
        if (sendImmediateIBR ||
            (startTime - lastDeletedReport > dnConf.deleteReportInterval)) {
          // 彙報已收到和已刪除的資料塊
          reportReceivedDeletedBlocks();
          // 更新lastDeletedReport
          lastDeletedReport = startTime;
        }

        // 再來一次完整的資料塊彙報
        List<DatanodeCommand> cmds = blockReport();
        processCommand(cmds == null ? null : cmds.toArray(new DatanodeCommand[cmds.size()]));

        // 處理namenode返回的命令
        DatanodeCommand cmd = cacheReport();
        processCommand(new DatanodeCommand[]{ cmd });
複製程式碼

有意思的是,這裡先單獨彙報了一次資料塊收到和刪除的情況,該RPC不需要等待namenode的返回值;又彙報了一次總體情況,此時需要等待RPC的返回值了。

因此,儘管對於增刪資料塊採取增量式彙報,但由於增量式彙報後必然跟著一次全量彙報,使得增量彙報的成本仍然非常高。為了提高併發,BPServiceActor#notifyNamenodeBlock修改緩衝區後立即返回,不關心彙報是否成功。也不必擔心彙報失敗的後果:在彙報之前,資料塊已經轉為FINALIZED狀態+持久化到磁碟上+修改了緩衝區,如果彙報失敗可以等待重試,如果datanode在發報告前掛了可以等啟動後重新彙報,必然能保證一致性。

暫時不關心總體彙報的邏輯,只看單獨彙報的BPServiceActor#reportReceivedDeletedBlocks():

  private void reportReceivedDeletedBlocks() throws IOException {

    // 構造報告,並重置sendImmediateIBR為false
    ArrayList<StorageReceivedDeletedBlocks> reports =
        new ArrayList<StorageReceivedDeletedBlocks>(pendingIncrementalBRperStorage.size());
    synchronized (pendingIncrementalBRperStorage) {
      for (Map.Entry<DatanodeStorage, PerStoragePendingIncrementalBR> entry :
           pendingIncrementalBRperStorage.entrySet()) {
        final DatanodeStorage storage = entry.getKey();
        final PerStoragePendingIncrementalBR perStorageMap = entry.getValue();

        if (perStorageMap.getBlockInfoCount() > 0) {
          ReceivedDeletedBlockInfo[] rdbi = perStorageMap.dequeueBlockInfos();
          reports.add(new StorageReceivedDeletedBlocks(storage, rdbi));
        }
      }
      sendImmediateIBR = false;
    }

    // 如果報告為空,就直接返回
    if (reports.size() == 0) {
      return;
    }

    // 否則通過RPC向自己負責的namenode傳送報告
    boolean success = false;
    try {
      bpNamenode.blockReceivedAndDeleted(bpRegistration,
          bpos.getBlockPoolId(),
          reports.toArray(new StorageReceivedDeletedBlocks[reports.size()]));
      success = true;
    } finally {
      // 如果彙報失敗,則將增刪資料塊的資訊放回緩衝區,等待重新彙報
      if (!success) {
        synchronized (pendingIncrementalBRperStorage) {
          for (StorageReceivedDeletedBlocks report : reports) {
            PerStoragePendingIncrementalBR perStorageMap =
                pendingIncrementalBRperStorage.get(report.getStorage());
            perStorageMap.putMissingBlockInfos(report.getBlocks());
            sendImmediateIBR = true;
          }
        }
      }
    }
  }
複製程式碼

有兩個注意點:

  • 不管namenode處於active或standy狀態,BPServiceActor執行緒都會彙報(儘管會忽略standby namenode的命令)
  • 最後success為false時,可能namenode已收到彙報,但將資訊新增會緩衝區導致重複彙報也沒有壞影響,這分為兩個方面:
    • 重複彙報已刪除的資料塊:namenode發現未儲存該資料塊的資訊,則得知其已經刪除了,會忽略該資訊。
    • 重複彙報已收到的資料塊:namenode發現新收到的資料塊與已儲存資料塊的資訊完全一致,也會忽略該資訊。

總結

1個客戶端+1個datanode構成了最小的管道。本文梳理了在這個最小管道上無異常情況下的寫資料塊過程,在此之上,再來分析管道寫的有異常的難度將大大降低。


本文連結:原始碼|HDFS之DataNode:寫資料塊(1)
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章