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

monkeysayhi發表於2019-02-28

上一篇原始碼|HDFS之DataNode:寫資料塊(1)分析了無管道無異常情況下,datanode上的寫資料塊過程。本文分析管道寫無異常的情況,假設副本系數3(即寫資料塊涉及1個客戶端+3個datanode),未發生任何異常

原始碼版本:Apache Hadoop 2.6.0

本文內容雖短,卻是建立在前文的基礎之上。對於前文已經說明的內容,本文不再贅述,建議讀者按順序閱讀。

開始之前

總覽

根據原始碼|HDFS之DataNode:寫資料塊(1),對於多副本的管道寫流程,主要影響DataXceiver#writeBlock()、BlockReceiver#receivePacket()、PacketResponder執行緒三部分。本文按照這三個分支展開。

文章的組織結構

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

建立管道:DataXceiver#writeBlock()

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

  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 {
        ...// 管道錯誤恢復相關
      }

      // 下游節點的處理:以當前節點為“客戶端”,繼續觸發下游管道的建立
      if (targets.length > 0) {
        // 連線下游節點
        InetSocketAddress mirrorTarget = null;
        mirrorNode = targets[0].getXferAddr(connectToDnViaHostname);
        if (LOG.isDebugEnabled()) {
          LOG.debug("Connecting to datanode " + mirrorNode);
        }
        mirrorTarget = NetUtils.createSocketAddr(mirrorNode);
        mirrorSock = datanode.newSocket();
        // 嘗試建立管道(下面展開)
        try {
          // 設定建立socket的超時時間、寫packet的超時時間、寫buf大小等
          int timeoutValue = dnConf.socketTimeout
              + (HdfsServerConstants.READ_TIMEOUT_EXTENSION * targets.length);
          int writeTimeout = dnConf.socketWriteTimeout + 
                      (HdfsServerConstants.WRITE_TIMEOUT_EXTENSION * targets.length);
          NetUtils.connect(mirrorSock, mirrorTarget, timeoutValue);
          mirrorSock.setSoTimeout(timeoutValue);
          mirrorSock.setSendBufferSize(HdfsConstants.DEFAULT_DATA_SOCKET_SIZE);
          
          // 設定當前節點到下游的輸出流mirrorOut、下游到當前節點的輸入流mirrorIn等
          OutputStream unbufMirrorOut = NetUtils.getOutputStream(mirrorSock,
              writeTimeout);
          InputStream unbufMirrorIn = NetUtils.getInputStream(mirrorSock);
          DataEncryptionKeyFactory keyFactory =
            datanode.getDataEncryptionKeyFactoryForBlock(block);
          IOStreamPair saslStreams = datanode.saslClient.socketSend(mirrorSock,
            unbufMirrorOut, unbufMirrorIn, keyFactory, blockToken, targets[0]);
          unbufMirrorOut = saslStreams.out;
          unbufMirrorIn = saslStreams.in;
          mirrorOut = new DataOutputStream(new BufferedOutputStream(unbufMirrorOut,
              HdfsConstants.SMALL_BUFFER_SIZE));
          mirrorIn = new DataInputStream(unbufMirrorIn);

          // 向下遊節點傳送建立管道的請求,未來將繼續使用mirrorOut作為寫packet的輸出流
          new Sender(mirrorOut).writeBlock(originalBlock, targetStorageTypes[0],
              blockToken, clientname, targets, targetStorageTypes, srcDataNode,
              stage, pipelineSize, minBytesRcvd, maxBytesRcvd,
              latestGenerationStamp, requestedChecksum, cachingStrategy, false);
          mirrorOut.flush();

          // 如果是客戶端發起的寫資料塊請求(滿足),則存在管道,需要從下游節點讀取建立管道的ack
          if (isClient) {
            BlockOpResponseProto connectAck =
              BlockOpResponseProto.parseFrom(PBHelper.vintPrefixed(mirrorIn));
            // 將下游節點的管道建立結果作為整個管道的建立結果(要麼從尾節點到頭結點都是成功的,要麼都是失敗的)
            mirrorInStatus = connectAck.getStatus();
            firstBadLink = connectAck.getFirstBadLink();
            if (LOG.isDebugEnabled() || mirrorInStatus != SUCCESS) {
              LOG.info("Datanode " + targets.length +
                       " got response for connect ack " +
                       " from downstream datanode with firstbadlink as " +
                       firstBadLink);
            }
          }

        } catch (IOException e) {
          ...// 異常處理:清理資源,響應ack等
        }
      }
      
      // 傳送的第一個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
  }
複製程式碼

與副本系數1的情況下相比,僅僅增加了“下游節點的處理”的部分:以當前節點為“客戶端”,繼續觸發下游管道的建立;對於下游節點,仍然要走一遍當前節點的流程當客戶端收到第一個datanode管道建立成功的ack時,下游所有的節點的管道一定已經建立成功,加上客戶端,組成了完整的管道。

另外,根據前文的分析,直到執行BlockReceiver.receiveBlock()才開始管道寫資料塊內容,結合管道的關閉過程,可知管道的生命週期分為三個階段:

  1. 管道建立:以管道的方式向下遊傳送管道建立的請求,從下游接收管道建立的響應。
  2. 管道寫:當客戶端收到管道建立成功的ack時,才利用剛剛建立的管道開始管道寫資料塊的內容。
  3. 管道關閉:以管道的方式向下遊傳送管道關閉的請求,從下游接收管道關閉的響應。

如圖說明幾個引數:

image.png

  • in:上游節點到當前節點的輸入流,當前節點通過in接收上游節點的packet。
  • replyOut::當前節點到上游節點的輸出流,當前節點通過replyOut向上遊節點傳送ack。
  • mirrorOut:當前節點到下游節點的輸出流,當前節點通過mirrorOut向下遊節點映象傳送packet。
  • mirrorIn:下游節點到當前節點的輸入流,當前節點通過mirrorIn接收下游節點的映象ack。

請求建立管道:Sender#writeBlock()

Sender#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,
      DataChecksum requestedChecksum,
      final CachingStrategy cachingStrategy,
      final boolean allowLazyPersist) throws IOException {
    ClientOperationHeaderProto header = DataTransferProtoUtil.buildClientHeader(
        blk, clientName, blockToken);
    
    ChecksumProto checksumProto =
      DataTransferProtoUtil.toProto(requestedChecksum);

    OpWriteBlockProto.Builder proto = OpWriteBlockProto.newBuilder()
      .setHeader(header)
      .setStorageType(PBHelper.convertStorageType(storageType))
      // 去掉targets中的第一個節點
      .addAllTargets(PBHelper.convert(targets, 1))
      .addAllTargetStorageTypes(PBHelper.convertStorageTypes(targetStorageTypes, 1))
      .setStage(toProto(stage))
      .setPipelineSize(pipelineSize)
      .setMinBytesRcvd(minBytesRcvd)
      .setMaxBytesRcvd(maxBytesRcvd)
      .setLatestGenerationStamp(latestGenerationStamp)
      .setRequestedChecksum(checksumProto)
      .setCachingStrategy(getCachingStrategy(cachingStrategy))
      .setAllowLazyPersist(allowLazyPersist);
    
    if (source != null) {
      proto.setSource(PBHelper.convertDatanodeInfo(source));
    }

    send(out, Op.WRITE_BLOCK, proto.build());
  }
  
  ...
  
  private static void send(final DataOutputStream out, final Op opcode,
      final Message proto) throws IOException {
    if (LOG.isTraceEnabled()) {
      LOG.trace("Sending DataTransferOp " + proto.getClass().getSimpleName()
          + ": " + proto);
    }
    op(out, opcode);
    proto.writeDelimitedTo(out);
    out.flush();
  }
複製程式碼

邏輯非常簡單。為什麼要去掉targets中的第一個節點?假設客戶端傳送的targets中順序儲存d1、d2、d3,當前節點為d1,那麼d1的下游只剩下d2、d3,繼續向下遊傳送管道建立請求時,自然要去掉當前targets中的第一個節點d1;d2、d3同理。

依靠這種targets逐漸減少的邏輯,DataXceiver#writeBlock()才能用targets.length > 0判斷是否還有下游節點需要建立管道。

客戶端也使用Sender#writeBlock()建立管道。但傳送過程略有不同:客戶端通過自定義的位元組流寫入資料,需要將位元組流中的資料整合成packet,再寫入管道。

向下遊管道傳送packet:BlockReceiver#receivePacket()

同步接收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);
    }

    // 管道寫相關:將in中收到的packet映象寫入mirrorOut
    if (mirrorOut != null && !mirrorError) {
      try {
        long begin = Time.monotonicNow();
        packetReceiver.mirrorPacketTo(mirrorOut);
        mirrorOut.flush();
        long duration = Time.monotonicNow() - begin;
        if (duration > datanodeSlowLogThresholdMs) {
          LOG.warn("Slow BlockReceiver write packet to mirror took " + duration
              + "ms (threshold=" + datanodeSlowLogThresholdMs + "ms)");
        }
      } catch (IOException e) {
        handleMirrorOutError(e);
      }
    }
    
    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);
  }
複製程式碼

由於已經在中建立了管道,接下來,管道寫的工作非常簡單,只涉及“管道寫相關”部分:

每收到一個packet,就將in中收到的packet映象寫入mirrorOut;對於下游節點,仍然要走一遍當前節點的流程

另外,BlockReceiver#shouldVerifyChecksum()也發揮了作用:管道的中間節點在落盤前不需要校驗

向上遊管道響應ack:PacketResponder執行緒

非同步傳送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的狀態轉換為FINALIZE,並關閉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執行緒如何處理以管道的方式響應ack,此處簡單複習,關注ack與pkt的關係。

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

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

一道面試題

早上碰巧看到一道面試題:

1個節點傳送100G的資料到99個節點,硬碟、記憶體、網路卡速度都是1G/s,如何時間最短?

猴子有篇筆記裡分析了“管道寫”技術的優勢。如果熟悉HDFS中的“管道寫”,就很容易解決該題:

單網路卡1G/s,那麼同時讀寫的速度最大500M/s。假設硬碟大於100G,記憶體大於1G,忽略零碎的建立管道、響應ack的成本,管道寫一個100G大小的資料塊,至少需要100G / (500M/s) = 200s

能不能繼續優化呢?其實很容易估計,看叢集中閒置資源還有多少。在管道寫的方案中,兩個節點間的頻寬上始終佔著500M資料,因此,只有管道中的頭節點與尾節點剩餘500M/s的頻寬,其他節點的頻寬都已經打滿。因此,已經無法繼續優化。

如果題目的資源並沒有這麼理想,比如硬碟讀800M/s,寫200M/s,那麼明顯管道寫的速度最高也只能到200M/s,其他資源和假設不變,則至少需要100G / (200M/s) = 500s。當然,實際情況比這裡的假設要複雜的多,管道寫的最大好處在於效能平衡,讓每個節點的資源佔用相當,不出現短板才可能發揮最大的優勢。

  • 忘記題目描述網路卡1G/s,還是頻寬1G/s。如果是後者,那麼速度快一倍,至少需要100s。
  • 題目還要求寫出偽碼。如果不考慮容錯性,完全可以按照這兩篇文章的分析,剝離出主幹程式碼完成題目,猴子就不囉嗦了。

總結

引用一張圖做總結:

image.png

瞭解了管道寫的正常流程,下文將分析管道寫中的部分錯誤處理策略。


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

相關文章