土法炼钢兴趣小组的算法知识备份

【分布式系统百科】从 GFS 到 HDFS:分布式文件系统的设计空间

文章导航

标签入口
#分布式系统#GFS#HDFS#分布式文件系统#对象存储

目录

Google 在 2003 年 SOSP 上发表的 GFS 论文,定义了现代分布式文件系统的基本架构模式。十几年过去,GFS 的设计思想深刻影响了开源世界的 HDFS、工业界的后继者 Colossus,以及我们对分布式存储的理解。本文深入分析 GFS 的核心设计决策,特别是单 Master 架构的利弊权衡;探讨 HDFS 如何继承并改进这一设计;最后讨论在对象存储(S3、GCS)盛行的今天,分布式文件系统的价值边界。

一、GFS 单 Master 设计:化繁为简的工程权衡

1.1 为什么选择单 Master

GFS 面对的核心问题是:如何用商用服务器构建 PB 级存储系统,服务 Google 内部的大规模数据处理任务(MapReduce、BigTable)。2003 年的工程背景下,Google 工程师做出了一个看似激进的选择——单 Master 架构。

这个决策背后的关键洞察:

工作负载特征驱动设计。Google 的典型负载是大文件(数百 MB 到数 GB)、顺序读写、批处理为主。这意味着元数据操作(文件创建、目录列举)相对不频繁,数据操作(读写)占据绝对主体。单 Master 可以处理元数据请求,而数据流直接在客户端和 ChunkServer 之间进行,避免 Master 成为数据路径瓶颈。

简化一致性模型。分布式系统最复杂的部分往往是元数据的一致性维护。单 Master 意味着所有元数据操作天然串行化,不需要复杂的分布式协议(Paxos、Raft)来协调多个元数据节点。这在 2003 年显著降低了实现复杂度。

快速失效恢复优于高可用。Google 的运维哲学是接受短暂不可用,通过快速恢复来保证整体可靠性。Master 故障时,系统暂停服务几秒到几十秒,等待新 Master 从检查点(Checkpoint)和操作日志(Operation Log)恢复。这比维护多个在线 Master 的复杂性更可控。

1.2 单 Master 的边界约束

单 Master 带来的限制同样明显:

元数据规模上限。Master 将所有元数据保存在内存中,每个文件和 Chunk 的元数据大约占用 64 字节。假设 Master 有 64GB 内存,可以管理约 10 亿个文件和 Chunk。对于 64MB 的 Chunk 大小,理论上可存储约 60PB 数据。但实际中,元数据还包括命名空间、访问控制列表(ACL)、文件锁等,可管理的数据规模会更小。

热点问题。某些热门文件的元数据请求可能集中到 Master,造成负载不均。GFS 通过客户端缓存元数据、批量请求等方式缓解,但无法根本解决。

单点失效影响面。尽管 Master 故障恢复快,但在恢复期间整个集群不可用。对于追求”九个九”可用性的在线服务,这是不可接受的。

二、Master 内存元数据:轻量级设计的精髓

2.1 元数据的三类信息

GFS Master 在内存中维护三类核心元数据:

命名空间(Namespace)。文件和目录的层次结构,采用前缀压缩的查找表实现。每个完整路径名对应一个读写锁,支持并发的目录操作。例如 /home/user/data.txt/home/user/config.txt 可以并发修改,因为它们共享的前缀 /home/user 只需读锁。

文件到 Chunk 的映射。每个文件被分割成固定大小(64MB)的 Chunk,每个 Chunk 有全局唯一的 64 位句柄(Chunk Handle)。Master 维护 filename -> [chunk_handle_1, chunk_handle_2, ...] 映射。

Chunk 副本位置。每个 Chunk 通常保存 3 个副本,分布在不同的 ChunkServer 上。Master 维护 chunk_handle -> [server_1, server_2, server_3] 映射。

注意:Chunk 位置信息不持久化。Master 启动时,通过向所有 ChunkServer 发送心跳请求,收集它们持有的 Chunk 列表。这个设计避免了 Master 和 ChunkServer 状态的不一致问题——ChunkServer 宕机、磁盘损坏、Chunk 迁移时,Master 不需要同步更新持久化存储,而是通过心跳自然感知到最新状态。

2.2 操作日志与检查点

Master 的持久化依赖两个机制:

操作日志(Operation Log)。所有改变元数据的操作(文件创建、重命名、删除)都追加到日志中。日志是 Master 状态的唯一权威来源(single source of truth)。日志条目按严格顺序记录,定义了操作的逻辑时间线。

[LogEntry 1] CREATE /data/file1.txt
[LogEntry 2] APPEND /data/file1.txt -> Chunk(0x1A2B3C)
[LogEntry 3] DELETE /data/temp.txt
[LogEntry 4] SNAPSHOT /data/dir -> /backup/dir

日志必须可靠持久化。GFS 的实现中,Master 将日志同步复制到多个远程机器,只有在多数副本确认写入后,才向客户端返回成功。这保证了即使 Master 机器彻底损坏,日志也能从副本恢复。

检查点(Checkpoint)。日志无限增长会导致恢复时间过长。Master 定期(通常几分钟到几小时)将内存状态序列化为检查点文件,保存到磁盘和远程副本。检查点是内存数据的紧凑 B-Tree 表示,可以直接映射到内存而无需解析。

恢复流程: 1. 加载最新的检查点到内存 2. 从检查点之后的日志位置开始,重放所有操作 3. 向 ChunkServer 发送心跳,重建 Chunk 位置信息 4. 完成恢复,开始服务请求

创建检查点时,Master 使用影子线程(shadow thread)在后台进行,不阻塞正常的元数据操作。具体做法是:fork 一个子进程(或创建一个新线程),子进程看到的是 fork 时刻的内存快照,通过写时复制(copy-on-write)机制,主线程继续处理请求,子进程将快照序列化到磁盘。

2.3 内存元数据的扩展性分析

假设一个实际的 GFS 集群: - 1000 万个文件 - 每个文件平均 10 个 Chunk(640MB 文件大小) - 总共 1 亿个 Chunk - 每个 Chunk 3 个副本

元数据内存占用估算: - 文件命名空间:1000 万 × 200 字节 ≈ 2GB - 文件到 Chunk 映射:1 亿 × 16 字节 ≈ 1.6GB - Chunk 元数据:1 亿 × 64 字节 ≈ 6.4GB - 总计约 10GB

这个规模在现代服务器(128GB+ 内存)上完全可行。但当集群扩展到数千 PB,文件数量达到数十亿时,单 Master 的内存容量就成为硬约束。这也是 Google 后来开发 Colossus 的原因之一。

三、GFS 的局限与 Colossus 的演进

3.1 GFS 在 Google 内部遇到的问题

2003-2010 年间,GFS 支撑了 Google 的快速增长,但逐渐暴露出无法克服的限制:

单 Master 容量墙。到 2010 年,一些大型 GFS 集群管理着数千万个文件和数亿个 Chunk,Master 的内存接近极限。增加内存只是延缓问题,无法根本解决。

小文件性能差。GFS 针对大文件优化,对于小文件(几 KB 到几 MB),每个文件至少占用一个 Chunk(64MB),造成严重的存储空间浪费。即使不考虑空间,小文件的元数据占比高,Master 压力大。

Master 故障恢复时间过长。随着元数据规模增长,从检查点和日志恢复 Master 需要数分钟,这对于在线服务不可接受。

缺乏细粒度的访问控制。GFS 的安全模型简单,假设集群内部可信。但随着多租户需求增加,需要更精细的权限管理和配额控制。

Chunk 大小固定。64MB 的 Chunk 对于某些工作负载(如小文件、随机访问)不是最优选择,但 GFS 不支持动态调整。

3.2 Colossus 的核心改进

Colossus 是 Google 从 2010 年开始开发的 GFS 继任者,解决了上述关键问题:

分布式 Master。Colossus 使用 BigTable 存储元数据,BigTable 本身是一个分布式系统,可以水平扩展。元数据被分片(sharding)到多个 Tablet 上,每个 Tablet 由一个 TabletServer 服务。客户端通过一致性哈希或范围分区路由到正确的 Tablet。这彻底打破了单 Master 的容量限制。

自动化的 Chunk 大小。Colossus 根据文件大小和访问模式动态选择 Chunk 大小,小文件使用小 Chunk(如 1MB),大文件使用大 Chunk(如 128MB)。

Reed-Solomon 纠删码(Erasure Coding)。对于冷数据,Colossus 使用 Reed-Solomon 编码(如 RS(9,3)),用 1.33 倍的存储开销提供容错能力,而三副本需要 3 倍开销。这显著降低了存储成本。

更快的故障恢复。Colossus 的 Master 是无状态的,元数据在 BigTable 中,Master 故障时,新 Master 可以立即从 BigTable 读取元数据并接管服务,恢复时间从分钟级降到秒级。

Colossus 的架构更复杂,但更适合 Google 当前的规模和多样化的工作负载。GFS 的论文发表时,Google 的数据规模是 PB 级;到 Colossus 时代,Google 存储的数据已达 EB 级甚至 ZB 级。

3.3 GFS 写入流程深度剖析

GFS 的写入流程是理解其架构设计最关键的路径。写入流程的核心特征是控制流与数据流的分离:客户端先从 Master 获取元数据(控制流),然后直接向 ChunkServer 推送数据(数据流),最后由 Primary ChunkServer 协调写入顺序。以下是一次完整写入的步骤分解:

  1. 客户端请求 Master:客户端向 Master 发送写请求,询问目标 Chunk 的 Primary ChunkServer 和 Secondary ChunkServer 的位置。Master 返回当前持有该 Chunk 租约(Lease)的 Primary 以及所有副本的 ChunkServer 列表。
  2. 客户端推送数据:客户端将数据以流水线方式推送到所有 ChunkServer(Primary 和 Secondary)。数据沿着网络拓扑上最近的链式路径传递:客户端发送给最近的 ChunkServer,该 ChunkServer 在接收数据的同时立即转发给下一个最近的 ChunkServer。每个 ChunkServer 将数据缓存在内存的 LRU 缓冲区中,尚未写入磁盘。
  3. 客户端发送写请求到 Primary:所有 ChunkServer 确认收到数据后,客户端向 Primary 发送正式的写请求。Primary 为这次写操作分配一个连续的序列号(Serial Number),确定写入在该 Chunk 上的执行顺序。
  4. Primary 转发请求到 Secondary:Primary 将带有序列号的写请求转发给所有 Secondary ChunkServer。每个 Secondary 按照 Primary 指定的顺序执行写入操作,保证所有副本的写入顺序一致。
  5. Secondary 回复 ACK:每个 Secondary 完成写入后,向 Primary 发送确认(ACK)。
  6. Primary 回复客户端:Primary 收到所有 Secondary 的 ACK 后,向客户端返回写入成功。如果任何一个 Secondary 写入失败,Primary 通知客户端写入失败,客户端需要从步骤 3 开始重试。
sequenceDiagram
    participant C as 客户端
    participant M as Master
    participant P as Primary ChunkServer
    participant S1 as Secondary-1
    participant S2 as Secondary-2

    C->>M: 请求 Chunk 位置和 Lease 持有者
    M-->>C: 返回 Primary=P, Secondaries=[S1, S2]

    Note over C,S2: 数据流(链式推送,与控制流分离)
    C->>P: 推送数据到缓冲区
    C->>S1: 推送数据到缓冲区
    C->>S2: 推送数据到缓冲区
    P-->>C: 数据已缓存
    S1-->>C: 数据已缓存
    S2-->>C: 数据已缓存

    Note over C,S2: 控制流(Primary 排序写入)
    C->>P: 发送写请求
    P->>P: 分配序列号,本地写入
    P->>S1: 转发写请求(携带序列号)
    P->>S2: 转发写请求(携带序列号)
    S1->>S1: 按序列号顺序写入
    S2->>S2: 按序列号顺序写入
    S1-->>P: ACK
    S2-->>P: ACK
    P-->>C: 写入成功

上图展示了 GFS 写入流程中控制流与数据流的分离机制。数据推送阶段,客户端将数据以链式方式送达所有 ChunkServer 的内存缓冲区,此时数据尚未落盘。控制流阶段由 Primary ChunkServer 主导,通过分配全局序列号来保证多个并发写入在所有副本上的执行顺序一致。这种分离设计使得 Master 完全不参与数据传输,仅在控制路径上提供元数据服务,从根本上避免了 Master 成为数据带宽瓶颈。

四、HDFS 架构:开源世界的 GFS 实现

4.1 HDFS 与 GFS 的对应关系

HDFS(Hadoop Distributed File System)起始于 2006 年,是 Apache Hadoop 项目的一部分,设计目标就是”开源的 GFS”。核心组件的对应关系:

GFS HDFS 职责
Master NameNode 管理文件系统命名空间和元数据
ChunkServer DataNode 存储数据块并提供读写服务
Chunk Block 固定大小的数据块(默认 128MB)
Operation Log EditLog 记录元数据变更的操作日志
Checkpoint FSImage 元数据的检查点文件
Client HDFS Client 提供文件系统接口

HDFS 的初始设计几乎照搬了 GFS 的架构,包括单 NameNode、内存元数据、客户端直连 DataNode 读写数据等核心思想。但在细节上有所改进和优化。

4.2 NameNode 的元数据管理

NameNode 将整个文件系统的命名空间保存在内存中,数据结构为:

// 简化的 NameNode 内存结构
class NameNode {
    FSDirectory namespace;        // 目录树
    BlockManager blockManager;    // Block 管理
    Map<Long, DatanodeDescriptor> datanodes; // DataNode 信息
    
    EditLog editLog;              // 操作日志
    FSImage fsImage;              // 检查点镜像
}

class FSDirectory {
    INode rootDir;                // 根目录
    Map<Long, INode> inodeMap;    // inode ID -> INode 映射
}

class INode {
    long id;
    String name;
    INode parent;
    INode[] children;             // 目录的子节点
    long modificationTime;
    long accessTime;
    FsPermission permission;
    
    // 对于文件节点
    BlockInfo[] blocks;           // 文件包含的 Block 列表
    long fileSize;
    short replication;
}

class BlockInfo {
    long blockId;
    long numBytes;
    DatanodeDescriptor[] replicas; // Block 的副本位置
}

NameNode 启动流程:

  1. 加载 FSImage。读取最新的 FSImage 文件(通常命名为 fsimage_0000000000012345),将命名空间和 Block 映射加载到内存。

  2. 重放 EditLog。从 FSImage 对应的事务 ID 开始,读取 EditLog 文件,逐条重放操作(创建、删除、重命名等),将内存状态更新到最新。

  3. 进入 Safemode。NameNode 进入安全模式(Safemode),此时文件系统只读。NameNode 等待足够数量的 DataNode 注册并报告它们持有的 Block。

  4. Block 报告与校验。NameNode 收集所有 DataNode 的 Block 报告,重建 Block 到 DataNode 的映射。检查每个 Block 是否有足够的副本(默认 3 个)。如果某些 Block 副本不足,标记为”待复制”。

  5. 离开 Safemode。当一定比例(默认 99.9%)的 Block 满足副本数要求后,NameNode 离开安全模式,开始正常服务。

EditLog 示例:

OP_ADD /user/hadoop/data.txt 1 3 1024 1678901234000
OP_ALLOCATE_BLOCK_ID 1234567890 /user/hadoop/data.txt
OP_ADD_BLOCK /user/hadoop/data.txt 1234567890 134217728
OP_CLOSE /user/hadoop/data.txt 134217728
OP_DELETE /user/hadoop/temp.txt
OP_RENAME /user/hadoop/old.txt /user/hadoop/new.txt

每条操作包含操作类型、文件路径、Block ID、副本数、时间戳等信息。

4.3 HDFS 的存储开销

HDFS 的内存开销计算:

假设: - 1 亿个文件 - 每个文件平均 2 个 Block(256MB 文件大小) - 2 亿个 Block - 每个 Block 3 个副本

内存占用: - INode:1 亿 × 150 字节 ≈ 15GB - Block:2 亿 × 150 字节 ≈ 30GB - 总计约 45GB

这还没包括其他数据结构(缓存、租约、锁等),实际中 NameNode 通常需要 64GB 以上内存。

HDFS 推荐的 NameNode 内存配置:

<configuration>
  <!-- hdfs-site.xml -->
  <property>
    <name>dfs.namenode.handler.count</name>
    <value>100</value>
    <description>NameNode RPC 处理线程数</description>
  </property>
  
  <property>
    <name>dfs.namenode.checkpoint.period</name>
    <value>3600</value>
    <description>CheckPoint 间隔(秒)</description>
  </property>
  
  <property>
    <name>dfs.namenode.checkpoint.txns</name>
    <value>1000000</value>
    <description>触发 CheckPoint 的事务数阈值</description>
  </property>
</configuration>

NameNode 堆内存设置:

export HDFS_NAMENODE_OPTS="-Xmx64g -Xms64g -XX:+UseG1GC -XX:MaxGCPauseMillis=200"

五、HDFS NameNode 高可用:从单点到双活

5.1 早期 HDFS 的单点问题

HDFS 1.x 继承了 GFS 的单 NameNode 设计,也继承了单点失效的问题。NameNode 故障时:

  1. 整个 HDFS 集群不可用,所有客户端无法读写文件
  2. 恢复需要重启 NameNode,加载 FSImage 和 EditLog,等待 DataNode 注册,耗时数分钟到数十分钟
  3. 如果 NameNode 机器彻底损坏,需要从备份恢复 FSImage 和 EditLog,可能丢失最近的操作

这在企业生产环境中不可接受。Hadoop 社区在 2.x 版本引入了 NameNode HA 方案。

5.2 NameNode HA 架构

HDFS HA 通过两个 NameNode 实现高可用:

关键挑战是:如何保证两个 NameNode 的元数据一致

5.2.1 JournalNode 共享存储

HDFS HA 使用 JournalNode 集群作为共享存储,保存 EditLog。典型配置是 3 个或 5 个 JournalNode(必须是奇数,用于 Quorum 投票)。

工作流程:

  1. Active NameNode 写入 EditLog
    • Active NameNode 执行元数据操作(如创建文件)
    • 将 EditLog 条目发送给所有 JournalNode
    • 等待多数(Quorum)JournalNode 确认写入成功
    • 向客户端返回操作成功
  2. Standby NameNode 读取 EditLog
    • Standby NameNode 定期(通常 1 秒)从 JournalNode 拉取新的 EditLog 条目
    • 将这些条目应用到自己的内存状态
    • Standby 的内存状态与 Active 保持秒级延迟
  3. Failover 切换
    • 检测到 Active NameNode 故障(心跳超时)
    • ZooKeeper(或其他外部协调服务)选举新的 Active
    • Standby NameNode 转为 Active,开始服务客户端
    • 旧 Active 被 Fencing(隔离),防止脑裂
<!-- hdfs-site.xml 配置示例 -->
<configuration>
  <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.rpc-address.mycluster.nn1</name>
    <value>node1.example.com:8020</value>
  </property>
  
  <property>
    <name>dfs.namenode.rpc-address.mycluster.nn2</name>
    <value>node2.example.com:8020</value>
  </property>
  
  <property>
    <name>dfs.namenode.shared.edits.dir</name>
    <value>qjournal://node3:8485;node4:8485;node5:8485/mycluster</value>
  </property>
  
  <property>
    <name>dfs.ha.automatic-failover.enabled</name>
    <value>true</value>
  </property>
  
  <property>
    <name>dfs.ha.fencing.methods</name>
    <value>sshfence
    shell(/bin/true)</value>
  </property>
  
  <property>
    <name>dfs.ha.fencing.ssh.private-key-files</name>
    <value>/home/hadoop/.ssh/id_rsa</value>
  </property>
</configuration>

5.2.2 Fencing 机制

Fencing(隔离)是防止脑裂的关键机制。当旧 Active 被认为故障,但实际上还在运行时(如网络分区),必须阻止它继续写入 EditLog,否则会与新 Active 产生冲突。

HDFS 支持多种 Fencing 方法:

sshfence:通过 SSH 连接到旧 Active 机器,执行 kill -9 杀死 NameNode 进程。

# 自动执行的 fencing 命令
ssh old-active-node "ps aux | grep NameNode | grep -v grep | awk '{print \$2}' | xargs kill -9"

shell 脚本:执行自定义脚本,可以调用硬件设备(如网络交换机)隔离旧 Active。

# 示例:禁用旧 Active 的网络端口
#!/bin/bash
snmpset -v2c -c private switch.example.com \
  IF-MIB::ifAdminStatus.24 i 2  # 2 表示 down

电源管理:通过 IPMI 或 ILO 接口,强制重启或关闭旧 Active 机器。

Fencing 必须可靠执行,否则可能导致数据损坏。HDFS 的设计原则是”宁可牺牲可用性,也不能牺牲一致性”。

5.2.3 NameNode 故障切换状态机

NameNode HA 的故障切换过程本质上是一个由 ZooKeeper 协调的状态机。Active NameNode 在 ZooKeeper 中持有一个临时节点(Ephemeral ZNode)作为活跃标记,Standby NameNode 通过 ZKFailoverController(ZKFC)进程监控该节点。当 Active NameNode 失效时,ZooKeeper 的会话超时机制触发临时节点删除,ZKFC 检测到变更后启动切换流程。

切换的关键步骤包括:ZKFC 先执行健康检查确认 Active 确实不可用,然后对旧 Active 执行 Fencing 操作确保其不再写入 JournalNode,最后将 Standby 提升为新的 Active。Standby 在切换前需要追平 JournalNode 中所有未消费的 EditLog 条目,确保元数据状态完整后才开始对外服务。整个过程通常在秒级完成,但如果 Fencing 操作耗时较长(例如 SSH 连接超时),切换时间可能延长到十秒级。

stateDiagram-v2
    [*] --> Active : 集群启动,选举完成

    Active --> HealthCheckFailed : ZKFC 检测到 Active 不响应
    HealthCheckFailed --> FencingOldActive : 确认故障,执行 Fencing
    FencingOldActive --> StandbyTakeover : Fencing 成功,Standby 追平 EditLog
    FencingOldActive --> ManualIntervention : Fencing 失败(无法隔离旧 Active)
    StandbyTakeover --> NewActive : Standby 提升为 Active,ZK 注册新临时节点
    NewActive --> Active : 新 Active 开始服务

    Active --> StandbyReady : 正常运行,Standby 持续同步 EditLog
    StandbyReady --> Active : 无故障,保持当前状态

    ManualIntervention --> FencingOldActive : 运维人员手动隔离后重试

该状态图展示了 NameNode 故障切换的完整生命周期。从健康检查失败到新 Active 就绪,每个状态转移都有严格的前置条件:Fencing 必须在 Standby 接管之前成功完成,否则进入人工干预状态。这种”先隔离再切换”的设计牺牲了切换速度,但从根本上避免了双 Active 脑裂问题——在分布式系统中,数据一致性始终优先于可用性。

5.3 Observer NameNode

HDFS 3.x 引入了 Observer NameNode,进一步提升了读性能。Observer 是第三种 NameNode 角色:

Observer 的好处:

  1. 分散读负载:多个 Observer 可以分担读请求,减轻 Active 的压力
  2. 降低读延迟:客户端可以连接到地理位置更近的 Observer
  3. 不影响故障切换:Failover 仍然在 Active 和 Standby 之间进行,Observer 只是辅助角色

Observer 的一致性保证:Observer 读到的数据可能比 Active 延迟几秒(因为 EditLog 同步有延迟),但保证单调读(monotonic reads)——同一客户端的后续读不会看到更旧的数据。

六、HDFS 联邦:多命名空间扩展

6.1 单 NameNode 的扩展性瓶颈

即使有 HA,单个 NameNode 的容量仍然受限于内存。随着集群规模增长到数千台机器、数 PB 数据,单 NameNode 无法管理所有文件。

HDFS 联邦(Federation)的解决方案:多个独立的 NameNode,共享同一个 DataNode 池

6.2 Federation 架构

Namespace1 (NameNode1)  Namespace2 (NameNode2)  Namespace3 (NameNode3)
      |                        |                        |
      +------------------------+------------------------+
                               |
                  共享的 DataNode 池
           +----------+----------+----------+
           | DataNode | DataNode | DataNode | ...
           +----------+----------+----------+

关键概念:

Namespace 分离:每个 NameNode 管理独立的命名空间(如 /user/data/logs)。命名空间之间完全隔离,不共享目录结构。

Block Pool:每个 NameNode 分配一个唯一的 Block Pool ID。DataNode 存储来自多个 Block Pool 的 Block,通过 Block Pool ID 区分它们属于哪个 NameNode。

DataNode 共享:DataNode 同时向所有 NameNode 注册,报告各自 Block Pool 的 Block 列表。DataNode 的存储容量被多个 NameNode 共享。

配置示例:

<!-- 客户端配置:定义挂载表 -->
<configuration>
  <property>
    <name>fs.defaultFS</name>
    <value>viewfs://mycluster</value>
  </property>
  
  <!-- ViewFileSystem 挂载表 -->
  <property>
    <name>fs.viewfs.mounttable.mycluster.link./user</name>
    <value>hdfs://nn1:8020/user</value>
  </property>
  
  <property>
    <name>fs.viewfs.mounttable.mycluster.link./data</name>
    <value>hdfs://nn2:8020/data</value>
  </property>
  
  <property>
    <name>fs.viewfs.mounttable.mycluster.link./logs</name>
    <value>hdfs://nn3:8020/logs</value>
  </property>
</configuration>

客户端使用 ViewFileSystem 统一访问:

# 客户端看到的是统一的文件系统
hdfs dfs -ls /user/hadoop     # 路由到 NameNode1
hdfs dfs -ls /data/warehouse  # 路由到 NameNode2
hdfs dfs -ls /logs/app        # 路由到 NameNode3

6.3 Federation 的优势与限制

优势

  1. 水平扩展:通过增加 NameNode 突破单节点内存限制
  2. 隔离性:不同租户或应用使用独立的 Namespace,故障不互相影响
  3. 性能:每个 NameNode 独立处理请求,避免单点性能瓶颈

限制

  1. 跨 Namespace 操作不支持:无法跨 NameNode 重命名或移动文件
  2. 运维复杂度:需要管理多个 NameNode,规划 Namespace 划分
  3. 负载均衡难题:如果某个 Namespace 热点过高,无法自动迁移

Federation 适合明确的租户隔离场景,但不能解决单个大租户的扩展性问题。对于后者,需要像 Colossus 那样的分布式 Master 设计。

七、客户端直连:数据与控制的分离

7.1 读流程

GFS 和 HDFS 的核心设计思想之一:元数据和数据流分离。客户端从 Master/NameNode 获取元数据,但数据直接在客户端和 ChunkServer/DataNode 之间传输。

HDFS 读文件流程:

// 客户端代码示例
FileSystem fs = FileSystem.get(conf);
FSDataInputStream in = fs.open(new Path("/user/data.txt"));

// 1. 打开文件:联系 NameNode 获取 Block 位置
// 内部调用 NameNode.getBlockLocations("/user/data.txt", 0, fileSize)
// NameNode 返回:[
//   Block1 -> [DataNode1, DataNode2, DataNode3],
//   Block2 -> [DataNode4, DataNode5, DataNode6]
// ]

// 2. 读取数据:直接连接 DataNode
byte[] buffer = new byte[4096];
int bytesRead = in.read(buffer);

// 内部流程:
// - 选择最近的 DataNode(通过网络拓扑)
// - 建立 TCP 连接到 DataNode
// - 发送读请求:ReadBlockOp(blockId, offset, length)
// - DataNode 从磁盘读取数据,返回给客户端
// - 如果 DataNode 失败,自动切换到下一个副本

in.close();

网络拓扑感知:HDFS 通过配置脚本定义机架拓扑,选择最近的 DataNode。

# 网络拓扑脚本示例
#!/bin/bash
# /etc/hadoop/topology.sh

while read -r ip; do
  case $ip in
    10.0.1.*) echo "/rack1" ;;
    10.0.2.*) echo "/rack2" ;;
    10.0.3.*) echo "/rack3" ;;
    *) echo "/default-rack" ;;
  esac
done
<property>
  <name>net.topology.script.file.name</name>
  <value>/etc/hadoop/topology.sh</value>
</property>

以下时序图完整展示了 HDFS 客户端读取一个文件的交互过程,包含 NameNode 元数据查询、DataNode 选择和故障切换:

sequenceDiagram
    participant C as HDFS Client
    participant NN as NameNode
    participant DN1 as DataNode-1(最近)
    participant DN2 as DataNode-2(备选)

    C->>NN: open("/user/data.txt")
    NN->>NN: 检查权限,查找文件元数据
    NN-->>C: 返回 Block 列表及副本位置<br/>[Block1→(DN1,DN2,DN3), Block2→(DN4,DN5,DN6)]

    Note over C,DN2: 按网络拓扑选择最近的 DataNode 读取
    C->>DN1: read(Block1, offset=0, len=128MB)
    DN1-->>C: 返回 Block1 数据(含校验和验证)

    Note over C,DN2: DN1 故障时自动切换副本
    C->>DN1: read(Block2, offset=0, len=128MB)
    DN1--xC: 连接超时
    C->>DN2: read(Block2, offset=0, len=128MB)(故障转移)
    DN2-->>C: 返回 Block2 数据

    C->>NN: reportBadBlocks(Block2, DN1)
    C->>C: close()

该时序图揭示了 HDFS 读取路径的三个关键设计要点。首先,NameNode 仅参与一次元数据查询,后续所有数据传输直接在客户端与 DataNode 之间进行。其次,客户端根据网络拓扑信息(机架感知)选择距离最近的 DataNode,优先读取同机架的副本以减少跨机架流量。最后,当某个 DataNode 不可达时,客户端自动切换到下一个副本并向 NameNode 上报坏块信息,整个过程对上层应用透明。

7.2 写流程

HDFS 写文件涉及数据流水线(Pipeline):

FSDataOutputStream out = fs.create(new Path("/user/output.txt"), (short)3);

// 1. 创建文件:联系 NameNode
// NameNode 检查权限、父目录存在性
// NameNode 在内存中创建 INode,返回成功

// 2. 写数据:客户端缓冲数据,达到 Block 大小时请求分配 Block
out.write(data);

// 内部流程:
// - 客户端向 NameNode 请求:allocateBlock()
// - NameNode 选择 3 个 DataNode(考虑机架感知),返回:[DN1, DN2, DN3]
// - 客户端建立 Pipeline:Client -> DN1 -> DN2 -> DN3
// - 客户端将数据切分为 Packet(默认 64KB)
// - Packet 沿着 Pipeline 流动:
//   * 客户端发送 Packet 到 DN1
//   * DN1 写入本地磁盘,同时转发给 DN2
//   * DN2 写入本地磁盘,同时转发给 DN3
//   * DN3 写入本地磁盘,发送 ACK 给 DN2
//   * DN2 收到 ACK,发送给 DN1
//   * DN1 收到 ACK,发送给客户端
// - 所有 Packet 确认后,客户端向 NameNode 报告 Block 完成

out.close();
// 3. 关闭文件:向 NameNode 发送 complete(),文件变为可见

Pipeline 的容错:如果 DN2 在写入过程中失败:

  1. 客户端检测到 ACK 超时
  2. 关闭当前 Pipeline
  3. 从失败的 Packet 开始,向 NameNode 请求新的 DataNode(DN4)
  4. 建立新 Pipeline:Client -> DN1 -> DN3 -> DN4
  5. 重新发送失败的 Packet 和后续数据
  6. NameNode 后台会删除 DN2 上的不完整 Block

机架感知副本放置策略:

例如:副本 1 在 Rack1/DN1,副本 2 在 Rack2/DN4,副本 3 在 Rack2/DN5。

这样既保证了机架级容错(Rack1 整体失效仍有 Rack2 的两个副本),又减少了跨机架网络流量(只有一次跨机架传输)。

八、GFS 记录追加:宽松的一致性模型

8.1 原子记录追加

GFS 为 MapReduce 等分布式应用设计了特殊的写入语义:原子记录追加(Atomic Record Append)

传统的写入语义(POSIX):客户端指定偏移量,GFS 保证数据写入该偏移量。如果多个客户端并发写同一区域,结果是未定义的(需要应用层加锁)。

GFS 的记录追加语义:客户端不指定偏移量,GFS 保证记录”至少一次”追加到文件末尾,并返回实际的偏移量。多个客户端可以并发追加,GFS 保证每条记录的原子性。

API 示例:

// GFS C++ API(简化)
gfs::WriteHandle handle = gfs::Open("/data/log.txt", gfs::APPEND);

std::string record = "event_12345";
uint64 offset;
gfs::Status status = gfs::RecordAppend(handle, record, &offset);

if (status.ok()) {
  // 记录成功写入,offset 是实际偏移量
  std::cout << "Appended at offset " << offset << std::endl;
} else {
  // 追加失败,需要重试
  std::cerr << "Append failed: " << status.ToString() << std::endl;
}

8.2 一致性保证与语义

GFS 的一致性模型比较宽松,分为几个级别:

Defined(已定义):所有客户端读到相同的数据。单客户端顺序写可以保证 Defined。

Consistent but Undefined(一致但未定义):所有客户端读到相同的数据,但数据可能包含来自多个写入的片段。并发写或失败重试会导致这种状态。

Inconsistent(不一致):不同客户端可能读到不同的数据。Chunk 副本之间短暂的不一致。

记录追加保证:

  1. 原子性:每条记录要么完整写入,要么失败。不会出现半条记录。
  2. 至少一次语义:如果客户端重试,记录可能被追加多次。应用需要处理重复(幂等性或去重)。
  3. 可能有填充或重复:为了对齐 Chunk 边界,GFS 可能在记录间插入填充(padding)。读取时需要跳过填充。
Chunk 1 (64MB)                          Chunk 2 (64MB)
+--------------------------------+      +--------------------------------+
| Record A | Record B | Padding  | -->  | Record C | ...
+--------------------------------+      +--------------------------------+
                      ^
                      |
                 如果 Record C 跨越 Chunk 边界,
                 GFS 会在 Chunk 1 填充剩余空间,
                 将 Record C 完整写入 Chunk 2

8.3 应用如何处理宽松一致性

GFS 的设计哲学:将复杂性推给应用层,换取系统的简单和性能

MapReduce 使用 GFS 的方式:

BigTable 使用 GFS 的方式:

应用层的设计模式:

  1. 记录包含校验和:每条记录包含 CRC32 或 MD5,读取时验证完整性
  2. 记录包含唯一 ID:去重时根据 ID 识别重复记录
  3. 追加后立即读取验证:写入后读取偏移量,验证数据正确
  4. 使用不可变文件:一旦文件写入完成,永不修改,避免复杂的并发控制

九、追加为主:为什么不支持随机写

9.1 追加优先的设计动机

GFS 不是通用文件系统,而是为特定工作负载优化:

批处理为主:MapReduce、日志收集、数据挖掘,这些应用的特点是大量顺序读写,很少随机访问。

一次写入,多次读取(Write-Once-Read-Many,WORM):数据生成后,通常只读不改。修改数据的需求极少。

简化并发控制:追加操作天然追加到文件末尾,不会覆盖现有数据,避免了复杂的锁机制。GFS 只需要一个简单的租约(Lease)机制,指定一个 ChunkServer 作为 Primary,负责排序并发追加请求。

提升性能:顺序写入对磁盘友好(减少寻道),批量操作提升吞吐量。

9.2 随机写的技术挑战

如果 GFS 支持随机写(修改文件中间位置的数据),会面临:

副本一致性复杂化:修改 Chunk 的某个字节范围时,需要保证 3 个副本同时修改。如果某个副本失败,需要回滚或重试,复杂度显著增加。

锁竞争:多个客户端修改同一 Chunk 的不同位置,需要细粒度的锁(如字节范围锁)。这会导致 Master 的锁管理负担急剧增加。

Chunk 版本管理:修改操作需要版本号来区分不同的修改,保证读取到最新数据。追加操作不需要版本,只需要偏移量。

GFS 的选择是:不支持随机写,只支持追加和覆盖整个文件。这大幅简化了实现,满足了 Google 内部 95% 的需求。剩余 5% 需要随机写的应用(如 BigTable),通过在 GFS 之上构建自己的存储层来实现。

9.3 HDFS 的追加支持

HDFS 最初也不支持追加,只支持”创建-写入-关闭”的一次性写入。后来在 Hadoop 0.19 版本引入了追加支持:

// HDFS 追加 API
FileSystem fs = FileSystem.get(conf);
FSDataOutputStream out = fs.append(new Path("/data/log.txt"));
out.write(data);
out.close();

但 HDFS 的追加有限制:

  1. 单客户端追加:同一时间只有一个客户端可以追加同一文件
  2. 需要 Lease:客户端持有 Lease 才能追加,Lease 超时后自动释放
  3. 性能不如 GFS:HDFS 的追加实现较保守,通过 NameNode 协调,性能不如 GFS 的 Primary-Secondary 模式

HDFS 的定位更倾向于”冷数据存储”,而不是高吞吐的追加写入场景。对于日志收集等需要高吞吐追加的场景,通常使用 Kafka 等专用系统,而不是 HDFS。

十、对象存储的崛起:DFS 还有未来吗

10.1 对象存储的优势

2006 年 AWS S3 发布,开创了对象存储的时代。对象存储的核心特点:

API 简单:HTTP REST API,只有 PUT、GET、DELETE、LIST 操作,没有复杂的 POSIX 语义(如目录、重命名、权限)。

# S3 API 示例
aws s3 cp local-file.txt s3://my-bucket/path/to/file.txt
aws s3 ls s3://my-bucket/path/
aws s3 rm s3://my-bucket/path/to/file.txt

无限扩展:对象存储是完全分布式的,没有单点的 Master(或 Master 是分布式的),可以无限扩展到 EB、ZB 规模。

按需付费:用户不需要预先规划容量,按实际使用量付费。不像 HDFS 需要购买和维护物理集群。

内置冗余和跨区域复制:S3、GCS 提供 11 个 9 的耐久性(99.999999999%),数据自动跨多个可用区(AZ)复制。用户不需要担心副本管理。

丰富的生态:对象存储与云服务深度集成,支持 Lambda 触发器、数据湖(Athena、Presto)、机器学习(SageMaker)等。

10.2 分布式文件系统的独特价值

尽管对象存储强大,分布式文件系统仍然在某些场景下不可替代:

低延迟随机访问:HDFS 部署在本地集群,网络延迟通常 < 1ms。S3 的跨互联网访问延迟 50-100ms。对于需要频繁随机读取的应用(如 HBase、Cassandra),本地文件系统更合适。

高吞吐顺序扫描:大数据分析(Spark、Hive)需要扫描 TB 级数据。HDFS 的本地读取可以达到 1GB/s+ 的带宽,而 S3 受限于互联网带宽和请求速率限制(默认每秒 3500 PUT、5500 GET)。

POSIX 语义:某些应用依赖 POSIX 语义,如原子重命名、目录操作、文件锁。对象存储不支持这些操作,或者支持得很弱(S3 的”文件夹”只是前缀约定,不是真正的目录)。

数据本地性(Data Locality):MapReduce、Spark 的性能优化依赖于计算靠近存储(move compute to data)。HDFS 的 DataNode 和计算节点在同一机器上,避免网络传输。使用 S3 时,数据必须跨网络读取,无法利用本地性。

成本:对于拥有物理机房的企业,本地部署 HDFS 的边际成本(磁盘、电力)可能低于云存储的持续费用。虽然云存储不需要前期投资,但长期大规模使用的成本不一定更低。

10.3 混合架构:最佳实践

现代大数据架构往往是混合的:

冷热分离:热数据(频繁访问)存储在 HDFS,冷数据(归档、备份)存储在 S3/GCS。Hadoop 的 Tiering 功能可以自动迁移冷数据。

<!-- HDFS Tiering 配置 -->
<property>
  <name>dfs.storage.policy.enabled</name>
  <value>true</value>
</property>
# 设置存储策略
hdfs storagepolicies -setStoragePolicy -path /data/archive -policy COLD

计算与存储分离:计算集群(Spark、Presto)按需启动,读取 S3 中的数据,计算完成后销毁集群。存储集群(HDFS)持续运行,服务低延迟查询。

S3 作为 HDFS 的备份:定期将 HDFS 数据快照到 S3,作为灾难恢复(DR)方案。

# DistCp 工具:HDFS 到 S3 复制
hadoop distcp hdfs://namenode:8020/data s3a://my-bucket/backup/data

HDFS 兼容层:某些对象存储提供 HDFS 兼容接口,允许 Hadoop 应用无缝接入。例如阿里云 OSS-HDFS、腾讯云 COS-HDFS。

<!-- Hadoop 配置:使用 S3A 文件系统 -->
<property>
  <name>fs.s3a.access.key</name>
  <value>YOUR_ACCESS_KEY</value>
</property>
<property>
  <name>fs.s3a.secret.key</name>
  <value>YOUR_SECRET_KEY</value>
</property>
<property>
  <name>fs.s3a.endpoint</name>
  <value>s3.amazonaws.com</value>
</property>
// Spark 代码:直接读取 S3
SparkSession spark = SparkSession.builder().appName("S3Example").getOrCreate();
Dataset<Row> df = spark.read().parquet("s3a://my-bucket/data/parquet");
df.show();

10.4 未来展望

分布式文件系统不会消失,但会专注于特定场景:

边缘计算和本地优先:在工厂、医院、零售店等边缘场景,本地部署的文件系统提供低延迟和离线能力。

高性能计算(HPC):科学计算、基因测序、天气预测等需要极致性能的场景,分布式文件系统(如 Lustre、BeeGFS)仍然是首选。

混合云和多云:企业不希望完全依赖单一云厂商,自建 HDFS 集群并与多个对象存储互通,保持灵活性。

对象存储会继续侵蚀分布式文件系统的市场,特别是在云原生和容器化场景下。但对于需要极致性能、复杂语义或数据主权控制的应用,分布式文件系统仍然不可或缺。

十一、总结

GFS 论文发表二十年后回看,其设计中的许多权衡和洞察依然深刻:

单 Master 设计:在特定场景下(大文件、追加为主、可接受短暂不可用),单 Master 的简单性胜过分布式 Master 的复杂性。但随着规模增长,这个权衡最终失效,Colossus 和 HDFS Federation 走向了分布式 Master。

数据与控制分离:客户端直连 ChunkServer/DataNode 读写数据,Master/NameNode 只管元数据,这个模式被后续几乎所有分布式存储系统采用。

宽松的一致性模型:GFS 的”至少一次”语义和填充、重复容忍,将复杂性推给应用层。这在当时是务实的选择,但现代系统(如 Kafka、Pulsar)提供了更强的保证。

追加优先语义:针对批处理优化的选择,牺牲了通用性换取性能和简单性。HDFS 继承了这一设计,但也因此失去了在线事务处理(OLTP)的市场。

对象存储的挑战:对象存储的崛起改变了存储格局,但分布式文件系统在低延迟、高吞吐、POSIX 语义等方面仍有独特价值。未来是混合架构,不是非此即彼。

从 GFS 到 HDFS,再到 Colossus 和对象存储,分布式存储的演进反映了技术与需求的共同进化。没有一劳永逸的完美设计,只有在特定约束下的最优解。理解这些权衡背后的原因,比记住具体的架构更有价值。

参考文献

  1. Sanjay Ghemawat, Howard Gobioff, and Shun-Tak Leung. “The Google File System.” SOSP 2003.
  2. Konstantin Shvachko, Hairong Kuang, Sanjay Radia, and Robert Chansler. “The Hadoop Distributed File System.” MSST 2010.
  3. Denis Serenyi et al. “Colossus: Google’s Next-Generation Distributed File System.” Presentation at Google SRE Book Meetup, 2016.
  4. Apache Hadoop Documentation: “HDFS Architecture”, “HDFS High Availability”, “HDFS Federation”.
  5. AWS S3 Documentation: “Amazon S3 Storage Classes”, “S3 Performance Guidelines”.
  6. Benjamin Hindman et al. “Mesos: A Platform for Fine-Grained Resource Sharing in the Data Center.” NSDI 2011. (讨论了 HDFS 与 Mesos 的集成)
  7. Luiz André Barroso and Urs Hölzle. “The Datacenter as a Computer: An Introduction to the Design of Warehouse-Scale Machines.” Morgan & Claypool, 2009. (Google 数据中心设计哲学)
  8. Jeffrey Dean and Sanjay Ghemawat. “MapReduce: Simplified Data Processing on Large Clusters.” OSDI 2004. (GFS 的主要使用者)
  9. Fay Chang et al. “Bigtable: A Distributed Storage System for Structured Data.” OSDI 2006. (基于 GFS 的构建)
  10. Sage Weil et al. “Ceph: A Scalable, High-Performance Distributed File System.” OSDI 2006. (对比 GFS 的另一种设计)

上一篇:Dynamo 论文精读
下一篇:Ceph 与 CRUSH

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13

【分布式系统百科】Dynamo 论文精读:最终一致性的工业级范本

2007 年,Amazon 在 SOSP 会议上发表了《Dynamo: Amazon's Highly Available Key-value Store》论文,这篇论文彻底改变了分布式存储系统的设计思路。与追求强一致性的传统数据库不同,Dynamo 选择了一条完全不同的道路:牺牲一致性,换取可用性和分区容错性。这个设…

2026-04-13

【分布式系统百科】新硬件对分布式系统的冲击

一个 RPC 调用耗时 500 微秒,其中网络往返占了 490 微秒。一次分布式事务需要两轮 RPC,总耗时超过 1 毫秒。为了掩盖这个延迟,工程师不得不引入批处理、异步流水线、预取缓存——系统复杂度因此翻了好几倍。过去三十年,几乎所有分布式系统的设计都建立在一个核心假设之上:网络比本地内存慢三到四个数量级。Share…


By .