Skip to content

设计一个分布式文件系统

提出问题

分布式文件系统是分布式存储的基石。Hadoop 的 HDFS 受 Google GFS 启发,是千亿级文件/百 PB 级别的存储主力。大数据生态(Hive、Spark、Flink)的底层依赖就是它,可以说分布式计算的底座就是 HDFS。面试官考察这道题,不光看你是否了解 GFS/HDFS 的架构,更想听你能否讲清楚:元数据怎么管、数据怎么放、单点故障怎么解、小文件怎么处理。这些是生产环境天天要面对的真问题。

分析问题

主从架构:NameNode 与 DataNode

分布式文件系统采用主从架构,一个 NameNode(元数据节点)管理文件系统的命名空间和文件到块的映射,多个 DataNode 负责实际存储数据块。

Client


┌──────────┐    心跳 & 块汇报     ┌──────────┐
│ NameNode │◄──────────────────────│ DataNode │
│ (元数据) │       (RPC)          │  (块存储)  │
│          │──────────────────────►│          │
└──────────┘    读写指令           └──────────┘
     │                                 │
     │  fsimage + edits               │  本地磁盘
     ▼                                 ▼
  ┌──────┐                      ┌──────────────┐
  │ 磁盘  │                      │ block 1, 2, 3 │
  └──────┘                      └──────────────┘

文件写入流程:Client 向 NameNode 请求文件创建 → NameNode 记录元数据并返回目标 DataNode 列表 → Client 将数据切块(默认 128MB)依次写入 DataNode 链 → 写完返回确认 → NameNode 更新元数据。

写入的详细时序:

Client                     NameNode              DataNode1     DataNode2     DataNode3
  │                          │                      │             │             │
  │── 1. create(/foo, repl=3)│                      │             │             │
  │                          │──────────────────────│─────────────│─────────────│
  │                          │ 2. 分配 blockId + 3 个目标节点         │             │
  │◄─── (blockId, [dn1,dn2,dn3]) ───────────────────│             │             │
  │                          │                      │             │             │
  │── 3. 建 pipeline ───────►│───────► │───────►            │
  │                          │     ack pipeline      │             │             │
  │                          │                      │             │             │
  │── 4. 写 packet(64KB) ───►│───────► │───────►            │
  │                          │     ack packet        │             │             │
  │  (重复直到所有 packet 写完)   │                  │             │             │
  │                          │                      │             │             │
  │── 5. close ─────────────►│───────► │───────►            │
  │◄─── complete ────────────│──────────────────────│─────────────│─────────────│

关键细节:写入时 Client 先建一条 DataNode 管道(pipeline),数据按 packet(默认 64KB)分块,从第一个 DataNode 向下游传递。每个 packet 收到下游确认后才发下一个,保证数据完整到达。这种链式复制模式不需要 NameNode 参与数据复制,写吞吐仅受限于 Client 的网卡带宽。

块存储:每个文件被切分成固定大小的块(Block),默认 128MB。每个块默认复制 3 份,副本分散在不同机架(机架感知)以容灾。

java
// 伪代码:NameNode 中的块分配策略(机架感知)
public List<DatanodeInfo> chooseTargets(int replication, String src) {
    List<DatanodeInfo> targets = new ArrayList<>();
    // 第一个副本:写在 Client 所在节点(或同机架)
    targets.add(chooseLocalNode());
    // 第二个副本:写在不同机架
    targets.add(chooseRemoteRackNode());
    // 第三个副本:写在与第二个副本同机架的不同节点
    targets.add(chooseNodeInSameRack(targets.get(1)));
    // 更多副本:随机选
    return targets;
}

为什么块大小是 128MB 而不是 4KB? 这是分布式文件系统和传统文件系统的核心差异。大块的目的:减少元数据条目(10PB 数据,128MB 块只需约 8 万条记录,4KB 块需要 25 亿条)、减少磁盘寻道时间(顺序读为主)、让 MapReduce 的计算本地化(data locality)更有效。但块太大也有问题:小文件浪费空间、MapReduce 的并行度降低。

副本放置策略与机架感知

副本放置是分布式文件系统可靠性的核心。HDFS 的默认策略是机架感知

  • 如果 Client 在集群内,第一个副本放在 Client 所在节点
  • 第二个副本放在不同机架的节点
  • 第三个副本放在与第二个副本同机架的不同节点

这种策略在容错与写带宽之间做了平衡:跨机架写需要网络传输,但任意机架宕机数据仍然可用。如果集群规模大(>6 节点),副本因子可以设到 3 以上,例如 5 副本在生产海量存储中也有使用。

副本因子选型对比

副本数存储开销容错能力写带宽影响典型场景
22x1 节点宕机测试/开发环境
33x1 机架宕机默认生产
55x2 机架宕机金融/监管合规
EC (Erasure Coding)1.4x可配(如 6+3)中(编解码开销)冷数据/归档

踩坑:机架感知配置不当的后果

某生产集群在部署时没有正确配置 topology.script.file.name,导致所有 DataNode 被识别为 /default-rack。结果:一个机架断电时,三个副本全部在该机架上,数据丢失 3 个副本,集群进入安全模式,恢复耗时 6 小时。教训:机架感知不是可选项,是必配项。

NameNode 单点与 HA 机制

NameNode 是分布式文件系统的关键瓶颈:所有元数据都在内存中,文件数量受限于单机内存。一个文件元数据大约 150-200 字节,10 亿文件就需要 200GB 内存——这是 NameNode 的硬上限。

更致命的是单点故障。HDFS 2.x 之后引入 QJM(Quorum Journal Manager) 实现 HA:

  • Active NameNode 将 edits 日志写入 JournalNode 集群(多数派写入)
  • Standby NameNode 从 JournalNode 读取 edits 并应用,保持内存与 Active 同步
  • ZKFC(ZooKeeper Failover Controller)监控 NameNode 健康,发生故障时触发切换
yaml
# 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://node1:8485;node2:8485;node3:8485/mycluster</value>
</property>

QJM 原理:JournalNode 集群(3/5/7 节点奇数)使用 Paxos 风格的多数派协议。Active NameNode 写 edits 时同步到所有 JN,大多数写入成功即算提交。Standby 从 JN 读取最新 edits 并应用到内存。切换时间通常在 30-60 秒,期间系统不可写但可读。

Failover 时序

Active NN                    ZKFC1            ZKFC2            Standby NN     JournalNodes
   │                          │                 │                 │              │
   │—— 心跳 (每 1s) —————————►│                 │                 │              │
   │                          │—— 持有 zk 锁 ——►│                 │              │
   │  (Active 宕机)           │                 │                 │              │
   │                          │  zk 会话超时     │                 │              │
   │                          │ (60s 默认太长)   │                 │              │
   │                          │                 │ 检测到锁丢失     │              │
   │                          │                 │ 尝试获取锁       │              │
   │                          │                 │ 成功获取锁       │              │
   │                          │                 │—— fence(Active) │              │
   │                          │                 │ 确保旧 Active 已死              │
   │                          │                 │ 触发过渡         │              │
   │                          │                 │                 │ 加载 edits   │
   │                          │                 │                 │ 成为 Active  │

踩坑:zk 会话超时设太短/太长

  • 设 10s:GC 抖动导致误切换,Standby 频繁抢锁,集群反复震荡
  • 设 60s(默认):NameNode 真正挂了,客户端要等 1 分钟才能恢复写入
  • 建议:30s,配合 dfs.ha.automatic-failover.enabled=truedfs.client.failover.proxy.provider 配置客户端自动重试

小文件问题

分布式文件系统对大文件友好,对小文件是灾难。原因在于:

  1. 元数据膨胀:每个文件(即使 1KB)都需要一条元数据记录,大量小文件会撑爆 NameNode 内存
  2. 读取效率低:小文件随机读需要多次 RPC 与磁盘寻道
  3. MapReduce 任务数膨胀:一个文件对应一个 InputSplit,任务数过多导致调度开销

真实案例:某日志平台每天产生 500 万个小文件(每个 1-10KB),一周后 NameNode 内存被打满(64GB Heap),GC 停顿升到 30 秒,NameNode 频繁进入安全模式。最终方案:在写入层用 Flume 的 HDFSEventSink 配置 rollInterval=300(5 分钟滚动一次),将小文件合并成 200-300MB 的文件,文件数从 3500 万降到 5 万。

解决方案对比

方案原理优点缺点适用场景
HAR (Hadoop Archive)打包成 .har 文件,减少元数据不改写入逻辑读取要解包,性能差历史归档
SequenceFile键值对序列文件支持压缩,可分割读取需要反序列化MapReduce 中间数据
写入层合并定时/定量合并后再写出最彻底增加写入延迟实时写入(Flume/Spark Streaming)
HBase/Hudi/Iceberg表格式管理支持 ACID 和更新架构复杂需要随机读写

数据一致性模型

HDFS 提供写一次读多次(Write Once Read Many)的语义。文件一旦创建并关闭,内容不可修改。这种设计简化了分布式一致性,但也带来了限制:

  • 不支持追加写(HDFS 2.x 支持 hflush,但只保证 client 可见,不保证全局持久化)
  • 不支持随机写
  • 文件删除后,被删除的块在 NameNode 中标记为"待删除",DataNode 后台线程异步清理

一致性保证:HDFS 保证读后写一致性(read-after-write consistency)。Client 写入完成后,任何后续读取都能看到完整数据。但如果写入过程中读取,读到的可能是部分数据或空。这是因为 DataNode 写入时先写 OS buffer(延迟刷盘),hflush 或 close 时才会刷盘对读可见。

高可用读优化:Standby 也提供服务

HDFS 2.x 之后,Standby NameNode 也可以提供只读服务。通过配置 dfs.ha.allow.stale.reads=true,客户端可以从 Standby 读取元数据,减轻 Active 的负载。但代价是 Standby 可能有短暂的元数据不一致(毫秒级延迟)。适合对一致性要求不高的场景(如离线分析)。

总结

分布式文件系统的核心设计要点:

维度设计决策原理踩坑点
架构主从(NameNode + DataNode)元数据集中便于管理,数据块分布式存储单点瓶颈,必须做 HA
数据分布块存储(128MB)+ 副本大块减少元数据,副本保证容错块大小设太小(<64MB)元数据爆炸
副本放置机架感知平衡容错与写带宽忘配机架感知=单机架挂全丢
一致性写一次读多次简化设计,适合批处理不支持追加写和随机写
HAQJM + ZKFC避免 NameNode 单点zk 超时配错导致误切换
小文件合并方案控制元数据总量写入层不做合并必炸内存

面试话术:"如果让我设计一个分布式文件系统,我会首先考虑元数据与数据分离的主从架构,数据用块存储 + 多副本 + 机架感知来保证可靠性和性能。NameNode 的 HA 是必须的,而小文件问题需要在写入层就做合并。参考 GFS/HDFS 的设计,这套架构已经在大规模生产环境中验证了十几年。如果让我选一个改进点,我会考虑用 Raft 替代 QJM 做 edits 日志复制,降低运维复杂度——HDFS 3.x 的 Router-based Federation 已经在朝这个方向走。"

参考

参考:GFS 论文 (The Google File System, SOSP 2003);HDFS 架构文档 (Apache Hadoop);《Hadoop: The Definitive Guide》第 3 章 HDFS

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。