Apache HDFS 存储引擎深度工程实战:从 NameNode 元数据内存模型、EditLog/FsImage、租约与块报告到写入管线恢复与短路读全链路
执行摘要
在对象存储与云原生文件系统的声浪里,HDFS 常被当作"上一代"的产物。但现实是:Hive、Spark、Iceberg、Hudi 之下,最大的那批数据湖仍然躺在 HDFS 上。而且 HDFS 的工程矛盾极其尖锐——一个进程要同时承载亿级文件的命名空间、每秒数万个块的位置变更请求、还不能成为单点。
本文不罗列 hdfs dfs 命令,而是拆开四个核心:NameNode 的全内存元数据模型与 EditLog/FsImage 双文件持久化、基于 Quorum Journal 的多数派 HA 与 fencing、写路径上的租约与管线恢复(pipeline recovery)、以及读路径上的短路读(SCR)与块报告的最终一致性。最后给出生产故障模式与排查清单。
一、为什么命名空间必须全内存:INode 树与 BlocksMap 的分离
HDFS 最反直觉的设计决策是:NameNode 把整棵命名空间树常驻堆内存,而且不写数据库。
INodeDirectory/INodeFile构成一棵树,父节点持有子节点的引用,路径解析就是一次树遍历。INodeFile里存该文件被切成的BlockInfo列表;BlockInfo再指向BlockInfoContiguous,后者挂着一个triplets[]数组——这是块到 DataNode 的映射(BlocksMap)。- 关键点:
BlocksMap不落盘。FsImage 里只存 INode 树和每个文件的块 ID 列表,存哪个 DataNode 上一概不记。
这就是 DataNode 启动时要做全量块报告的原因:NameNode 重启后命名空间恢复了,但副本位置信息是空的,必须等所有 DataNode 汇报上来才能退出安全模式。把位置信息排除在持久化之外,代价是启动慢,收益是副本位置变更完全不需要写磁盘——一个 3 副本的块在机器宕机后被重新复制,只是一次内存里的指针改写,一条 EditLog 都不用写。
内存估算的工程经验值:单个文件 INode 约 150~200 字节,单个块 BlockInfo 约 150 字节,加上副本 triplet。1 GB 堆大致对应 100 万个块。一个 5000 万块的中等集群就需要接近 8 GB 堆,这正是 Federation(按目录切分命名空间给多个 NameNode)存在的根本理由——不是性能问题,是堆的物理上限。
// 简化的 INode 抽象:注意 parent 反查与 BlockInfo 关联
public abstract class INode implements INodeAttributes {
private final long id; // inode id,FsImage 里用它做引用
private byte[] name; // 仅存分量,不存全路径 —— 省内存的关键
private INodeDirectory parent;
// INodeFile 特有
private BlockInfoContiguous[] blocks; // 该文件的所有块,按偏移有序
}
// 块到副本位置的映射:triplets 是扁平数组,每 3 个 slot 描述一个副本
// triplets[3*i] = DatanodeStorageInfo
// triplets[3*i+1] = 该 DataNode 上前一个 block(双向链表)
// triplets[3*i+2] = 该 DataNode 上后一个 block
注意那个 triplets 扁平数组:它不是一个 List<DatanodeStorageInfo>。每个 DataNode 上的所有块还被串成一条双向链表,这样某台机器全部块报告失效时可以 O(1) 摘链,而不用扫描整个 BlocksMap。这是纯为"机器宕机"这个高频操作做的内存布局优化。
二、EditLog 与 FsImage:一个 WAL 加一个检查点
命名空间的持久化只有两个东西:
- FsImage —— 某一时刻命名空间树的完整序列化快照,是检查点。
- EditLog —— 自那个快照以来的所有变更操作(OP_ADD、OP_CLOSE、OP_SET_REPLICATION...),是WAL。
启动流程:加载 FsImage 到内存,然后重放 EditLog 直到末尾,命名空间即恢复。只要 EditLog 无限增长,启动就会无限变长,所以必须有 checkpoint 机制把 EditLog 合并回 FsImage。
在非 HA 时代这是 SecondaryNameNode 的活(名字极具误导性,它不是备机,只是个"定期做 checkpoint 的搬运工")。HA 时代由 Standby NameNode 承担:它默认开启 dfs.ha.standby.checkpoints=true,定期(默认 1 小时或 100 万条事务)从 JournalNode 拉取 edits,在本地合并出新 FsImage,再 PUT 回 Active 的 fsimage 目录。Active 自己不做 checkpoint——这是把 CPU 和 IO 开销转移出去的经典手法。
# 离线解析 FsImage 为 CSV,做容量治理(找出小文件元凶)
hdfs oiv -p Delimited -delimiter "," \
-i /data/nn/current/fsimage_0000000000001234567 \
-o /tmp/fsimage.csv
# 统计目录文件数分布,定位需要 compaction 的分区
awk -F, 'NR>1 {n=split($1,a,"/"); c[a[1]"/"a[2]"/"a[3]]++} END {for (k in c) print c[k], k}' \
/tmp/fsimage.csv | sort -rn | head -20
# 查看 EditLog 的人类可读形式(排查"某个目录被谁删了")
hdfs oev -i edits_0000000000001234-0000000000001250 -o /tmp/edits.xml -p xml
一个常被忽略的生产约束:FsImage 保存期间是用临时文件写再 rename 的,所以 dfs.namenode.name.dir 必须留足两倍镜像的空间。镜像 40 GB 的集群,单个 name dir 至少要 100 GB,否则 checkpoint 会失败并静默地让 EditLog 无限堆积。
三、QJM:EditLog 的多数派写入与 fencing
HA 方案里,共享存储的选择决定了整个系统的正确性边界。HDFS 官方用 Quorum Journal Manager(QJM):2N+1 个 JournalNode(通常 3 或 5),Active 把每条 edit 同步写给他们,收到多数派 ACK 即认为提交。
QJM 用 epoch number 做 fencing,这一点值得单独说:
- 每次 NameNode 转为 Active,先向 JN 多数派发起
newEpoch(N+1),JN 记录"我见过的最大 epoch 并承诺不再接受更小 epoch 的写入"。 - 旧 Active(比如被网络分区隔离的那个)如果还想写 edit,会带着旧 epoch 过来,被 JN 直接拒绝——它自己就发现自己不再是 Active 了。
这是典型的存储层 fencing,比"用 SSH 把对方 kill 掉"那种 STONITH 更可靠:fencing 不依赖外部动作的及时性,被隔离者本来就无法完成写入。它和 Raft 的区别在于:Raft 要求日志匹配、有 leader 选举、状态机一致;QJM 只做"这一条 edit 是否已被多数派持久化",没有日志追赶逻辑,因为追赶由 NameNode 自己从 JN 拉 edits 完成。职责边界刻意切得很窄。
<!-- hdfs-site.xml:HA 的最小正确配置骨架 -->
<property><name>dfs.nameservices</name><value>mycluster</value></property>
<property><name>dfs.ha.namenodes.mycluster</name><value>nn1,nn2</value></property>
<property>
<name>dfs.namenode.shared.edits.dir</name>
<value>qjournal://jn1:8485;jn2:8485;jn3:8485/mycluster</value>
</property>
<!-- 必须配,否则旧 Active 不会被 fencing,存在脑裂双写风险 -->
<property>
<name>dfs.ha.fencing.methods</name>
<value>shell(/bin/true)</value>
</property>
那个 shell(/bin/true) 是最容易埋雷的地方:很多人抄文档配了"什么都不做"的 fencing 方法。在 QJM 模式下它勉强可用(因为 JN epoch 已经兜底),但一旦将来切到 NFS 共享存储,就会真脑裂。正确做法是配 sshfence 加 shell 二级回退。
四、写路径:租约、Packet 与管线恢复
一次 FSDataOutputStream.write() 的实际路径远比"写三个副本"复杂。
4.1 租约(Lease)—— 单写入者语义
NameNode 用 LeaseManager 保证一个文件同一时刻只有一个写入者。客户端 create() 时拿到租约,之后必须持续续约(默认由 DFSClient 的心跳隐式完成)。
- 软超时 60 秒(
dfs.namenode.lease-recheck-interval-ms检查):到期后其他客户端不能抢占。 - 硬超时 60 分钟:到期后 NameNode 强制回收(Lease Recovery)。
回收过程是个小型协议:NameNode 选出该文件所有块里最新副本所在的 DataNode 作为 primary,让它向其余副本发起 recoverBlock,比对各副本的 Generation Stamp(GS,世代戳) 与长度,选最长的合法副本为准,把短的截断补齐,最后向 NameNode 汇报最终长度并关闭文件。Generation Stamp 才是 HDFS 判断"哪个副本是新的"的真正依据,而不是时间戳。
4.2 Packet 与三层管线
DFSOutputStream 内部是经典的三队列结构:
write(bytes) → dataQueue(待发送) → ackQueue(已发送待确认) → 收到 ACK 则移出
- 数据按 chunk(512 B) + checksum(4 B) 组装,每 127 个 chunk 打成一个 packet(约 64 KB)。
- packet 顺着管线
client → DN1 → DN2 → DN3逐级流式转发,不是 client 同时写三份。这样 client 的出口带宽只有一份,机架内带宽被复用。 - DN3 回 ACK 给 DN2,DN2 给 DN1,DN1 给 client。client 收到整个 packet 的 ACK 才从 ackQueue 移除。
4.3 管线恢复:三种失败的分级处理
这才是 HDFS 写路径最见功力的地方。管线中途某台 DataNode 挂了,不回滚整体写入,而是分级处理:
- Stage 1 — 管线关闭:client 立刻停止写入,把 ackQueue 里的 packet 全部退回 dataQueue 头部(保证不丢已写数据),向 NameNode 申请新的 Generation Stamp。
- Stage 2 — 重建管线:NameNode 挑一个正常的 DataNode 替换坏节点,新管线用新的 GS 建立。关键点:参与过旧管线的节点,其块被打了"旧 GS"标记,因此旧 GS 的数据不会被误认为有效——用 GS 做逻辑隔离,替代物理删除。
- Stage 3 — 数据补齐:DataNode 之间做 transfer,把已落盘的部分同步到新节点。此时 primary DN 可能要做长度对齐。
这里有个生产上经常吃到的坑:hflush() 与 hsync() 的语义完全不同。
FSDataOutputStream out = fs.create(new Path("/data/x.log"));
out.write(bytes);
out.hflush(); // 只保证数据到达所有 DataNode 的 *内存*,未 fsync 到磁盘
// 全部 DN 同时断电 → 数据丢失
out.hsync(); // 保证数据到达所有 DataNode 的 *磁盘*(DN 端 forceSync)
// 单台 DN 断电也不丢,但吞吐可能掉一个数量级
// 生产建议:HBase WAL 这类场景用 hsync;日志采集场景用 hflush 即可
HBase 的 WAL 是 hsync 的重度用户,这也是为什么"给 HBase 换了 DN 磁盘策略"能直接改变写入 TPS。
五、块报告:为什么副本视图是最终一致的
NameNode 里"某块在哪几台机器上"这个视图,从来不是强一致的,而是靠 DataNode 汇报维系:
| 机制 | 周期 | 内容 | 代价 |
|---|---|---|---|
| 全量块报告(FBR) | 默认 6 小时(dfs.blockreport.intervalMsec) | 该 DN 上所有块的列表 | 大 DN 上单次报告可能数百万条 |
| 增量块报告(IBR) | 默认 100 毫秒批量(dfs.blockreport.incremental.intervalMsec) | 只报最近新增/删除/完成接收的块 | 轻量,承载了绝大多数变更 |
真正的工程难点在于:处理块报告要在 FSNamesystem 全局写锁内完成。一个存了 50 万块的 DataNode 做一次 FBR,可能把 NameNode 的全局锁持有数秒,期间所有元数据操作(create、getBlockLocations、甚至读)全部堵塞。这是 HDFS 名声在外的"NameNode 重启后长时间卡顿"的主因之一。
社区的解法一路演进:把 FBR 切分成多次 RPC 分批上报(dfs.blockreport.split.threshold,默认 100 万条切一批)、引入块报告租约(BR Lease,把全量报告限流到"同一时刻只有有限个 DN 在做")、以及在 3.x 里把部分处理挪出写锁。如果你的集群超过 1000 个 DataNode,这几个参数必须调,默认值是按几百台规模设计的。
# 观察 NameNode 的块报告压力与挂起的复制任务
hdfs dfsadmin -metasave /tmp/meta.txt
grep -iE "Blocks awaiting replication|PendingDeletionBlocks|numBlocks" /tmp/meta.txt
# JMX 关键指标:块报告处理耗时、全局锁持有时间
curl -s "http://nn1:9870/jmx?qry=Hadoop:service=NameNode,name=FSNamesystem" \
| python3 -c "import sys,json; d=json.load(sys.stdin)['beans'][0];
[print(k,v) for k,v in d.items() if 'BlockReport' in k or 'Lock' in k]"
六、短路读:绕过 DataNode 进程直读本地盘
默认读路径是 client → DataNode(进程) → 磁盘,即便 client 和 DataNode 在同一台机器上,也要走一次 TCP 加两次用户态/内核态拷贝。对 Spark、HBase 这类"计算跟着数据走"的场景,这个开销是纯浪费。
短路读(Short-Circuit Read, SCR) 的做法:
- client 读取时通过
DomainSocket(Unix domain socket,路径由dfs.domain.socket.path指定)向本地 DataNode 请求块文件描述符。 - DataNode 做权限校验后,把打开的文件描述符(fd)通过
SCM_RIGHTS传给 client。 - client 拿到 fd 后直接 pread 本地文件,完全绕过 DataNode 进程。
<!-- 短路读的完整配置(少一个都不生效) -->
<property><name>dfs.client.read.shortcircuit</name><value>true</value></property>
<!-- 必须是 /var/lib 这类非 /tmp 目录,且父目录只有 root/hdfs 可写 -->
<property>
<name>dfs.domain.socket.path</name>
<value>/var/lib/hadoop-hdfs/dn_socket</value>
</property>
<!-- 3.x 起推荐:用 mmap 共享内存传递 fd 槽位,进一步减少开销 -->
<property><name>dfs.client.read.shortcircuit.streams.cache.size</name><value>4096</value></property>
这里有个安全边界必须讲清楚:把 fd 交给客户端等于把文件的直接访问权限交出去,所以 DataNode 只在能确认对方身份时才做。Kerberos 未开启时,靠的是 Unix 用户与 dfs.block.local-path-access.user 白名单;这也是为什么很多生产集群开了短路读后,HBase 的 RegionServer 必须精确落在 hdfs 组里,否则会静默退回普通路径——性能下降了但没有任何报错,是最难查的一类问题。
七、生产故障模式与排查清单
| 故障现象 | 根因 | 排查与处置 |
|---|---|---|
| NameNode 长期处于 SafeMode 不退出 | DN 块报告未到齐,或副本率低于阈值 | hdfs dfsadmin -safemode get;检查 Datanode 存活数与 missed blocks;临时 safemode leave 只会让丢块数据永久不可用 |
客户端写入报 AlreadyBeingCreatedException | 前一个 writer 的租约尚未释放(软超时 60s 内) | hdfs fsck <path> -files -blocks -locations;等待软超时或触发 lease recovery |
出现大量 UnderReplicatedBlocks 且持续不掉 | 副本放置策略找不到合法机架(机架配置错误) | hdfs dfsadmin -printTopology 检查机架;确认 net.topology.script.file.name 输出正确 |
| 写入极慢,ackQueue 长期满 | 管线中某 DN 磁盘慢或网络丢包,拖累整条管线 | 看 DN 的 SlowPeerReports;把 dfs.datanode.transfer.socket.timeout 减半,加快坏节点剔除 |
| EditLog 目录持续膨胀 | 无 Standby 做 checkpoint,或 checkpoint PUT 失败 | 确认 Standby 存活且 dfs.ha.standby.checkpoints=true;检查 name dir 剩余空间是否小于 2 倍 fsimage |
| JN 之间 epoch 不一致导致 Active 起不来 | 多数派 JN 宕机或磁盘满 | dfs.journalnode.edits.dir 单独挂盘;至少 3 JN 且分布在不同机架 |
| 小文件导致 NameNode 频繁 Full GC | 文件数而非数据量耗尽堆 | hdfs oiv 定位小文件目录;上游做 compaction、合并成 SequenceFile 或迁 Iceberg |
八、结论
HDFS 的设计取舍高度自洽,一句话概括是:用"位置信息不持久化 + 全内存命名空间"换取元数据写路径的极致简单,用"租约 + 世代戳 + 多数派 EditLog"把并发与故障的正确性兜住,用"管线恢复 + 短路读"把数据路径的性能找回来。
落到工程实践,真正要记住的是这几条:
- 容量规划看的是文件数和块数,不是数据量。 "1 GB 堆 ≈ 100 万块"的换算必须刻在脑子里,超过就上 Federation。
- EditLog 的健康比 FsImage 重要。 Standby 不工作的 HA 是假 HA。
hflush不等于hsync。 是否要 fsync 到磁盘,取决于你能不能接受整机断电丢数据,这是个业务问题不是配置问题。- 块报告期间持全局锁是大集群最隐蔽的抖动源。 超过千台 DataNode 必须调 split 与 BR Lease 参数。
理解了这套约束,再去看 Ceph 的 CRUSH、JuiceFS 的元数据引擎、或者对象存储的最终一致性,会发现它们只是把同一个取舍放在了坐标系的不同位置而已。

发表评论 取消回复