namenode主要负责文件元信息的管理和文件到数据块的映射。有了源码|HDFS之NameNode:创建目录对创建目录过程中文件元信息操作的分析基础,就可以相对轻松的分析创建文件的流程了。
计划分三篇文章,分别分析创建文件节点(只涉及文件元信息)、添加数据块(涉及文件元信息、datanode交互)、完成文件(涉及文件元信息、datanode交互、容错性)三个子流程。仅“完成文件”子流程中涉及部分容错性的分析,即“文件创建成功后,部分数据块副本数低于最小副本系数”,其他容错性方案均需要客户端主导,统一放到对客户端的分析中讨论。
今天分析创建文件节点子流程。
源码版本:Apache Hadoop 2.6.0
可参考猴子追源码时的速记打断点,亲自debug一遍。
源码|HDFS之NameNode:创建目录中分析过的内容将不再重复。
开始之前
总览
根据HDFS-1.x、2.x的RPC接口与源码|HDFS之NameNode:启动过程,我们得知,与创建文件节点过程联系最紧密的是ClientProtocol协议、RpcServer线程、FSNamesystem、FSDirectory(同创建目录过程)。
具体过程如下:
- 客户端通过ClientProtocol协议向RpcServer发起创建文件的RPC请求。
- FSNamesystem封装了各种HDFS操作的实现细节,RpcServer调用FSNamesystem中的相关方法以创建目录。
- 进一步的,FSDirectory封装了各种目录树操作的实现细节,FSNamesystem调用FSDirectory中的相关方法在目录树中创建目标文件,并通过日志系统备份文件系统的修改。
- 最后,RpcServer将RPC响应返回给客户端。
创建文件的RPC接口为ClientProtocol#create():
|
|
对应的RPCServer实现为NameNodeRpcServer#create()。
后文将以NameNodeRpcServer#create()为主流程进行分析。
文章的组织结构
- 如果只涉及单个分支的分析,则放在同一节。
- 如果涉及多个分支的分析,则在下一级分多个节,每节讨论一个分支。
- 多线程的分析同多分支。
- 每一个分支和线程的组织结构遵循规则1-3。
发起RPC请求
上传一个文件NOTICE.txt
至/test/create_file
目录,触发提前设置好的断点:
|
|
上传文件的本质是“读本地文件+写HDFS文件”。
执行命令前,该文件不存在。
主流程:NameNodeRpcServer#create()
|
|
NameNodeRpcServer#create()涉及的参数较多,解释几个关键参数:
- src:要创建的文件路径。上传文件时,HDFS并不会直接创建目标文件名的文件,而是先创建一个”.CPOPYING“后缀的文件。此处即
/test/create_file/NOTICE.txt._CPOPYING_
。 - flag:创建文件的选项。此处包含CREATE、OVERWRITE两个选项,详见FSNamesystem#startFileInt()方法。
- createParent:true表示“如果父目录不存在就创建”,false表示“如果父目录不存在就写失败”。此处为true。
- clientName:表征唯一客户端,同clientMachine一起用于租约管理。暂时忽略,以后分析客户端时讨论。
FSNamesystem#startFile():
|
|
整体思路很容易理解:
- 检查
- 创建文件(可能需要删除旧文件节点)
- 查询FileStatus
- 移除待删除数据块(如果有旧文件的话)
“1. 检查”部分需要关注的是58-63行。flag是一个EnumSet,保存了当前create操作的选项,如:
- CREATE:创建新文件,与APPEND区分
- OVERWRITE:重写文件,如果文件已存在,则先删除旧文件(创建文件时的默认选项)。
- APPEND:追加写旧文件,与CREATE
- SYNC_BLOCK:强制数据块即时落盘。暂不关心。
- LAZY_PERSIST:优先将数据块存储于瞬时存储(如内存中),加速读写,牺牲数据持久性。暂不关心。
EnumSet可以简单理解为枚举类型的集合,源码实现不难,暂略。
此处的flag包含CREATE、OVERWRITE两个选项,则create置true,overwite置true,isLazyPersist置false。
2-4部分主要对应FSNamesystem#startFileInternal()、FSDirectory#getFileInfo()、FSNamesystem#removeBlocks()三个方法。
创建文件:FSNamesystem#startFileInternal()
FSNamesystem#startFileInternal():
|
|
通过${dfs.permissions.enabled}参数配置isPermissionEnabled。该选项默认值是true,猴子没有设置,该值却被置为false,奇怪。。。
假设仅考虑{CREATE, OVREWRITE}
写,且旧文件存在(尽管实际情况是旧文件不存在)。整体思路如下:
- 检查
- 从目录树中删除节点
- 从FSDirectory#inodeMap中删除节点
- 递归创建父目录
- 创建文件节点
- 添加租约
- 记录打开文件(此处为创建文件)的日志
- 返回待删除数据块
55行从目录树中删除节点,61行从FSDirectory#inodeMap中删除节点。要区分“目录树”(或“节点树”)与FSDirectory#inodeMap:
- 目录树是以INode为中间节点或叶子节点的树结构,以INode之间的引用关系相互维系。
- FSDirectory#inodeMap是一个一维的线性结构,仅仅是INode的集合(正如BlockManager#blocksMap是Block的集合)。
1、4、6、7、8不展开,下面讨论2、3、5。
其中,“5. 创建文件节点”的FSDirectory#addFile()方法与创建目录过程中的FSDirectory#unprotectedMkdir()方法对标,可对照分析。
从目录树中删除节点:FSDirectory#delete()
FSDirectory#delete():
|
|
- 尽管此处删除的只是一个文件,但FSDirectory#delete()方法支持子目录树的删除(如果src指向一个有孩子节点的目录节点)。
- 读者可参考创建目录过程中的FSDirectory#unprotectedMkdir()分析FSDirectory#unprotectedDelete(),重点是对目录树的操作。
从FSDirectory#inodeMap中删除节点:FSNamesystem#removePathAndBlocks()
FSNamesystem#removePathAndBlocks():
|
|
- 同FSDirectory#delete(),尽管此处删除的只是一个文件,但FSNamesystem#removePathAndBlocks()方法支持子目录树的删除(如果src指向一个有孩子节点的目录节点)。
由于FSNamesystem#startFileInternal()中将FSNamesystem#removePathAndBlocks()的参数blocks显示置为null,则此处仅从FSDirectory#inodeMap中删除节点,而不处理待删除数据块。正因此,外层的FSNamesystem#startFileInternal()才能返回待删除数据块,交给更外层的FSNamesystem#startFileInt()移除。后文的FSNamesystem#removeBlocks()方法将移除这些数据块。
租约
是一个<holder, paths, lastUpdate>
形式的三元组,描述“客户端holder拥有的对多个目录paths的写权限的租期,从lastUpdate开始”。客户端移除、文件移除、租约过期,都需要相应移除租约。此处只删除一个文件,直接移除租约即可;如果src指向了一个子目录树,则需要移除所有前缀为src的租约。
创建文件节点:FSDirectory#addFile()
|
|
FSDirectory#addFile()方法与创建目录过程中的FSDirectory#unprotectedMkdir()方法对标,具体为:
- 创建文件节点newNode
- 将newNode转换为“正在写”UnderConstruction状态
- 将newNode加入目录树与FSDirectory#inodeMap
- 如果添加成功,则返回newNode;否则返回null,表示添加失败
主要有两点区别:未使用INodesInPath管理创建过程中的状态(也不太需要);创建目录过程不需要第2步的状态转换。当然,1、3步骤的具体实现也不完全相同。
下面分别讨论1、2、3。
创建文件节点:FSDirectory.newINodeFile()
FSDirectory.newINodeFile()创建了一个大小为0字节的文件节点:
|
|
其本身只是常见的静态工厂方法。唯一的知识点是INodeFile的继承关系。
INodeFile的继承关系
INodeFile.<init>()
:
|
|
INodeFile继承自INodeWithAdditionalFields,增加了文件头(一个long型数,存储数据块大小、副本系数、存储策略三种属性)、数据块数组(BlockInfo[])。
与之相比,INodeDirectory也继承自INodeWithAdditionalFields,只增加了children属性记录孩子节点。
INodeWithAdditionalFields.<init>()
:
|
|
INodeWithAdditionalFields继承自INode,增加了id、name、permission、modificationTime、accessTime等文件节点INodeFile、目录节点INodeDirectory、符号节点INodeSymlink(基本概念同Linux符号连接)的基本属性,都以long型数或byte[]的形式表示,序列化友好。
INode.<init>
():
|
|
INode是目录树中节点的基本抽象,只存储了parent属性。它有一些重要的子孙类:
转换为UnderConstruction状态:INodeFile#toUnderConstruction()
INodeFile#toUnderConstruction():
|
|
与数据块不同,文件只有两个状态:“正在写”UnderConstruction、“已完成”Completed。
HDFS 1.x中,处于“正在写”状态的文件使用INodeFileUnderConstruction表示,带quota功能的目录使用INodeDirectoryWithQuota表示。这样每增加一个feature就扩展一个INode子类的设计方法so ugly,HDFS 2.x将这些feature抽象为Feature类及其子类,增加一个特性就add一个feature,简化了INode的继承结构,未来也更容易扩展出其他Feature。
对于处于UnderConstruction状态的文件而言,通常文件关联的最后一个数据块处于数据块的UnderConstruction状态。
以上是官方对Feature抽象的解释。不过,猴子认为与快照、quota等功能特性相比,UnderConstruction不能算作一个feature,而应该作为INodeFile的一个状态,将对该状态的处理封装在INodeFile内部。
加入目录树与FSDirectory#inodeMap:FSDirectory#addINode()
FSDirectory#addINode():
|
|
同创建目录过程中的FSDirectory#unprotectedMkdir()一样,最后也使用了INodesInPath,并走到了FSDirectory#addChild()方法。可对照创建目录过程复习。
查询FileStatus:FSDirectory#getFileInfo()
FSDirectory#getFileInfo():
|
|
在创建文件的流程中,执行该方法时,节点已经成功创建并添加到目录树中(理想)。因此,INodesInPath#inodes[-1]即刚刚创建的文件节点。然后根据相关信息创建FileStatus并返回。
FSDirectory#createFileStatus()的实现告诉了我们一个很重要的信息:HDFS并没有存储FileStatus,而是在每次查询时创建。这在某些时候与我们的直觉相悖,不过考虑到单集群上千节点元信息对namenode造成的内存压力,“尽量减少冗余信息”的实现方式就合理了。
FSDirectory#getFileInfo()方法还被RPC方法NameNodeRpcServer#getFileInfo()调用,而查询的目标路径可能不存在文件节点,对应INodesInPath#inodes[-1]为null(参考创建目录过程中对INodesInPath用法的分析)。因此15行需要判断null的逻辑。
移除待删除数据块:FSNamesystem#removeBlocks()
先回顾FSNamesystem#startFileInt()中的相关逻辑:
|
|
此处开启了OVERWRITE选项,如果存在同名旧文件,则FSNamesystem#startFileInternal()中已经删除了旧文件的INodeFile,并返回旧文件关联的待删除数据块toRemoveBlocks。
猴子上传NOTICE.txt
时,目标路径不存在同名文件,toRemoveBlocks为null。现在假设存在同名文件,那么toRemoveBlocks不为null,将执行43行FSNamesystem#removeBlocks()删除这些数据块。
FSNamesystem#removeBlocks():
|
|
遍历待删除数据块,依次调用BlockManager#removeBlock()。
BlockManager#removeBlock():
|
|
方法主要分为两部分:
- 直接将待删除数据块加入
正在删除数据块缓冲区BlockManager#invalidateBlocks
,表示需要删除该数据块(根据源码|HDFS之NameNode:启动过程,BlockManager#ReplicationMonitor线程定时扫描缓冲区,生成副本删除任务,随心跳下发到datanode;此处跳过了需要删除数据块缓冲区BlockManager#excessReplicateMap
)。 - 将待删除数据块从
已损坏数据块缓冲区BlockManager#corruptReplicas
、几乎全量数据块缓冲区BlockManager#blocksMap
、需要复制数据块缓冲区BlockManager#neededReplications
、正在复制数据块缓冲区BlockManager#pendingReplications
中移除,表示除删除外,不再需要对该数据块作出任何处理。
对其他缓冲区:租约已经删除,对于namenode来说,已不存在“正在写”状态的数据块;也不需要从复制超时数据块缓冲区BlockManager#pendingReplications#timedOutItems
中移除数据块,因为有工作线程能定期扫描BlockManager#pendingReplications#timedOutItems,发现数据块已经不需要再复制,就直接删除了。
以上删除操作都是幂等的,如果存在就删除,不存在就什么都不做。
猴子有两个疑问没解决:
- 没看懂block.setNumBytes的作用,可能与数据块汇报有关?
- 为什么跳过
需要删除数据块缓冲区BlockManager#excessReplicateMap
的逻辑?为什么不及时将数据块从需要删除数据块缓冲区BlockManager#excessReplicateMap
中移除?现在的方案可能导致多发出一个无效命令,尽管带来的损耗非常小。
总结
创建文件节点的过程与创建目录节点大同小异,大部分时候只涉及文件元信息的操作,少量涉及数据块操作(移除待删除数据块):
- 【复习】各关键组件的交互方式:
- 最外层:NameNodeRpcServer#create()简单检查,主要逻辑交给FSNamesystem#startFile()。
- 中间层:FSNamesystem中的
“startFile - startFileInt - startFileInternal”
三级结构。 - 最内层:FSNamesystem最终封装
"FSDirectory#addFile() + FSEditLog#logOpenFile()"
组合(FSDirectory#addFile()违背了unprotected前缀命名习惯)。
- 【复习】INodesInPath的结构与用法,特别是INodesInPath#inodes的用法。
- 【新】创建文件时(包括后续整个写文件流程),文件处于UnderConstruction状态(同时,发起创建文件请求的客户端持有该文件的租约)。
- 【新】INode的继承关系。
- 【新】如何通过缓冲区移除待删除数据块。