HDFS DataNode 设计实现解析与异常处理
2017-05-09 17:46:17 阿炯

本文解析了HDFS DataNode 的设计和实现要点。

文件存储


DataNode 正如其名是负责存储文件数据的节点。HDFS 中文件的存储方式是将文件按块(block)切分,默认一个 block 64MB(该大小可配置)。若文件大小超过一个 block 的容量可能会被切分为多个 block,并存储在不同的 DataNode 上。若文件大小小于一个 block 的容量,则文件只有一个 block,实际占用的存储空间为文件大小容量加上一点额外的校验数据。也可以这么说一个文件至少由一个或多个 block 组成,而一个 block 仅属于一个文件。

block 是一个逻辑概念对象,由 DataNode 基于本地文件系统来实现。每个 block 在本地文件系统中由两个文件组成,第一个文件包含文件数据本身,第二个文件则记录 block 的元信息(metadata)如:数据校验和(checksum)。所以每一个 block 对象实际物理对应两个文件,但 DataNode 不会将文件创建在同一个目录下。因为本机文件系统可能不能高效的支持单目录下的大量文件,DataNode 会使用启发式方法决定单个目录下存放多少文件合适并在适当时候创建子目录。

文件数据存储的可靠性依赖多副本保障,对于单一 DataNode 节点而言只需保证自己存储的 block 是完整且无损坏的。DataNode 会主动周期性的运行一个 block 扫描器(scanner)通过比对 checksum 来检查 block 是否损坏。另外还有一种被动的检查方式,就是当读取时检查。

文件操作

HDFS 支持的文件操作包括写入(新增、追加)、读取和删除。HDFS 定义了一种 multi-reader, single-writer 的文件访问语义。而访问标准依然参照大家熟悉的依据 POSIX(Portable Operating System Interface)为单机文件系统定义的 API。

 Open 打开文件
 Read/Write 读写文件
 Close 关闭文件

下面我们分别讲述文件操作的设计实现要点。

写文件


写文件流程如图示,在分布式环境下,Client 请求 NameNode 获得一个针对指定文件的租约(lease,本质上是一种分布式锁)。只有持有该租约的 Client 可以向该文件写入,以这种机制来确保写文件的 single-writer 的语义。获得写入租约后 NameNode 向 Client 分配一组用于存放文件数据的 DataNodes,若配置的副本数为 3,则会返回 3 个 DataNode。这一组 DataNodes 被组成一条流水线来写入,有效提升写入性能降低写入延迟。Client 将文件组织成一个个 packet 发送给流水线上第一个 DataNode,第一个 DataNode 存储下该 packet 后再转发给第二个 DataNode,依此类推。然后 DataNodes 再按流水线反方向发回确认 packet 给 Client。当所有文件 block 写入完成后,DataNodes 会向 NameNode 报告文件的 block 接收完毕,NameNode 相应去改变文件元数据的状态。

写文件的主体流程如上所述,如果过程中一切正常那么多么简单美好。但实际在分布式环境下,写文件过程涉及 Client、NameNode 和一组 DataNodes,这其中任何一个环节都有可能产生异常。按照分布式设计第一原则:Design for failure,我们需要考这个流程中的所有参与者都有可能出现失败异常的情况。这里先提出这个问题,考虑每种失败异常的场景下,软件设计实现要怎么去处理?本文先不在这里展开论述,后面会专门撰文深入分析。

读文件


读文件流程如图示,Client 首先请求 NameNode 定位文件 block 所在的 DataNodes。然后按顺序请求对应的 DataNodes 读取其上存储的 block。关于读取顺序,HDFS 有一个就近读取的优化策略,DataNodes 的读取排序会按照它们离 Client 的距离来确定。距离的概念主要区分以下几种场景:
 距离 0,表示在同一个节点上
 距离 2,表示同一个机架下的不同节点
 距离 4,表示同一个数据中心的不同机架下
 距离 8,表示不同的数据中心

删文件

文件删除的处理首先将文件重命名后放进 /trash 目录。文件会在 /trash 目录中存放一段时间(可配置),在时间到期后再自动清理。所以实际上文件删除操作非常轻量级,仅仅是 NameNode 的内存数据结构的变动,真正的物理删除在后续的自动清理时才做。

可见性

在文件写入过程中,HDFS 不保证文件对其他 Client Reader 可见。只有文件的 block 已经写入 DataNode,并报告给了 NameNode 更新到正确的状态才对其他 Reader 可见。简单说,如果一个文件有多个 block,写入总是发生在最后一个 block 上,那么前面的 block 对其他 Reader 是可见的,但最后一个 block 则不可见,这涉及 block 的状态变化,这里先不展开,后面会专门撰文深入分析。

生命周期

DataNode 启动后首先连接到 NameNode 完成握手,握手的目的是验证 DataNode 的软件版本和 namespace ID。namespace ID 是整个 HDFS 集群的唯一标识,如果 DataNode namespace ID 或 软件版本与 NameNode 不匹配,DataNode 将无法加入集群并自动关闭。若是一个全新的 DataNode 启动时没有 namespace ID,则在握手时由 NameNode 分配并加入集群。此外,NameNode 还会分配一个集群全局唯一的 storage ID 给 DataNode 用于唯一标记,之后不再改变。

完成握手后,DataNode 会立刻向 NameNode 发送 block report 信息,block report 就是 DataNode 上存储了哪些文件 block 的列表。之后会定期(默认 1 小时)向 NameNode 报告。此外,DataNode 将定时向 NameNode 发送心跳(默认 3 秒)来报告自身的存活性。一段时间(默认 10 分钟)收不到 DataNode 最近的心跳,NameNode 会认定其死亡,并不会再将 I/O 请求转发到其上。心跳除了用于 DataNode 报告其存活性,NameNode 也通过心跳回复来捎带控制命令要求 DataNode 执行,因为 NameNode 设计上不直接调用 DataNode 其控制命令都是通过心跳回复来执行,所以心跳的默认间隔比较短。

除了 DataNode 的非正常死亡外,DataNode 还可以正常退休,可以通过管理端标记一个 DataNode 进入退休中(decommissioning)状态。处于退休中状态的 DataNode 不再服务于写请求(包括从 Client 写入或从其他 DataNode 复制),但它可以继续服务读请求。进入退休中状态的 DataNode 将被安排将其上存储的所有 block 复制到其他节点,完成这个过程后 NameNode 将其标记为已退休(decommissioned)状态,然后就可以安全下线了。


下面对HDFS的读写分别开始解析

HDFS read解析

Open文件流

如图所示


HDFS Client通过FileSystem.get()方法实例化一个FileSystem对象。

调用FileSystem.open打开一个文件的数据流FSDataInputStream,open中会用rpc方法getBlockLocations得到block的locations信息,默认会先得到prefetchSize(conf.getLong(DFS_CLIENT_READ_PREFETCH_SIZE_KEY, 10 * defaultBlockSize))大小文件的信息。

HDFS Client通过FSDataInputStream流读取离客户端最近的dn上的block,在第2步中得到的block location信息已经将block的副本按照离客户端的网络拓扑距离进行了排序,此过程是在nn端完成的。

读取完当前block的数据后,关闭与当前的DataNode连接,并为读取下一个block寻找最佳的DataNode。

当读完列表的block后,且文件读取还没有结束,客户端会继续向Namenode获取下一批的block列表。

读取完一个block都会进行checksum验证,如果读取datanode时出现错误,客户端会通知Namenode,然后再从下一个拥有该block拷贝的datanode继续读。

相关概念

inode
文件数据都储存在”块”中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做inode,中文译名为”索引节点”。每一个文件都有对应的inode,里面包含了与该文件有关的一些信息。
在HDFS中inode也就是一个文件在内存中的一个抽象,保存了该文件的一些元数据信息。
INODE在代码中是一个最底层的抽象类

INodeFile
文件节点类,继承自INode,表示一个文件,其中有个重要的属性private BlockInfo[] blocks,blocks中存储的是该文件的block信息BlockInfo,其中每个block(BlockInfo)是一个具有3个元素的数组(triplets),也就是三个指针域,大小为3replicas,其中replicas是Block副本数量。triplets包含的信息:
triplets[3i]:Block所在的DataNode A;(DatanodeStorageInfo对象)
triplets[3i+1]:该DataNode A上前一个Block;(指向前一个block的BlockInfo对象引用)
triplets[3i+2]:该DataNode A上后一个Block;(指向后一个block的BlockInfo对象引用)
其中i表示的是Block的第i个副本,i取值[0,replicas)。

INodeDirectory
文件目录类,也是继承自INode.他的孩子中是文件集也可能是目录。

INodeDirectoryWithQuota
有配额限制的目录,这是为了适应HDFS中的配额策略。

INodeFileUnderConstruction
处于构建状态的文件类,可以从INodeFile中转化而来。

FSNamesystem中相关的属性

文件流中read

读取文件流中的数据通过文件流FSDataInputStream对象的read方法进行读取,最终调用了DFSInputStream的read方法。

HDFS write解析

其写入流程与普通文件写入流程类似,首先创建一个输出流OutputStream,然后通过这个输出流写入数据。在HDFS中数据传输的基本单元为Packet(默认64k),每个packet又由很多个chunk组成,chunk是文件校验的基本单位,一个chunk一个chunksum,chunk是校验单位也就是写入单位,将chunk写入packet,一个packet写满之后,将packet发送到pipeline中。

下面从代码层次去详细解读下write流程。其写入流程图如下:


创建一个输出流

HDFS写文件跟java写文件类似,都需要先打开一个文件流,HDFS是通过FileSystem对象打开文件流的,代码流程为通过FileSystem.get(conf)得到一个FileSystem对象,然后调用create(Path)或者append(Path)打开一个FSDataOutputStream流。

向输出流中写bytes数据流

写入操作的API是通过FSDataOutputStream的对象out调用write(byte[])。

DFSOutputStream.DataStreamer发送packet

DFSOutputStream中有两个队列,一个dataQueue一个ackQueue,两个队列大小的和不能超过dfs.client.write.max-packets-in-flight的值。

DataXceiver线程写入DataNode

以上的流程可以看做是client端,client端将数据发送到dn上,由dn负责将packet写入本地磁盘,并向下一个dn发送。这其中涉及到DataXceiverServer线程和DataXceiver线程,DataXceiverServer相当于监听器,而DataXceiver相当于handle,由DataXceiverServer监听来自client的socket请求,根据请求创建DataXceiver线程。由DataXceiver线程进行写dn。

发送接收ACK

receivePacket从流中读出packet,在其向downstream发送时,先将packet当如ackQueue队列中,由PacketResponder线程等待接收此packet的ack,然后向downstream发送packet,最后将packet写入本地磁盘。

hdfs write的流程为:

通过FileSystem.create创建一个FSDataOutputStream输出流,在此过程中client通过rpc向namenode添加一个文件记录,得到该文件的租约,启动一个DataStreamer线程(DataStreamer线程中维护dataQueue和ackQueue队列),并持续更新租约。

FSDataOutputStream输出流建好之后,就可以调用FSDataOutputStream.write方法进行数据的写入。在写入过程中先将数据写入client本地的buf中,此buf默认是9个chunk的大小,当本地buf写满之后(如果要写入的数据长度大于本地buf的长度,则直接将buf长度的数据写入currentPacket中),计算这些数据的checksum,并写入currentPacket中,currentPacket写满之后放入dataQueue中排队并通知DataStreamer线程去dataQueue中消费数据。(数据先写入本地buf,然后写入packet,等packet满之后才向namenode申请blockId)。

DataStreamer线程从dataQueue中取出packet,如果DataStreamer的stage为PIPELINE_SETUP_CREATE时,表示当前block的pipeline还没有建立,向namenode申请blockId和block的locations,将申请到的locations组成一个pipeline,与第一个dn建立socket连接,由Sender发送写请求。新启动ResponseProcessor线程接收dn返回的packet ack,并更新DataStreamer的stage,由PIPELINE_SETUP_CREATE变为DATA_STREAMING。

将要发送的packet从dataQueue中移到ackQueue中,然后向pipeline中发送packet。

以上的流程都发生在client端,下面的流程发生在dn端

dn在client创建pipeline时,通过DataXceiverServer接收到client的socket请求,创建一个DataXceiver线程,由DataXceiver线程处理来自client的写请求。

DataXceiver线程会实例化一个BlockReceiver对象,并判断是否有downstream,如果有则创建一个downstream的socket,发送写请求。

与downstream建立连接之后,在blockReceiver.receiveBlock循环调用receivePacket接收packet,向downstream发送packet之前将packet放入ackQueue(当前ackQueue是PacketResponder线程维护的)中,然后将data和checksum写入磁盘。

在blockReceiver.receiveBlock中还会启动一个PacketResponder线程,此线程负责接收downstream发送的packet ack,校验成功之后从ackQueue中移除,向upstream发送自己的ack和downstream的ack。

最终所有的ack都汇集到ResponseProcessor线程中,如果ack没有error则从ackQueue中移除;如果有error,先将ackQueue中的packet移到dataQueue中,然后将发生error的dn从pipeline中删除,从namenode中重新申请dn与原有的没有发生error的dn组成新的pipeline,在addDatanode2ExistingPipeline中判断是否要transfer已经发送的packet,将已经发送成功的packet从之前正常的dn上transfer到新增加的dn上,并更新block是stamp,这样发生故障的DataNode节点上的block数据会在节点恢复正常后被删除。


HDFS租约解析

租约(Lease)是一种广泛应用与分布式系统领域的协议,主要用来维护分布式系统的一致性。

租约是在解决缓存一致性时被提出的。所谓租约,其实就是一个合同,即服务器给予客户端在一定期限内可以控制修改操作的权力。如果服务器要修改数据,首先要征求拥有这块数据的租约的客户端的同意,之后才可以修改。客户端从服务器读取数据时往往就同时获取租约,在租约期限内,如果没有收到服务器的修改请求,就可以保证当前缓存中的内容就是最新的。如果在租约期限内收到了修改数据的请求并且同意了,就需要清空缓存。在租约过期以后,客户端如果还要从缓存读取数据,就必须重新获取租约,我们称这个操作为续约。

租约特性

租约的一个重要的属性就是期限,一般情况下,应当选择较短的租约期限。与长租约相比,短租约有三个优点。首先,在失效情况下修改操作往往需要等待租约过期,因此短租约就意味着更短的失效延迟。其次,就算一个客户端已经不再需要读取数据,但在其租约过期前,任何的修改操作仍然需要征求它的同意,这种情况叫做“假共享”,显然租约期限越长,这个问题就越严重。最后,短租约也使得服务器要维护的客户端信息更少。然而短租约也意味着更大的续约开销,因此对于要反复读取却很少修改的数据,长租约会更有效。因此,对租约期的选择要权衡失效延迟、假共享开销和续约开销等多个因素,服务器可以根据数据访问特性和客户端的性质灵活设置期限。事实上,如果我们把租约期限设为零,就相当于轮询,此时修改操作随时可以进行,而读取数据总是要联系服务器。如果把租约期限设为无限长,就相当于回调。

除了期限的选择,还有很多管理选项。对客户端来说,可以选择是否续约、何时续约以及是否同意修改等。比如为了减少读取延迟,客户端可以在租约过期前就续约,不过这样可能加重服务器的负担。对服务器来说,可以选择是否发放租约、租约覆盖粒度以及对如何进行修改操作。比如在收到修改请求后,服务器可以不征求客户端同意,而是简单的等待所有租约过期(等待时不再发放新租约以避免无限期的延迟)。对于“安装文件”,也就是修改极少的文件(比如头文件、库文件),服务器可以用一个租约来覆盖一批文件,同时定期广播续约通知来节省开销,如果需要修改数据,就停止广播并等待租约过期即可。

在很多时候,租约的定义似乎很模糊,有的时候租约类似心跳,有的时候又类似于锁(读锁和写锁)。到底租约的本质是什么呢?

回到租约最原始的定义:租约就是在一定期限内给予持有者特定权力的协议。我觉得这里的期限就是租约的根本特性,正是这一特性使得租约可以容忍机器失效和网络分割。在期限之内,租约其实就是服务器和客户端之间的协议,而这个协议的内容可以五花八门。如果协议内容是服务器确认客户端还存活,那么这个租约的功能就相当于心跳;如果协议内容是服务器保证内容不会被修改,那么这个租约就相当于读锁;如果协议内容是服务器保证内容只能被这个客户端修改,那么这个租约就相当于写锁。租约这种灵活性和容错性,使其成为了维护分布式系统一致性的有效工具。

租约在HDFS中的应用–写锁

hdfs支持write-once-read-many,也就是说不支持并行写,那么对读写的互斥同步就是靠Lease实现的。Lease说白了就是一个有时间约束的锁。客户端写文件时需要先申请一个Lease,持有该租约的客户端才可以对相应的文件进行块的添加。

正常情况下,客户端向集群写文件前需要向NameNode的LeaseManager申请Lease;写文件过程中定期更新Lease时间,以防Lease过期,周期与softLimit相关;写完数据后申请释放Lease。

整个过程可能发生两类问题:
(1)写文件过程中客户端没有及时更新Lease时间;
(2)写完文件后没有成功释放Lease。两个问题分别对应为softLimit和hardLimit。两种场景都会触发LeaseManager对Lease超时强制回收。如果客户端写文件过程中没有及时更新Lease超过softLimit时间后,另一客户端尝试对同一文件进行写操作时触发Lease软超时强制回收;如果客户端写文件完成但是没有成功释放Lease,则会由LeaseManager的后台线程LeaseManager.Monitor检查是否硬超时后统一触发超时回收。不管是softLimit还是hardLimit超时触发的强制Lease回收,处理逻辑都一样:FSNamesystem.internalReleaseLease,逻辑本身比较复杂,已在上面详细介绍。简单的说先对Lease过期前最后一次写入的Block进行检查和修复,之后释放超时持有的Lease,保证后面其他客户端的写入能够正常申请到该文件的Lease。


HDFS 异常处理与恢复

这一部分将深入探讨 HDFS 文件操作涉及的错误处理与恢复过程。

读异常与恢复

读文件可能发生的异常有两种:
 读取过程中 DataNode 挂了
 读取到的文件数据损坏

HDFS 的文件块多副本分散存储机制保障了数据存储的可靠性,对于第一种情况 DataNode 挂了只需要失败转移到其他副本所在的 DataNode 继续读取,而对于第二种情况读取到的文件数据块若校验失败可认定为损坏,依然可以转移到读取其他完好的副本,并向 NameNode 汇报该文件 block 损坏,后续处理由 NameNode 通知 DataNode 删除损坏文件 block,并根据完好的副本来复制一份新的文件 block 副本。

因为读文件不涉及数据的改变,所以处理起来相对简单,恢复机制的透明性和易用性都非常好。

写异常与恢复

之前的文章中对写文件的过程做了描述,这个过程中可能发生多种不同的错误异常对应着不同的处理方式。先看看有哪些可能的异常?

异常模式

可能的异常模式如下所列:
 Client 在写入过程中,自己挂了
 Client 在写入过程中,有 DataNode 挂了
 Client 在写入过程中,NameNode 挂了

对于以上所列的异常模式,都有分别对应的恢复模式。

恢复模式

当 Client 在写入过程中,自己挂了。由于 Client 在写文件之前需要向 NameNode 申请该文件的租约(lease),只有持有租约才允许写入,而且租约需要定期续约。所以当 Client 挂了后租约会超时,HDFS 在超时后会释放该文件的租约并关闭该文件,避免文件一直被这个挂掉的 Client 独占导致其他人不能写入。这个过程称为 lease recovery。

在发起 lease recovery 时,若多个文件 block 副本在多个 DataNodes 上处于不一致的状态,首先需要将其恢复到一致长度的状态。这个过程称为 block recovery。 这个过程只能在 lease recovery 过程中发起。

当 Client 在写入过程中,有 DataNode 挂了。写入过程不会立刻终止(如果立刻终止,易用性和可用性都太不友好),取而代之 HDFS 尝试从流水线中摘除挂了的 DataNode 并恢复写入,这个过程称为 pipeline recovery。

当 Client 在写入过程中,NameNode 挂了。这里的前提是已经开始写入了,所以 NameNode 已经完成了对 DataNode 的分配,若一开始 NameNode 就挂了,整个 HDFS 是不可用的所以也无法开始写入。流水线写入过程中,当一个 block 写完后需向 NameNode 报告其状态,这时 NameNode 挂了,状态报告失败,但不影响 DataNode 的流线工作,数据先被保存下来,但最后一步 Client 写完向 NameNode 请求关闭文件时会出错,由于 NameNode 的单点特性,所以无法自动恢复,需人工介入恢复。

上面先简单介绍了对应异常的恢复模式,详细过程后文再描述。在介绍详细恢复过程前,需要了解文件数据状态的概念。因为写文件过程中异常和恢复会对数据状态产生影响,我们知道 HDFS 文件至少由 1 个或多个 block 构成,因此每个 block 都有其相应的状态,由于文件的元数据在 NameNode 中管理而文件数据本身在 DataNode 中管理,为了区分文件 block 分别在 NameNode 和 DataNode 上下文语境中的区别,下面我们会用 replica(副本)特指在 DataNode 中的 block,而 block 则限定为在 NameNode 中的文件块元数据信息。在这个语义限定下 NameNode 中的 block 实际对应 DataNodes 上的多个 replicas,它们分别有不同的数据状态。我们先看看 replica 和 block 分别在 DataNode 和 NameNode 中都存在哪些状态?

Replica 状态

Replica 在 DataNode 中存在的状态列表如下:
    FINALIZED:表明 replica 的写入已经完成,长度已确定,除非该 replica 被重新打开并追加写入。
    
    RBW:该状态是 Replica Being Written 的缩写,表明该 replica 正在被写入,正在被写入的 replica 总是打开文件的最后一个块。
    
    RWR:该状态是 Replica Waiting to be Recovered 的缩写,假如写入过程中 DataNode 挂了重启后,其上处于 RBW 状态的 replica 将被变更为 RWR 状态,这个状态说明其数据需要恢复,因为在 DataNode 挂掉期间其上的数据可能过时了。
    
    RUR:该状态是 Replica Under Recovery 的缩写,表明该 replica 正处于恢复过程中。
    
    TEMPORARY:一个临时状态的 replica 是因为复制或者集群平衡的需要而创建的,若复制失败或其所在的 DataNode 发生重启,所有临时状态的 replica 会被删除。临时态的 replica 对外部 Client 来说是不可见的。

DataNode 会持久化存储 replica 的状态,每个数据目录都包含了三个子目录:
    current:目录包含了 FINALIZED 状态 replicas。

    tmp:目录包含了 TEMPORARY 状态的 replicas。

    rbw:目录则包含了 RBW、RWR 和 RUR 三种状态的 relicas,从该目录下加载的 replicas 默认都处于 RWR 状态。

从目录看出实际上只持久化了三种状态,而在内存中则有五种状态,从下面的 replica 状态变迁图也可以看出这点。


我们从 Init 开始简单描述下 replica 的状态变迁图。

    从 Init 出发,一个新创建的 replica 初始化为两种状态:
        由 Client 请求新建的 replica 用于写入,状态为 RBW。
        由 NameNode 请求新建的 replica 用于复制或集群间再平衡拷贝,状态为 TEMPORARY。

    从 RBW 出发,有三种情况:
        Client 写完并关闭文件后,切换到 FINALIZED 状态。
        replica 所在的 DataNode 发生重启,切换到 RWR 状态,重启期间数据可能过时了,可以被丢弃。
        replica 参与 block recovery 过程(详见后文),切换到 RUR 状态。

    从 TEMPORARY 出发,有两种情况:
        复制或集群间再平衡拷贝成功后,切换到 FINALIZED 状态。
        复制或集群间再平衡拷贝失败或者所在 DataNode 发生重启,该状态下的 replica 将被删除。

    从 RWR 出发,有两种情况:
        所在 DataNode 挂了,就变回了 RBW 状态,因为持久化目录 rbw 包含了三种状态,重启后又回到 RWR 状态。
        replica 参与 block recovery 过程(详见后文),切换到 RUR 状态。

    从 RUR 出发,有两种情况:
        如上,所在 DataNode 挂了,就变回了 RBW 状态,重启后只会回到 RWR 状态,看是否还有必要参与恢复还是过时直接被丢弃。
        恢复完成,切换到 FINALIZED 状态。

    从 FINALIZED 出发,有两种情况:
        文件重新被打开追加写入,文件的最后一个 block 对应的所有 replicas 切换到 RBW。
        replica 参与 block recovery 过程(详见后文),切换到 RUR 状态。

接下我们再看看 NameNode 上 block 的状态有哪些以及时如何变化的。

Block 状态

Block 在 NameNode 中存在的状态列表如下:

    UNDER_CONSTRUCTION:当新创建一个 block 或一个旧的 block 被重新打开追加时处于该状态,处于改状态的总是一个打开文件的最后一个 block。
    
    UNDER_RECOVERY:当文件租约超时,一个处于 UNDER_CONSTRUCTION 状态下 block 在 block recovery 过程开始后会变更为该状态。
    
    COMMITTED:表明 block 数据已经不会发生变化,但向 NameNode 报告处于 FINALIZED 状态的 replica 数量少于最小副本数要求。
    
    COMPLETE:当 NameNode 收到处于 FINALIZED 状态的 replica 数量达到最小副本数要求后,则切换到该状态。只有当文件的所有 block 处于该状态才可被关闭。

NameNode 不会持久化存储这些状态,一旦 NameNode 发生重启,它将所有打开文件的最后一个 block 设置为 UNDER_CONSTRUCTION 状态,其他则全部设置为 COMPLETE 状态。

下图展示了 block 的状态变化过程。


我们还是从 Init 开始简单描述下 block 的状态变迁图。

    从 Init 出发,只有当 Client 新建或追加文件写入时新创建的 block 处于 UNDER_CONSTRUCTION 状态。
    从 UNDER_CONSTRUCTION 出发,有三种情况:
        当客户端发起 add block 或 close 请求,若处于 FINALIZED 状态的 replica 数量少于最小副本数要求,则切换到 COMMITTED 状态,这里 add block 操作影响的是文件的倒数第二个 block 的状态,而 close 影响文件最后一个 block 的状态。
        当客户端发起 add block 或 close 请求,若处于 FINALIZED 状态的 replica 数量达到最小副本数要求,则切换到 COMPLETE 状态
        若发生 block recovery,状态切换到 UNDER_RECOVERY。

    从 UNDER_RECOVERY,有三种情况:
        0 字节长度的 replica 将直接被删除。
        恢复成功,切换到 COMPLETE。
        NameNode 发生重启,所有打开文件的最后一个 block 会恢复成 UNDER_CONSTRUCTION 状态。

    从 COMMITTED 出发,有两种情况:
        若处于 FINALIZED 状态的 replica 数量达到最小副本数要求或者文件被强制关闭或者 NameNode 重启且不是最后一个 block,
        则直接切换为 COMPLETE 状态。
        NameNode 发生重启,所有打开文件的最后一个 block 会恢复成 UNDER_CONSTRUCTION 状态。

    从 COMPLETE 出发,只有在 NameNode 发生重启,其打开文件的最后一个 block 会恢复成 UNDER_CONSTRUCTION 状态。这种情况,若 Client 依然存活,有 Client 来关闭文件,否则由 lease recovery 过程来恢复(详见下文)。

理解了 block 和 replica 的状态及其变化过程,我们就可以进一步详细分析上述简要提及的几种自动恢复模式。

Lease Recovery 和 Block Recovery

前面讲了 lease recovery 的目的是当 Client 在写入过程中挂了后,经过一定的超时时间后,收回租约并关闭文件。但在收回租约关闭文件前,需要确保文件 block 的多个副本数据一致(分布式环境下很多异常情况都可能导致多个数据节点副本不一致),若不一致就会引入 block recovery 过程进行恢复。下面是整个恢复处理流程的简要算法描述:

    1.获取包含文件最后一个 block 的所有 DataNodes。
    2.指定其中一个 DataNode 作为主导恢复的节点。
    3.主导节点向其他节点请求获得它们上面存储的 replica 信息。
    4.主导节点收集了所有节点上的 replica 信息后,就可以比较计算出各节点上不同 replica 的最小长度。
    5.主导节点向其他节点发起更新,将各自 replica 更新为最小长度值,保持各节点 replica 长度一致。
    6.所有 DataNode 都同步后,主导节点向 NameNode 报告更新一致后的最终结果。
    7.NameNode 更新文件 block 元数据信息,收回该文件租约,并关闭文件。

其中 3~6 步就属于 block recovery 的处理过程,这里有个疑问为什么在多个副本中选择最小长度作为最终更新一致的标准?想想写入流水线过程,如果 Client 挂掉导致写入中断后,对于流水线上的多个 DataNode 收到的数据在正常情况下应该是一致的。但在异常情况下,排在首位的收到的数据理论上最多,末位的最少,由于数据接收的确认是从末位按反方向传递到首位再到 Client 端。所以排在末位的 DataNode 上存储的数据都是实际已被确认的数据,而它上面的数据实际在不一致的情况也是最少的,所以算法里选择多个节点上最小的数据长度为标准来同步到一致状态。

Pipeline Recovery


如上图所示,pipeline 写入包括三个阶段:
    pipeline setup:Client 发送一个写请求沿着 pipeline 传递下去,最后一个 DataNode 收到后发回一个确认消息。Client 收到确认后,pipeline 设置准备完毕,可以往里面发送数据了。
    
    data streaming:Client 将一个 block 拆分为多个 packet 来发送(默认一个 block 64MB,太大所以需要拆分)。Client 持续往 pipeline 发送 packet,在收到 packet ack 之前允许发送 n 个 packet,n 就是 Client 的发送窗口大小(类似 TCP 滑动窗口)。
    
    close:Client 在所有发出的 packet 都收到确认后发送一个 Close 请求,pipeline 上的 DataNode 收到 Close 后将相应 replica 修改为 FINALIZED 状态,并向 NameNode 发送 block 报告。NameNode 将根据报告的 FINALIZED 状态的 replica 数量是否达到最小副本要求来改变相应 block 状态为 COMPLETE。

Pipeline recovery 可以发生在这三个阶段中的任意一个,只要在写入过程中一个或多个 DataNode 遭遇网络或自身故障。我们来分别分析下。

从 pipeline setup 错误中恢复

在 pipeline 准备阶段发生错误,分两种情况:
    新写文件:Client 重新请求 NameNode 分配 block 和 DataNodes,重新设置 pipeline。
    追加文件:Client 从 pipeline 中移除出错的 DataNode,然后继续。

从 data streaming 错误中恢复

    当 pipeline 中的某个 DataNode 检测到写入磁盘出错(可能是磁盘故障),它自动退出 pipeline,关闭相关的 TCP 连接。
    当 Client 检测到 pipeline 有 DataNode 出错,先停止发送数据,并基于剩下正常的 DataNode 重新构建 pipeline 再继续发送数据。
    Client 恢复发送数据后,从没有收到确认的 packet 开始重发,其中有些 packet 前面的 DataNode 可能已经收过了,则忽略存储过程直接传递到下游节点。

从 close 错误中恢复

到了 close 阶段才出错,实际数据已经全部写入了 DataNodes 中,所以影响很小了。Client 依然根据剩下正常的 DataNode 重建 pipeline,让剩下的 DataNode 继续完成 close 阶段需要做的工作。

以上就是 pipeline recovery 三个阶段的处理过程,这里还有点小小的细节可说。

当 pipeline 中一个 DataNode 挂了,Client 重建 pipeline 时是可以移除挂了的 DataNode,也可以使用新的 DataNode 来替换。这里有策略是可配置的,称为 DataNode Replacement Policy upon Failure,包括下面几种情况:

    NEVER:从不替换,针对 Client 的行为
    DISABLE:禁止替换,DataNode 服务端抛出异常,表现行为类似 Client 的 NEVER 策略
    DEFAULT:默认根据副本数要求来决定,简单来说若配置的副本数为 3,如果坏了 2 个 DataNode,则会替换,否则不替换
    ALWAYS:总是替换

总结

本文讲述了 HDFS 异常处理与恢复的处理流程和细节,它确保 HDFS 在面对网络和节点错误的情况下保证数据写入的持久性和一致性。读完本篇相信你会对 HDFS 内部的一些设计和工作状态有更深的认识,本文特地抽象的描述了一些涉及具体算法的部分。因为 HDFS 作为一个开源软件还在不断发展演变,具体算法代码实现总会变化,但设计理念和 design for failure 的设计思考要点的持续性要长久的多,会更具参考价值。如果你还想对某些特定细节的实现做进一步的了解,只能去深入阅读对应部分的代码,这无法通过阅读文档或相关文章来获得,这也是代码开源的价值所在。


参考来源

分布式存储--HDFS DataNode 设计实现解析
混绅士的 Hadoop 分类
HDFS 异常处理与恢复