上一篇把 Hadoop 整体压成一句话:以"节点总会失败"为前提,HDFS 切大文件、YARN 切资源、MapReduce 切计算。本篇进入 HDFS。

HDFS 常被介绍为"分布式文件系统"。这个说法没错但不够准确。准确的说法是:HDFS 是一个把元数据、数据块、节点监控切成三层独立职责的分布式文件系统。三层职责分离是 HDFS 设计的核心抽象,决定了 HDFS 的所有后续行为——为什么元数据全内存、为什么写文件要 Pipeline、为什么读文件可以就近选副本、为什么 NameNode 是单点。

本篇只抓一个问题:NameNode、DataNode、客户端这三层职责是怎么切开的,以及这种切法为什么会让 HDFS 长成现在的样子。

三层职责切分

HDFS 的架构可以用一张三层图概括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────────────────────────────────────────────┐
│ NameNode(元数据层) │
│ - 文件名 → block 列表的映射 │
│ - block → DataNode 列表的映射 │
│ - 全部驻留内存(FSImage + EditLog 落盘) │
│ - 单一节点(HA 部署为 Active / Standby) │
└─────────────────────────────────────────────────────────────────┘
▲ │
│ 心跳 + 块报告 │ 块操作指令
│ (每 3 秒) │ (创建/删除/复制)
▼ ▼
┌──────────────────────┬──────────────────────┬────────────────────┐
│ DataNode-1 │ DataNode-2 │ DataNode-3 │
│ blk-001 (replica 1) │ blk-001 (replica 2) │ blk-001 (replica 3)│
│ blk-002 (replica 1) │ blk-002 (replica 2) │ blk-003 (replica 1)│
│ blk-003 (replica 2) │ blk-004 (replica 1) │ blk-004 (replica 2)│
│ 本地磁盘存储 │ 本地磁盘存储 │ 本地磁盘存储 │
└──────────────────────┴──────────────────────┴────────────────────┘
▲ │
│ 读 / 写数据 │
│ (直连,不经 NameNode) │
▼ ▼
Client

三层职责分别如下:

NameNode 只管元数据。它知道每个文件被切成哪些块、每个块存放在哪些 DataNode 上、每个文件的目录树结构。NameNode 不参与数据传输,不存储任何用户数据的字节。所有元数据驻留在 NameNode 内存里(具体内存模型第四篇展开),写操作同时追加到磁盘上的 EditLog,定期合并到 FSImage 落盘。

DataNode 只管数据块。它知道自己本地磁盘上存了哪些 block,每个 block 多大,校验和是否正确。DataNode 不知道任何文件级别的概念——它手里的 block 没有文件名,只有一个 64 位的 block ID。DataNode 周期性向 NameNode 发心跳(默认 3 秒)和块报告(默认 6 小时),并执行 NameNode 下发的块操作指令(创建、删除、复制)。

Client 是 HDFS 的应用接口。它要从 HDFS 读文件时,先问 NameNode 拿这个文件对应的 block 列表和每个 block 所在的 DataNode,然后直连 DataNode 传输数据。它要写文件时,先问 NameNode 申请一批 block ID 和目标 DataNode 列表,然后直连 DataNode 写数据。NameNode 不参与数据传输。

这种切分有一个关键推论:HDFS 的元数据路径和数据路径完全分离。元数据路径经过 NameNode,是慢路径但只在打开/关闭文件时走一次;数据路径绕过 NameNode 直接连 DataNode,是快路径,承担所有数据字节传输。

NameNode 为什么要全内存

NameNode 把所有元数据驻留内存这件事是 HDFS 设计里最反直觉的决定。一个 1000 节点集群、10 亿文件的 HDFS 集群,NameNode 内存动辄上百 GB。为什么要把元数据全放进内存?

答案是 HDFS 把元数据访问路径设计成单点同步——任何元数据操作(创建文件、删除文件、查询 block 位置)都必须经过 NameNode。如果元数据放在磁盘上,每次操作都要做磁盘 I/O,在 1000 个 DataNode 同时心跳和成千上万个客户端并发访问的场景下,NameNode 会成为严重瓶颈。把元数据放内存让元数据操作延迟降到微秒级,足够支撑每秒几万次操作。

HDFS 元数据的内存占用大致可以用一组经验值估算(Hadoop 3.3.x):

1
2
3
4
5
6
7
每个文件 / 目录 / block 的 NameNode 元数据 ≈ 150 字节

一个 100GB 大小的文件(切成 781 个 128MB block):
1 个文件条目 + 781 个 block 条目 = 782 × 150 ≈ 117 KB

10 亿个文件(每个 100GB):
10 亿 × 782 × 150 ≈ 117 GB(NameNode 内存)

这就是 HDFS 著名的"小文件问题"。如果同样的 100GB 数据被切成 10 亿个 100 字节的小文件,NameNode 内存占用是 10 亿 × 150 = 150 GB,但实际数据量只有 100GB——元数据比数据本身还大。把小文件合并成大文件(HAR、Sequence File、Hadoop 3.x 的 EC)是 HDFS 运维的核心主题之一。

NameNode 的内存设计也带来一个副作用:元数据修改必须先写 EditLog 才能返回客户端成功,否则 NameNode 宕机会丢元数据。EditLog 是顺序追加写,性能很好,但 NameNode 必须在写完 EditLog 之后才能更新内存表。这个顺序决定了 HDFS 写操作的延迟下限。第四篇会展开 EditLog 与 FSImage 的细节。

DataNode 的两种心跳

DataNode 与 NameNode 之间有两条独立的"心跳"通道,分别承担不同职责:

第一条是 DatanodeProtocol.sendHeartbeat。这条 RPC 默认每 3 秒发一次,主要携带三类信息:DataNode 当前存储容量、当前正在传输的块操作数、DataNode 状态。NameNode 收到心跳后回执里携带一批指令,例如"删除以下 block"、“复制以下 block 到某 DataNode”、“恢复以下损坏 block”。

第二条是 DatanodeProtocol.blockReport。这条 RPC 默认每 6 小时发一次(Hadoop 3.x 默认值,早期版本是 1 小时),把 DataNode 上所有 block 的完整清单一次性发给 NameNode。NameNode 用这份清单校验自己内存里的 block → DataNode 映射表是否准确。如果不一致(比如某个 block 在 NameNode 看来副本数是 3 但 blockReport 显示只有 2),NameNode 会触发对应补救操作。

为什么要分两条?因为这两类信息有完全不同的特性:

1
2
3
4
5
6
7
                    心跳(sendHeartbeat)      块报告(blockReport)
───────────────── ───────────────────── ─────────────────────────
频率 3 秒 6 小时
大小 几十字节 可达几十 MB(百万级 block)
内容 节点状态 完整 block 清单
用途 存活探测 + 即时指令 元数据一致性校验
NameNode 处理代价 微秒级 秒级(要扫表比对)

把存活探测(高频小请求)和一致性校验(低频大请求)拆成两条 RPC,避免了用 3 秒间隔的请求传输百万级 block 清单带来的网络与 CPU 压力。这是分布式系统常见的"控制平面 vs 数据平面"分离,在 HDFS 这里得到了对应:心跳是控制平面,块报告是元数据一致性平面。

NameNode 通过心跳的"最后联系时间"判断 DataNode 是否死亡。默认配置是 10 分 30 秒(dfs.namenode.heartbeat.recheck-interval 默认 5 分钟 × 2 + 心跳间隔 3 秒 × 10 ≈ 630 秒)之后判定为 dead。一旦判定 dead,该 DataNode 上所有 block 被标记为副本数减一,NameNode 调度其他 DataNode 复制补齐到目标副本数。这个延迟是 HDFS 容错机制的固有时间窗口——比 3 秒心跳慢得多,原因是必须避免网络抖动造成的误判。

Client 的元数据获取模式

Client 与 HDFS 交互的标准流程里,元数据路径和数据路径严格分离:

读文件流程:

1
2
3
4
1. Client → NameNode:open("/path/to/file")
2. NameNode 返回:文件被切成 [blk-1, blk-2, blk-3],每个 block 的 DataNode 副本列表
3. Client 拿到列表后,按顺序直连 DataNode 读 blk-1 / blk-2 / blk-3
4. 读完后 Client 关闭连接

注意步骤 2 里 NameNode 返回的不只是 block 列表,还包括每个 block 的副本所在 DataNode 列表。这个列表会按"距离 Client 最近的副本优先"排序(机架感知,第二篇展开)。Client 拿到列表后所有数据传输都直连 DataNode,不再经过 NameNode。

写文件流程:

1
2
3
4
5
6
7
1. Client → NameNode:create("/path/to/file")
2. NameNode 在 EditLog 里记录创建文件 + 在内存表里登记
3. NameNode 返回:DFSOutputStream 句柄
4. Client 调用 write() 写数据
5. DFSOutputStream 攒够一个 block 大小(默认 128MB)后向 NameNode 申请 block ID + 目标 DataNode 列表
6. Client 直连 DataNode Pipeline 写数据
7. 文件关闭时 Client 通知 NameNode,NameNode 持久化文件关闭标记

这种"元数据走 NameNode、数据走 DataNode"的设计,让 HDFS 的吞吐可以线性扩展。1000 节点集群上,1000 个 DataNode 同时承担数据读写,NameNode 只承担元数据 RPC。如果数据也走 NameNode,NameNode 网卡会立刻成为瓶颈。

代价是 NameNode 的元数据 RPC 仍然是单点瓶颈。这是 HDFS 单 NameNode 的固有上限,也是后续 HDFS Federation、Router-based Federation、Observer NameNode 等设计出现的原因(第六篇展开)。

实验:观察 NameNode Web UI

任何一个能访问的 Hadoop 集群,NameNode 默认监听 9870 端口(Hadoop 3.x 起,2.x 是 50070)暴露 Web UI。打开 http://namenode-host:9870 可以看到 NameNode 的运行时状态。

Summary 标签页展示的信息直接对应本篇讨论的几个核心概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Cluster ID:           my-cluster
Block Pool ID: BP-1234567890-namenode-host
Started: Mon Jul 26 09:00:00 CST 2026
Version: 3.3.6

Cluster Summary:
Live Nodes: 3 (Decommissioned: 0) ← 在线 DataNode 数
Dead Nodes: 0 (Decommissioned: 0) ← 心跳超时 DataNode 数
Decommissioning: 0 ← 正在下线 DataNode 数
Total Capacity: 600 GB
DFS Used: 10 GB
Non DFS Used: 50 GB
DFS Remaining: 540 GB
Block Pool Used: 10 GB
Under Replicated Blocks: 0 ← 副本不足的 block 数
Blocks With Corrupt Replicas: 0 ← 副本损坏的 block 数
Missing Blocks: 0 ← 完全丢失的 block 数

Datanodes 标签页列出每个 DataNode 的详细状态:最后心跳时间、容量、块数。如果某个 DataNode 的 Last Contact 字段大于 30 秒,说明该节点心跳延迟,可能即将被判定为 dead。

观察 NameNode Web UI 时有几个值得注意的细节:

Under Replicated Blocks 长期不为 0 是 HDFS 不健康的标志。常见原因包括 DataNode 容量不均(无法分配新副本)、网络分区(部分 DataNode 心跳超时但实际存活)、副本放置策略触发限制(机架感知要求副本跨机架但某个机架满了)。这个数字应该在几分钟内自动归零,否则需要人工介入。

Missing Blocks 长期不为 0 是数据丢失。HDFS 不会自动放弃丢失的 block,但读取这些 block 的请求会失败。运维层面需要从其他备份恢复数据,或者用 hdfs fsck / -delete 清理(这条命令是破坏性操作,必须谨慎)。

Block Pool ID 是 HDFS Federation 引入的概念。一个 NameNode 管理的 block 集合称为一个 Block Pool。Federation 部署里多个 NameNode 各自管理独立 Block Pool,第六篇会展开。

模式提炼

HDFS 的三层职责切分可以提炼成一个可迁移的设计模式:

1
2
3
4
5
6
模式:元数据与数据平面分离 + 单点元数据 + 多点数据

- 把"知道数据在哪里"(元数据)和"存数据本身"(数据)分成两个职责
- 元数据由单一节点承担,全部驻留内存以追求低延迟
- 数据由大量节点承担,每个节点只存独立的数据块
- 客户端访问时先走元数据节点拿位置,再直连数据节点传输

这个模式不只在 HDFS 出现。GFS 用同样的设计(Master + ChunkServer)。MongoDB 的 Config Server + Shard Server 是同一思路在文档数据库上的具体化。Ceph 的 MON + OSD 做了去中心化改造,但本质上还是元数据/数据分离。Kafka 的 Controller + Broker 是同一思路在流式消息上的具体化(Kafka 的元数据规模比 HDFS 小得多,所以 Controller 不需要全内存)。

反过来,分布式数据库(Spanner、CockroachDB、TiDB)选择把元数据和数据混在一起,让每个节点都承担部分元数据 + 部分数据。这种设计的代价是元数据访问延迟变高(要 Raft 共识),收益是元数据本身也分布式的,没有单点。HDFS 选了另一条路——元数据单点,但追求极致元数据访问性能。

工程迁移表

HDFS 概念 GFS Ceph MongoDB Kafka
NameNode(元数据单点) Master MON 集群 Config Server Controller
DataNode(数据节点) ChunkServer OSD Shard (mongod) Broker
Block(大对象切块) Chunk(64MB) Object(可变) Chunk(64MB) Partition Replica
元数据内存镜像 Master 内存 MON 内存 Config Server 内存 Controller 内存
EditLog(操作日志) Operation Log MON Paxos Log Config Server OpLog KRaft Log / ZK
块报告 ChunkServer 报告 OSD PG 报告 chunk version ISR 同步
心跳存活探测 Master → CS MON → OSD CS → mongos Controller → Broker

注意 Kafka 这一列。Kafka 的 Controller 和 HDFS 的 NameNode 在概念上对应,但 Kafka 元数据规模小(只有 topic / partition / ISR),所以 Controller 不需要全内存设计,也不需要 FSImage/EditLog 这种重型持久化机制。这是为什么 Kafka 在 2.8 之后可以直接用 KRaft 替代 ZooKeeper——元数据规模决定了可以采用的算法复杂度。

常见误解

误解一:“NameNode 是 HDFS 的 Master,所以数据也归它管”。NameNode 不存数据字节,只存元数据。任何把数据字节传给 NameNode 的设计都会立刻让 NameNode 网卡成为瓶颈。Client 写数据时 NameNode 只分配 block ID 和 DataNode 列表,然后 Client 直连 DataNode。

误解二:“NameNode 内存越大集群越快”。NameNode 内存决定的是能存多少元数据(即能存多少文件 / block),不是访问速度。如果集群只有 1TB 数据但被切成 10 亿个小文件,NameNode 需要 150GB+ 内存才能装下元数据,但读写速度反而比 10 个大文件慢得多——每个文件打开都要走一次 NameNode RPC。集群快慢的核心是"文件数量 vs 单文件大小"的比例,不是 NameNode 内存本身。

误解三:“HDFS 是强一致文件系统”。HDFS 不是 POSIX 文件系统。一个文件一旦写入并关闭就是 immutable,不允许随机修改(只允许 append)。多个客户端同时写同一个文件在 HDFS 是不允许的(lease 机制会拒绝)。这个限制简化了实现:写文件时不需要锁机制,副本一致性靠 Pipeline 顺序写入保证。

误解四:“DataNode 死了数据就丢了”。默认副本数 3 的 HDFS,单个 DataNode 宕机不会丢数据——只是把对应 block 的副本数从 3 降到 2,NameNode 会自动调度其他 DataNode 补齐到 3。同时两个 DataNode 宕机才可能丢数据(如果它们恰好持有同一个 block 的两个副本)。三个 DataNode 同时宕机的概率是数据丢失事件的真实门槛。生产集群通常把副本数提到 3,配合机架感知策略让副本跨机架放置,进一步降低同时丢失所有副本的概率。

误解五:“DataNode 宕机立刻被发现”。NameNode 判断 DataNode dead 默认要 10 分 30 秒(5 分钟 recheck × 2 + 30 秒心跳间隔)。这个延迟是 HDFS 容错的固有窗口——窗口内 DataNode 上的 block 不会被补救。短延迟靠心跳判断的话网络抖动会误判。

练习

  1. 在 Hadoop 集群打开 NameNode Web UI(默认 http://namenode:9870),找到 Under Replicated Blocks 和 Missing Blocks 字段。如果集群里没有这两个字段都为 0,思考可能原因。

  2. hdfs fsck / -files -blocks 列出根目录下所有文件的 block 分布。观察一个大文件被切成了多少个 block、每个 block 在哪些 DataNode 上。这条命令是 HDFS 元数据体检的标准工具。

  3. apache/hadoop 源码里找到 DatanodeProtocol.java 接口定义(hadoop-hdfs-project 下),观察 sendHeartbeat 和 blockReport 两个方法的参数和返回类型。这是 NameNode 与 DataNode 之间 RPC 契约的源头。

  4. 思考题:如果让 NameNode 元数据也分布式存储(像 Spanner 那样每个节点存一部分),HDFS 的设计可以做哪些简化?反过来,会引入哪些新问题?

系列导航

序号 主题 状态
00 导读:Hadoop 的核心前提是节点总会失败 上一篇
01 HDFS 架构:NameNode、DataNode 与元数据的三层切分 本篇
02 文件写入路径:从客户端到 DataNode 的流水线 下一篇
03 文件读取路径:副本选择与短路读
04 NameNode 内存模型:FSImage、EditLog 与启动恢复
05 HDFS HA:Quorum Journal Manager、ZKFC 与脑裂防御
06 HDFS 3.x 演进:纠删码、路由联邦与 Observer NameNode

参考资料

  • Sanjay Ghemawat, Howard Gobioff, Shun-Tak Leung. The Google File System. SOSP 2003.(NameNode + ChunkServer 设计原型)
  • Konstantin Shvachko, Hairong Kuang, Sanjay Radia, Robert Chansler. The Hadoop Distributed File System. MSST 2010.(HDFS 1.x 时代官方架构说明,三层职责的最早正式描述)
  • Apache Hadoop 官方文档:HDFS Architecture. https://hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-hdfs/HdfsDesign.html
  • Apache Hadoop 源码:DatanodeProtocol.javaNameNodeRpcServer.java. https://github.com/apache/hadoop
  • Tom White. Hadoop: The Definitive Guide. O’Reilly, 4th Edition 2015. Chapter 3 “The Hadoop Distributed File System” 详细描述了 NameNode 内存占用估算公式。