如果你只用过 hdfs dfs -put,那么在黑盒视角下,文件进入集群、hdfs fsck 查到三个副本,过程似乎很顺利。但在这条命令背后,客户端和整个集群协同完成了一次"多级流水线写入加逐级确认"的复杂流程:先向 NameNode 申请块,把数据切成 packet,沿着 DataNode 组成的流水线一级级传下去,每一级写完还要反向把 Ack 传回来,最后客户端才敢认为写入成功。而读到这条数据时,又要靠机架感知选出离你最近的副本。
这篇文章会把 HDFS 读写全链路拆开,重点讲清楚三件事:写入时的流水线怎么建立、Ack 怎么逐级回传、机架感知在副本放置和读取选点里到底做了什么。适合刚接触 HDFS 想搞懂原理的开发,也适合被面试题里"HDFS 读写流程"问倒的朋友,以及那些想排查写入慢、副本摆放不合理问题的运维。
1. 先把读写链路的地基打牢:NameNode、DataNode 和客户端各管哪一段
1.1 NameNode 不是文件数据库,它只记"户口"
很多人第一次接触 HDFS 时,会以为 NameNode 像 MySQL 一样存了所有数据。其实 NameNode 只维护文件系统的元数据:路径、权限、文件由哪些 block 组成、每个 block 的副本放在哪些 DataNode 上。真正存储数据的是 DataNode,而且 DataNode 是拿本地磁盘按块存储的,一个 block 对应一个文件,超过一定大小还会分成多个块段。
理解这个分工至关重要。因为后面讲的所有读写流程,本质上都是"客户端找 NameNode 要元数据,再去 DataNode 搬数据"。NameNode 不参与数据搬运,数据搬运是客户端和 DataNode 之间的事。这也解释了为什么 HDFS 适合大文件、流式访问:文件被拆成 128MB 甚至更大的块,读写路径上就能避免每次操作都把所有元数据加载到位,NameNode 内存压力也不会随文件数量线性爆炸。
1.2 DataNode 用"块"为单位存数据,配合心跳上报
DataNode 启动后会向 NameNode 注册并周期发送心跳,同时通过 BlockReport(块报告)把本节点上的所有 block 列表上报给 NameNode。这个设计让 NameNode 永远知道"每个 block 的每个副本在哪"。
但 DataNode 并不是写完数据就立刻上报。实际处理是:DataNode 在写入过程中只负责保存数据和校验和,等一个 block 完整写入且所有副本确认后,由流水线末端的 DataNode 向 NameNode 发起 blockReceivedAndDeleted 调用,通知"这个 block 已经落盘了"。这一步如果做错顺序,比如先上报再刷盘,宕机时就会出现"NameNode 认为有副本、实际磁盘没数据"的严重事故。
1.3 一个高频疑问:客户端写 HDFS 时,有没有"写一半"的中间状态
新手常问:客户端把文件写进 HDFS,如果我写到一半 Ctrl+C 退出,集群里会不会残留半个文件?
答案是会有,但名称节点会通过租约(lease)机制最终回收。客户端打开文件做写入时,会向 NameNode 申请一个租约,持有租约的客户端独占写权限。如果租约超时后客户端仍未续约,NameNode 会强制回收租约,关闭该文件并把未完整写入的 block 标记为不稳定状态,后台线程再把损坏副本清理掉。
这也是为什么 HDFS 写入流程里必须区分"flush"和"close":你不主动 close 文件,数据可能还在客户端缓冲区或 DataNode 的 OS page cache 里,并没有真正落盘。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 写入流程逐拍拆解:create、分包、流水线和 Ack
2.1 create() 阶段就决定了副本往哪摆
客户端调用 FileSystem.create(path) 时,DistributedFileSystem 会通过底层 RPC 向 NameNode 发起 create 操作。NameNode 要在这时做三件事:检查路径是否已存在、检查权限、检查目录配额。通过后,NameNode 会为文件分配一个 block ID,并按机架感知策略预选一组 DataNode(默认三个),作为这个 block 首个 pipeline 的目标列表,返回给客户端。
这里有个容易被忽略的细节:NameNode 此时只会给"第一个 block"分配目标 DataNode,后续 block 是文件在写入过程中按需申请的(通过 addBlock 调用)。所以大文件写入过程中,客户端并不是一次拿到所有块的位置,而是一块一块申请,每块都重新计算一次目标节点。这也让副本放置策略能更动态地适配集群当前状态。
2.2 chunk 与 packet 的封装逻辑:为什么校验和是 512 字节
拿到目标节点后,客户端开始把字节流切块。HDFS 的切块分两层:
- chunk:默认每 512 字节数据配一个 4 字节的 CRC32 校验和。这个 512 字节不是随便定的,太大会让坏块检测粒度变粗,单字节损坏就要重读大量数据;太小则校验和占据过多存储开销。512 字节配 4 字节 CRC32 是历史经验平衡后的选择。
- packet:默认 64KB(由
dfs.client.write.packet-size控制)。一个 packet 里包含若干 chunk,以及一个 packet header,header 里最关键的是包序号 seqno。所有 chunk 的校验和加起来,会在 DataNode 侧做二次校验。
客户端并不是每攒一个 packet 就丢出去,而是先往本地内存队列里放。这个队列就是 dataQueue,它起到削峰填谷的作用。真正发送 packet 的线程是 DFSOutputStream 内部的 DataStreamer,它从 dataQueue 取 packet,发给流水线第一个 DataNode。
2.3 流水线怎么建立,packet 为什么要带 seqno
写入 pipeline 的建立过程可以这样理解:DataStreamer 拿到目标 DataNode 列表(比如 DN1、DN2、DN3),只主动建立到 DN1 的 TCP 连接,同时在协议握手阶段把 DN2、DN3 的地址也一起传给 DN1。DN1 就在服务端继续发起对 DN2 的连接,DN2 再去连 DN3。这样整条链路是"一节节拉起来"的,客户端不需要维护三条连接,资源和超时管理都会简单很多。
流水线建好后,数据就是在 DN1 接收、落一块,同时转发给 DN2,DN2 转发给 DN3,和工厂流水线非常像。packet 里的 seqno 就是这流水线的工单号:每个 DataNode 收到 packet 后,需要根据 seqno 计算这个包在 block 里的偏移,再决定写入本地文件的哪个位置。如果某个包在中途丢了,DataNode 也能通过 seqno 缺口及时发现。
2.4 Ack 回传的全过程,以及 dataQueue 和 ackQueue 的配合
数据级联向后传,确认则是反向传。DN3 落盘并校验成功后,会给 DN2 回一个 Ack packet;DN2 收到后,确认自己也写成功了,才给 DN1 回 Ack;DN1 再汇总自己这级和下游的确认结果,回给客户端。所以客户端收到一个 Ack,代表的是"这个 packet 在全链路每台机器都写成功了",而不是"只有第一台写完了"。
与之配套的是两个队列状态:
- dataQueue:DataStreamer 已经发给第一个 DataNode、但尚未收到 Ack 的 packet。
- ackQueue:packet 已经发送、进入 Ack 等待状态的包。
实际流转是:DataStreamer 从 dataQueue 取包发送,同时把这个包放入 ackQueue;收到最终 Ack 后,才从 ackQueue 移除。如果 ackQueue 里堆积了大量包,说明当前写入链路出现瓶颈,后面读到的指标监控会用到这一点。
2.5 写入中途宕机:流水线重建和未确认 packet 的重放
分布式系统的魅力在故障时体现得最充分。假设现在 DN2 在中途宕机,正在传输的 packet 就会卡在流水线上。客户端会感知到写入异常,然后干这几件事:
- 把 ackQueue 里所有未确认的 packet 重新放回 dataQueue 队首,准备重发。
- 调用 NameNode 的 updatePipeline 或 abandonBlock,让 NameNode 知道当前 pipeline 里的 DN2 已经失效。
- 重新选择一个替代 DataNode,建立新的流水线(比如 DN1、DN3、DN4),并把还没确认的数据从新流水线头部重新写入。
这一套机制保证了"至少一次"的语义。但也意味着,写入过程中的客户端代码必须容忍这种"重发",不能在重发时把已经写了一半的数据再重复追加一遍。好在 HDFS 是按 block 维度管理的,重发的是同一个 block 的后续数据,block 本身的边界不会变,所以不会出现数据错乱。
3. 读取流程逐拍拆解:块定位、就近原则和短路读
3.1 open() 时 NameNode 返回的不是全量列表
读流程比写流程简单得多。客户端调 FileSystem.open(path),NameNode 只是返回文件的 block 位置列表:每个 block 的 ID、长度、以及该 block 的副本所在 DataNode 列表。这里关键点是,NameNode 返回的是按网络距离排序后的"就近优先"列表,而不是随机列表。排序依据就是各 DataNode 与当前客户端的网络拓扑距离。
需要注意,NameNode 在 open 时只返回了元数据,真正的数据连接是客户端后面对 DataNode 建立的。所以读流程里如果客户端进程所在机器不在集群内,距离计算会影响第一个副本的选择,但它拿到的位置列表并不是集群全量状态,而是一个"可用副本优先、距离近的排在前面"的精简结果。
3.2 网络距离怎么参与读取选点
客户端拿到 block 位置列表后,会选排在最前面的 DataNode 发起读取。这个"距离"不是 ping 值,而是机架感知里的拓扑距离。默认情况下,如果客户端正好运行在某台 DataNode 上,而该节点恰好持有目标 block 的副本,客户端就会直接连本机 DataNode,不走网络;否则会选择同一机架内的其它副本;再不行才跨机架。
这里有个很多人忽略的点:读取时并不要求所有副本都返回数据,而是只读一份。机架感知在这时发挥的作用是"尽力让你读到最近的那份",从而减少跨机房、跨交换机的带宽消耗。如果选择的副本读取失败或校验失败,客户端会把它临时加入坏节点名单,然后从列表里换下一个副本重试。
3.3 Short-Circuit Read:客户端与 DataNode 同机时的直通路径
如果客户端进程就和 DataNode 在同一个节点上,还非要跑一遍 TCP 协议栈,那也太浪费了。HDFS 提供了短路读特性:当客户端与 DataNode 同机且开启了 dfs.client.read.shortcircuit=true 时,客户端可以通过 Unix 域套接字直接把块文件描述的句柄传给客户端进程,让客户端直接读本地文件,完全绕过网络层。
但短路读有个前提:需要配置 dfs.domain.socket.path,而且这不是安全漏洞,因为 DataNode 端仍会校验客户端权限。在某些容器化部署里,客户端进程和 DataNode 不再共享本地文件系统,短路读就无法生效。这一点在调优时要特别注意,别以为配置了参数就会自动加速。
3.4 读取过程中的校验与坏块处理
读取时客户端会对每个 chunk 做 CRC 校验,如果发现校验和不一致,说明该副本损坏,会放弃这个副本并换下一个重读。同时这个坏块信息会通过 reportBadBlocks 上报给 NameNode,NameNode 会把它排进坏块列表,未来读该文件时不会再优先返回这个节点,后台还会触发副本复制来恢复副本数。
这个机制直接回答了另一个常见问题:为什么 HDFS 能保证数据不静默损坏。因为每次读取都校验,写的时候也校验,数据从写入到读出全程带着"防伪标识"(CRC32),哪怕只是某一块磁盘上翻转了 1bit,也能被检测到并启动修复。
4. 机架感知:从拓扑树到副本放置策略
4.1 网络拓扑树和距离计算:从两个三层路径算出来的"跳数"
HDFS 把节点位置表示成树形结构,默认是两到三层。比如 /dc1/rack1/node1,表示数据中心 dc1、机架 rack1、节点 node1。网络拓扑距离定义为从两个节点出发向上找最近公共祖先,然后各自到该祖先的跳数之和。
举个例子,看这张表:
| 节点A | 节点B | 最近公共祖先 | 距离 |
|---|---|---|---|
| /d1/r1/n1 | /d1/r1/n1 | /d1/r1/n1 | 0 |
| /d1/r1/n1 | /d1/r1/n2 | /d1/r1 | 2 |
| /d1/r1/n1 | /d1/r2/n3 | /d1 | 4 |
| /d1/r1/n1 | /d2/r1/n4 | / | 6 |
距离 0 代表同一节点,2 说明同机架不同节点,4 说明同数据中心跨机架,6 说明跨数据中心。这个距离值会直接影响读写选副本的优先顺序。
4.2 三副本放置:经典策略为什么是"不同机架 1、2,第三副本回第二副本机架"
HDFS 默认三副本的放置并不是"三个副本三个机架",而是这样的:
- 第一副本:如果客户端恰好是集群内的一个 DataNode,则放本机;否则随机挑一个 DataNode。
- 第二副本:放到与第一副本不同机架的节点。
- 第三副本:放到与第二副本同一机架的另一个节点。
为什么这样放?如果用一句话概括:在容错与写带宽之间做折中。跨机架写数据的带宽成本比同机架高,所以第三副本不放在第三个机架,而是和第二个副本共享机架,节省跨机架带宽;两个不同机架又保证了单个机架断电时集群仍有两个副本存活,容错率不受影响。相比简单的"三副本三机架",这种策略同时考虑了写入性能和数据安全。
在更高版本的 HDFS 中,还引入了基于 nodegroup 的放置策略,把机架进一步划分为更细的故障域,让副本分布颗粒度更精细。但理解经典策略,已经足够帮你分析大多数部署问题。
4.3 机架感知没配置时,HDFS 默认怎么摆
如果集群没有配置机架感知脚本,所有 DataNode 都默认属于 /default-rack。此时任意两个节点的"距离"都是一样的,副本放置策略就退化成随机选择节点,也就是说你没法控制副本是否真的分散到不同机架。
这在单机架测试环境没问题,但生产环境非常危险:如果某个机架断电或交换机故障,存储在该机架的所有副本可能同时不可用,整个 block 就丢了。我见过一些小型 Hadoop 集群,扩容了几十台机器后一直没配机架感知,以为靠副本数就能保平安,结果机房断电时才发现数据不可恢复。所以机架感知在读写流程中的角色,其实比很多人想象的更基础。
4.4 配置方式与验证方法:脚本返回什么,fsck 怎么看摆放结果
配置机架感知通常分两步。先在 hdfs-site.xml 中指定脚本:
xml复制<property>
<name>net.topology.script.file.name</name>
<value>/opt/hadoop/etc/rack-mapping.sh</value>
</property>
脚本接收 DataNode 的 IP 作为参数,输出该节点对应的机架路径,比如:
bash复制#!/bin/bash
ip=$1
case $ip in
192.168.1.*) echo "/dc1/rack1" ;;
192.168.2.*) echo "/dc1/rack2" ;;
*) echo "/default-rack" ;;
esac
配置完成后,重启 NameNode 或触发刷新,再用 hdfs dfsadmin -printTopology 看拓扑树是否按预期生成。更直接的验证是用 hdfs fsck /path/to/file -files -blocks -locations,它会输出每个 block 的各个副本分别落在哪些节点上,你就能肉眼确认副本是否真的跨机架了。
5. 那些文档不写的实践细节:延迟、参数与故障排查
5.1 写入确认级别的取舍:HDFS 并没有 Kafka 里的 acks=0/1/all
很多人把 Kafka 的 ack 机制套到 HDFS 上,问"能不能设置写一半就返回成功"。答案是 HDFS 的写入确认是硬性的:只有 pipeline 上所有 DataNode 都返回成功,客户端才会收到最终确认。你无法设置"只要第一个节点写完就返回"。这是 HDFS 保证数据不丢的基本前提。
但这不代表没有可调参数。写入相关的核心参数是 dfs.replication(默认 3)和 dfs.namenode.replication.min(默认 1)。如果某个 block 在超时时间内写不满副本数,NameNode 会把它标记为 under-replicated,并在后台继续补副本。所以"写成功"和"副本达到 3"之间其实是异步的,看到 fsck 报告 healthy 之前,副本可能还在后台追赶。
5.2 flush 与 hsync:流式写入时必须分清的两个操作
如果你在写实时数据(比如 Flume 或 Spark Streaming 的输出),会用到 FSDataOutputStream.hflush() 或 hsync()。这两个操作都是把客户端缓冲区的数据推出去,但语义不同:hflush 保证数据到达各 DataNode 的 OS 缓存,不保证落盘;hsync 则会等待 DataNode 把数据刷入磁盘后才返回。
对可靠性要求极高的场景,应该用 hsync。但 hsync 会让每个 packet 的写入延迟明显上升,因为每个 DataNode 都要做一次 fsync。我实际操作时,写入吞吐会因此下降 20% 到 50%,所以要在可靠性和速度之间做取舍,而不是盲目追求每次写入都 fsync。
5.3 流水线故障处理的关键参数:replace-datanode-on-failure 与容错阀值
写入流水线中如果某个 DataNode 失败,HDFS 会按策略决定是否替换节点继续写。相关参数是:
| 参数 | 默认值 | 含义 |
|---|---|---|
| dfs.client.block.write.replace-datanode-on-failure.policy | NEVER | 是否允许在流水线中替换失败节点 |
| dfs.namenode.replication.min | 1 | block 至少需要写入几个副本才算"写入成功" |
| dfs.client.block.write.replace-datanode-on-failure.best-effort | true | 如果替换失败是否继续尽力写入 |
其中 NEVER 是较新版默认,意思是"不要在写入过程中替换失败的 DataNode"。这在很多场景下会让写入直接失败,需要客户端重试整个 block。如果你希望故障时能快速恢复写,可以考虑改成 ALWAYS,但要清楚这会导致副本可能被重新分布,块放置不像计划那样均匀。
5.4 通过日志和指标观察数据管线,而不是靠猜
排查写入慢或失败问题时,最直接的方式是看客户端和 DataNode 日志。DataNode 日志里能看到类似 Receiving block blk_xxx 和 Received block 的记录,一旦出现 PacketResponder 相关异常,多半就是流水线中断了。
另一个有用的指标是 DFSClient 的 writePacketDuration 和 ackQueue 长度。如果你在监控里看到 ackQueue 持续不降,说明 packet 发出去后长时间得不到确认,大概率是网络延迟大、某个 DataNode 磁盘 IO 卡顿,或者流水线下游节点已经失联。这种问题靠排查网络比靠调参更有效。
5.5 我踩过的机架感知配置坑
最后分享一个容易忽略的坑。机架感知脚本如果返回异常,或网络抖动导致命令执行超时,DataNode 在 NameNode 的视图里可能被标记为 stale(过期)。stale 节点会被优先排除在读写流程之外,虽然它实际还活着,但集群整体读写性能会下降。
我在一次扩容后就遇到这个情况:新增机器全部进不来,printTopology 里显示它们都被分配到旧机架路径;看了日志发现是脚本读取静态 IP 映射文件时,没有覆盖新机房的网段,导致所有新节点都被 fallback 到 default-rack。所以生产环境的机架映射脚本一定要写异常保护,别让一个解析错误把整个拓扑打乱,否则后续所有读写选点都会受影响。
我在实际集群里见过太多"自认为配了机架感知,其实没有生效"的情况。建议每次扩容后都跑一遍 fsck -files -blocks -locations,抽查几个文件的副本分布,确认不是所有副本都挤在一个机架。毕竟 HDFS 的可靠性设计,是从一个 block 的数据分成三份撒到不同故障域开始的,而这一步就发生在你看不见的读写流程里。
