HDFS之DataNode工作原理超全总结

来源:互联网 发布:js 如何拖拽 编辑:程序博客网 时间:2024/06/15 02:38

  • 启动流程
  • 存储目录的文件结构以及特点
  • 对存储目录的管理
    • DataStorage
      • DataStorage的升级操作
      • 升级过程中临时文件的用途
      • 数据节点Storage状态机
    • FSDataset
      • 打开数据块writeToBlock
      • 提交数据块finalizeBlock
      • 删除数据块unfinalizeBlock和invalidate
      • 数据节点上数据块的状态及状态转移
  • 流式接口的实现
    • DataXceiverServer和DataXceiver
      • 流式接口实现原理
      • DataXceiverServer
      • DataXceiver
    • 读数据
      • 解析读请求数据帧字段
      • 数据的准备
      • 发送读请求应答头
      • 发送应答数据包
      • 数据的清理
    • 写数据
      • 解析写请求数据帧字段
      • 创建数据块接收器
      • 建立数据流管道
      • 从下游接收应答并向上游转发PacketResponder
      • 从上游接收数据并向下游转发BlockReceiver
      • 将处理结果上报给NameNode
    • 数据块恢复客户端发起
  • 服务线程offerService
    • 发送心跳以及命令的执行
    • 数据块上报
      • 数据块接收上报
      • 数据块信息上报
  • 数据块扫描器

启动流程

  1. 检查配置项${dfs.data.dir}对应的存储目录是否创建,是否具有读写的权限检查;
  2. 获取本节点的主机名称NameNode的地址,以及其他一些运行时需要的配置项
  3. 构造向NameNode注册需要的DataNodeRegistration对象,并在接下来进行一些属性的初始化;
  4. 建立到NameNodeIPC连接,并调用handshake()方法与NameNode进行握手,得到了NamespaceInfo对象,包含了layoutVersionnamespaceIDcTime等信息;
  5. 构造数据节点存储DataStorage对象,执行可能的存储空间状态恢复,接着构造数据节点使用的FSDataset对象;
  6. 创建流式接口服务器DataXceiverServer对象;
  7. 创建数据块扫描器DataBlockScanner对象;
  8. 创建数据节点上的HTTP信息服务器,该服务器提供了数据节点上的运行状态信息
  9. 创建该数据节点IPC服务器,对外提供ClientDatanodeProtocolInterDatanodeProtocol远程接口服务;
  10. 最后,调用NameNode的远程接口register()进行注册,并启动DataNode线程开始对外提供服务。

存储目录的文件结构以及特点

image

  1. blocksBeingWritten:保存了客户端发起的,当前正在写的数据块;

  2. detach:用于配合数据节点升级,==数据块分离==操作时的临时文件夹;

  3. tmp:保存了用于数据块复制时,当前正在写的数据块;

  4. in_user.lock:表示当前目录已经被使用,实现了一种机制,这样DataNode可以独自使用该目录;

  5. current:保存了已写入HDFS文件系统的数据块和一些系统工作时需要的文件;

    • blk_开头的文件HDFS数据块,用来保存HDFS文件内容;
    • .meta后缀文件CRC32校验文件,保存数据块的校验信息;

    current目录达到一定规模时(由配置项${dfs.datanode.numblocks}指定),DataNode会在current目录下新创建一个子目录subdir*,用于保存新的数据块和元数据。

    默认配置下,current目录下最多只有64个数据块(128个文件)和64个子目录。通过这种手段,DataNode既保证目录不会太深,影响文件检索性能,同时也避免了某个目录保存大量的数据块。

对存储目录的管理

数据节点的业务逻辑主要由文件结构对象提供的服务进行管理,数据节点的文件结构管理包括两部分内容:数据节点存储DataStorage文件系统数据集FSDataSet

DataStorage

image

数据节点存储DataStorage是抽象类Storage的子类,而抽象类Storage又继承自StorageInfo。和DataStorage同级的FSImage类主要用于组织NameNode的磁盘数据,FSImage的子类CheckpointStorage,则管理SecondaryNameNode使用的文件结构。

StorageInfo包含了三个重要的共有的属性,包括HDFS存储系统信息结构版本号layoutVersion、存储系统标识namespaceID和存储系统创建时间cTime。这些信息都保存在current目录下的VERSION文件中,典型的VERSION文件内容为:

namespaceID=1301932004storageID=DS-1056522850-172.31.207.102-50010-1498861743747cTime=0storageType=DATA_NODElayoutVersion=-32

StorageInfo的基础上,抽象类Storage可以管理多个目录,存储目录的实现类为StorageDirectory,它是Storage的内部类,提供了在存储目录上的一些通用操作

DataStorage扩展了Storage,专注于数据节点存储空间的生命周期管理,其代码可以分为两个部分:升级相关升级无关的。在数据节点第一次启动时,会调用DataStorage.format()创建存储目录结构,通过删除原有目录及数据,并重新创建目录,然后将VERSION文件的属性赋值,并持久化到磁盘中。

DataStorage的升级操作

HDFS升级时需要复制以前版本的元数据(对NameNode数据(对DataNode。在DataNode上,升级并不需要两倍的集群存储空间,DataNode使用了Linux文件系统的硬链接技术,可以保留对同一个数据块的两个引用,即当前版本以前版本。通过这样的技术,就可以在需要的时候,轻松回滚到以前版本的文件系统。

硬链接是一种特殊的文件系统机制,它允许一个文件可以有多个名称。当一个文件有多个名称时,删除其中的一个名称,并不会删除文件数据,只有所有的文件名被删除后,文件系统才会真正删除文件数据。

好比在Java中,一个对象的多个引用。将其中一个引用置为null,垃圾收集器并不会去回收该对象,只有所有对该对象的引用都断开时,该对象才会被垃圾回收器回收。

  1. 升级操作

    HDFS升级最多保留前一版本的文件系统,没有多版本的升级、回滚机制。同时引入升级提交机制,该机制用于删除以前的版本,所以在升级提交后,就不能回滚到以前版本了。综上所述,DataNode和NameNode升级过程采用了下图的状态机:

    image

    数据节点的升级主要由DataStorage.doUpgrade()方法实现,其中升级过程主要涉及如下几个目录:

    • curDir:当前版本目录,通过StorageDirectory.getCurrentDir()获得,目录名为current
    • prevDir:上一版本目录,目录名为previous
    • tmpDir:上一版本的临时目录,目录名为${dfs.data.dir}/previous.tmp

    DataStorage.doUpgrade()升级流程如下:

    • 确保上述涉及的目录处于正常状态:current存在、previous存在则删除、previous.tmp存在则抛出异常;
    • current目录改名为previous.tmp
    • 使用HardLink,在新创建的current目录下,建立到previous.tmp目录中数据块数据块校验文件的硬链接;
    • 在新的current目录下,创建新版本的VERSION文件。
    • 最后,将previous.tmp目录改名为previous目录,完成升级。

    这时,DataNode上存储着previouscurrent两个目录,而他们包含了同样的数据块和数据块校验文件,但他们有各自的VERSION文件。

  2. 升级回滚

    • 检查previous目录是否存在,如果不存在,则无法回滚;
    • 检查previous目录下的VERSION文件中的layoutVersioncTime,如果preLayoutVersion小于当前系统的HDFS存储系统信息结构版本号或者preCTime小于当期存储系统的创建时间cTime,则无法进行回滚。
    • current目录重命名为removed.tmp
    • previous目录重命名为current
    • 删除removed.tmp目录;
  3. 升级提交

    • previous重命名为finalized.tmp
    • 启动一个线程删除finalized.tmp

升级过程中临时文件的用途

因为升级、升级提交或升级回滚都需要进行一定的耗时操作。在系统升级过程中,doUpgrade()需要建立数据块文件的硬链接,在这一过程中,如果突然出现故障,那么存储空间就有可能处于某一中间状态。因此,引入上述的目录,系统就能够判断目前doUpgrade()处于什么阶段,并采取相应的措施。

数据节点Storage状态机

image

StorageDataStorage提供了一个完美的HDFS数据节点升级机制,Storage状态机、以及配合状态机工作的临时文件,提供了一个完备的升级方法。在升级过程中或者升级回滚过程中的任意步骤出现错误,都可以通过状态机恢复到正常状态。

FSDataset

image

  1. FSDatasetInterface接口

    FSDatasetInterface接口的方法主要分为三类:

    • 数据块相关的方法FSDataset管理了DataNode上的数据块,大量的FSDataset方法和数据块相关,如创建数据块、打开数据块的输入、输出流、提交数据块等;
    • 数据块校验信息文件相关的方法:包括维护数据块和校验信息文件的关系,如校验信息文件输入流等方法。
    • 其他方法:包括FSDataset健康检查、关闭FSDatasetshutDown()等方法。
  2. FSDirFSVolumeFSVolumeSet

    FSDataset借鉴了LVM的一些概念,可以管理多个数据目录,文件数据集将它管理的存储空间分为三个级别,分别用FSDirFSVolumeFSVolumeSet进行抽象。

    • FSDir:表示current目录下的子目录,其成员变量children包含了目录下的所有子目录,形成目录树;
    • FSVolume:表示数据目录配置项${dfs.data.dir}中的一项,DataNode可以管理一个或多个数据目录,系统中也就存在着一个或多个FSVolume对象;
    • FSVolumeSetFSVolume对象由FSVolumeSet管理。
  3. FSDataset的成员变量

    • volumes:管理数据节点所有的存储空间;
    • ongoingCreates:保存着当前正在进行写操作Block和对应文件ActiveFile的映射;
    • volumeMap:保存着已经提交Block和对应文件DatanodeBlockInfo的映射。

打开数据块writeToBlock()

image

数据块分离时,需要进行数据块文件复制,复制过程中的临时文件会保存在${dfs.dir.data}/detach目录中,复制结束后,该临时文件才会被移动到目标目录中。

数据块分离操作

提交数据块finalizeBlock()

用于提交一个被打开的数据块,作用类似于关闭一个打开的文件。提交数据块finalizeBlock()的过程非常简单:

  1. 获得数据块保存在ongoingCreatesvolumeMap中的信息;
  2. 根据这些信息,通过FSVolume.addBlock()方法,将数据文件校验信息文件移动到current的某个子目录下;
  3. 最终,更新volumeMap中保存的信息,并删除在ongoingCreates中的记录。

其中,FSDir对象代表current目录树中的某一个subdir,每个FSDir对象最多保存${dfs.datanode.numblocks}个数据块文件。

删除数据块unfinalizeBlock()和invalidate()

unfinalizeBlock()用于删除复制不成功的数据块;invalidate()用于删除已经提交的数据块,一次性删除比较多的数据块特别耗时,因此invalidate()使用了异步操盘操作服务FSDatasetAsyncDiskServiceFSDataset的成员变量asyncDiskService是该类的子类),降低方法的响应时间。

数据节点上数据块的状态及状态转移

FSDataset是一个比较复杂的类,一方面,它隐藏了数据节点存储空间复杂的结构;另一方面,他需要根据DataNode上层逻辑对数据块的可能操作,维护数据块的正确状态

image

流式接口的实现

为了保证HDFS设计的目标,提供高吞吐的数据访问,数据节点使用基于TCP的流数据访问接口,实现HDFS文件的读写。

DataXceiverServer和DataXceiver

流式接口实现原理

JDK基本套接字提供了java.net.Socketjava.net.ServerSocket类,在服务器端构造ServerSocket对象,并将该对象绑定到某空闲端口上,然后调用accept()方法监听此端口的入站连接。当客户端连接到服务器时,accept()方法会返回一个Socket对象,服务器使用该Socket对象与客户端进行交互,直到一方关闭为止。

而以上的ServerSocketSocket就对应着流式接口中的DataXceiverServerDataXceiver,它们分别实现了对ServerSocketSocket的封装。并采用了一客户一线程(DataXceiver)的方式,满足了数据节点流式接口批量读写数据高吞吐量的特殊要求。

DataXceiverServer

  1. DataXceiverServer包含的成员变量

    • childSockets:包含了所有打开的用于数据传输的Socket,这些SocketDataXceiver对象进行维护;
    • maxXceiverCount:数据节点流式接口能够支持的最大客户端数,由配置项${dfs.datanode.max.xceivers}指定,默认值256。在一个繁忙的集群中,应该适当提高该值。
    • ss:用于监听客户端入站连接的ServerSocket对象;
  2. DataXceiverServer.run()

    DataXceiverServerrun()方法中实现了ServerSocketaccept()循环,也就是说DataXceiverServer用于监听来自客户端或其他DataNode的请求。

    每当阻塞方法accept()返回新的请求时,DataXceiverServer会创建一个新的DataXceiver线程对象,实现一对一的客户服务。

    public void run() {   while (datanode.shouldRun) {       try {           Socket s = ss.accept();           s.setTcpNoDelay(true);           new Daemon(datanode.threadGroup,                   new DataXceiver(s, datanode, this)).start();       } catch (SocketTimeoutException ignored) {           // wake up to see if should continue to run       } catch (AsynchronousCloseException ace) {           datanode.shouldRun = false;       } catch (IOException ie) {       } catch (Throwable te) {           datanode.shouldRun = false;       }   }   try {       ss.close();   } catch (IOException ie) {   }}

DataXceiver

  1. DataXceiver包含的成员变量

    • s:实现与客户端一对一交互的Socket对象;
  2. DataXceiver.run()方法

    DataXceiver.run()实现了管理每个实际Socket请求的输入输出数据流,其执行流程如下:

    • 打开输入流,并读取数据流中请求帧的第一个字段进行版本号检查;
    • 接着检查该请求是否超出数据节点的支撑能力,以确保数据节点的服务质量;
    • 读入请求码,并根据请求码调用相应的方法;

读数据

读数据就是从数据节点的某个数据块中读取一段文件数据,它的操作码为81。当客户端需要读取数据时,它通过和数据节点的TCP连接,发送请求,由于TCP是基于字节流的,没有消息边界的概念,所以,必须在流上定义一个数据帧并通过该数据帧交互消息。

解析读请求数据帧字段

image

  • blockId(数据块ID):要读取的数据块标识,数据节点通过它定位数据块;
  • generationStamp(数据块版本号):用于版本检查,防止读取错误的数据;
  • startOffset(偏移量):要读取的数据位于数据块中的起始位置
  • length(数据长度):客户端需要读取的数据长度;
  • clientName(客户端名字):发起读请求的客户端名字;
  • accessToken(访问令牌):与安全性相关;

数据的准备

数据准备主要由BlockSender的构造函数完成,初始化流程如下:

  1. 完成成员变量的赋值操作;

  2. 准备数据块的校验信息,从数据块的校验信息文件中获取校验方法**checksum、检验和长度checksumSize和每个校验块大小**bytesPerChecksum。

  3. 根据偏移量startOffset和需要读取的数据长度length两个参数,计算能够完整校验的用户读取数据块的范围offsetendOffset

    image

    • 计算offset

      this.offset = (startOffset - (startOffset % bytesPerChecksum));
    • 计算endOffset

      endOffset = blockLength;···if(length >=0){long tmpLen = startOffset + length;if(tmpLen % bytesPerChecksum !=0){ tmpLen += (bytesPerChecksum - tmpLen % bytesPerChecksum);}if(lemLen < endOffset){ endOffset = tmpLen;}}
  4. 计算数据块校验信息文件的读取范围

    if(offset > 0){ long checksumSkip = (offset / bytesPerChecksum) * checksumSize; if(checksumSkip > 0){   IOUtils.skipFully(checksumIn, checksumSkip); }}
  5. 最后,通过FSDataset打开数据块文件输入流。

发送读请求应答头

应答头

  • OP_STATUS_SUCCESS:代表了一次成功的操作,值为0;
  • type:数据的校验方式(CRC-32校验或者空校验),CHECKSUM_CRC32的编码为1,CHECKSUM_NULL为0;
  • bytesPerChecksum:每个校验块大小,默认是512字节,也就是说每512字节产生一个4字节的校验和;
  • offset:可选的偏移量,该偏移量给出了应答数据在数据块中的实际开始位置。

发送应答数据包

image

  1. 读应答数据包的字段

    • packageLen(包长度):从数据长度(length)开始的包长度,包括数据长度字段(4字节)、校验数据数据块数据的长度;
    • offset(偏移量):应答数据位于数据块中的起始位置
    • seqno(顺序号):该数据包在所有应答包中的顺序号
    • tail(最后应答包标志):该数据包是否是最后一个应答包;
    • length(数据长度):包中包含的实际数据data长度;
  2. 数据块发送的方式

    切分block

    通过将Block切分为多个chunk,每 maxChunkPerPacketchunk 组合成一个packet进行发送,并且packet的缓冲区大小采用冗余分配的方式,会为数据块内容预留空间,以防止数据块内容变化的时候重新计算校验和

    数据块内容是否发生变化,可以通过MemoizedBlock.hasBlockChanded()方法进行判断,如果返回true,则在packet的缓冲区中重新计算数据的校验和,然后在发送数据。

    当所有数据发送完毕后,最后会往客户端的输出流中写入0,代表块的读取已经结束。而客户端成功读取一个完整的数据块(包括校验后),会发送一个附加的响应码,通知数据节点校验成功,这个信息会更新在DataBlockScanner中,这样数据块扫描器下次就不需要再次验证该块了。

  3. 零拷贝数据传输

    DataNode是一个I/O密集型的Java应用,为了充分利用Java NIO带来的性能提升,BlockSender能够支持两种数据发送方式:

    • 普通方式:使用传统基于Java流的API
    • NIO方式:利用Java NIO中的transferTo()方法,以零拷贝的方式实现数据传输;

    客户端读取数据块时,DataNode两种发送数据的方式对比:

    image

    • 当通过传统基于Java流的方式发送数据时,首先应用通过Linux内核读出文件数据,这些数据需要跨越内核拷贝到DataNode中;然后,DataNode需要再次将数据推动回内核,通过Linux内核写往套接字;
    • 当利用Java NIOtransferTo()方法时,直接将字节从它被调用的通道上传输到另外一个可写字节通道上,之后在写往网卡缓冲区,数据无需经过应用程序。

    在右侧使用零拷贝时,对比传统方法,控制流上下文切换的次数从4次减少到2次,数据复制次数从4次减少到3次(其中只有一次涉及到CPU)。如果底层网卡支持收集操作,就可以进一步减少内核的数据复制,实现右下角的描述符传递方式。

    不过,以零拷贝进行数据高效的传输,使得数据不经过DataNode,带来的问题是:数据节点失去了在DataNode读取过程中进行数据校验的能力。不过解决方案是:第一、通过数据块扫描器进行数据校验;第二、在客户端进行数据校验,并上报校验结果。

  4. 读写数据的速度控制

    系统的磁盘I/O和网络I/O往往会成为系统的瓶颈:

    • 客户端读取数据的时候需要从磁盘获取数据,同时也需要网络传输数据;
    • 数据节点根据NameNode自行进行的数据块复制也需要从磁盘读取数据;
    • 数据块扫描器在周期性地验证数据节点存储的数据块时,也需要读取磁盘数据;
    • HDFS系统正常工作和负载均衡需要在数据节点间移动数据块;

    这些不同的任务都共享磁盘网络带宽,所以在必要的时候,必须对他们使用磁盘和网络进行一定的速度控制,特别是在数据块扫描器节点间数据块移动时,不能影响系统的对外服务,以保证系统的吞吐量延迟

    节流器BlockTransferThrottler正是为了这样的目的而设计的,为数据节点提供了一个非常简单的流控机制。BlockTransferThrottler主要保证请求者在某一时间段内发送/接收数据的平均速度不超过指定阈值,但是不能保证请求者在指定时间段内均匀的发送/接收数据。在throttle()调用结束后,往往会突然产生大量的IO操作,影响其他共享磁盘和网络带宽任务。

    BlockBalanceThrottler继承了BlockTransferThrottler,在控制IO速度的同时,还控制了共享节流器的实例数。在一个数据节点上,包括发送和接收数据,平衡器Balancer最多能拥有5个工作任务。

数据的清理

清理工作主要由BlockSender.close()完成,主要工作就是关闭可能打开的数据块文件输入流数据块校验信息文件输入流

写数据

写数据的复杂程度远远超过读数据操作,该操作用于往数据节点的某一数据块上追加数据,其操作码为80。HDFS的写数据是通过数据流管道来实现的,其目的是:在写一份数据的多个副本时,可以充分利用集群中每一台机器的带宽,避免网络瓶颈和高延时的连接,最小化推送所有数据的延迟。

数据流管道

假设目前客户端写数据的文件副本数是3,那么在该HDFS集群上,一共有三个数据节点会保存这份数据的三个副本。而Client在发送数据的时候,不是同时往三个数据节点上写数据,而是将数据发送往DataNode1,然后,DataNode1在本地保存数据,同时推送数据到DataNode2,随后照这样进行,直到管道中的最后一个数据节点。这时,确认包由最后一个数据节点产生,并逆流往客户端方向回送,沿途的数据节点在确认本地写成功后,才往上游传递应答。

相对于客户端往多个不同的数据节点同时写数据的方式,处于数据流管道上的每个节点都承担了写数据过程中的部分网络流量,降低了客户端发送多份数据对网络的冲击

解析写请求数据帧字段

  1. blockId(数据块ID):写数据的数据块标识,数据节点通过它定位数据块;
  2. generationStamp(版本号):数据块的版本号,用于进行版本检查
  3. pipelineSize(数据流管道的大小):参与到写过程中所有数据节点的个数
  4. isRecovery(是否是数据恢复过程):本次写操作是否是数据恢复操作发起的;
  5. clientName(客户端名字):发起写请求的客户端名字,可能为空;
  6. hasSrcDataNpde(源信息标记):写请求是否携带源信息,如果为true,则包含源信息;
  7. srcDataNode(源信息,可选):类型为DatanodeInfo,包含发起写请求的数据节点信息;
  8. numTargets(目标数据节点大小):当前数据节点还有多少个下游数据推送目标;
  9. targets(数据目标列表):当前DataNode的下游数据推动目标列表
  10. accessToken(访问令牌):与安全相关;
  11. checksum(数据校验信息):类型为DataChecksum,包含了后续写数据包的校验方式。

创建数据块接收器

BlockReceiver的构造函数中,会使用FSDataset.writeToBlock()方法为写数据块和校验信息文件打开输出数据流。如果在BlockReceiver的构造函数汇总抛出IOException,那么catch子句会先执行资源清理,然后再检查这个异常是不是因为磁盘错误导致的。

建立数据流管道

image

在数据流管道中,顺流的是HDFS的文件数据(粗箭头方向),而写操作的确认包会逆流而上,所以这里需要两个Socket对象。

如果当前数据节点不是数据管道的最末端(targets.length>0),那么当前数据节点就会使用数据目标列表的第一项(targets[0] )建立到下一个数据节点的Socket连接。当连接建立后,通过输出流mirrorOut,往下一个数据节点发起写请求,除了数据目标列表大小字段会相应变化以外,其他字段和从上游读到的请求信息是一致的。

往下一个数据节点的写请求发送以后,当前数据节点会调用mirrorIn.readShort()方法,阻塞等待请求的应答,这是一个同步的过程。对于一个数据节点,往上游返回响应的时机有两个:

  • 建立管道的过程中,当构造数据块接收器对象出现错误时,抛出的异常会被最外层的异常处理捕捉,并在最后利用finally子句,直接关闭到上游的Socket连接,由于上游数据节点由于在流mirrorIn上等待应答,readShort()方法这时候会抛出IOException异常,从而判断下游数据节点出现了问题。在IOException的异常处理中,会往上游发送出错应答,附件的信息包含OP_STATUS_ERROR下一个数据节点 的名称;

  • 为写进行的准备工作完成后返回的成功应答

    • 对于数据流管道中间节点,准备工作完成意味着从流mirrorIn中读到下游的应答,则继续将该成功应答向上游转发;
    • 对于数据流管道末端节点,主要BlockReceiver对象成功构造,准备工作就完成了,这时会往上游节点返回OP_STATUS_SUCCESS

由于数据流中会有多个数据节点,所以建立数据流管道会花比较长的时间,这也是HDFS不适合用于低延迟数据访问场景的原因之一。

从下游接收应答并向上游转发(PacketResponder)

BlockReceiver处理客户端的写数据请求时,方法receiveBlock()接收数据包,校验数据并保存到本地的数据块文件校验信息文件中,如果节点处于数据流管道的中间,它还需要向下游数据节点转发数据包。同时,当前数据节点还需要从下游接收数据包确认,并向上游转发。

因此,数据块接收器引入了PacketResponder线程,它和BlockReceiver所在的线程一起工作,分别用于从下游接收应答并向上游转发从上游接收数据并向下游转发

那为什么需要引入两个线程呢?因为,从输入流中读取数据,如果流中由可读的数据,则立即读取;否则阻塞等待。那么,如果只使用一个线程,轮流读取两个输入流,就会在这两个输入流间引起耦合。

  • 客户端如果长时间不往数据节点发送数据,那么就很可能阻塞了下游确认接收。
  • 另一个极端是,客户端往数据节点写入了大量的数据,但由于处理过程正在等待mirrorIn的输入,也就没有机会进行处理,从而影响了数据的吞吐。

PacketResponder线程将两个输出流(inmirrorIn)的处理过程分开,该线程从下游数据节点接收确认,并在合适的时候,往上游发送,这是合适包括两个条件:

  1. 当前数据节点已经顺利处理完该数据包;
  2. (数据节点处于管道中间时)当前数据节点收到下游数据节点的数据包确认;

如果以上两个条件都满足,说明当前数据节点数据流管道的后续节点都完成了对某个数据包的处理。

PacketResponder中的成员变量ackQueue保存了BlockReceiver线程已经处理的写请求数据包,BlockReceiver.receivePacket()每处理完一个数据包,就通过PacketResponder.enqueue()将对应的信息放入队列中,而队列ackQueue中的信息由PacketResponderrun()方法处理,这是一个典型的生产者——消费者模型。

PacketResponder.run()方法主要分为两个部分:

  1. 等待上述两个条件满足

    通过Java的同步工具wait(),等待ackQueue中的数据,这里wait()等待的是enqueue()方法中的notifyAll()通知。

    如果ackQueue有数据,则获取第一个记录,接着,如果当前数据节点正好位于数据流管道的中间,则在mirrorIn上读取下游确认,如果顺利读取到下游的响应,则表明第一步处理已经完成。

  2. 两个条件满足后接下来的处理

    • 如果处理的是整个写请求的最后一个确认数据包,那么需要执行一下步骤

      if (lastPacketInBlock && !receiver.finalized) { receiver.close();// 关闭数据块接收器对象 block.setNumBytes(receiver.offsetInBlock); // 设置数据块长度 datanode.data.finalizeBlock(block); // 本地提交数据块 datanode.myMetrics.incrBlocksWritten(); // 通知NameNode,完成了一个数据块接收 datanode.notifyNamenodeReceivedBlock(block, DataNode.EMPTY_DEL_HINT);}
    • 无论当前处理的是否是最后一个数据包,也无论当前数据节点是否是管道的最后一个节点,确认包都需要往上游发送。当客户端最终收到确认包时,可以断定数据流管道上的所有数据节点已经接收到对应的数据包。

从上游接收数据并向下游转发(BlockReceiver)

客户端写往数据节点的数据主要由BlockReceiver.receiveBlock()接收处理,而receiveBlock()将数据包的接收和转发工作交给了receviePacket()

  1. receviePacket()数据包的接收和转发
    • 一开始通过readNextPacket()至少读入一个完整的数据包;
    • 从缓冲区内读取数据包的包头信息,包括数据包的长度、数据包在块中的偏移量、数据包的顺序号以及是否是最后一个数据包等信息;
    • 调用setBlockPosition()方法,设置写数据时的文件位置,包括数据块文件和校验信息文件;如果此次写(追加)数据的起始位置落在某个校验块的中间,则需要重新计算校验信息。
    • 如果当前数据节点是数据流管道中的中间节点,则将数据包转发给下游数据节点;
    • 如果当前数据节点是数据流管道的最后一个节点,则需要对数据进行校验。当校验发现问题时,则会上报NameNode,并抛出异常;
    • 最后,将数据块数据和校验文件数据写入磁盘,并将此次的数据包信息通过PacketResponder.enqueue()方法发送给PacketResponder线程。
  2. 所有数据包接收完成后的收尾工作
    • 发送一个空的数据包到下游节点,表示此次数据节点的写操作完全结束;
    • 等待PacketResponder线程结束,表明所有的响应已发送到上游节点,同时本节点的处理结果也已经通知NameNode
    • 关闭文件,并更新数据块文件的长度;
    • 最后,通过FSDataset.finalizeBlock()提交数据块。

将处理结果上报给NameNode

与读操作不同的是,写数据对HDFS文件的数据块进行了修改,因此,操作的处理结果需要上报到NameNodeNameNode后续会进行一些登记、清理工作。

  • PacketResponder顺利发送最后一个数据包确认,并向FSDataset提交数据块以后,通过DataNode.notifyNamenodeReceivedBlock()通知名字节点,确认写数据块成功;
  • 失败的情况可能发生在BlockReceiver.verifyChunks()中,当对输入数据进行校验时,发现数据校验和和请求包的校验和不一致时,会立即调用NameNode.reportBadBlocks()方法通知NameNode,删除数据块。并抛出异常,通知调用者;

由于数据节点上的写操作比较频繁,为了减少到NameNode的数据块提交请求量,数据节点会将多个提交合并成一个请求。所以,notifyNamenodeReceivedBlock()只是简单地在请求队列中插入记录。该队列信息由数据节点的服务线程读出,通过Hadoop远程过程调用DatanodeProtocol.blockReceived()提交到NameNode

数据块恢复(客户端发起)

当客户端写数据时,数据节点出现错误,一般来说,会关闭到上游节点的Socket连接,接着由上游数据节点检测错误并发送携带错误信息的确认包。这样的设计,简化了数据节点写请求处理的实现,把故障恢复工作转移给客户端或者名字节点。

当客户端检测到异常发生后,会过滤掉targets中异常的数据节点,然后在targets中选取一个作为恢复的主数据节点,建立到该节点的IPC连接,并调用该节点的recoverBlock()方法。所以主节点接收到命令的时候同时还收到了该blocktargets 数组(其中就包括该主节点自身)。

/*** @param block      携带了被恢复数据块的信息* @param keepLength 恢复策略的选择*                   1. 如果为true,则只恢复【本地block长度】和【传入数据块长度】相同的数据块*                   2. 如果为false,由主数据节点获取参与到恢复过程中的各个数据节点上的数据块长度,*                   计算最小值,并将这些数据节点上的数据块长度截断到该值* @param targets    参与到恢复过程的数据节点列表(包括主导数据恢复的主数据节点自身)* @return 返回一个带有【新版本号】或者保持【原版本号】的LocatedBlock,但无论是否有新的版本号,但blockToken一定是最新的* @throws IOException*/LocatedBlock recoverBlock(Block block, boolean keepLength, DatanodeInfo[] targets) throws IOException;

客户端发起的数据块恢复流程如下:

  1. 数据块恢复的主数据节点都需要根据InterDatanoeProtocol协议和targets列表中的数据节点建立IPC连接,如果是自己,则返回自身实例;
  2. 接着会获取参与到数据块恢复过程中的各个数据节点上的数据块信息,将这些信息保存到blockRecords中。返回的是BlockRecoveryInfo对象,该对象包含了数据块信息和标志位wasRecoveredOnStartup,该标志位意思是”在数据节点重启时数据块是否已恢复完成“,如果为true,则就过滤掉该数据块,不进行数据块的恢复;
  3. 接着会遍历上一步生成的blocksRecords列表,根据keepLength标志位(客户端发起的数据块恢复时,该值为true),过滤出本地Block长度与恢复数据块长度一致的数据块。不过这里传入恢复的数据块block的长度为0,而本地Block的长度可能有两种情况:
    • 在写数据过程数据校验出错,本地数据块的可见长度可能不为0。这时就会出现syncList为空的情况,则恢复的主数据节点会通知名字节点,删除数据块。接着返回与原数据块版本号相同的LocatedBlock给客户端。
    • 在构建BlockReceiver的时候出错,本地数据块的可见长度为0。这时syncList不为空,这种情况下,syncBlock()方法的主要流程如下:
      • 使用远程方法DatanodeProtocol.nextGenerationStamp()向数据节点申请一个新的数据块版本号,作为恢复后数据块的版本号。这个新的版本号可以防止出现故障的数据节点重新启动后,上报过时的数据块;
      • 使用恢复后的数据块长度和新的数据块版本号,构造新的数据块信息newBlock,再次使用远程接口InterDatanodeProtocol.updateBlock()方法更新这些数据节点上的数据块,将数据块的长度截断到与传入恢复数据块一致的长度(长度为0);
      • 同步操作结束后,通过远程方法commitBlockSynchronization()向名字节点汇报这次数据块恢复的结果。
    • 最后,返回给客户端一个新的block信息,客户端可以重新建立数据流管道,发起一个写操作。

服务线程offerService

发送心跳以及命令的执行

名字节点根据数据节点的定期心跳,判断数据节点是否正常工作。心跳上报过程中,数据节点会发送能够描述当前节点负载的一些信息,如数据节点的存储容量、目前已使用容量等。名字节点根据这些信息估计数据节点的工作状态,均衡各个节点的负载。

if (startTime - lastHeartbeat > heartBeatInterval) {    //    // All heartbeat messages include following info:    // -- Datanode name    // -- data transfer port    // -- Total capacity    // -- Bytes remaining    //    lastHeartbeat = startTime;    DatanodeCommand[] cmds = namenode.sendHeartbeat(      dnRegistration,/*数据节点的标记*/      data.getCapacity(),/*数据节点的存储容量*/      data.getDfsUsed(),/*目前已使用的容量*/      data.getRemaining(),/*剩余容量*/      xmitsInProgress.get(),/*正在进行数据块拷贝的线程数*/      getXceiverCount());/*DataXceiverServer中服务线程数*/    myMetrics.addHeartBeat(now() - startTime);    // LOG.info("Just sent heartbeat, with name " + localName);    if (!processCommand(cmds))        continue;}

远程方法sendHeartbeat()执行结束后,会携带名字节点到数据节点的指令,数据节点执行这些指令,保证HDFS系统的健康、稳定运行。这些指令最后都由DataNode.processCommand()方法处理,方法的主体是一个case语句,根据命令编号执行不同的方法。

  • DNA_INVALIDATE:该指令用于删除数据块扫描器和文件系统数据集中的数据块,FSDataset.invalidate()通过异步磁盘操作服务FSDatasetAsyncDiskServices删除Linux文件系统上的数据块文件和校验信息文件,降低了processCommand()的执行时间。
  • DNA_RECOVERBLOCK:该指令用于恢复数据块,这是由名字节点发起的数据块恢复。该指令可以恢复客户端永久崩溃形成的,还处于写状态的数据块。由名字节点触发的数据块恢复,恢复策略永远是截断keepLength=false),即将数据块恢复到参与到过程的各个数据节点上数据块副本的最小值;同时,恢复过程结束后,主数据节点通知名字节点,关闭上面还处于打开状态的文件。
  • DNA_TRANSFER:该命令的实现是transferBlocks(),会创建一个DataNode.DataTransfer对象,该对象拥有自己的线程,并利用这个线程发起到目标数据节点的写数据操作。

数据块上报

数据块接收上报

前面提到,数据节点的写操作结束后,需要将新的数据块信息上报给NameNode,由于写操作的频繁,这些信息并不会立即上报,而是存储在一个receivedBlockList中。而offserService()每次循环的时候,会扫描该提交请求队列,将队列中的信息解析出DatanodeProtocol.blockReceived()需要的形式,通过远程接口发送给NameNode

数据块信息上报

名字节点保存并持久化了整个文件系统的文件目录树以及文件的数据块索引,但名字节点不持久化数据块的保存位置HDFS启动时,数据节点需要报告它上面保存的数据块信息,帮助名字节点建立数据块和保存数据块的数据节点之间的对应关系。

DataNode的服务线程启动后,主循环offserService()会使用blockReport()方法扫描该数据节点中所有的数据存储目录下存储的所有数据块列表,然后将这些数据块信息序列化成一个长整型数组,发送该数组到名字节点。

当远程方法blockReport()汇报结束后,名字节点会返回一个名字节点指令,数据节点随后将执行该指令。

数据块扫描器

每个数据节点都会执行一个数据块扫描器DataBlockScanner,它周期性地验证节点所存储的数据块,通过DataBlockScanner数据节点可以尽早发现有问题的数据块,并汇报给数据节点。

数据块扫描器是数据节点中一个独立的模块,其类继承结构图如下图所示。扫描器的主要实现DataBlockScanner和辅助类BlockScanInfoLogEntryLogFileHandler等。

数据块扫描器默认情况下会每隔三周扫描一次,会将扫描的结果草训在文件里,以防数据节点重启后丢失。扫描器日志文件保存在${dfs.data.dir}/current/dncp_block_verification.log.curr${dfs.data.dir}/current/dncp_block_verification.log.pre中,日志存储格式如下:

date="2017-07-01 06:31:22,206"   time="1498861882206"    genstamp="1009"     id="1892005245624030777"date="2017-07-01 06:33:58,483"   time="1498862038483"    genstamp="1010"     id="6909827159828588123"date="2017-07-01 06:49:28,316"   time="1498862968316"    genstamp="1011"     id="2894930232784497572"

数据块扫描器DataBlockScanner对数据块的验证是借助BlockSender进行的,其原理是将数据发送到一个空的数据流。这时,BlockSender会在读取数据的过程中进行校验,并将数据写入NullOutputStream中。根据BlockSender.sendBlock()是否抛出异常,即可得到扫描结果,并调用updateScanStatus()更新DataBlockScanner对象的状态并写日志。如果两次检查,数据块都发生错误,verifyBlock()使用handleScanFailure()通知名字节点,报告数据块错误。

数据块扫描器拥有自己的线程,DataBlockScanner.run()会隔一段时间检查DataBlockScanner.blockInfoSet中的第一个记录,并记录结果。由于blockInfoSet中保存的记录按数据块最后一次扫描的时间lastScanTime排序,所以,第一个记录对应的数据块就是最长时间没有检查的数据块