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,而是串行地发:

  1. 客户端把packet发给第一个DataNode(DN1)
  2. DN1收到后,把packet转发给第二个DataNode(DN2)
  3. DN2收到后,把packet转发给第三个DataNode(DN3)
  4. DN3写完本地后,返回ack给DN2
  5. DN2返回ack给DN1
  6. 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的故障处理机制虽然完善,但也不是万能的。我建议你在设计上层应用时,一定要做好重试和降级策略。毕竟,分布式系统的第一原则就是:假设一切都会失败。