handshake()
方法与NameNode进行握手,得到了NamespaceInfo对象,包含了layoutVersion、namespaceID、cTime等信息;register()
进行注册,并启动DataNode线程开始对外提供服务。blocksBeingWritten:保存了客户端发起的,当前正在写的数据块;
detach:用于配合数据节点升级,==数据块分离==操作时的临时文件夹;
tmp:保存了用于数据块复制时,当前正在写的数据块;
in_user.lock:表示当前目录已经被使用,实现了一种锁机制,这样DataNode可以独自使用该目录;
current:保存了已写入HDFS文件系统的数据块和一些系统工作时需要的文件;
当current目录达到一定规模时(由配置项${dfs.datanode.numblocks}指定),DataNode会在current目录下新创建一个子目录subdir*,用于保存新的数据块和元数据。
默认配置下,current目录下最多只有64个数据块(128个文件)和64个子目录。通过这种手段,DataNode既保证目录不会太深,影响文件检索性能,同时也避免了某个目录保存大量的数据块。
数据节点的业务逻辑主要由文件结构对象提供的服务进行管理,数据节点的文件结构管理包括两部分内容:数据节点存储DataStorage和文件系统数据集FSDataSet。
数据节点存储DataStorage是抽象类Storage的子类,而抽象类Storage又继承自StorageInfo。和DataStorage同级的FSImage类主要用于组织NameNode的磁盘数据,FSImage的子类CheckpointStorage,则管理SecondaryNameNode使用的文件结构。
StorageInfo包含了三个重要的共有的属性,包括HDFS存储系统信息结构版本号layoutVersion、存储系统标识namespaceID和存储系统创建时间cTime。这些信息都保存在current目录下的VERSION文件中,典型的VERSION文件内容为:
namespaceID=1301932004
storageID=DS-1056522850-172.31.207.102-50010-1498861743747
cTime=0
storageType=DATA_NODE
layoutVersion=-32
在StorageInfo的基础上,抽象类Storage可以管理多个目录,存储目录的实现类为StorageDirectory,它是Storage的内部类,提供了在存储目录上的一些通用操作。
DataStorage扩展了Storage,专注于数据节点存储空间的生命周期管理,其代码可以分为两个部分:升级相关和升级无关的。在数据节点第一次启动时,会调用DataStorage.format()
创建存储目录结构,通过删除原有目录及数据,并重新创建目录,然后将VERSION文件的属性赋值,并持久化到磁盘中。
HDFS升级时需要复制以前版本的元数据(对NameNode)和数据(对DataNode)。在DataNode上,升级并不需要两倍的集群存储空间,DataNode使用了Linux文件系统的硬链接技术,可以保留对同一个数据块的两个引用,即当前版本和以前版本。通过这样的技术,就可以在需要的时候,轻松回滚到以前版本的文件系统。
硬链接是一种特殊的文件系统机制,它允许一个文件可以有多个名称。当一个文件有多个名称时,删除其中的一个名称,并不会删除文件数据,只有所有的文件名被删除后,文件系统才会真正删除文件数据。
好比在Java中,一个对象的多个引用。将其中一个引用置为null,垃圾收集器并不会去回收该对象,只有所有对该对象的引用都断开时,该对象才会被垃圾回收器回收。
升级操作
HDFS升级最多保留前一版本的文件系统,没有多版本的升级、回滚机制。同时引入升级提交机制,该机制用于删除以前的版本,所以在升级提交后,就不能回滚到以前版本了。综上所述,DataNode和NameNode升级过程采用了下图的状态机:
数据节点的升级主要由DataStorage.doUpgrade()
方法实现,其中升级过程主要涉及如下几个目录:
StorageDirectory.getCurrentDir()
获得,目录名为current;DataStorage.doUpgrade()
升级流程如下:
这时,DataNode上存储着previous和current两个目录,而他们包含了同样的数据块和数据块校验文件,但他们有各自的VERSION文件。
升级回滚
升级提交
因为升级、升级提交或升级回滚都需要进行一定的耗时操作。在系统升级过程中,doUpgrade()
需要建立数据块文件的硬链接,在这一过程中,如果突然出现故障,那么存储空间就有可能处于某一中间状态。因此,引入上述的目录,系统就能够判断目前doUpgrade()
处于什么阶段,并采取相应的措施。
Storage和DataStorage提供了一个完美的HDFS数据节点升级机制,Storage状态机、以及配合状态机工作的临时文件,提供了一个完备的升级方法。在升级过程中或者升级回滚过程中的任意步骤出现错误,都可以通过状态机恢复到正常状态。
FSDatasetInterface接口
FSDatasetInterface接口的方法主要分为三类:
shutDown()
等方法。FSDir、FSVolume和FSVolumeSet
FSDataset借鉴了LVM的一些概念,可以管理多个数据目录,文件数据集将它管理的存储空间分为三个级别,分别用FSDir、FSVolume和FSVolumeSet进行抽象。
FSDataset的成员变量
数据块分离时,需要进行数据块文件复制,复制过程中的临时文件会保存在${dfs.dir.data}/detach目录中,复制结束后,该临时文件才会被移动到目标目录中。
用于提交一个被打开的数据块,作用类似于关闭一个打开的文件。提交数据块finalizeBlock()
的过程非常简单:
FSVolume.addBlock()
方法,将数据文件和校验信息文件移动到current的某个子目录下;其中,FSDir对象代表current目录树中的某一个subdir,每个FSDir对象最多保存${dfs.datanode.numblocks}个数据块文件。
unfinalizeBlock()
用于删除复制不成功的数据块;invalidate()
用于删除已经提交的数据块,一次性删除比较多的数据块特别耗时,因此invalidate()
使用了异步操盘操作服务FSDatasetAsyncDiskService(FSDataset的成员变量asyncDiskService是该类的子类),降低方法的响应时间。
FSDataset是一个比较复杂的类,一方面,它隐藏了数据节点存储空间复杂的结构;另一方面,他需要根据DataNode上层逻辑对数据块的可能操作,维护数据块的正确状态。
为了保证HDFS设计的目标,提供高吞吐的数据访问,数据节点使用基于TCP的流数据访问接口,实现HDFS文件的读写。
JDK基本套接字提供了java.net.Socket和java.net.ServerSocket类,在服务器端构造ServerSocket对象,并将该对象绑定到某空闲端口上,然后调用accept()
方法监听此端口的入站连接。当客户端连接到服务器时,accept()
方法会返回一个Socket对象,服务器使用该Socket对象与客户端进行交互,直到一方关闭为止。
而以上的ServerSocket和Socket就对应着流式接口中的DataXceiverServer和DataXceiver,它们分别实现了对ServerSocket和Socket的封装。并采用了一客户一线程(DataXceiver)的方式,满足了数据节点流式接口批量读写数据、高吞吐量的特殊要求。
DataXceiverServer包含的成员变量
DataXceiverServer.run()
DataXceiverServer在run()
方法中实现了ServerSocket的accept()
循环,也就是说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包含的成员变量
DataXceiver.run()
方法
DataXceiver.run()
实现了管理每个实际Socket请求的输入输出数据流,其执行流程如下:
读数据就是从数据节点的某个数据块中读取一段文件数据,它的操作码为81。当客户端需要读取数据时,它通过和数据节点的TCP连接,发送请求,由于TCP是基于字节流的,没有消息边界的概念,所以,必须在流上定义一个数据帧并通过该数据帧交互消息。
数据准备主要由BlockSender的构造函数完成,初始化流程如下:
完成成员变量的赋值操作;
准备数据块的校验信息,从数据块的校验信息文件中获取校验方法**checksum、检验和长度checksumSize和每个校验块大小**bytesPerChecksum。
根据偏移量startOffset和需要读取的数据长度length两个参数,计算能够完整校验的用户读取数据块的范围offset和endOffset;
计算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;
}
}
计算数据块校验信息文件的读取范围
if(offset > 0){
long checksumSkip = (offset / bytesPerChecksum) * checksumSize;
if(checksumSkip > 0){
IOUtils.skipFully(checksumIn, checksumSkip);
}
}
最后,通过FSDataset打开数据块文件输入流。
读应答数据包的字段
数据块发送的方式
通过将Block切分为多个chunk,每 maxChunkPerPacket 个chunk 组合成一个packet进行发送,并且packet的缓冲区大小采用冗余分配的方式,会为数据块内容预留空间,以防止数据块内容变化的时候重新计算校验和。
数据块内容是否发生变化,可以通过MemoizedBlock.hasBlockChanded()
方法进行判断,如果返回true,则在packet的缓冲区中重新计算数据的校验和,然后在发送数据。
当所有数据发送完毕后,最后会往客户端的输出流中写入0,代表块的读取已经结束。而客户端成功读取一个完整的数据块(包括校验后),会发送一个附加的响应码,通知数据节点校验成功,这个信息会更新在DataBlockScanner中,这样数据块扫描器下次就不需要再次验证该块了。
零拷贝数据传输
DataNode是一个I/O密集型的Java应用,为了充分利用Java NIO带来的性能提升,BlockSender能够支持两种数据发送方式:
transferTo()
方法,以零拷贝的方式实现数据传输;客户端读取数据块时,DataNode两种发送数据的方式对比:
transferTo()
方法时,直接将字节从它被调用的通道上传输到另外一个可写字节通道上,之后在写往网卡缓冲区,数据无需经过应用程序。在右侧使用零拷贝时,对比传统方法,控制流上下文切换的次数从4次减少到2次,数据复制次数从4次减少到3次(其中只有一次涉及到CPU)。如果底层网卡支持收集操作,就可以进一步减少内核的数据复制,实现右下角的描述符传递方式。
不过,以零拷贝进行数据高效的传输,使得数据不经过DataNode,带来的问题是:数据节点失去了在DataNode读取过程中进行数据校验的能力。不过解决方案是:第一、通过数据块扫描器进行数据校验;第二、在客户端进行数据校验,并上报校验结果。
读写数据的速度控制
系统的磁盘I/O和网络I/O往往会成为系统的瓶颈:
这些不同的任务都共享磁盘和网络带宽,所以在必要的时候,必须对他们使用磁盘和网络进行一定的速度控制,特别是在数据块扫描器和节点间数据块移动时,不能影响系统的对外服务,以保证系统的吞吐量和延迟。
节流器BlockTransferThrottler正是为了这样的目的而设计的,为数据节点提供了一个非常简单的流控机制。BlockTransferThrottler主要保证请求者在某一时间段内发送/接收数据的平均速度不超过指定阈值,但是不能保证请求者在指定时间段内均匀的发送/接收数据。在throttle()
调用结束后,往往会突然产生大量的IO操作,影响其他共享磁盘和网络带宽任务。
BlockBalanceThrottler继承了BlockTransferThrottler,在控制IO速度的同时,还控制了共享节流器的实例数。在一个数据节点上,包括发送和接收数据,平衡器Balancer最多能拥有5个工作任务。
清理工作主要由BlockSender.close()
完成,主要工作就是关闭可能打开的数据块文件输入流和数据块校验信息文件输入流。
写数据的复杂程度远远超过读数据操作,该操作用于往数据节点的某一数据块上追加数据,其操作码为80。HDFS的写数据是通过数据流管道来实现的,其目的是:在写一份数据的多个副本时,可以充分利用集群中每一台机器的带宽,避免网络瓶颈和高延时的连接,最小化推送所有数据的延迟。
假设目前客户端写数据的文件副本数是3,那么在该HDFS集群上,一共有三个数据节点会保存这份数据的三个副本。而Client在发送数据的时候,不是同时往三个数据节点上写数据,而是将数据发送往DataNode1,然后,DataNode1在本地保存数据,同时推送数据到DataNode2,随后照这样进行,直到管道中的最后一个数据节点。这时,确认包由最后一个数据节点产生,并逆流往客户端方向回送,沿途的数据节点在确认本地写成功后,才往上游传递应答。
相对于客户端往多个不同的数据节点同时写数据的方式,处于数据流管道上的每个节点都承担了写数据过程中的部分网络流量,降低了客户端发送多份数据对网络的冲击。
在BlockReceiver的构造函数中,会使用FSDataset.writeToBlock()
方法为写数据块和校验信息文件打开输出数据流。如果在BlockReceiver的构造函数汇总抛出IOException,那么catch子句会先执行资源清理,然后再检查这个异常是不是因为磁盘错误导致的。
在数据流管道中,顺流的是HDFS的文件数据(粗箭头方向),而写操作的确认包会逆流而上,所以这里需要两个Socket对象。
如果当前数据节点不是数据管道的最末端(targets.length>0),那么当前数据节点就会使用数据目标列表的第一项(targets[0] )建立到下一个数据节点的Socket连接。当连接建立后,通过输出流mirrorOut,往下一个数据节点发起写请求,除了数据目标列表大小字段会相应变化以外,其他字段和从上游读到的请求信息是一致的。
往下一个数据节点的写请求发送以后,当前数据节点会调用mirrorIn.readShort()
方法,阻塞等待请求的应答,这是一个同步的过程。对于一个数据节点,往上游返回响应的时机有两个:
建立管道的过程中,当构造数据块接收器对象出现错误时,抛出的异常会被最外层的异常处理捕捉,并在最后利用finally子句,直接关闭到上游的Socket连接,由于上游数据节点由于在流mirrorIn上等待应答,readShort()
方法这时候会抛出IOException异常,从而判断下游数据节点出现了问题。在IOException的异常处理中,会往上游发送出错应答,附件的信息包含OP_STATUS_ERROR和下一个数据节点 的名称;
为写进行的准备工作完成后返回的成功应答
由于数据流中会有多个数据节点,所以建立数据流管道会花比较长的时间,这也是HDFS不适合用于低延迟数据访问场景的原因之一。
当BlockReceiver处理客户端的写数据请求时,方法receiveBlock()
接收数据包,校验数据并保存到本地的数据块文件和校验信息文件中,如果节点处于数据流管道的中间,它还需要向下游数据节点转发数据包。同时,当前数据节点还需要从下游接收数据包确认,并向上游转发。
因此,数据块接收器引入了PacketResponder线程,它和BlockReceiver所在的线程一起工作,分别用于从下游接收应答并向上游转发和从上游接收数据并向下游转发。
那为什么需要引入两个线程呢?因为,从输入流中读取数据,如果流中由可读的数据,则立即读取;否则阻塞等待。那么,如果只使用一个线程,轮流读取两个输入流,就会在这两个输入流间引起耦合。
- 客户端如果长时间不往数据节点发送数据,那么就很可能阻塞了下游确认接收。
- 另一个极端是,客户端往数据节点写入了大量的数据,但由于处理过程正在等待mirrorIn的输入,也就没有机会进行处理,从而影响了数据的吞吐。
PacketResponder线程将两个输出流(in和mirrorIn)的处理过程分开,该线程从下游数据节点接收确认,并在合适的时候,往上游发送,这是合适包括两个条件:
如果以上两个条件都满足,说明当前数据节点和数据流管道的后续节点都完成了对某个数据包的处理。
PacketResponder中的成员变量ackQueue保存了BlockReceiver线程已经处理的写请求数据包,BlockReceiver.receivePacket()
每处理完一个数据包,就通过PacketResponder.enqueue()
将对应的信息放入队列中,而队列ackQueue中的信息由PacketResponder的run()
方法处理,这是一个典型的生产者——消费者模型。
PacketResponder.run()
方法主要分为两个部分:
等待上述两个条件满足
通过Java的同步工具wait()
,等待ackQueue中的数据,这里wait()
等待的是enqueue()
方法中的notifyAll()
通知。
如果ackQueue有数据,则获取第一个记录,接着,如果当前数据节点正好位于数据流管道的中间,则在mirrorIn上读取下游确认,如果顺利读取到下游的响应,则表明第一步处理已经完成。
两个条件满足后接下来的处理
如果处理的是整个写请求的最后一个确认数据包,那么需要执行一下步骤
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.receiveBlock()
接收处理,而receiveBlock()
将数据包的接收和转发工作交给了receviePacket()
。
receviePacket()
数据包的接收和转发 readNextPacket()
至少读入一个完整的数据包;setBlockPosition()
方法,设置写数据时的文件位置,包括数据块文件和校验信息文件;如果此次写(追加)数据的起始位置落在某个校验块的中间,则需要重新计算校验信息。PacketResponder.enqueue()
方法发送给PacketResponder线程。FSDataset.finalizeBlock()
提交数据块。与读操作不同的是,写数据对HDFS文件的数据块进行了修改,因此,操作的处理结果需要上报到NameNode,NameNode后续会进行一些登记、清理工作。
DataNode.notifyNamenodeReceivedBlock()
通知名字节点,确认写数据块成功;BlockReceiver.verifyChunks()
中,当对输入数据进行校验时,发现数据校验和和请求包的校验和不一致时,会立即调用NameNode.reportBadBlocks()
方法通知NameNode,删除数据块。并抛出异常,通知调用者;由于数据节点上的写操作比较频繁,为了减少到NameNode的数据块提交请求量,数据节点会将多个提交合并成一个请求。所以,notifyNamenodeReceivedBlock()
只是简单地在请求队列中插入记录。该队列信息由数据节点的服务线程读出,通过Hadoop远程过程调用DatanodeProtocol.blockReceived()
提交到NameNode。
当客户端写数据时,数据节点出现错误,一般来说,会关闭到上游节点的Socket连接,接着由上游数据节点检测错误并发送携带错误信息的确认包。这样的设计,简化了数据节点写请求处理的实现,把故障恢复工作转移给客户端或者名字节点。
当客户端检测到异常发生后,会过滤掉targets中异常的数据节点,然后在targets中选取一个作为恢复的主数据节点,建立到该节点的IPC连接,并调用该节点的recoverBlock()
方法。所以主节点接收到命令的时候同时还收到了该block的targets 数组(其中就包括该主节点自身)。
/**
* @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;
客户端发起的数据块恢复流程如下:
InterDatanodeProtocol.updateBlock()
方法更新这些数据节点上的数据块,将数据块的长度截断到与传入恢复数据块一致的长度(长度为0);commitBlockSynchronization()
向名字节点汇报这次数据块恢复的结果。名字节点根据数据节点的定期心跳,判断数据节点是否正常工作。心跳上报过程中,数据节点会发送能够描述当前节点负载的一些信息,如数据节点的存储容量、目前已使用容量等。名字节点根据这些信息估计数据节点的工作状态,均衡各个节点的负载。
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语句,根据命令编号执行不同的方法。
FSDataset.invalidate()
通过异步磁盘操作服务FSDatasetAsyncDiskServices删除Linux文件系统上的数据块文件和校验信息文件,降低了processCommand()
的执行时间。transferBlocks()
,会创建一个DataNode.DataTransfer对象,该对象拥有自己的线程,并利用这个线程发起到目标数据节点的写数据操作。前面提到,数据节点的写操作结束后,需要将新的数据块信息上报给NameNode,由于写操作的频繁,这些信息并不会立即上报,而是存储在一个receivedBlockList中。而offserService()
每次循环的时候,会扫描该提交请求队列,将队列中的信息解析出DatanodeProtocol.blockReceived()
需要的形式,通过远程接口发送给NameNode。
名字节点保存并持久化了整个文件系统的文件目录树以及文件的数据块索引,但名字节点不持久化数据块的保存位置。HDFS启动时,数据节点需要报告它上面保存的数据块信息,帮助名字节点建立数据块和保存数据块的数据节点之间的对应关系。
当DataNode的服务线程启动后,主循环offserService()
会使用blockReport()
方法扫描该数据节点中所有的数据存储目录下存储的所有数据块列表,然后将这些数据块信息序列化成一个长整型数组,发送该数组到名字节点。
当远程方法blockReport()
汇报结束后,名字节点会返回一个名字节点指令,数据节点随后将执行该指令。
每个数据节点都会执行一个数据块扫描器DataBlockScanner,它周期性地验证节点所存储的数据块,通过DataBlockScanner数据节点可以尽早发现有问题的数据块,并汇报给数据节点。
数据块扫描器是数据节点中一个独立的模块,其类继承结构图如下图所示。扫描器的主要实现DataBlockScanner和辅助类BlockScanInfo、LogEntry和LogFileHandler等。
数据块扫描器默认情况下会每隔三周扫描一次,会将扫描的结果草训在文件里,以防数据节点重启后丢失。扫描器日志文件保存在${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排序,所以,第一个记录对应的数据块就是最长时间没有检查的数据块。