上一篇讲了写入路径——客户端通过 Pipeline 把字节推到三个 DataNode。本篇讲读取路径。

HDFS 读文件常被描述成"客户端从 HDFS 把数据读出来"。这个描述和写路径一样把机制掩盖了。准确的说法是:客户端先问 NameNode 拿 block 列表 + 每个 block 的副本所在 DataNode 列表(按距离排序),然后按距离顺序直连 DataNode 拉取数据,读完后切到下一个 block。任何 DataNode 慢或失败时,客户端透明切换到该 block 的下一个副本。

本篇只抓一个问题:客户端怎么挑副本、本地短路与零拷贝怎么工作、Hedged Read 在什么场景下值得打开。

读取路径的五个步骤

一次完整的 HDFS 读展开成五步:

1
2
3
4
5
6
7
1. open(/path) 客户端调用 NameNode 打开文件
2. NameNode 返回:[blk-1 副本列表, blk-2 副本列表, ...] 每个 block 的副本按距离客户端由近到远排序
3. 客户端按 block 顺序处理:
3a. 取 blk-N 的副本列表第一个 DataNode,直连 TCP 拉数据
3b. 拉完 blk-N 切到 blk-N+1,可能换 DataNode
4. 任意读异常:客户端切到 blk-N 的副本列表下一个 DataNode,重试
5. 所有 block 读完后 close()

注意步骤 2 是一次性元数据往返——客户端打开文件时 NameNode 默认会返回整个文件所有 block 的位置(小文件)或第一批 block 位置(大文件分批返回,默认每批若干个 block)。这是 HDFS 读取路径对吞吐的关键优化:客户端拿到 block 列表后所有数据传输都直连 DataNode,不再经过 NameNode。

步骤 3b 决定了 HDFS 大文件读的并发度。每个 block 可能来自不同 DataNode,所以读取一个大文件时,客户端实际上是在和多个 DataNode 通信。客户端可以做到"读完 blk-1 立刻开始读 blk-2",让多个 DataNode 的带宽都被利用。

步骤 4 是 HDFS 容错在读取路径上的具体化。如果某个 DataNode 网络抖动或磁盘故障,客户端切到该 block 副本列表的下一个 DataNode 重试。这种切换对应用层透明——应用层只看到一次 read() 调用可能比平均慢一些。

距离计算:客户端怎么挑副本

NameNode 返回的 block 副本列表不是随机的,而是按"距离客户端由近到远"排序。HDFS 用一个"路径距离"算法计算两个节点之间的距离。

每个 DataNode 在集群拓扑里有一个路径(例如 /default-rack/rack-A/10.0.0.1)。两个节点的距离定义是它们到最近共同祖先的路径长度之和:

1
2
3
4
5
6
7
8
9
                                  / (cluster)
/ \
rack-A rack-B
/ \ / \
10.0.0.1 10.0.0.2 10.0.0.3 10.0.0.4

distance(10.0.0.1, 10.0.0.2) = 2 (共同祖先是 rack-A,每边 1 步)
distance(10.0.0.1, 10.0.0.3) = 4 (共同祖先是 cluster,每边 2 步)
distance(10.0.0.1, 10.0.0.1) = 0 (同一节点)

客户端打开文件时把客户端节点(运行客户端的 JVM 所在主机)告诉 NameNode,NameNode 用上面的距离算法把每个 block 的副本 DataNode 列表按距离排序。距离 0 的 DataNode(同节点)排在最前,距离 2 的(同机架)次之,距离 4 的(跨机架)最后。

这种"距离感知副本排序"带来一个关键收益:客户端读取时优先从本地或本机架 DataNode 拉数据,避开跨机架带宽瓶颈。如果一个 MapReduce 作业的某个 Task 在 10.0.0.1 节点上运行,NameNode 会优先返回 10.0.0.1 上存的 block 副本,让这个 Task 直接本地读,绕过网络。这是 Hadoop “数据本地化”(data locality)优化的基础——计算向数据靠拢,而不是数据向计算传输。

注意"距离感知"依赖集群管理员配置机架脚本。如果没有配置脚本,所有节点都默认在 /default-rack。同节点副本仍然可能排在最前,但机架级故障域和跨机架排序会失真,HDFS 只能在同一个默认机架里做近似排序。

短路读:绕过 DataNode 直接读文件

HDFS 默认读取路径是客户端 → DataNode → TCP socket → 客户端。即使客户端和某个 block 副本在同一个节点上,数据也要走一次 TCP socket(loopback),经过 DataNode 进程的用户态缓冲区。这次 socket 传输虽然走 loopback,但仍然有 kernel ↔ user space 上下文切换开销。

短路读(Short-Circuit Read)是 Hadoop 2.x 引入的优化。如果客户端和某个 block 副本在同一个物理节点上,客户端可以绕过 DataNode 直接读 block 文件。具体机制:

1
2
3
4
1. 客户端询问 DataNode:"这个 block 在你这里吗?"
2. DataNode 验证权限 + 块存在后,把 block 文件的文件描述符(FD)通过 UNIX domain socket 传给客户端
3. 客户端拿到 FD 后直接读文件,绕过 DataNode 进程
4. 客户端读完后关闭 FD

FD 传递是用 sendmsg / recvmsg 系统调用配合 SCM_RIGHTS 实现的,这是 UNIX 系下进程间传递文件描述符的标准机制。客户端拿到 FD 后所有 read() 调用都直接走 kernel 的文件系统层,DataNode 进程完全不参与。

短路读的收益是省掉 DataNode 的用户态缓冲。在 HBase 这类高 QPS、低延迟读场景下,短路读可以显著降低 P99 延迟。在 MapReduce 大文件顺序读场景下,短路读收益不大(瓶颈是磁盘而不是 CPU 上下文切换)。

启用短路读需要在 hdfs-site.xml 配置:

1
2
3
4
5
6
7
8
<property>
<name>dfs.client.read.shortcircuit</name>
<value>true</value>
</property>
<property>
<name>dfs.domain.socket.path</name>
<value>/var/lib/hadoop-hdfs/dn_socket</value>
</property>

dfs.domain.socket.path 是 DataNode 创建的 UNIX domain socket 路径,客户端用这个 socket 跟 DataNode 协商 FD 传递。这个路径在所有需要短路读的节点上必须可访问(包括权限和路径存在性)。

短路读不是默认开启的,因为它要求 HDFS 客户端和 DataNode 在同一台物理机上(典型场景是 HBase RegionServer 与 DataNode 同部署,或 MapReduce Task 与 DataNode 同部署)。如果在多租户集群里客户端进程不在 DataNode 节点上,短路读配置不会生效,会自动回退到 TCP socket 路径。

零拷贝:transferTo 直达 socket

短路读之外,HDFS 还有一个零拷贝优化:DataNode 在传输 block 给客户端时,可以用 FileChannel.transferTo 直接把文件内容从磁盘缓存页(page cache)传到网卡,绕过 DataNode 进程的用户态缓冲。

这种机制依赖 Linux 的 sendfile 系统调用。transferTo 把数据从文件描述符直接传到 socket 描述符,全程在 kernel 态完成,没有 user space ↔ kernel space 之间的数据拷贝。

Hadoop 3.4.1 默认允许 DataNode 使用 transferTodfs.datanode.transferTo.allowed=true)。这条路径服务的是普通 DataNode 到客户端的数据传输;短路读则是本地客户端绕过 DataNode 读取 block 文件,两者是不同优化。在 MapReduce 大文件顺序读场景下,零拷贝可以降低 DataNode CPU 占用,提高单 DataNode 能承载的并发读取数。

零拷贝的限制是不能在传输过程中对数据做修改。如果客户端要解压、解密 block 数据,零拷贝路径不可用,会回退到普通 read + write 路径。

Hedged Read:对抗慢节点

HDFS 集群里偶尔会出现"慢节点"——某个 DataNode 因为磁盘故障、GC 停顿、网络抖动等原因,响应时间显著高于其他 DataNode。这种慢节点对读取延迟的影响被放大:客户端按距离排序选了第一个 DataNode,如果它恰好慢,整个 read 调用就慢。

Hedged Read 是 Hadoop 2.x 引入的对抗慢节点的机制。客户端向第一个副本 DataNode 发出读请求后等待一段时间(Hadoop 3.4.1 默认阈值 500ms,可配 dfs.client.hedged.read.threshold.millis),如果还没收到响应,客户端会向第二个副本 DataNode 并发发出同样的读请求。哪个 DataNode 先返回数据,客户端用哪个的结果;另一个请求被取消。

1
2
3
4
T=0    Client → DN-1:read blk-N
T=500ms 没收到 DN-1 响应
T=500ms Client → DN-2:read blk-N (并发)
T=505ms DN-1 返回,客户端用 DN-1 的结果,取消 DN-2 请求

Hedged Read 的设计假设是慢节点出现概率低,所以并发请求只在少数情况下被触发,整体集群额外负载可控。它主要压低尾延迟,不保证把 P99 固定拉到某个数值;具体收益取决于慢节点比例、阈值和副本分布。

启用 Hedged Read:

1
2
3
4
5
6
7
8
<property>
<name>dfs.client.hedged.read.threadpool.size</name>
<value>10</value>
</property>
<property>
<name>dfs.client.hedged.read.threshold.millis</name>
<value>500</value>
</property>

Hedged Read 不适合所有场景。如果集群慢节点比例高,并发请求会成倍放大 DataNode 网络压力,反而拖慢整体吞吐。Hadoop 3.4.1 的线程池默认值是 0,表示默认不开启;要启用它,需要把 dfs.client.hedged.read.threadpool.size 设为正数,再按集群延迟分布调阈值。MapReduce 大吞吐场景通常更依赖 Task 级别的 Speculative Execution 对抗慢节点。

实验:观察读取路径

实验状态:UNVERIFIED_RUNTIME。下面是验证步骤,本轮没有连接真实 Hadoop 集群执行。

准备一个 HDFS 上 300MB 文件,可以用 Java 客户端代码观察读取路径:

1
2
3
4
5
6
7
8
9
10
11
Configuration conf = new Configuration();
FileSystem fs = FileSystem.get(conf);
try (FSDataInputStream in = fs.open(new Path("/test/test-300mb.bin"))) {
byte[] buf = new byte[1024 * 1024];
long totalRead = 0;
int n;
while ((n = in.read(buf)) > 0) {
totalRead += n;
}
System.out.println("read bytes: " + totalRead);
}

这段代码看似简单,背后走完了完整读取路径:open() 调用 NameNode,拿到 block 列表;in.read() 直连 DataNode 拉数据;切换 block 时自动换 DataNode。

更细的观察可以用 hdfs dfs -D dfs.client.hedged.read.enabled=true -cat /test/test-300mb.bin > /dev/null。如果集群里有慢节点(可以人为限制某个 DataNode 的网卡带宽模拟),开启 Hedged Read 后读取时间会显著下降。

距离计算可以用 hdfs dfsadmin -printTopology 直接观察:

1
2
3
4
5
6
7
8
Cluster topology
=============
/Rack-A
10.0.0.1:9866 (10.0.0.1)
10.0.0.2:9866 (10.0.0.2)
/Rack-B
10.0.0.3:9866 (10.0.0.3)
10.0.0.4:9866 (10.0.0.4)

这个输出展示了集群拓扑。NameNode 在返回 block 副本列表时会按客户端节点到副本节点的拓扑距离排序;-printTopology 本身只显示拓扑结构,不等同于某个文件的副本返回顺序。

短路读是否生效可以在 DataNode 日志里搜 “ShortCircuit” 关键字。如果启用了短路读但日志里没看到相关条目,常见原因是 dfs.domain.socket.path 配置的路径权限不对或路径不存在。

模式提炼

HDFS 读取路径体现的设计模式:

1
2
3
4
5
6
7
模式:副本位置感知 + 客户端择优 + 故障自动切换

- 元数据中枢把每个数据块的副本位置按客户端距离排序后一次性返回
- 客户端拿到位置列表后直连副本节点,绕开元数据中枢
- 当前副本慢或失败时,客户端透明切到列表下一个副本
- 同节点副本可以用 FD 传递绕过数据节点进程
- 慢节点对抗:并发请求多个副本,谁先返回用谁

这个模式不只是 HDFS。Ceph 的客户端读 PG 主 OSD(或指定副本)也按拓扑距离选副本。Cassandra 的客户端读用 LOCAL_ONE / LOCAL_QUORUM 一致性级别控制副本选择范围。Kafka 的消费者默认从 Partition Leader 读,但也支持 follower fetch 让消费者从最近的 follower 读(KIP-392)。

数据库里类似的优化是 MySQL 的"读写分离"——读请求路由到从库,按延迟选最近从库。HDFS 把这种思路用在了文件系统层,每个 block 独立选副本。

工程迁移表

HDFS 读取概念 GFS Ceph Cassandra Kafka
副本距离排序 Master 计算拓扑 CRUSH 算法计算 snitch 计算 rack/dc leader / follower
客户端直连副本 是(默认 leader)
慢副本切换 是( speculative read ) 否(leader 失败才切换)
本地短路读 是(直接 read chunk) 是(rbd 内核模块)
并发副本请求 是(read-repair) KIP-392 follower fetch
数据本地化优化 MapReduce local read CephFS / rados local 否(key-value,没必要)

注意 Kafka 这一列的差异。Kafka 默认消费者只从 Partition Leader 读,不感知 follower 距离。这是因为 Kafka 的一致性模型要求读 leader(offset 是 leader 维护的)。KIP-392 引入了从 follower 读的扩展,但需要客户端显式选择,没有成为默认行为。这是 HDFS(读优先选近的副本)和 Kafka(读默认选 leader)的设计差异。

常见误解

误解一:“HDFS 读取经过 NameNode”。完全不经过。NameNode 只在 open() 时返回 block 列表 + 副本 DataNode 列表,之后所有数据传输都直连 DataNode。这是 HDFS 吞吐能扩展的关键设计——NameNode 不参与任何数据字节传输。

误解二:“短路读总是更快”。短路读的收益是省 DataNode 的用户态缓冲,在低延迟高 QPS 场景下(HBase)收益显著。在大文件顺序读场景下(MapReduce),瓶颈是磁盘带宽而不是 CPU,短路读收益不明显。短路读要求客户端和副本同节点,多租户集群里很多客户端不在 DataNode 节点上,这种场景短路读配置不生效。

误解三:“Hedged Read 让读变快两倍”。Hedged Read 只在慢节点场景下生效。如果集群所有 DataNode 都正常,Hedged Read 永远不会触发并发请求(因为第一个请求总是在阈值内返回)。它的价值是降低 P99,不是降低 P50。在健康的集群上开 Hedged Read 几乎没有额外开销(线程池在等待),但也不会带来性能提升。

误解四:“HDFS 读是 POSIX 一致的”。HDFS 不是 POSIX 文件系统。一个客户端写入并关闭文件后,另一个客户端立刻打开能看到新内容。但同一个文件被多个客户端并发写不允许(lease 机制拒绝),同一客户端写入但未关闭时其他客户端看不到部分内容(hflush / sync 显式刷新可见)。这种"写一次关闭后立即可见、写过程中不可见"的语义简化了实现。

误解五:“距离最近的副本总是最快”。拓扑距离只是近似。两个同机架节点如果在不同交换机/不同 VLAN 上,实际延迟可能比跨机架但同一交换机的两个节点还高。HDFS 的拓扑模型假设集群网络拓扑和实际物理拓扑一致,运维必须保证机架脚本输出的拓扑与实际交换机/链路一致,否则距离排序会失真。

练习

  1. 在能访问的集群上运行 hdfs dfsadmin -printTopology,观察集群拓扑输出。如果没有机架脚本配置,输出会显示所有 DataNode 都在 /default-rack。配置机架脚本后重新运行,观察拓扑变化。

  2. 用 Java HDFS 客户端代码读取一个大文件,在每次 read() 调用前后记录时间戳,找出最慢的 block 读取。如果集群里有 Hedged Read 配置,对比开启和关闭两种情况下的 P99 读延迟。

  3. apache/hadoop 源码里找到 BlockReaderLocal.javaDFSInputStream.java(hadoop-hdfs-client 模块),观察短路读和副本切换的实现。

  4. 思考题:如果客户端在读取过程中崩溃了,已经读了一半的数据怎么办?HDFS 不会自动恢复(不像 Pipeline 写入有 ACK 队列),应用层如何保证读到的数据完整?

系列导航

序号 主题 状态
00 导读:节点总会失败
01 HDFS 架构与三层切分
02 文件写入路径与流水线 上一篇
03 文件读取路径与副本选择 本篇
04 NameNode 内存模型与启动恢复 下一篇
05 HDFS HA 与脑裂防御
06 HDFS 3.x 演进与纠删码
07 YARN 架构与三方契约
08 YARN 资源模型、Container 与 NodeLabel
09 YARN 调度器对比:FIFO、Capacity、Fair
10 YARN 应用程序生命周期
11 YARN HA 与 Federation
12 YARN Timeline Service v2
13 MapReduce 编程模型与分而治之
14 MapReduce Shuffle 全流程
15 MRv2 on YARN ApplicationMaster 与 Task Attempt
16 Hadoop RPC 协议栈
17 序列化与压缩
18 Hadoop 安全 Kerberos Token ProxyUser
19 监控与运维 Metrics JMX 日志聚合
20 Hadoop 生态 Hive HBase Pig
21 Hadoop 与对象存储 Kubernetes 演进对比
22 Hadoop 设计遗产从 GFS MapReduce 到云原生

参考资料