主题
从 GFS 到 HDFS:分布式文件系统的 Master/ChunkServer 设计
经典为什么值得讲
Google File System(GFS),2003 年那篇论文,到今天面试还在问,不是因为它新——它老了。但 GFS 的设计思想散落在太多现代系统里:Kafka 的 partition 是 chunk 的变体、TiDB 的 region 也长得很像、HDFS 基本就是 GFS 的 Java 实现。分布式存储的故事,绕不开 GFS。
面试官问 GFS 也不是真想听你背论文,而是想看你能不能理解一个系统在特定约束下做的设计决策。GFS 的约束很明确:硬件故障是常态,文件巨大(GB 起步),主要访问模式是追加写入而非随机修改。这套假设决定了它整个架构。
一个文件怎么存
架构骨架
GFS 的架构:一个 Master 管元数据,一堆 ChunkServer 存数据,客户端先问 Master 再直接找 ChunkServer。
┌──────────────┐
│ Master │ ← 存元数据(命名空间、文件→chunk 映射、chunk 位置)
│ (单节点) │
└──────┬───────┘
│ 控制流(元数据请求)
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ChunkServer │ │ ChunkServer │ │ ChunkServer │
│ chunk-001 │ │ chunk-001 │ │ chunk-001 │
│ chunk-002 │ │ chunk-005 │ │ chunk-003 │
│ ... │ │ ... │ │ ... │
└──────────────┘ └──────────────┘ └──────────────┘
▲
│ 数据流(客户端直连 ChunkServer)
│
┌──────────────┐
│ Client │
└──────────────┘Master 不存文件数据,只存三样东西:文件到 chunk 的映射、每个 chunk 对应的 ChunkServer 列表、命名空间树。这三样都在内存里,所以 Master 很快。
客户端读文件时先问 Master "这个文件 offset X 在哪个 chunk",Master 返回 chunk handle + 副本位置列表,然后客户端直接连最近的 ChunkServer 读数据。控制流和数据流分离——这是 GFS 设计里最重要的模式,避免 Master 成为 IO 瓶颈。
64MB 的 chunk
GFS 的 chunk 大小是 64MB,比普通文件系统 block(4KB)大了四个数量级。为什么这么大?
- 减少 Master 元数据量:64MB 的 chunk,1TB 文件只需要 1.6 万个 chunk 条目,Master 内存够
- 减少客户端与 Master 的交互次数:一次拿 chunk 映射,客户端可以连续读 64MB 再问下一次
- 降低网络开销:单个大 chunk 的 TCP 连接可以复用,长连接比小文件短连接快
但大 chunk 也有代价:小文件浪费空间(一个 1KB 的文件也占一个 64MB chunk)、热点文件被大量客户端并发读同一个 chunk 时性能下降。GFS 的应对是让客户端缓存 chunk 位置 + 增大副本数,但小文件问题没解决。HDFS 沿用了这个设计,公司里跑 HDFS 的团队都知道小文件太多会把 NameNode 内存撑爆。
三副本与流水线复制
GFS 默认三副本,副本分布在不同的机架。目的是防机架级故障——一个机架断电,至少还有两副本在其他机架可用。
写数据时,GFS 不用"客户端分别发给三个副本"的方式,而是流水线复制:
Client ──chunk──→ ChunkServer A ──→ ChunkServer B ──→ ChunkServer C数据从客户端传给最近的副本 A,A 收到一部分就转发给 B,B 再转发给 C。这样每个节点只发一次数据,吞吐量接近网络带宽上限。对比"客户端同时发三份"——客户端出带宽要三倍,且三个 ChunkServer 的写入延迟要等最慢的那个。
流水线复制的问题:如果中间节点(A)挂了,C 只能收到部分数据。GFS 的应对是让数据在转发时同时校验 checksum,写完后如果发现不一致,标记该 chunk 由其他副本重新生成。
追加写与租约
为什么 GFS 不擅长随机写
GFS 的写入模型是追加(append)为主,不是随机覆盖。追加写的特点:
- 写操作总是追加到文件末尾,不需要查表找位置
- 不需要修改已写入的数据,并发写的冲突概率低
- 适合日志、爬虫数据、监控数据等场景
随机写(覆盖已有数据)在 GFS 里很慢——需要先读旧 chunk、改数据、写回、同步副本,且并发写同一 chunk 时冲突处理复杂。Google 后来的 Colossus 改进了这个问题,但 GFS 时代的设计就是面向追加场景的。
租约:谁控制写入顺序
GFS 的写入要保证所有副本的变更顺序一致,不能 A 先写 B 后写最后副本对不上。GFS 的解法是租约(lease):
1. 客户端问 Master "我要写文件 X 的 chunk Y"
2. Master 给 chunk Y 的其中一个副本授予 60 秒租约,标记为 主副本(primary)
3. 客户端把数据推给所有副本(按流水线方式)
4. 所有副本确认收到数据后,客户端通知主副本 "提交"
5. 主副本决定写入顺序,按顺序写数据,然后通知其他副本按相同顺序写
6. 所有副本写完后,主副本回复客户端 "完成"租约的好处:Master 不需要参与每次写入的编排,只需要决定谁当主副本。租约续期由主副本自动向 Master 发起,Master 不做主动推送,所以不会成为瓶颈。
如果主副本挂掉,租约到期后 Master 重新授租给另一个副本。这个设计后来被 Kafka 的 controller 选举、etcd 的 lease 机制都借鉴了。
记录追加的"至少一次"语义
GFS 的原子追加操作(record append)是面试高频点。它的语义是:客户端在追加时,GFS 保证数据至少被写入一次,但可能被写入多次(如果副本在写入过程中故障)。
为什么会出现重复?看这个场景:
- 主副本把数据写入 chunk 末尾,同步给其他副本
- 副本 C 写入成功,但主副本在回复客户端前挂了
- 客户端超时重试,新选的主副本从日志里发现数据已经写入过,但客户端认为还没写完
- 数据被追加了两次
GFS 认为重复比丢失好。上游应用(比如 Google 的爬虫系统)自己处理去重,GFS 只保证数据不丢。这个设计选择后来被 Kafka 的 exactly-once 语义(需要事务协调器)做了对比——Kafka 选择了更严格的保证,代价是更复杂的实现。
从 GFS 到 HDFS
HDFS 的工程化改动
HDFS 是 Apache 对 GFS 论文的 Java 实现,核心架构一致,但做了几个重要工程化改进:
NameNode 元数据持久化:edits 与 fsimage
GFS 的 Master 元数据在内存里,通过定期打快照到 ChunkServer 持久化。HDFS 的 NameNode 用 WAL 的思路:所有元数据变更先写 edits 日志文件,定期合并成 fsimage。NameNode 启动时加载 fsimage + 回放 edits 恢复状态。
NameNode 内存 ← 加载 → fsimage(全量快照)
↓
edits(增量日志,每次操作追加)
↓
SecondaryNameNode 定期合并 edits → 新 fsimageSecondaryNameNode 不是备机——它只是辅助合并 fsimage 和 edits 的帮手,不让 NameNode 的 edits 文件膨胀到严重影响启动速度。很多人误以为它有 HA 功能,它没有。NameNode 挂了,SecondaryNameNode 不能自动接管。
HA with QJM(Quorum Journal Manager)
HDFS 2.0 之后引入了真正的 HA:两个 NameNode 配置 Active/Standby,通过 ZK 选主,元数据变更通过 QJM 同步到 JournalNode 集群(基于 Paxos 的多数派写入)。Active 写 edits 时,必须多数派 JournalNode 确认才算持久化成功,Standby 从 JournalNode 回放 edits 保持内存同步。
Active NameNode ──写入 edits──→ JournalNode[0], JournalNode[1], JournalNode[2]
│
(Paxos 多数派确认)
│
▼
Standby NameNode ←──回放 edits──── JournalNode 集群DataNode 与 Block 结构
HDFS 把 chunk 改叫 block,默认大小 128MB(可配置)。DataNode 报告 block 列表给 NameNode 的方式是心跳 + blockreport——每 3 秒心跳一次,每 6 小时全量报告一次 block 列表。
java
// HDFS 数据写入的简化流程
public void write(Path path, InputStream in, Configuration conf) {
// 1. 创建文件,获取输出流
FSDataOutputStream out = fs.create(path);
// 2. 按 block 大小切分写入
byte[] buffer = new byte[128 * 1024 * 1024]; // 128MB
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
// 每条数据流被拆成 packet(默认 64KB)
// packet 再拆成 chunk(默认 512B + 4B checksum)
// DataStreamer 按 pipeline 方式发送给 DataNode 链
out.write(buffer, 0, bytesRead);
}
// 3. 关闭时等待所有 ack 返回
out.close();
// 内部:hflush() 确保数据落盘,close() 等待所有 pipeline ack
}与 Ceph 的路线对比
Ceph 走的是另一条路——去中心化。Ceph 的 CRUSH 算法让客户端直接计算数据该放哪,不需要中心元数据节点。GFS/HDFS 的 Master/NameNode 是单点(虽然有 HA 方案),Ceph 不依赖任何中心节点就能定位数据。
各有取舍:
| 维度 | GFS / HDFS | Ceph |
|---|---|---|
| 元数据定位 | 中心化 Master 内存查询 | 客户端 CRUSH 算法计算 |
| 容错 | NameNode HA + 三副本 | 无中心节点 + 副本/纠删码 |
| 小文件 | 差(大量元数据撑爆 NameNode) | 好(无元数据瓶颈) |
| 一致性 | 强一致(租约控制) | 最终一致(CRUSH + PG 同步) |
| 运维复杂度 | 中(NameNode 调优经验多) | 较高(PG 数量、网络要求高) |
为什么面试爱问 GFS
GFS 论文虽然 20 年了,但它是分布式存储的" Hello World"——append-log 架构的源头。TiDB 的 region 分片、Kafka 的 partition 日志、HDFS 的 block 设计,都能看到 GFS 的影子。理解 GFS 的设计选择,相当于理解了分布式存储系统面对的共同问题清单:元数据放哪、副本怎么放、写入顺序怎么保证、故障怎么恢复。这些问题套在任何一个现代分布式存储系统上都能问。
面试官问 GFS 真正想听的是:你能不能从设计约束倒推架构决策,而不是背 chunk 大小是 64MB 这个数字。
参考
- Google 论文:The Google File System(2003)
- Apache HDFS 文档:HDFS Architecture Guide
- Ceph: A Scalable, High-Performance Distributed File System(Sage Weil, 2007)
- Kleppmann, Designing Data-Intensive Applications Chapter 10: Batch Processing 中关于 GFS 的讨论