4、HDFS读写流程:客户端读取文件的完整链路、客户端写入文件的完整链路、故障场景下的处理机制
4.1 客户端读取文件:一条完整的请求链路
先聊聊读流程。说实话,HDFS的读操作比写操作简单不少,但坑也不少。
客户端要读一个文件,第一步是调用FileSystem.open()。这个调用会触发一个RPC请求,发给NameNode。NameNode拿到文件路径后,会去查自己的元数据——说白了就是查这个文件被切成了多少个block,每个block又存在哪些DataNode上。
NameNode返回的不是文件内容,而是一份「地址清单」。这份清单里包含了每个block的ID,以及对应的DataNode列表。注意,这个列表是有顺序的——NameNode会按照网络拓扑距离排序,把离客户端最近的DataNode排在最前面。
我当年刚接触HDFS时,以为客户端会一次性把所有block都读完。其实不是。客户端拿到地址清单后,会创建一个FSDataInputStream对象,然后调用read()方法。这个read()会逐个block地去读,读完一个再读下一个。
具体到每个block的读取,流程是这样的:
- 客户端从地址清单中取出当前block的DataNode列表
- 选择列表中的第一个DataNode(网络距离最近的那个)
- 建立TCP连接,发送读取请求
- DataNode从本地磁盘读取block数据,通过socket流式传输给客户端
- 客户端校验数据的完整性(用checksum)
- 读完一个block后,继续读下一个
关键点:客户端只从DataNode读数据,不经过NameNode。NameNode只负责告诉客户端「数据在哪」,不负责传输数据。这就是HDFS能支撑高吞吐的核心原因之一。
4.2 客户端写入文件:三步走战略
写流程就复杂多了。我习惯把它拆成三个阶段:创建文件、写数据、关闭确认。
第一阶段:创建文件
客户端调用FileSystem.create(),NameNode会做一系列检查:文件路径是否存在?父目录是否存在?客户端有没有写权限?都通过了,NameNode就在自己的元数据里创建一个空文件条目,返回一个输出流给客户端。
嗯,这里要注意:这时候文件在HDFS里是「可见但不可读」的状态。为什么?因为还没写入任何数据,block数为0。
第二阶段:写数据
客户端开始写数据时,会先把数据写入本地的缓冲区。缓冲区默认大小是多少?我记得是128MB?不对,那是block大小。缓冲区默认是8KB,但可以通过io.file.buffer.size参数调整。
当缓冲区满了,或者客户端主动调用了flush(),就会触发一个packet的发送。一个packet大概64KB,包含数据块和校验信息。
这里有个重要的设计——pipeline写入。客户端不是把数据同时发给所有副本所在的DataNode,而是串行地发:
- 客户端把packet发给第一个DataNode(DN1)
- DN1收到后,把packet转发给第二个DataNode(DN2)
- DN2收到后,把packet转发给第三个DataNode(DN3)
- DN3写完本地后,返回ack给DN2
- DN2返回ack给DN1
- DN1返回ack给客户端
这个pipeline机制,说白了就是用时间换空间。虽然延迟增加了,但每个DataNode只需要跟一个节点通信,网络开销大大降低。
避坑指南:我曾经遇到过一个案例,集群写入性能突然下降。排查了半天,发现是某个DataNode的磁盘IO被打满了。pipeline中只要有一个节点慢,整个写入链路都会被拖慢。所以监控每个DataNode的健康状态非常重要。
第三阶段:关闭确认
客户端写完所有数据后,调用close()。这时候输出流会把缓冲区里剩余的数据刷出去,然后通知NameNode:文件写完了。
NameNode收到通知后,会做两件事:
- 更新文件的元数据,把block信息正式提交
- 触发后台的block报告核对,确保所有副本都到位了
这时候文件才变成「可读」状态。
4.3 故障场景下的处理机制
分布式系统嘛,故障是常态。HDFS在设计时就考虑了几种典型故障场景。
场景一:DataNode宕机
读操作遇到DataNode宕机怎么办?客户端在读一个block时,如果连接超时或收到错误响应,会立刻从地址清单中选下一个DataNode重试。这个重试是透明的,上层应用完全感知不到。
写操作遇到DataNode宕机呢?pipeline会断开。客户端会收到一个异常,然后做以下处理:
- 把宕机的DataNode从pipeline中移除
- 通知NameNode,这个block的副本数减少了
- NameNode会在后台启动副本复制任务,补足副本数
- 客户端继续用剩下的DataNode完成写入
场景二:NameNode宕机
这个就比较严重了。NameNode是单点,一旦宕机,整个HDFS集群就不可用了。客户端的所有读写请求都会失败。
不过,HDFS有高可用方案(HA)。如果配置了Active/Standby两个NameNode,当Active宕机时,Standby会通过ZooKeeper选举成为新的Active。这个过程大概需要几十秒。
我记得有一次线上故障,NameNode因为Full GC导致长时间无响应。幸好配置了HA,ZooKeeper检测到心跳超时后,自动触发了切换。整个过程大概30秒,客户端那边只是报了几个超时异常,重试后就恢复了。
注意事项:NameNode切换期间,正在执行的写操作会失败。客户端需要捕获异常并重试整个写流程。所以应用层要做好重试逻辑,不能指望HDFS帮你搞定一切。
场景三:网络分区
网络分区是最难处理的故障之一。假设客户端和DataNode之间的网络断了,但DataNode和NameNode之间的网络是好的。这时候会发生什么?
客户端会不断重试,直到超时。DataNode那边呢?它会一直等着客户端的请求,但收不到任何数据。NameNode通过心跳知道DataNode还活着,所以不会把它标记为宕机。
这种场景下,最终结果是客户端超时报错,DataNode上的block写入不完整。NameNode会在超时后,把不完整的block标记为「under replication」,然后启动副本复制。
场景四:磁盘故障
DataNode的磁盘坏了怎么办?DataNode会定期向NameNode发送block报告。如果某个磁盘坏了,DataNode会把这个磁盘上的所有block从报告中移除。NameNode发现这些block的副本数减少了,就会在其他DataNode上创建新的副本。
你想想看,这个过程完全是自动的。运维人员只需要更换坏掉的磁盘,DataNode重新上线后,NameNode会重新分配block到这块新磁盘上。
| 故障类型 | 影响范围 | 恢复机制 | 客户端感知 |
|---|---|---|---|
| DataNode宕机 | 部分block不可读 | 自动切换到其他副本 | 短暂延迟,透明恢复 |
| NameNode宕机 | 整个集群不可用 | HA切换(需配置) | 连接失败,需重试 |
| 网络分区 | 部分客户端不可用 | 超时重试+副本修复 | 超时异常 |
| 磁盘故障 | 部分block丢失 | 自动副本复制 | 无感知 |
最后说一句,HDFS的故障处理机制虽然完善,但也不是万能的。我建议你在设计上层应用时,一定要做好重试和降级策略。毕竟,分布式系统的第一原则就是:假设一切都会失败。