看完Client端的错误恢复和追加写, 也就是pipeline恢复 的过程后, 再来看看Namenode端的相关异常处理, 不过这部分内容不多, 放最后简要说下
主要篇幅是介绍一个, 与写流程间相对独立, 但在整个HDFS和分布式文件系统中很重要的概念租约 , 以及由NN主导的触发租约恢复 的概念
文档正在完善ing, 主要是补图…
0x00. 租约の概念 租约的概念形象说就像读写锁 里面带有过期时间(TTL)的“写锁” , 也就是说一个文件在写入的时候仅有一个载体能够操作它. 它实现比较简单, 但是不可或缺.
核心对象有两个 :
Lease Manager (包租婆): 它记录着所有准备写文件的client(借租的记录) [Server ]
Monitor (看门狗): 定时检查租约是否过期, 默认2s检查一次
Lease (租约): 租约的本体(数据结构), 维护”债主名”, “最后续约时间” , “此债主借的所有文件”三个参数
Lease Renewer (续约器): client使用它来维系与NN的租约关系, 超过30s就请求NN更新一下 [Client –> NN ]
它有两个超时时间: (不可调整)
软超时 (1min): 超过1min原客户端没有续租(租房合同到期), 那么此时其他客户端(房客)就可让此文件进入租约恢复, 然后获得它的租约(入住)
硬超时 (1h): 租约软超时后如果一直没有 客户端来写(入住)此文件, 那它不会自动释放, 而是一直等到超过1h后, 则由NN(房产局)拥有文件租约并进入租约恢复, 完成后释放租约.
这两个超时概念需要清晰理解, 一个文件如果触发硬超时, 那必然是某种原因(比如Client挂了)先触发了软超时, 然后超时之后仍然长时间没人尝试重新写它.
上面的关键在于LeaseManger和Lease的数据结构, 搞清楚它们的关系, 后面理解就容易许多, 下面引用一张美团Tech 画的挺好的图:
其中sortedLeases
是一个按时间先后 排序的集合, 第一个元素就是未更新租约时间最长的.
然后租约本身的数据结构中, 维护了一个Set<INodeId>
, 从而对应多个文件. 所以在后面移除租约的时候, 会把对应的所有文件都依次移除 . 细节可参考源码
0x01. 租约の管理 搞清楚租约和包租婆之间的关系后, 这个部分内容比较简单, 主要就是针对租约的CURD(增删改查)操作的实现, 当然也是基础 (其中增删 的代码就不说了)
A. 租约添加 添加租约的情况比较简单, 写/追加写文件, 会在租约管理器里添加该文件(INode)的映射记录, 暂略
B. 租约检查 检查租约分两类 情况, 一是租约管理器每2s 定时检查所有租约(自动), 另一种是特定操作在NN执行之前会先去检查租约是否正常(手动). 先看看定时检查 逻辑:
每2s查看一下按时间排序的Set集合, 看看第一个租约元素是否硬超时 (1h)
如果超时, 尝试逐个 恢复此租约下的所有文件
如果恢复失败, 先跳当前文件, 继续对此租约下剩余文件 进行租约恢复. (路径非法的文件会被直接移除 租约)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 synchronized boolean checkLeases () { boolean needSync = false ; long start = monotonicNow(); while (!sortedLeases.isEmpty() && sortedLeases.first().expiredHardLimit() && !isMaxLockHoldToReleaseLease(start)) { Lease leaseToCheck = sortedLeases.first(); final List<Long> removing = new ArrayList<>(); Collection<Long> files = leaseToCheck.getFiles(); Long[] leaseINodeIds = files.toArray(new Long[files.size()]); FSDirectory fsd = fsnamesystem.getFSDirectory(); String p = null ; String newHolder = getInternalLeaseHolder(); for (Long id : leaseINodeIds) { try { INodesInPath iip = INodesInPath.fromINode(fsd.getInode(id)); p = iip.getPath(); validatePath(p); final INodeFile lastINode = iip.getLastINode().asFile(); if (fsnamesystem.isFileDeleted(lastINode)) { removeLease(lastINode.getId()); continue ; } boolean completed = false ; try { completed = fsnamesystem.internalReleaseLease(leaseToCheck, p, iip, newHolder); } catch (IOException e) { LOG.warn("Cannot release the path xx in lease xx. Retry later." ); continue ; } if (!needSync && !completed) needSync = true ; } catch (IOException e) { removing.add(id); } if (isMaxLockHoldToReleaseLease(start)) break ; } for (Long id : removing) removeLease(leaseToCheck, id); } return needSync; }
C. 租约删除 知道一个文件什么时候租约会被删除, 或者释放是很重要的, 因为这涉及到没有正常释放 的进入租约恢复的情况
完成文件(complet file)后
删除文件 (包括重命名)
文件或路径不存在导致租约恢复失败后
D. 租约恢复(*) 不管是软超时导致的租约持有者变更, 还是硬超时导致的持有者变成Namenode自身, 租约恢复简单说是是对租约的修改 , 它在以下几个地方可能会触发:
硬超时后, Namenode检测到进入错误恢复, 所有权先变成NN自身, 恢复完成再移除
软超时后, 其他客户端调用写/追加写此文件, 会触发租约恢复使文件写权转移
直接RPC调用recoverLease()
, 强制 进行租约恢复释放租约, 此时无视软超时限制. (比如DFSAdmin 释放租约命令)
直接调用truncate-RPC
剔除文件部分数据时 (FShell 的新命令)
其中第一种情况是NN后台自动触发, 属于一类; 其他的情况都是Client手动触发, 属于另一类, 先来看看这一类的通用判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 boolean recoverLeaseInternal (params.., String holder, boolean force) { INodeFile file = iip.getLastINode().asFile(); if (file.isUnderConstruction()) { Lease lease = leaseManager.getLease(holder); if (!force && lease != null ) { Lease leaseFile = leaseManager.getLease(file); if (leaseFile != null && leaseFile.equals(lease)) throw new AlreadyBeingCreatedException("xx is already the current lease holder." )); } FileUnderConstructionFeature uc = file.getFileUnderConstructionFeature(); String clientName = uc.getClientName(); lease = leaseManager.getLease(clientName); if (lease == null ) throw new AlreadyBeingCreatedException("the file is UC but no leases found." )); if (force) { return internalReleaseLease(lease, src, iip, holder); } else { if (lease.expiredSoftLimit()) { if (internalReleaseLease(lease, src, iip, null )) { return true ; } else { throw new RecoveryInProgressException("lease recovery is in progress.." )); } } else { final BlockInfo lastBlock = file.getLastBlock(); if (lastBlock != null && lastBlock.getBlockUCState() == UNDER_RECOVERY) { throw new RecoveryInProgressException("another recovery is in progress xx" ); } else { throw new AlreadyBeingCreatedException("Lease already created xx" ); } } } } else { return true ; } }
整体上的 , 对租约的增删改查操作就说完了, 下面开始说其中的细节, 也就是主角—-租约恢复 .
0x02. 租约の恢复 租约恢复是租约里相对最复杂 的部分, 它是一个文件的租约没有被正常续约后超时触发的, 而续约超时, 多半说明写入操作 没有正常完成(中断了), 也意味着
文件本身(INode
)处于中间状态
文件对应3DN上的副本状态可能不一致 , 实际长度不统一
租约没有被正常释放, 无法继续新的写操作
所以如果要继续对这个文件进行操作, 得先让文件和对应的块副本恢复到一个一致的状态, 然后再释放租约, 整个过程 就称之为租约恢复了.
租约恢复通过判断文件块的状态, 大概分为两种情况 :
一种是NN可以自己解决的, 可以认为只是异常处理 (简单)
另一种是需要DN参与进来的, 这是真正意义上的租约恢复 (复杂)
1. 租约恢复(前置) 流程图待补
NN的租约检查线程发现租约异常的文件. 开始逐个遍历检查
检查文件的最后两个block 的状态
如果处于committed, 尝试直接complete它, 然后完成文件, 跳过 后续步骤
如果最后一个block处于UC态, 需要找到对应的DN列表, 然后选择主恢复DN, 带着新的块时间戳 发给主DN
如果处于非法状态, 或者其他异常, 则跳过当前文件恢复, 之后 再重新单独尝试
主DN接收到新的时间戳后, 会向剩余DN发送RPC请求每个DN上的副本信息
然后根据所有DN返回的信息, 计算出一个状态最优 的, 发RPC让所有DN统一
完成后主DN给NN回复这次块恢复的结果
正常, NN更新块的信息, 然后移除自己持有的租约
异常, 说明此次恢复失败, 然后怎么感知呢? 恢复失败的文件如何处理呢? (这里有疑问)
上面说的两种租约恢复情况, 都在NN的一个方法中进行判断, 简单情况NN直接处理, 其他需要DN参与, 代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 boolean internalReleaseLease (param.., String recoveryLeaseHolder) { final INodeFile pendingFile = iip.getLastINode().asFile(); int nrBlocks = pendingFile.numBlocks(); BlockInfo[] blocks = pendingFile.getBlocks(); int nrCompleteBlocks; BlockInfo curBlock = null ; for (nrCompleteBlocks = 0 ; nrCompleteBlocks < nrBlocks; nrCompleteBlocks++) { curBlock = blocks[nrCompleteBlocks]; if (!curBlock.isComplete()) break ; } if (nrCompleteBlocks == nrBlocks) { finalizeINodeFileUnderConstruction(src, pendingFile, iip.getLatestSnapshotId(), false ); return true ; } if (twoBlocksNotComplete(nrCompleteBlocks)) throw new IOException("xxx" ); final BlockInfo lastBlock = pendingFile.getLastBlock(); BlockUCState lastBlockState = lastBlock.getBlockUCState(); BlockInfo penultimateBlock = pendingFile.getPenultimateBlock(); boolean penultimateBlockMinStorage = penultimateBlock == null || blockManager.hasMinStorage(penultimateBlock); switch (lastBlockState) { case COMPLETE: assert false : "Already checked that the last block is incomplete" ; break ; case COMMITTED: if (penultimateBlockMinStorage && blockManager.hasMinStorage(lastBlock)) { finalizeINodeFileUnderConstruction(params..., false ); return true ; } throw new AlreadyBeingCreatedException(message); case UNDER_CONSTRUCTION: case UNDER_RECOVERY: BlockUnderConstructionFeature uc = lastBlock.getUnderConstructionFeature(); BlockInfo recoveryBlock = uc.getTruncateBlock(); boolean truncateRecovery = recoveryBlock != null ; boolean copyOnTruncate = truncateRecovery && recoveryBlock.getBlockId() != lastBlock.getBlockId(); assert !copyOnTruncate || recoveryBlock.getBlockId() < lastBlock.getBlockId() && recoveryBlock.getGenerationStamp() < lastBlock.getGenerationStamp() && recoveryBlock.getNumBytes() > lastBlock.getNumBytes() : "wrong recoveryBlock" ; if (uc.getNumExpectedLocations() == 0 ) uc.setExpectedLocations(lastBlock...); if (uc.getNumExpectedLocations() == 0 && lastBlock.getNumBytes() == 0 ) { pendingFile.removeLastBlock(lastBlock); finalizeINodeFileUnderConstruction(src, pendingFile,iip.getLatestSnapshotId(), false ); return true ; } if (blockManager.addBlockRecoveryAttempt(lastBlock)) { long blockRecoveryId = nextGenerationStamp(blockManager.isLegacyBlock(lastBlock)); if (copyOnTruncate) { lastBlock.setGenerationStamp(blockRecoveryId); } else if (truncateRecovery) { recoveryBlock.setGenerationStamp(blockRecoveryId); } uc.initializeBlockRecovery(lastBlock, blockRecoveryId, true ); lease = reassignLease(lease, src, recoveryLeaseHolder, pendingFile); leaseManager.renewLease(lease); break ; } return false ; }
2. 租约恢复(正式) 这对应上面第二种情况, 需要DN参与的流程, 用时序图来看会很清晰, 如下所示: (图待画 , 这是旧的先占个位)
sequenceDiagram
participant A as Client
participant B as DFSOutputStream
participant C as DataStreamer
participant D as NameNode
%%participant E as DataNode
A->>+B: close()
B->>B: flushBuffer()
alt packet非空
B->>+B: 剩余数据包入队()
B->>-C: waitAndQueuePacket()
activate C
end
B->>+B: 生成空尾包()
B->>-C: 发尾包, 等待ack收尾
deactivate C
B->>D: RPC请求: complete()
Note right of D: 此时NN释放租约
B->>+B: closeThreads()
B->>C: 关闭线程与socket
B->>-B: 客户端停止更新租约
B-->>-A: 写完成
然后来看看, NN部分就做两个事, 一是选出主恢复DN, 二是准备好环境信息随心跳包发送过去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 public void initializeBlockRecovery (params..) { setBlockUCState(UNDER_RECOVERY); blockRecoveryId = recoveryId; if (!startRecovery) return ; if (replicas.length == 0 ) { primaryNodeIndex = -1 ; return ; } boolean allLiveReplicasTriedAsPrimary = true ; for (ReplicaUnderConstruction replica : replicas) { if (replica.isAlive()) { allLiveReplicasTriedAsPrimary = allLiveReplicasTriedAsPrimary && replica.getChosenAsPrimary(); } } if (allLiveReplicasTriedAsPrimary) { for (ReplicaUnderConstruction replica : replicas) replica.setChosenAsPrimary(false ); } long mostRecentLastUpdate = 0 ; ReplicaUnderConstruction primary = null ; primaryNodeIndex = -1 ; for (int i = 0 ; i < replicas.length; i++) { if (!(replicas[i].isAlive() && !replicas[i].getChosenAsPrimary())) continue ; final ReplicaUnderConstruction ruc = replicas[i]; final long lastUpdate = ruc.getExpectedStorageLocation() .getDatanodeDescriptor().getLastUpdateMonotonic(); if (lastUpdate > mostRecentLastUpdate) { primaryNodeIndex = i; primary = ruc; mostRecentLastUpdate = lastUpdate; } } if (primary != null ) { primary.getExpectedStorageLocation().getDatanodeDescriptor() .addBlockToBeRecovered(blockInfo); primary.setChosenAsPrimary(true ); } }
然后最后主DN做完了租约恢复后, 通过发送commitBlockSynchronization
的RPC确认DN块恢复完成, 然后NN正式更新块信息和文件信息, 释放租约, 完成此次租约恢复
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 void commitBlockSynchronization (params...) { final String src; writeLock(); boolean copyTruncate = false ; BlockInfo truncatedBlock = null ; try { final BlockInfo storedBlock = getStoredBlock(ExtendedBlock.getLocalBlock(oldBlock)); if (storedBlock == null ) { if (deleteblock) { return ; } else { throw new IOException("Block (=" + oldBlock + ") not found" ); } } final long oldGenerationStamp = storedBlock.getGenerationStamp(); final long oldNumBytes = storedBlock.getNumBytes(); if (storedBlock.isDeleted()) { throw new IOException("The blockCollection of " + storedBlock + " is null, likely because the file owning this block was" + " deleted and the block removal is delayed" ); } final INodeFile iFile = getBlockCollection(storedBlock); src = iFile.getFullPathName(); if (isFileDeleted(iFile)) throw new FileNotFoundException("likely due to delayed block removal" ); if ((!iFile.isUnderConstruction() || storedBlock.isComplete()) && iFile.getLastBlock().isComplete()) { return ; } truncatedBlock = iFile.getLastBlock(); final long recoveryId = truncatedBlock.getUnderConstructionFeature() .getBlockRecoveryId(); copyTruncate = truncatedBlock.getBlockId() != storedBlock.getBlockId(); if (recoveryId != newgenerationstamp) { throw new IOException("The recovery id " + newgenerationstamp + " does not match current recovery id " + recoveryId + " for block " + oldBlock); } if (deleteblock) { Block blockToDel = ExtendedBlock.getLocalBlock(oldBlock); boolean remove = iFile.removeLastBlock(blockToDel) != null ; if (remove) { blockManager.removeBlock(storedBlock); } } else { if (!copyTruncate) { storedBlock.setGenerationStamp(newgenerationstamp); storedBlock.setNumBytes(newlength); } final DatanodeStorageInfo[] dsInfos = blockManager.getDatanodeManager(). getDatanodeStorageInfos(newDNInfo...); if (closeFile && dsInfos != null ) { for (int i = 0 ; i < dsInfos.length; i++) { if (dsInfos[i] != null ) { if (copyTruncate) { dsInfos[i].addBlock(truncatedBlock, truncatedBlock); } else { Block bi = new Block(storedBlock); if (storedBlock.isStriped()) { bi.setBlockId(bi.getBlockId() + i); } dsInfos[i].addBlock(storedBlock, bi); } } } } if (copyTruncate) { iFile.convertLastBlockToUC(truncatedBlock, dsInfos); } else { iFile.convertLastBlockToUC(storedBlock, dsInfos); if (closeFile) { blockManager.markBlockReplicasAsCorrupt(oldBlock.getLocalBlock(), storedBlock, oldGenerationStamp, oldNumBytes, dsInfos); } } } if (closeFile) { if (copyTruncate) { closeFileCommitBlocks(src, iFile, truncatedBlock); if (!iFile.isBlockInLatestSnapshot(storedBlock)) { blockManager.removeBlock(storedBlock); } } else { closeFileCommitBlocks(src, iFile, storedBlock); } } else { FSDirWriteFileOp.persistBlocks(dir, src, iFile, false ); } blockManager.successfulBlockRecovery(storedBlock); } finally { writeUnlock("commitBlockSynchronization" ); } getEditLog().logSync(); }
0x03. 客户端の租约
由于租约这块需要整体理解, 所以并没有在第一篇Client篇单独讲客户端写入社会的续约租约相关操作, 一并放在此说.
对客户端而言, 其实要做的就是获得租约, 然后定期去续约 , 如果超时没有续约, 那就可能触发租约过期 , 搞清楚这两个概念, 再来看看具体实现
这是客户端LeaseRenewer
这的核心代码. 主要就是续约 操作, 比较简单.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 private void beginFileLease (final long inodeId, final DFSOutputStream out) { synchronized (filesBeingWritten) { putFileBeingWritten(inodeId, out); getLeaseRenewer().put(this ); } } public synchronized void put (final DFSClient dfsc) { if (dfsc.isClientRunning()) { if (!isRunning() || isRenewerExpired()) { final int id = ++currentId; daemon = new Daemon(new Runnable() { @Override public void run () { try { LeaseRenewer.this .run(id); } catch (InterruptedException e) { } finally { synchronized (LeaseRenewer.this ) { Factory.INSTANCE.remove(LeaseRenewer.this ); } } } }); daemon.start(); } emptyTime = Long.MAX_VALUE; } } private void run (final int id) throws InterruptedException { for (long lastRenewed = Time.monotonicNow(); !Thread.interrupted(); Thread.sleep(getSleepPeriod())) { final long elapsed = Time.monotonicNow() - lastRenewed; if (elapsed >= getRenewalTime()) { try { renew(); lastRenewed = Time.monotonicNow(); } catch (SocketTimeoutException ie) { List<DFSClient> dfsclientsCopy; synchronized (this ) { DFSClientFaultInjector.get().delayWhenRenewLeaseTimeout(); dfsclientsCopy = new ArrayList<>(dfsclients); dfsclients.clear(); emptyTime = 0 ; Factory.INSTANCE.remove(LeaseRenewer.this ); } for (DFSClient dfsClient : dfsclientsCopy) { dfsClient.closeAllFilesBeingWritten(true ); } break ; } catch (IOException ie) {....} } if (!clientsRunning() && emptyTime == Long.MAX_VALUE) { emptyTime = Time.monotonicNow(); } } } }
0x04. Namenode的错误恢复 因为相对租约恢复来说, 上一篇追加写和错误恢复 中, 涉及到Namenode这部分的内容相对简单许多, 就放这里一起简单说一嘴, 后续有需要再补详细.
1. abandonBlock 同样实际代码调用在FSDirWriteFileOp
类中, 逻辑很简单, 就不贴代码和流程图了, 只是将当前空的block从INode关联和BlockMap两个映射里删掉, 就没了.
2. updateBlockForPipeline 这个RPC是为当前block生成一个新的时间戳/Token, 逻辑也很简单, 如果blockId是随机生成就给随机, 如果是顺序增加则时间戳在原有基础上+1, 然后通过BlockManager生成一个新的Block对象 , 返回给客户端
3. updatePipeline 这个RPC和上面的分配新的时间戳搭配使用, 它会检查分配的新时间戳是否合理(比旧的大). 然后在blockManager中替换掉旧的Block.
4. getAdditionalDatanode 核心是调用BlockManager的chooseTarget4AdditionalDatanode()
方法, 然后底下也是调用addBlock时的选择DN的逻辑, 在这不重复说了.
5. append 相比而言, 追加写在Namenode这端做的操作要多一些, 并且因为它涉及到block状态的变更, 所以导致问题的可能性也大一些.
0x05. 疑问 租约这里的疑难点基本都在异常情况下, 也就是租约恢复仍然失败 的情况, 整个流程可能无法形成闭环, 所以之后需要确认几个大点.
1. 租约恢复的死循环 我们上面讲述的租约恢复流程, 基本是说的普通情况, 但是举个最简单的例子:
在租约恢复大流程的时候, 如果失败抛出异常, 外层的方法catch住后直接跳过 了当前文件继续对当前租约对应的其他文件进行租约恢复, 之后这个恢复失败被跳过的文件还会重新进入 错误恢复的过程, 那问题就来了.
这个租约恢复失败的文件原本就是排序Set的第一个, 也就是每次都会先把它读出来, 它一直恢复失败重新恢复, 后面的文件不就都无法正常操作了?
从而引出另一个问题, 租约恢复的线程是只有1个么? 这个过程又加了锁, 那大集群这样一个个排队等也很慢吧.
如果是, 但是仍然报错, 比如副本数一直无法达到最低要求, 那不还是会触发死循环 么? (早期HDFS版本就有这个问题, 且没有很好解决)
2. 租约被强制移除后的文件状态 不同于``HDFS3.1的做法,
HDFS2.X`版本的做法里, 一个文件如果租约恢复异常, 则会被直接删除租约, 这样似乎至少不会导致上述的死循环发生
但是又会引出一个新的棘手问题, 一个处于中间状态 的文件, 它的块可能处于UB状态, 此时直接duang一下删除它的租约后, 这个文件能被正常读写 么?
如果能, 岂不是读写的时候DN状态也不一致…而且读多少呢? 还是说文件本身也会在读写异常时被删掉 ? 那似乎也不太合理… 而且既然高版本取消了这个做法, 说明应该是有所考虑的, 可以参考一下相关JIRA
问题..
补充一下HDFS官方blog 的引用, 就可以看出租约恢复和写异常中断就有一系列已被确认和未被修复的问题
下一篇是追加写/错误恢复, 以及租约部分对应的Datanode篇 , 也是收尾篇了, 内容同样也很多.
参考资料:
HDFS3.1.2官方源码
Hadoop 2.X HDFS源码剖析-Chap.3.4
Cloudera-HDFS官方blog-part2
Namenode内存结构