全文共 2,734 字 预计阅读 8 分钟
bg

bg3.hdfs

Hdfs 是一个分布式文件系统,简单来说就是把一个大文件切成固定大小的 block、每块存多份副本,分散在多台机器上。通过 NameNode 在 内存中维护全部元数据,用 DataNode 来存储对应的块。DataNode 需要定期向 NameNode 发心跳和块汇报并执行读写。NameNode 就是常驻在内存中的 JVM 堆,存储 目录树、Map<文件, List<块>>、Map<块, List<节点>>。 Block 设置为 128 MB 的原因:

  • 机械盘一次寻道约 10 ms,顺序传输速率约 100 MB/s。
  • 我们希望寻道时间只占一次块读取总时间的一个很小的比例,比如 1%。
  • 那么传输时间应约为 10 ms / 1% = 1 s;1 秒能传 100 MB/s × 1 s = 100 MB。
  • 所以块大小取到 100 MB 量级,寻道开销就被摊薄到可忽略——128 MB 正是这个量级的取整。 3 个副本不是随便扔的。HDFS 默认的放置策略(rack awareness,机架感知)在可靠性和写入带宽之间做权衡:
  • 第 1 副本:放在写入客户端所在的那台 DataNode(若客户端在集群外,则随机挑一台)。
  • 第 2 副本:放到另一个机架的某台节点上。
  • 第 3 副本:放在与第 2 副本同机架、但不同节点上。 什么是机架:机架就是机房里面一排排的机柜,服务器、硬盘都插在机架上,机架之间用交换机互联。不同机架之间的带宽更低、延迟更高。每台 DataNode 启动时,会上报自己所属的机架编号给 NameNode。
sequenceDiagram
    autonumber
    participant C as 客户端 Client
    participant NN as NameNode(元数据)
    participant D1 as DataNode-1
    participant D2 as DataNode-2
    participant D3 as DataNode-3

    C->>NN: 1. create("/logs/a.log") 建文件条目
    NN-->>C: 校验权限/是否已存在,返回OK(此时还没分配块)
    Note over C: 客户端把数据切成 packet,压入内部 data queue
    C->>NN: 2. addBlock() 申请一个新块
    NN-->>C: 返回 blk_1 的 ID + 目标节点[D1,D2,D3](按机架感知选出)
    Note over C,D3: 3. 三个 DataNode 组成写入流水线(pipeline)
    C->>D1: packet 流
    D1->>D2: 边落盘边转发
    D2->>D3: 边落盘边转发
    D3-->>D2: ack
    D2-->>D1: ack
    D1-->>C: ack(沿流水线反向确认)
    Note over C: 块写满→重复步骤2申请下一块,建新流水线
    C->>NN: 4. close() → complete() 收尾,NN 记录块位置最终态
sequenceDiagram
    autonumber
    participant C as 客户端 Client
    participant NN as NameNode(元数据)
    participant D as 最近的 DataNode

    C->>NN: 1. open("/logs/a.log") 请求块位置
    NN-->>C: 2. 返回每个块的副本节点列表,按"离客户端由近到远"排序
    C->>D: 3. 直连最近的副本,流式读取 blk_1
    D-->>C: 块数据字节流
    Note over C: 读完 blk_1 → 找 blk_2 的最近副本,继续
    Note over C,D: 若某副本节点读失败→自动切到下一个副本,并上报 NN 该副本可疑

HDFS 的文件模型是一次写入、追加为主、不支持随机改写(早期连追加都不支持)。因为一个块有 3 份副本、分散在 3 台机器。要支持"改文件第 500 MB 处的 10 个字节",就得让 3 份副本同时、一致地改那一处——这就退化成第 02 篇里那个昂贵的强一致性多副本写协议,延迟和复杂度陡增。HDFS 的取舍是:放弃随机写,只保留"整块顺序写一次 + 反复读",把一致性问题简化成"块要么完整存在、要么不存在"。代价:它天然不适合需要频繁更新单行的场景(那是 OLTP 数据库或 HBase 的活)。但大数据分析恰好是"写一次日志/表,反复扫描分析"——需求和模型正好对上,这也是 HDFS 成为分析型存储地基的根本原因。

  1. NameNode 把全部元数据常驻 JVM 堆内存 —— 目录、文件、块,每一个都是内存里的一个对象。
  2. 经验值:每个文件、目录、块对象在 NameNode 内存里约占 150 字节(这是 Hadoop 社区/Cloudera 长期引用的经验数字,非硬性规范;精确值随版本和对象类型浮动,核实途径:实际压测 NameNode 堆占用,或查对应版本的 NameNode 内存估算文档)。
  3. 一个文件至少 = 1 个文件对象 + 1 个块对象 ≈ 300 字节。
  • 1000 万个小文件(每个 1 个块)≈ 2000 万个对象 × 150 字节 ≈ 约 3 GB NameNode 堆内存。
  • 同样 1000 万 × 128 MB ≈ 1.2 PB 数据如果装在大文件里,块数量少几个数量级,元数据只占很小一块内存。 结论与因果:HDFS 的内存瓶颈不看你存了多少 TB,只看你有多少个文件和块。小文件把"对象数量"这个真正的瓶颈指标顶满,而数据量可能才几百 GB——你为一点点数据付出了撑爆主节点的元数据代价。这也是为什么大数据里反复强调"合并小文件"、Spark 里要控制输出分区数。

对象存储(object storage)是一个通过 HTTP/RESTful 接口访问的、扁平的 key→value 大仓库:key 是一个字符串(对象名),value 是一段字节(对象内容 + 元数据)。没有真正的目录树,只有桶(bucket)和对象(object)。 在 S3/TOS 控制台看到的 logs/2026/07/a.log 像一个多层目录,其实只是一个完整的 key 字符串,/ 只是 key 里的普通字符。系统内部没有 logs/ 这个目录节点,也没有 2026/ 节点。所谓"目录",是控制台按 / 前缀(prefix)帮你模拟出来的视图。 同样存数据,为什么对象存储比自建 HDFS 便宜、还号称"无限"?

  1. 没有 NameNode 式的集中内存元数据瓶颈。对象存储的元数据(key→数据位置)本身是分片、分布式存储的(按 key 哈希打散到很多元数据节点),不塞在单机内存里。所以它的容量不被"某台主节点的内存"卡住——这是它敢说"近乎无限"、也不怕小文件(海量 key)的根本原因,正好补上 HDFS 的短板。
  2. 用纠删码(erasure coding)替代 3 副本。HDFS 3 副本意味着存 1 份数据要占 3 份空间(200% 冗余开销)。对象存储后台普遍用纠删码(例如把数据切 10 块 + 4 块校验,记作 10+4),坏任意 4 块都能恢复,冗余开销约 40%——同样的可靠性,存储成本降到一半以下。代价是写入/恢复要做编解码计算,且小对象用纠删码不划算(所以内部对小对象另有策略)。
  3. 规模效应 + 按量付费。云厂商用海量廉价硬件摊薄单位成本,你用多少付多少,不用像自建 HDFS 那样为了未来增长预留一大堆空转的磁盘。
flowchart TB
    subgraph A["存算一体(HDFS + YARN 同一批机器)"]
        direction TB
        N1["机器1<br/>CPU+磁盘"]
        N2["机器2<br/>CPU+磁盘"]
        N3["机器3<br/>CPU+磁盘"]
        note1["计算任务调度到<br/>数据所在的本机磁盘<br/>→ 数据本地化,省网络<br/>但存储扩容=连CPU一起加"]
    end

    subgraph B["存算分离(计算与存储各自独立)"]
        direction TB
        subgraph BC["计算层(无状态,弹性伸缩)"]
            C1["计算节点"]
            C2["计算节点"]
            C3["用完即释放"]
        end
        subgraph BS["存储层(对象存储 S3/TOS)"]
            OS[("近乎无限<br/>按量付费")]
        end
        BC -- "每次读写都过网络" --> BS
        note2["计算和存储各自<br/>独立扩缩容<br/>但数据不在本地<br/>→ 网络成瓶颈,需缓存补偿"]
    end

存算一体(HDFS + YARN 部署在同一批机器)的优点是数据本地化:计算任务被调度到数据所在的节点,读走本地磁盘,几乎不走网络。这在自建机房、带宽紧张的年代非常划算。 它的结构性缺陷是扩容耦合——用火山官方文档的说法(火山 EMR Proton 文档):HDFS "对存储资源使用多,对计算资源使用少",存储与计算深度绑定。展开因果:

  • 数据涨了要加存储,但机器是"CPU + 磁盘"的整体,你被迫连 CPU 一起买,买回来 CPU 却闲着——为存储付了算力的钱。
  • 大促要加算力跑批,又被迫连磁盘一起加,磁盘却空着。
  • 结果:两种资源永远有一种在空转,利用率上不去;而且集群一旦建起来,想缩容很难(数据在本地,关机=丢副本)。
  • 叠加 NameNode 的内存元数据天花板,整个集群的上限被主节点单点焊死。 解决:
  • 存储交给对象存储——独立扩缩、按量付费、无 NameNode 内存瓶颈。
  • 计算集群做成无状态:需要时拉起一批节点跑完就释放,算力和存储各按各的曲线伸缩,互不牵连。这正是云数仓 / Serverless 数据湖能"按查询付费、闲时零成本"的前提。 代价: 数据不再躺在计算节点本地,每一次读写都要过网络到远端对象存储。第三节反复强调的"数据本地化"优势,在这里被直接放弃了。由此派生:
  1. 网络带宽成为新瓶颈:全集群的扫描流量都压在计算层到对象存储的网络链路上,带宽打满就是查询变慢。
  2. 延迟升高:读本地盘是毫秒级且无网络往返;读对象存储要走一次(或多次)HTTP 请求,RTT 叠加,尤其对海量小请求敏感。
  3. 对象存储特有开销:list 慢、rename 非原子。 怎么补偿——缓存 / 数据本地化优化: 业界的通用解法是在计算节点近端加一层缓存,把热数据和元数据从对象存储拉到本地,重建一部分"本地化"。这不是消灭了代价,而是用一层缓存的复杂度和成本,去换回被网络吃掉的性能——因果闭环。 以火山 EMR 的 Proton 为例(来源:火山 EMR《Proton 存算分离概述》,文档更新于 2024-10-31):它通过实现 HDFS 标准 API,把上层 Hadoop 生态对文件系统的访问透明地映射成对 TOS 的访问,从而平滑迁移;并提供两种模式:
  • 无缓存模式:只用 SDK,官方称性能"可以做到和自建 HDFS 性能持平"。
  • 有缓存模式:用高可用三副本 MetaServer(部署在 Master 节点,缓存元数据)+ 若干 DataServer(部署在 Core 节点,缓存 TOS 对象数据),缓存命中时读吞吐大幅提升;缓存服务不可用时自动 fallback 到 TOS。

Hdfs 的问题:

  • 小文件问题:瓶颈是 NameNode 内存里的对象数量,不是数据量。海量小文件先合并,别硬塞。
  • NameNode 单点与联邦(Federation):
    • 早期 NameNode 是单点:它挂了,整个文件系统不可读写(数据还在 DataNode,但没人知道块在哪)。Hadoop 2.x 用 Standby NameNode + QJM 做了高可用,解决"挂了没人顶"。
    • 但高可用没解决容量上限——Active/Standby 存的是同一份元数据,内存天花板一样。要突破,用 HDFS Federation(联邦):部署多个 NameNode,每个只管理命名空间的一部分(如 /user 归 NN1、/data 归 NN2),把元数据压力水平拆分。代价是运维复杂度上升、跨命名空间的操作更麻烦。
    • 何时不该继续堆 HDFS:当你发现要靠联邦硬扛元数据、且集群还得为存储扩容被迫买算力时,往往就是该考虑存算分离的信号。

对象存储的问题:

  • list 慢:没有真目录,"列出 logs/2026/07/ 下所有对象"其实是按前缀扫描海量 key 的操作,还要分页。目录深、对象多时,list 明显慢于 HDFS 的目录遍历。火山文档亦明确:"TOS 对象存储的 List 操作相对 HDFS 比较耗时"(来源:火山 EMR Proton 文档)。
  • rename 非原子:对象存储没有"改名"这个原语。把 a.log 改名成 b.log,底层是复制一份新 key + 删除旧 key(copy + delete)。这意味着:① 慢(数据要重拷,大对象尤其);② 非原子(拷到一半失败,可能新旧都在或都残缺)。火山文档:"TOS 对象存储的 Rename 操作比较耗时,且原子性语义不容易保证"。 这个 rename 坑影响深远,是一处伏笔:很多计算引擎(如 Spark)默认的作业提交机制,靠"先写临时目录、成功后 rename 到正式目录"来保证输出的原子可见。在对象存储上 rename 不再原子,这套机制就会出问题——这正是数据湖表格式(Iceberg 等)要用元数据/清单文件来管理"哪些文件属于这张表"的重要动因之一。
Back to Blog