深入 Hadoop 03 - 文件读取路径与副本选择
上一篇讲了写入路径——客户端通过 Pipeline 把字节推到三个 DataNode。本篇讲读取路径。
HDFS 读文件常被描述成"客户端从 HDFS 把数据读出来"。这个描述和写路径一样把机制掩盖了。准确的说法是:客户端先问 NameNode 拿 block 列表 + 每个 block 的副本所在 DataNode 列表(按距离排序),然后按距离顺序直连 DataNode 拉取数据,读完后切到下一个 block。任何 DataNode 慢或失败时,客户端透明切换到该 block 的下一个副本。
本篇只抓一个问题:客户端怎么挑副本、本地短路与零拷贝怎么工作、Hedged Read 在什么场景下值得打开。
读取路径的五个步骤
一次完整的 HDFS 读展开成五步:
1 | |
注意步骤 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 | |
客户端打开文件时把客户端节点(运行客户端的 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,距离算法失效,NameNode 只能随机返回副本列表,数据本地化优化消失。
短路读:绕过 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 | |
FD 传递是用 sendmsg / recvmsg 系统调用配合 SCM_RIGHTS 实现的,这是 UNIX 系下进程间传递文件描述符的标准机制。客户端拿到 FD 后所有 read() 调用都直接走 kernel 的文件系统层,DataNode 进程完全不参与。
短路读的收益是省掉 DataNode 的用户态缓冲。在 HBase 这类高 QPS、低延迟读场景下,短路读可以显著降低 P99 延迟。在 MapReduce 大文件顺序读场景下,短路读收益不大(瓶颈是磁盘而不是 CPU 上下文切换)。
启用短路读需要在 hdfs-site.xml 配置:
1 | |
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 在 dfs.client.use.datanode.local.read(短路读配置)和 dfs.datanode.transferTo.allowed(DataNode 是否允许 transferTo)两个开关都打开时启用这条路径。在 MapReduce 大文件顺序读场景下,零拷贝可以降低 DataNode CPU 占用,提高单 DataNode 能承载的并发读取数。
零拷贝的限制是不能在传输过程中对数据做修改。如果客户端要解压、解密 block 数据,零拷贝路径不可用,会回退到普通 read + write 路径。
Hedged Read:对抗慢节点
HDFS 集群里偶尔会出现"慢节点"——某个 DataNode 因为磁盘故障、GC 停顿、网络抖动等原因,响应时间显著高于其他 DataNode。这种慢节点对读取延迟的影响被放大:客户端按距离排序选了第一个 DataNode,如果它恰好慢,整个 read 调用就慢。
Hedged Read 是 Hadoop 2.x 引入的对抗慢节点的机制。客户端向第一个副本 DataNode 发出读请求后等待一段时间(默认 50ms,可配 dfs.client.hedged.read.threshold.millis),如果还没收到响应,客户端会向第二个副本 DataNode 并发发出同样的读请求。哪个 DataNode 先返回数据,客户端用哪个的结果;另一个请求被取消。
1 | |
Hedged Read 的设计假设是慢节点出现概率低(比如 1%),所以并发请求只在少数情况下被触发,整体集群额外负载可控。在 1% 慢节点场景下,Hedged Read 让 P99 读延迟降低到接近 P50。
启用 Hedged Read:
1 | |
Hedged Read 不适合所有场景。如果集群慢节点比例高(比如 10%+),并发请求会成倍放大 DataNode 网络压力,反而拖慢整体吞吐。HBase 低延迟场景通常开启 Hedged Read,MapReduce 大吞吐场景通常不开(MapReduce 容忍单 block 慢,靠 Task 级别的 Speculative Execution 对抗慢节点)。
实验:观察读取路径
准备一个 HDFS 上 300MB 文件,用 Hadoop 自带的 fi(fault injection)或者直接用 Java 客户端代码观察读取路径:
1 | |
这段代码看似简单,背后走完了完整读取路径: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 | |
这个输出展示了集群拓扑。NameNode 在返回 block 副本列表时会按客户端节点到副本节点的拓扑距离排序,输出顺序就是客户端优先连接的顺序。
短路读是否生效可以在 DataNode 日志里搜 “ShortCircuit” 关键字。如果启用了短路读但日志里没看到相关条目,常见原因是 dfs.domain.socket.path 配置的路径权限不对或路径不存在。
模式提炼
HDFS 读取路径体现的设计模式:
1 | |
这个模式不只是 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 的拓扑模型假设集群网络拓扑和实际物理拓扑一致,运维必须保证机架脚本输出的拓扑与实际交换机/链路一致,否则距离排序会失真。
练习
-
在能访问的集群上运行
hdfs dfsadmin -printTopology,观察集群拓扑输出。如果没有机架脚本配置,输出会显示所有 DataNode 都在/default-rack。配置机架脚本后重新运行,观察拓扑变化。 -
用 Java HDFS 客户端代码读取一个大文件,在每次 read() 调用前后记录时间戳,找出最慢的 block 读取。如果集群里有 Hedged Read 配置,对比开启和关闭两种情况下的 P99 读延迟。
-
在
apache/hadoop源码里找到BlockReaderLocal.java和DFSInputStream.java(hadoop-hdfs-client 模块),观察短路读和副本切换的实现。 -
思考题:如果客户端在读取过程中崩溃了,已经读了一半的数据怎么办?HDFS 不会自动恢复(不像 Pipeline 写入有 ACK 队列),应用层如何保证读到的数据完整?
系列导航
| 序号 | 主题 | 状态 |
|---|---|---|
| 00 | 导读:Hadoop 的核心前提是节点总会失败 | |
| 01 | HDFS 架构:NameNode、DataNode 与元数据的三层切分 | |
| 02 | 文件写入路径:从客户端到 DataNode 的流水线 | 上一篇 |
| 03 | 文件读取路径:副本选择与短路读 | 本篇 |
| 04 | NameNode 内存模型:FSImage、EditLog 与启动恢复 | 下一篇 |
| 05 | HDFS HA:Quorum Journal Manager、ZKFC 与脑裂防御 | |
| 06 | HDFS 3.x 演进:纠删码、路由联邦与 Observer NameNode |
参考资料
- Sanjay Ghemawat, Howard Gobioff, Shun-Tak Leung. The Google File System. SOSP 2003. Section 3.3 “Record mutations and their append semantics” 描述了 GFS 读取路径的副本选择。
- Apache Hadoop 官方文档:Short-Circuit Local Reads. https://hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-hdfs/ShortCircuitLocalReads.html
- Apache Hadoop 官方文档:Hedged Read. https://hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-hdfs/HdfsDesign.html
- Apache Hadoop 源码:
BlockReaderLocal.java、DFSInputStream.java、PeerServer.java. https://github.com/apache/hadoop - Himabindu Pucha et al. Hedged Requests in HDFS.(Hadoop Summit 2013,描述了 Hedged Read 的设计与生产部署效果)
