本系列文章分为五篇
- 分布式存储的战争(一)大数据的基石-HDFS的崛起
- 分布式存储的战争(二)对象存储的挑战 MinIO/SeaweedFS/Ceph
- 分布式存储的战争(三)存算分离的必然性-Alluxio/JuiceFS数据编排层
- 分布式存储的战争(四)AI的咆哮-GPFS/Deepseek 3FS 并行文件系统
- 创作中
本文为第一篇
1. 为大数据而生的王者
在HDFS的诞生之前,大容量存储通常都是以一种称为“硬件定义存储”的方式来实现,比如通过构建 SAN(存储区域网络) 等复杂的硬件互联架构来实现。而2003 Google GFS的论文则给了另外一种思路,使用”软件定义存储“,即利用普通商用服务器和通用网络,通过软件层的设计来构建高可靠、高扩展的分布式存储系统。
当时 Doug Cutting 在开发 Nutch 搜索引擎时,面临海量网页数据“存不下、算不动”的难题。受 Google GFS 论文启发,他与 Mike Cafarella 在 Nutch 项目中实现了分布式文件系统(NDFS)和 MapReduce 框架。由于这部分代码具有通用价值,于 2006 年从 Nutch 中剥离出来,成为独立的 Hadoop 项目。HDFS 作为该项目的核心组件,为海量数据提供了底层的分布式存储支持。
本系列文章专注于剖析 HDFS 的主要设计点和其问题,并分析分布式存储是如何兴起的,以及HDFS在云+AI 时代 HDFS 是如何落伍的。Hadoop 的崛起与衰落我会放在后面系列中再讲。
1.1 整体架构
为了能够说明 HDFS 的主要设计点,我们先看一下 HDFS 的架构。如下图所示,HDFS 是非常典型的主从架构,NameNode 是主节点,负责存储文件元信息和物理存储位置信息等;而 DataNode 是从节点,负责存储真实的数据。
在当时的需求和软硬件背景下,Doug Cutting 为 HDFS/Hadoop 定义了几条核心设计原则:
- 硬件故障是常态:即需要做容灾设计,比如主节点 NameNode需要高可用 ,文件需要通过多副本来保证可靠性。
- 大文件存储(GB~TB 级):主要解决大文件在单机条件下无法存储的问题,不考虑小文件的分布式存储。
- 一次写入,多次读取:主要针对性当时的数据分析、构建索引等需求,而引入多次写操作会导致系统过于复杂。
- 移动计算比移动数据更便宜:受限于当时的网络条件,要求存储节点也是计算节点,在计算时尽量在对应的数据节点上完成计算,节省网络开销。
在文件的读取和写入流程方面:
- 读取流程:Client 首先会与 NameNode 建立连接,NameNode会根据客户端的位置信息和文件block的机架等信息来返回给客户端一组DataNode(存储数据的节点),让客户端优先读取距离最近的副本;客户端获取到数据的真实存储节点DataNode后,再请求 DataNode 传输数据。
- 写入流程:(如下图所示)
- 由于文件的大小是不固定的,HDFS 默认采用 128MB 进行切分,进行分块存储。因为假设硬件故障是常态,HDFS 默认是每个文件的分块存储三个副本;当一个副本丢失后,会有另外两个副本兜底。
- HDFS在写入时会采用Pipeline的模式,即Client写入第一个副本,第一个副本在接收数据的同时写入第二个副本,以此往后传递。这样的目的是最大化利用集群带宽资源。
1.2 NameNode 高可用
由于 NameNode 是整个集群的单节点入口,必然就存在 NameNode 宕机进而导致整个集群不可用的风险。
Standby NameNode
在主从架构中,解决「有状态主节点」的高可用(即 SPOF,Single Point of Failure 问题)的方案通常是设置Standby备用节点。正常情况下Standby处于待命状态,当主节点故障时,Standby通过某种选举机制接替主节点工作。HDFS 就采用了这种方案。当 NameNode(这里称 Active NameNode)宕机后,Standby 节点接替工作,从而保证 HDFS 集群可用。这样的选择就引出了两个子问题:
- 如何同步 Active NameNode 的数据至 Standby NameNode,并且保证两者的数据一致
- 在 Active NameNode 宕机后,如何安全地进行 Active NameNode 和 Standby NameNode 的切换
对于第一个问题,实践中的方案可以大致分为两类:
- 状态持久化至第三方存储:典型如 HBase 的 HMaster,将状态持久化至 ZooKeeper 和 HDFS(hbase:meta表),或者Deepseek 3FS将状态持久化至FoundationDB中。
- 自己实现状态持久化与状态同步:以 HDFS 为例,因其自身是最底层的存储系统,选择了自行处理状态持久化与状态同步。
主备 NameNode 数据同步与一致性保证
首先来看NameNode元数据的持久化问题。对于操作元数据的持久化,几乎所有的系统都采用了同一种方式:Write Ahead Log,即在做真正的数据修改前先将这个操作顺序写入存储。
HDFS 采用了一组 JournalNodes(下图深红色块,后续简称为 JNs;Journal 在英语中有“日记”的意思)来记录 NameNode 的 WAL 日志(Write Ahead Log),这个 WAL 日志在 HDFS 被称作 EditLog。
为什么要采用 JournalNodes 这么复杂的系统,而不直接使用一个或多个节点来同步操作信息呢?可以想到最简单的方法就是 Active NameNode 直接将数据发送给 Standby NameNode,问题是 Standby NameNode 也有概率不可用(比如宕机、GC Pause)。那多几个 Standby NameNode 行不行呢?这样又会引入多个 Standby NameNode 数据不一致的问题。到这里实际上就演化成了 JNs (即多个Journal Nodes) 的方案,Journal Nodes 类似于西方的陪审团机制,通常由奇数个节点组成,用于处理节点间数据不一致的问题。
Active NameNode 接收到修改请求后(创建文件、重命名、删除、追加写等),会将这条指令同步发送给 JNs,当收到多数 JNs 的 EditLog 写入成功响应后,Active NameNode 才会返回给客户端修改成功的结果。Standby NameNode 持续监听 JNs 并重放 EditLog,这样来保证自己内存中的元数据和 Active NameNode 是同步的。
故障切换
当 Active NameNode 发生故障时,运行在同一节点上的 ZooKeeper FailoverController(后续简称 ZKFC)检测到了这一事件,或者同一节点上的 ZooKeeper FailoverController 也发生故障。那么这个 ZKFC 就会停止续约 ZooKeeper 上的一个临时节点,ZooKeeper 会删除这个临时节点。
与此同时,其他 Standby NameNode 就会感知到这个事件,并尝试注册新的临时节点。第一个成功注册临时节点的 Standby NameNode 就可能成功 Active NameNode。
在成为真正的 Active NameNode 前,这个 Standby NameNode 附属的 ZKFS 会确保老的 Active NameNode“死透了”且不会“复活”,避免分布式集群中的脑裂问题。通常它会 SSH 登陆至老的 Active NameNode 节点上,亲手“杀死旧主”,这个过程叫做“Fencing Mechanism”(隔离机制)。在确保自己已经完全同步了 JNs 上的 EditLog 后,它就可以“登基”了,成为“唯一的王”。
1.3 POSIX 随机读
POSIX 是一套为类 Unix 操作系统定义的标准接口,旨在促进不同类 Unix 系统间的兼容性,让各类系统遵循统一规范。例如,在任何兼容 POSIX 的系统中,执行 write(fd, buffer, 50)语句,均表示向文件描述符 fd 所对应的文件位置追加写入 50 字节的数据。
HDFS 的后两个字母表明它是文件系统File System,所以它在文件系统设计上部分兼容了 POSIX 协议,支持如随机读、追加写等功能。HBase 作为构建在 HDFS 之上的随机读写数据库,对 HDFS 的随机读和追加写能力有着很强的依赖。这是因为 HBase 的数据存储与读写操作都基于 HDFS 的文件管理能力。
随机读的设计比较简单直接,这里不做赘述。
1.4 POSIX 追加写
早期 HDFS 并不支持追加写,因为这会引入过多复杂度。在 HBase WAL 日志需求的推动下,HDFS 才开始支持追加写。最开始HDFS的文件状态仅有 Under Construction(正在创建中)、Closed(已完成)等。为支持追加写,HDFS 给文件新增了两个状态:Appending(追加中)、Append Recovering(追加恢复中)。
追加写流程可简单概括如下:NameNode 先查找到文件最后一个 Block 的所有副本。数据先写入第一个副本,随后执行 Pipeline 写入(副本 1 在写入的同时也将数据传输给副本 2,副本 2 到副本 3 同理;随后反向链式ACK,副本 3 回复副本 2 ACK,副本 2 回应副本 1 ACK,副本 1 回复客户端 ACK)。写入过程中,若当前 Block 达到 128MB 限制,就新开一个 Block。客户端切换至新 Block 继续 Append。
从上述流程看似乎并不复杂,但实际实现中,HDFS 在最初支持这一特性时遇到诸多困难,出现大量 bug。经过不断优化,直到 HDFS 2.0 版本才稳定可用。
分布式环境下的追加写与单机环境截然不同。在分布式环境中,需考虑网络故障、单节点故障(包括服务端和客户端)等问题。就 HDFS 追加写而言,需着重考虑以下方面:
- 原子性写入:写入操作要么全部完成,要么一个字节都不写入,即要具备撤销写入的机制。
- 读写可见性:文件在追加写时该文件仍然可读(即写锁和读锁不冲突),但读客户端不会读取到正在写入的数据,因为只有所有副本在写入成功之后,文件长度的元信息才会被更新,读客户端才可以看到新追加写的内容。
- 多副本的一致性:在写入过程中,网络故障或单节点故障常可能会导致部分副本的写入丢失了。HDFS 采用流水线写模式(Pipeline Replication),即副本 1 转发写入数据至副本 2,副本 2 再转发至副本 3。若过程中有副本写入失败,则重试或者重建副本,最终再结合文件校验来保证数据完整性。
- 客户端异常与数据恢复:当客户端发生异常时,HDFS 需具备相应机制撤销已写入的数据。
1.5 POSIX 随机写
不幸的是,HDFS 不支持随机写。从图灵完备性来说,HDFS 的设计从理论上是可以支持随机写能力,但 HDFS 并没有做出牺牲复杂度来支持随机写能力。因为 HDFS 从设计上来说是为了支持大数据分析与计算的,并不是像 GlusterFS 这种用于网络文件共享。
在分布式文件系统中引入随机写会带来极高的复杂度(如多副本数据一致性维护、校验和更新等),且性能通常非常差。更重要的是,HDFS 的设计初衷是服务于批处理场景(一次写入,多次读取),随机写这种低延迟、小 IO 的访问模式与其高吞吐的设计目标是背道而驰的。如果业务确实需要随机读写能力,正确的做法是使用上层组件(如 HBase,它将随机写转换为顺序写)或者分布式块存储系统。
实际上,当前几乎所有的大数据场景下分布式文件存储(不包括块存储)都不支持随机写。但在一些面向非大数据场景下的分布式文件系统如 CephFS 确实支持了随机写能力,但通常性能不高,这个我们后续会进行分析。
2. 王者登基:大数据的基石
在只有集中式一体机存储的年代,开源的 HDFS 凭借其能够在廉价普通机器集群上构建 EB 级存储的特性,加之当时互联网迅猛发展,产生的数据规模远超单机存储能力,迅速风靡全球。
HDFS 所属的 Hadoop 生态圈具备一系列强大功能组件。例如,Hive 作为数据仓库,用于数据的存储与查询;HBase 提供大规模列式 KV 存储服务;Iceberg、Hudi、Delta Lake、Paimon 等专注于数据湖存储领域;Spark 则是强大的数据计算引擎。这些组件在海量日志存储与分析、商业 BI 与决策、大型搜索 / 推荐 / 广告引擎建设等场景中得到了广泛应用。几乎每一家信息技术或互联网公司都在使用 HDFS,它当之无愧地成为大数据领域的基石。
然而,时代不断发展,人们对数据存储与处理提出了更高要求,如更大的数据承载量、更低的成本以及更高效的运维。在这些方面,HDFS 逐渐暴露出一些问题。
3. 光环褪去:那些被忽视的问题
3.1 小文件引发的 NameNode 内存瓶颈问题
HDFS 将整个集群文件的元数据信息存储在单一 NameNode 的内存里,包括文件名称、文件夹结构、权限信息、分块信息、节点位置等。不论文件大小,每个文件的元信息大约都会占用 150 Bytes 左右的存储空间。当集群中小文件过多时,NameNode 会出现内存瓶颈问题。
这里的根本原因在于采用单一节点的内存存储方式。假如采用可扩展的分布式节点(比如一致性哈希),或者利用 RocksDB 将元数据存储在单一节点的磁盘上,这个问题就没有这么的突出。
那么 HDFS 为何如此设计呢?HDFS 设计初衷并非用于存储小文件,而是针对大文件。然而,实践中一方面系统中不可避免会存在一些需要存储的小文件,例如当下热门的 Lakehouse 的元数据;另一方面,由于大家习惯了使用 HDFS,且切换到其他存储存在成本,所以即便面对小文件存储需求也懒得更换存储方式。在实际应用中,甚至出现将 HDFS 当作数据库使用的情况(“我不熟悉MySQL,MySQL connector都要倒腾半天,HDFS又不是不能存")。
3.2 NameNode 单点 CPU 处理瓶颈
由于 NameNode 是单点的,当大量的请求时,NameNode 会被大量的请求给捶晕。
但实际在实践中,内存瓶颈的问题总是比 CPU 问题出现的早。通常 CPU 出现瓶颈问题并不是常态,而是偶发问题。而内存瓶颈则不是偶发。因为请求可以随着时间减少,而文件存进去就真的存进去了,要删除就很难了。
3.3 存算一体导致资源扩展成本高
由于 HDFS 的设计原则是「移动计算,而不是移动数据」,在那个带宽受限的时代是非常合理的,甚至在今天对计算时效性敏感的场景依然合理。
但随着时代的发展,需求变得越来越复杂,存储的需求变的更加旺盛,存储和计算需求的增长比例失衡。比如在机器学习场景下,很多样本和特征并不是高频使用,但是需要保留下来以备不时之需。而 HDFS 的存储和计算是绑定在一起的,假如我需要存储一批机器学习的样本数据,即使它使用非常低频,我也需要对存储和计算资源进行对等扩容。这种做法会导致高昂的成本和资源浪费。
3.4 三副本存储导致成本高昂
HDFS 作为构建在普通商用服务器上的分布式存储系统,必须面对硬件故障随时发生的现实。为了保证数据的高可靠性和不丢失,HDFS 默认采用了 “三副本机制” 。这种策略在存储量非常大的时候就显得很不经济了。
实际上,为了保证分布式不丢数有更高明的做法,比如 EC 纠删码某些配置下的存储效率可以比 HDFS 高一倍。这个会在下篇详述。
4. 中兴之志:破局与重塑
HDFS 自己也意识到了这些问题,也在尝试去解决。
4.1 解决 Namenode 单点问题的联邦机制
这个特性是 HDFS 在 2013 年 2.2.0 版本开始稳定支持的。“联邦”这个词可能有点陌生,类比美国就是个联邦制国家,意思是美国这个国家是由多个“邦”或者“州”共同组成,每个“邦”自治,而不是先有了美国,然后划分出的“邦”。
类比到 Namenode 这里,其实原先是一个 Active NameNode,现在就是一组 Active NameNode。HDFS 将原先一个大的 namespace 下的元数据拆分成多个 namespace,每个 Namenode 来负责存储一个 namespace 的元数据,但这组 Namenode 没有一个主节点。
这种做法实际上只是缓解了这个问题,并没有从根本上解决 Namenode 的瓶颈问题。假如一个子 namespace 下的文件太多,这个 namespace 所对应的 Namenode 依然是个瓶颈。
4.2 适配 EC 纠删码解决三副本问题
上文提到过,EC 纠删码的存储效率更高。HDFS 3.0 版本在 2018 年左右引入稳定版的 EC 编码,如果你不了解 EC 编码也没关系,我将会在下篇中详述这一原理。
4.3 Apache Ozone
作为 Hadoop 生态的新一代分布式存储,于 2019 年左右推出正式版本。
Ozone将NameNode切分成两个组件:
- Ozone Manager: 只管理文件层面的元数据,比如namespace、文件名称等。
- Storage Container Manager: 管理文件和物理存储DataNode的关系映射。
并且上述元数据基于 RocksDB 存储,解决了 HDFS 在海量小文件场景下的元数据瓶颈。Ozone 原生兼容 S3 API,支持在 Kubernetes 上运行以实现存算分离,并支持 EC(纠删码) 策略,大幅降低了存储成本。
5. 总结
透过历史的后视镜来看,HDFS 即有过辉煌的历史,是应对超大文件的完美方案,于此对应也有着环境变迁后应对新场景新需求的疲态。这也是大多数系统的命运,技术总是在推陈出新,很少有系统能够躲过这个轮回。
老的技术在设计时总是从当下的角度去审视问题并设计对应的解决方案,比如 HDFS 最开始是为了应对超大文件的分布式存储和分析问题。当环境或者软硬件发生变化时,再牛逼的系统也会渐渐暴露出问题,并渐渐被扫进历史的垃圾堆。
这对我们的启示就是,不要总是去追逐新鲜的技术,而要去深挖技术的本质。不仅仅去看单一存储的设计甚至源码、更要从宏观角度去看技术的变迁和对比。不能仅仅知道是如何设计的,更要知道为什么做出这样的设计。
下一篇,我们将深入剖析对象存储在云原生的浪潮下是如何一步步取代 HDFS,成为新的存储基石。