上一篇原始碼|HDFS之DataNode:寫資料塊(1)分析了無管道無異常情況下,datanode上的寫資料塊過程。本文分析管道寫無異常的情況,假設副本系數3(即寫資料塊涉及1個客戶端+3個datanode),未發生任何異常。
原始碼版本:Apache Hadoop 2.6.0
本文內容雖短,卻是建立在前文的基礎之上。對於前文已經說明的內容,本文不再贅述,建議讀者按順序閱讀。
開始之前
總覽
根據原始碼|HDFS之DataNode:寫資料塊(1),對於多副本的管道寫流程,主要影響DataXceiver#writeBlock()、BlockReceiver#receivePacket()、PacketResponder執行緒三部分。本文按照這三個分支展開。
文章的組織結構
- 如果只涉及單個分支的分析,則放在同一節。
- 如果涉及多個分支的分析,則在下一級分多個節,每節討論一個分支。
- 多執行緒的分析同多分支。
- 每一個分支和執行緒的組織結構遵循規則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()才開始管道寫資料塊內容,結合管道的關閉過程,可知管道的生命週期分為三個階段:
- 管道建立:以管道的方式向下遊傳送管道建立的請求,從下游接收管道建立的響應。
- 管道寫:當客戶端收到管道建立成功的ack時,才利用剛剛建立的管道開始管道寫資料塊的內容。
- 管道關閉:以管道的方式向下遊傳送管道關閉的請求,從下游接收管道關閉的響應。
如圖說明幾個引數:
- 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執行緒的核心工作如下:
- 接收下游節點的ack
- 比較ack.seqno與當前隊頭的pkt.seqno
- 如果相等,則向上遊傳送pkt
- 如果是最後一個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。
- 題目還要求寫出偽碼。如果不考慮容錯性,完全可以按照這兩篇文章的分析,剝離出主幹程式碼完成題目,猴子就不囉嗦了。
總結
引用一張圖做總結:
瞭解了管道寫的正常流程,下文將分析管道寫中的部分錯誤處理策略。
本文連結:原始碼|HDFS之DataNode:寫資料塊(2)
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。