Hexiaoqiao

Focus on BigData,Distributed System,Hadoop Ecosystem

HDFS集中式缓存管理

一、背景

Hadoop设计之初借鉴GFS/MapReduce的思想:移动计算的成本远小于移动数据的成本。所以调度通常会尽可能将计算移动到拥有数据的节点上,在作业执行过程中,从HDFS角度看,计算和数据通常是同一个DataNode节点,即存在大量的本地读写。

但是HDFS最初实现时,并没有区分本地读和远程读,二者的实现方式完全一样,都是先由DataNode读取数据,然后通过DFSClient与DataNode之间的Socket管道进行数据交互。这样的实现方式很显然由于经过DataNode中转对数据读性能有一定的影响。

社区很早也关注到了这一问题,先后提出过两种方案来提升性能,分别是HDFS-347HDFS-2246

HDFS-2246是比较直接和自然的想法,既然DFSClient和DataNode在同一个节点上,当DFSClient对数据发出读请求后,DataNode提供给DFSClient包括文件路径,偏移量和长度的三元组(path,offset,length),DFSClient拿到这些信息后直接从文件系统读取数据,从而绕过DataNode避免一次数据中转的过程。但是这个方案存在两个问题,首先,HDFS需要为所有用户配置白名单,赋予其可读权限,当增加新用户需要更新白名单,维护不方便;其次,当为用户赋权后,意味着用户拥有了访问所有数据的权限,相当于超级用户,从而导致数据存在安全漏洞。

HDFS-347使用UNIX提供的Unix Domain Socket进程通讯机制实现了安全的本地短路读取。DFSClient向DataNode请求数据时,DataNode打开块文件和元数据文件,通过Unix Domain Socket将对应的文件描述符传给DFSClient,而不再是路径、偏移量和长度等三元组。文件描述符是只读的,DFSClient不能随意修改接收到的文件。同时由于DFSClient自身无法访问块所在的目录,也就不能访问未授权数据。

虽然本地短路读在性能上有了明显的提升,但是从全集群看,依然存在几个性能问题:
(1)DFSClient向DataNode发起数据读请求后,DataNode在OS Buffer对数据会进行Cache,但是数据Cache的分布情况并没有显式暴漏给上层,对任务调度透明,造成Cache浪费。比如同一Block多个副本可能被Cache在多个存储这些副本的DataNode OS Buffer,造成内存资源浪费。
(2)由于Cache的分布对任务调度透明,一些低优先级任务的读请求有可能将高优先级任务正在使用的数据从Cache中淘汰出去,造成数据必须从磁盘读,增加读数据的开销从而影响任务的完成时间,甚至影响到关键生产任务SLA。

针对这些问题,社区在2013年提出集中式缓存方案(Centralized cache management)HDFS-4949,由NameNode对DataNode的Cache进行统一集中管理,并将缓存接口显式暴漏给上层应用,该功能在2.3.0发布。这个功能对于提升HDFS读性能和上层应用的执行效率与实时性有很大帮助。

集中式缓存方案的主要优势:
(1)用户可以指定常用数据或者高优先级任务对应的数据常驻内存,避免被淘汰到磁盘。例如在数据仓库应用中事实表会频繁与其他表JOIN,如果将这些事实表常驻内存,当DataNode内存使用紧张的时候也不会把这些数据淘汰出去,可以很好的实现了对于关键生产任务的SLA保障;
(2)由NameNode统一进行缓存的集中管理,DFSClient根据Block被Cache分布情况调度任务,尽可能实现本地内存读,减少资源浪费;
(3)明显提升读性能。当DFSClient要读取的数据被Cache在同一DataNode时,可以通过ZeroCopy直接从内存读,略过磁盘IO和checksum校验等环节,从而提升读性能;
(4)由于NameNode统一管理并作为元数据的一部分进行持久化处理,即使DataNode节点出现宕机,Block移动,集群重启,Cache不会受到影响。

二、部署与使用

2.1 部署

集群开启HDFS集中式缓存特性非常简单,虽然HDFS本身为集中式缓存在NameNode/DataNode端均提供了多个配置参数,但是大多不是必须配置项,最核心的配置项是DataNode侧一个参数。

hdfs-site.xmlhdfs-default.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
<property>
  <name>dfs.datanode.max.locked.memory</name>
  <value>0</value>
  <description>
    The amount of memory in bytes to use for caching of block replicas in
    memory on the datanode. The datanode's maximum locked memory soft ulimit
    (RLIMIT_MEMLOCK) must be set to at least this value, else the datanode
    will abort on startup.
    By default, this parameter is set to 0, which disables in-memory caching.
    If the native libraries are not available to the DataNode, this
    configuration has no effect.
  </description>
</property>

如配置项描述所述,该配置的默认值为0,表示集中式缓存特性处于关闭状态,选择适当的值打开该特性。

开启集中式缓存特性需要注意两个前提:
(1)DataNode的native库必须可用;因为集中式缓存特性通过系统调用mmap/mlock实现,DataNode需要通过native库支持完成系统调用,否则会导致该特性不生效。
(2)系统memlock至少与配置值相同;因为集中式缓存特性通过系统调用mmap/mlock实现,所以系统最大锁定内存空间需要至少与DataNode配置的锁定空间大小相同,否则会导致DataNode进行启动失败。

此外,HDFS还包括了其他可选的配置项:

hdfs-site.xmlhdfs-default.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<property>
<name>dfs.namenode.path.based.cache.refresh.interval.ms</name>
<value>30000</value>
<description>The amount of milliseconds between subsequent path cache rescans. Path cache rescans are when we calculate which blocks should be cached, and on what datanodes. By default, this parameter is set to 30 seconds.</description>
</property>
<property>
<name>
dfs.namenode.path.based.cache.block.map.allocation.percent
</name>
<value>0.25</value>
<description>The percentage of the Java heap which we will allocate to the cached blocks map. The cached blocks map is a hash map which uses chained hashing. Smaller maps may be accessed more slowly if the number of cached blocks is large; larger maps will consume more memory.</description>
</property>
<property>
<name>dfs.cachereport.intervalMsec</name>
<value>10000</value>
<description>Determines cache reporting interval in milliseconds. After this amount of time, the DataNode sends a full report of its cache state to the NameNode. The NameNode uses the cache report to update its map of cached blocks to DataNode locations. This configuration has no effect if in-memory caching has been disabled by setting dfs.datanode.max.locked.memory to 0 (which is the default). If the native libraries are not available to the DataNode, this configuration has no effect.</description>
</property>
property>
<name>dfs.datanode.fsdatasetcache.max.threads.per.volume</name>
<value>4</value>
<description>The maximum number of threads per volume to use for caching new data on the datanode. These threads consume both I/O and CPU. This can affect normal datanode operations.</description>
</property>

2.2 使用

HDFS集中式缓存对数据读写接口并没有影响,正常调用已缓存数据的读写即可使用缓存特性。 为了便于数据管理,HDFS通过CacheAdmin对外暴露了一系列缓存管理的接口,其中CLI接口如下。

CacheAdmin
1
2
3
4
5
6
7
8
9
10
11
Usage: bin/hdfs cacheadmin [COMMAND]
          [-addDirective -path <path> -pool <pool-name> [-force] [-replication <replication>] [-ttl <time-to-live>]]
          [-modifyDirective -id <id> [-path <path>] [-force] [-replication <replication>] [-pool <pool-name>] [-ttl <time-to-live>]]
          [-listDirectives [-stats] [-path <path>] [-pool <pool>] [-id <id>]
          [-removeDirective <id>]
          [-removeDirectives -path <path>]
          [-addPool <name> [-owner <owner>] [-group <group>] [-mode <mode>] [-limit <limit>] [-maxTtl <maxTtl>]
          [-modifyPool <name> [-owner <owner>] [-group <group>] [-mode <mode>] [-limit <limit>] [-maxTtl <maxTtl>]]
          [-removePool <name>]
          [-listPools [-stats] [<name>]]
          [-help <command-name>]

CacheAdmin主要对用户暴露的是对缓存数据/缓存池(在系统架构及原理进行详细解释)的增删改查功能,另外还提供了相关的缓存策略,供用户灵活使用。
如这里期望能够缓存数据仓库中被频繁访问的user表数据:

CacheAdmin CLI
1
2
$HADOOP_HOME/bin/hdfs cacheadmin -addPool factPool -owner hadoop-user -group hadoop-user -mod 777 -limit 1024000000 -ttl 2d
$HADOOP_HOME/bin/hdfs cacheadmin -addDirective -path /user/hive/warehouse/dw.db/user -pool factPool -force -replication 3 -ttl 1d

首先新建名称为factPool的缓存池,并赋予相关的用户组及权限等信息,另外限制该缓存池可以缓存的最大空间及缓存数据的最大TTL等;
然后将user表数据加入到缓存池factPool进行缓存,并指定缓存时间为1天,缓存3个副本;
之后当有读user表数据的请求过来后即可调度到缓存节点上从内存直接读取,从而提升读性能。其它CLI的用法可类比这里不再一一罗列。

2.3 适用场景

当前内存相比HDD成本还比较高,另外对于Hadoop集群,节点的内存大部分是分配给YARN供计算使用,所以剩余的内存资源其实非常有限,能够提供给HDFS集中式缓存使用的部分更少,为了使有限的资源发挥出最好的效率,这里提供几点建议:
(1)数据仓库中存在一部分事实表被频繁访问,与其他事实表/维度表JOIN,将访问频率较高的部分事实表进行缓存,可以提高数据生产的效率;
(2)根据局部性原理,最近写入的数据最容易被访问到,从数据仓库应用来看,每天有大量报表统计任务,需要读取前一天数据做分析,事实上大量表都是按天进行分区,可以把符合要求的热点分区数据做缓存处理,过期后清理缓存,也能大幅提升生产和统计效率;
(3)资源数据,当前存在非常多的计算框架依赖JAR/SO等一些公共资源,传统的做法是将这些资源数据写入到HDFS,通过Distributed Cache进行全局共享,也便于管理,如Spark/Tez/Hive/Kafaka等使用到的公共JAR包。如果将这部分资源数据进行长期缓存,可以优化JVM初始化时间,进而提升效率;
(4)其他;

三、系统架构及原理

3.1 架构

设计文档中定义集中式缓存机制(Centralized cache management):

An explicit caching mechanism that allows users to specify paths to be cached by HDFS.

其中包含了若干具体的目标:

Strong semantics for cached paths
Exposing cache state to framework schedulers
Defer cache policy decisions to higher­ level frameworks
Backwards compatibility
Management, debugging, metrics
Security
Quotas

为了实现上述目标,首先引入两个重要的概念:CacheDirective,CachePool。其中CacheDirective定义了缓存基本单元,本质上是文件系统的目录或文件与具体缓存策略等属性的集合;为了便于灵活管理,将属性类似的一组CacheDirective组成缓存池(CachePool),在缓存池CachePool上可以进行权限、Quota、缓存策略和统计信息等灵活控制。
在具体展开集中式缓存的系统架构和原理前,首先梳理对CacheDirective缓存的详细流程,具体如图1:
(1)用户通过调用客户端接口向NameNode发起对CacheDirective(Directory/File)缓存请求;
(2)NameNode接收到缓存请求后,将CacheDirective转换成需要缓存的Block集合,并根据一定的策略并将其加入到缓存队列中;
(3)NameNode接收到DataNode心跳后,将缓存Block的指令下发到DataNode;
(4)DataNode接收到NameNode发下的缓存指令后根据实际情况触发Native JNI进行系统调用,对Block数据进行实际缓存;
(5)此后DataNode定期(默认10s)向NameNode进行缓存汇报,更新当前节点的缓存状态;
(6)上层调度尽可能将任务调度到数据所在的DataNode,当客户端进行读数据请求时,通过DFSClient直接从内存进行ZeroCopy,从而显著提升性能;

HDFS集中式缓存管理流程图

HDFS集中式缓存的架构如图2所示,这里主要涉及到NameNode和DataNode两侧的管理和实现,NameNode对数据缓存的统一集中管理,并根据策略调度合适的DataNode对具体的数据进行数据的缓存和置换,DataNode根据NameNode的指令执行对数据的实际缓存和置换。

HDFS集中式缓存架构图

3.2 DataNode

DataNode是执行缓存和置换的具体执行者,具体来说即cacheBlock和uncacheBlock调用。FsDatasetImpl#FsDatasetCache类是该操作的执行入口。

Manages caching for an FsDatasetImpl by using the mmap(2) and mlock(2) system calls to lock blocks into memory. Block checksums are verified upon entry into the cache.

FsDatasetCache的核心是称为mappableBlockMap的HashMap,用于维护当前缓存的Block集合,其中Key为标记该Block的ExtendedBlockId,为了能够实现与Federation的兼容,在blockid的基础上增加了blockpoolid,这样多个blockpool的block不会因为blockid相同产生冲突;Value是具体的缓存数据及当前的缓存状态。

FsDatasetCache#mappableBlockMap
1
private final HashMap<ExtendedBlockId, Value> mappableBlockMap = new HashMap<ExtendedBlockId, Value>();

mappableBlockMap更新只有FsdatasetCache提供的两个具体的函数入口:

FsDatasetCache.java
1
2
synchronized void cacheBlock(long blockId, String bpid, String blockFileName, long length, long genstamp, Executor volumeExecutor)
synchronized void uncacheBlock(String bpid, long blockId)

可以看出,对mappableBlockMap的并发控制实际上放在了cacheBlock和uncacheBlock两个方法上,虽然锁粒度比较大,但是并不会对并发读写带来影响。原因是:cacheBlock在系统调用前构造空Value结构加入mappableBlockMap中,此时该Value维护的Block状态是CACHING,之后将真正缓存数据的任务加入异步任务CachingTask去完成,所以锁很快会被释放,当处于CACHING状态的Block被访问的时候会退化到从HDD访问,异步任务CachingTask完成数据缓存后将其状态置为CACHED;uncacheBlock是同样原理。所以,虽然锁的粒度比较大,但是并不会阻塞后续的数据缓存任务,也不会对数据读写带来额外的开销。

顺着自底向上的思路,再来看触发cacheBlock和uncacheBlock的场景,通过函数调用关系容易看到缓存和置换的触发场景都比较简单。
(1)cacheBlock:唯一的入口是从ANN(HA Active NameNode)下发的指令;


(2)uncacheBlock:与cacheBlock不同,uncacheBlock存在三个入口:append,invalidate和uncache。其中uncache也是来自ANN下发的指令;append和invalidate触发uncacheBlock的原因是:append会导致数据发生变化,缓存失效需要清理后重新缓存,invalidate来自删除操作,缓存同样失效需要清理。


DataNode的处理逻辑比较简单,到这里整个实现的主路径基本梳理完成。

3.3 NameNode

相比DataNode的实现逻辑,NameNode侧要复杂的多,缓存管理继承了NameNode一贯的模块化思路,通过CacheManager实现了整个集中式缓存在管理端的复杂处理逻辑。
CacheManager通过几个关键数据结构组织对数据缓存的实现。

CacheManager.javagithub
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * Cache directives, sorted by ID.
 */
private final TreeMap<Long, CacheDirective> directivesById = new TreeMap<Long, CacheDirective>();
/**
 * Cache directives, sorted by path
 */
private final TreeMap<String, List<CacheDirective>> directivesByPath = new TreeMap<String, List<CacheDirective>>();
/**
 * Cache pools, sorted by name.
 */
private final TreeMap<String, CachePool> cachePools = new TreeMap<String, CachePool>();
/**
 * All cached blocks.
 */
private final GSet<CachedBlock, CachedBlock> cachedBlocks;
private CacheReplicationMonitor monitor;

通过上述的几个核心集合类数据结构,很容易实现用户层对缓存数据/缓存池的增删改查功能调用的支持。

虽然上述的几个核心集合类数据结构能够容易支持用户对相关接口的调用,但是从整个集中式缓存全局来看,并没有将用户调用接口对目录/文件的缓存同DataNode实际数据缓存建立起有效连接。CacheReplicationMonitor发挥的即是这种作用,CacheReplicationMonitor使得缓存接口调用、核心数据结构以及数据缓存真正有序流动起来。

如果对BlockManager#ReplicationMonitor比较熟悉的话,可以发现CacheReplicationMonitor的工作模式几乎从ReplicationMonitor复制而来,CacheReplicationMonitor本质上也是一个定时线程,与ReplicationMonitor稍微不同的是,CacheReplicationMonitor除了定时触发外用户的缓存调用也会触发。其核心是其rescan方法,具体来看主要做三个具体工作:
(1)resetStatistics:对CacheManager从CacheDirective和CachePool两个维度对统计信息进行更新;
(2)rescanCacheDirectives:顺序扫描CacheManager#directivesById数据结构(与CacheManager#directivesByPath实际上等价),检查哪些CacheDirective(目录或文件)需要进行cache处理,一旦发现有文件需要进行缓存,立即将该文件的所有Block加入到CacheReplicationMonitor#cachedBlocks(GSet<CachedBlock, CachedBlock> cachedBlocks)中,后续工作由rescanCachedBlockMap接着进行;
(3)rescanCachedBlockMap:顺序扫描CacheReplicationMonitor#cachedBlocks的所有CacheBlock,由于CacheBlock也有副本个数的概念,rescanCachedBlockMap在扫描的过程中会发现实际缓存的Block副本数与预设的缓存副本有差异,比如新增缓存请求/节点宕机/心跳异常/节点下线等等导致Cache Block副本数与期望值之间产生差异,所以需要CacheReplicationMonitor进行周期检查和修复,根据差异多少关系将对应的CacheBlock加入到DatanodeManager管理的对应DatanodeDescriptor#{pendingCached,pendingUncached}数据结构中,待对应的DataNode心跳过来后将其转化成对应的执行指令下发给DataNode实际执行。关于心跳与指令下发的细节已经在之前文章中多处提到,这里不再展开。

这里还遗留一个问题,由于Block与CacheBlock均存在多副本的关系,如何选择具体的DataNode执行缓存或置换。
(1)uncacheBlock:uncacheBlock选择对应的DataNode其实比较简单,顺序遍历所有已经缓存了该Block的DataNode,为其准备uncacheBlock指令,直到缓存副本达到预期即可;
(2)cacheBlock:cacheBlock稍微复杂,先从Block副本所在的所有DataNode集合中排除DecommissionInProgress/Decommissioned/CorruptBlock所在DataNode/已经缓存了该Block的DataNode之后,在剩下的DataNode集合中随机进行选择即可。

DataNode实际执行完成NameNode下发的cacheBlock/uncacheBlock指令后,在下次cacheReport(默认时间间隔10s)汇报给NameNode,CacheManager根据汇报情况对缓存执行情况对CacheManager#cachedBlocks进行更新。

至此,NameNode端的集中式缓存逻辑形成了合理有效的闭环,基本实现了设计目标。

3.3 DFSClient

客户端本身的读数据逻辑基本没有变化,与传统的读模式区别在于,当客户端向DataNode发出REQUEST_SHORT_CIRCUIT_FDS请求到达DataNode后,DataNode会首先判断数据是否被缓存,如果数据已经缓存,将缓存信息返回到客户端后续直接进行内存数据的ZeroCopy;如果数据没有缓存采用传统方式进行ShortCircuit-Read。

四、总结

通过前述分析,可以看到HDFS集中式缓存优势非常明显:
1、显著提升数据读性能;
2、提升集群内存利用率;

虽然优势明显,但是HDFS集中式缓存目前还有一些不足:
1、内存置换策略(LRU/LFU/etc.)尚不支持,如果内存资源不足,缓存会失败;
2、集中式缓存与Balancer之间还不能很好的兼容,大量的Block迁移会造成频繁内存数据交换;
3、缓存能力仅对读有效,对写来说其实存在副作用,尤其是Append;
4、与Federation的兼容非常不友好;

总之,内存模式在存储和计算的多个方向上已经成为业界广泛关注的方向和趋势,Hadoop社区也在投入精力持续在内存上发力,从集中式缓存来看,收益非常明显,但是还存在一些小问题,在实际应用过程中还需要优化和改进。

五、引用

[1] https://issues.apache.org/jira/browse/HDFS-4949
[2] http://hadoop.apache.org/docs/r2.7.1/hadoop-project-dist/hadoop-hdfs/CentralizedCacheManagement.html
[3] https://issues.apache.org/jira/secure/attachment/12610186/caching-design-doc-2013-10-24.pdf
[4] https://en.wikipedia.org/wiki/Zero-copy
[5] https://en.wikipedia.org/wiki/Mmap
[6] https://issues.apache.org/jira/browse/HDFS-347
[7] https://issues.apache.org/jira/browse/HDFS-2246

NameNode内存详解

一、背景

NameNode内存数据主要对整个文件系统元数据的管理。Namenode目前元数据管理可以分成两个层次,一个是Namespace的管理层,这一层负责管理HDFS分布式文件系统中的树状目录和文件结构;另一层则为Block管理层,这一层负责管理HDFS分布式文件系统中存储文件到物理块之间的映射关系BlocksMap元数据。其中对Namespace的管理数据除在内存常驻外,会定期Flush到持久化设备中;对BlocksMap元数据的管理只存在内存;当NameNode发生重启,需要从持久化设备中读取Namespace管理数据,并重新构造BlocksMap。这两部分数据结构占用巨大的JVM Heap空间。

除了对文件系统本身元数据的管理外,NameNode还需要维护DataNode本身的元数据,这部分空间相对固定,且占用空间较小。

从实际Hadoop集群环境历史数据看,当Namespace中包含INode(目录和文件总量)~140M,数据块数量~160M,常驻内存使用量达在~50G。随着数据规模的持续增长,内存占用接近同步线性增长。在整个HDFS服务中,NameNode的核心作用及内存数据结构的重要地位,所以分析内存使用情况对维护HDFS服务稳定性至关重要。

这里在《NameNode内存全景》基础上,进一步对NameNode内存中关键数据结构的细节进行详细解读。

二、内存分析

2.1 NetworkTopology

NameNode除对文件系统本身元数据的管理外还需要维护DataNode的信息,主要通过NetworkTopology中DatanodeDescriptor对DataNode进行表示,该类继承结构如下图示:


在整个继承机构中各个类的内存占用情况:


其中DatanodeDescriptor还包括一部分非常驻内存对象,这里没有详细统计,所以结果可能会有少许误差。

假设集群中包括1000个DataNode节点,仅DataNode部分占用内存情况:

(48+88+64)*1000=200000=195+K

所以仅NetworkTopology维护的DataNode信息,相比整个NameNode所占的内存空间微乎其微。

2.2 Namespace

与传统单机文件系统相似,HDFS对文件系统的目录结构也是按照树状结构维护,Namespace保存的正是目录树及每个目录/文件节点的属性,包括:名称(name),编号(id),所属用户(user),所属组(group),权限(permission),修改时间(mtime),访问时间(atime),子目录/文件(children)等信息。

下图为整个Namespace中INode的类图结构,从类图可以看出,INodeFile和INodeDirectory的继承关系。其中目录在内存中由INodeDirectory对象来表示,并用List children成员列表来标识该目录下的子目录和文件;文件在内存中则由INodeFile来表示,并用BlockInfo[] blocks成员数组来标识该文件由那些blocks分块组成。其他的目录/文件属性在该类继承结构的各个相应子类成员变量标识。


在整个继承关系中不同类的内存占用情况:


其中,除了上面提到的结构外,如目录节点的附加属性等非通用数据结构,没有在统计范围内。另外,INodeFile和INodeDirectory.withQuotaFeature在内存中使用最为广泛的两个结构。

假设Namespace包含INode数为1亿,仅Namespace占用内存情况:
(24+104+8+avg(32+44+ 8 * 2, 56+ 8 * 2)) * 100000000=21800000000=20.30GB

Namespace数据会定期持久化到外存设备上,内存也会常驻,在整个NameNode的生命周期内一直缓存在内存中,随着HDFS中存储的数据增多,文件数/目录树也会随之增加,占用内存空间也会同步增加。NameNode进程、单机内存容量及JVM对内存管理能力将成为制约HDFS的主要瓶颈。

2.3 BlocksMap

在HDFS中,每个block都对应多个副本,存储在不同的存储节点DataNode上。在NameNode元数据管理上需要维护从Block到DataNode列表的对应关系,描述每一个Block副本实际存储的物理位置,当前BlocksMap解决的即是从Block对对应DataNode列表的映射关系。

BlocksMap内部数据结构如下图示:


随着存储量规模不断增加,BlocksMap在内存中占用的空间会随之增加,社区在BlocksMap的数据结构使用上做过优化,最初直接使用HashMap解决从Block到BlockInfo的映射关系,之后经过优化使用重新实现的LightWeightGSet代替HashMap,该数据结构通过数据保存元素信息,利用链表解决碰撞冲突,达到更少的内存使用。

该数据结构里Block对象中只记录了blockid,blocksize和timestamp。BlockInfo继承自Block,除了Block对象中保存的信息外,最重要的是该block对应的DataNode的列表信息。

内存占用情况如下:


LightWeightGSet与HashMap相比,减少了HashMap在load factor避免冲突的额外内存开销,即使经过优化,BlocksMap也是占用了大量的内存空间,假设HDFS中存在1亿Block ,其占用内存情况:

(40+120+8)* 100000000=16800000000=15.65GB

BlocksMap数据常驻内存,在整个NameNode生命周期内一直缓存内存中,随着数据规模的增加,对应Namespace和Block数会随之增多,NameNode进程、单机内存容量及JVM对内存管理能力将成为主要瓶颈。

2.4 小结

根据前述对当前线上集群数据量:Namespace中包含INode(目录和文件总量):~140M,数据块数量:160M,计算内存使用情况:

Namespace:(24+104+8+avg(32+44+ 8 * 2, 56+ 8 * 2)) * 140M = ~29.0 GB
BlocksMap:(40+120+8) * 160M = ~25.0 GB
二者组合结果:29.0 GB + 25.0 GB = 53.0 GB

结果来看与监控常驻内存~50GB接近,基本符合实际情况。

从前面的讨论可以看出,在NameNode整个内存对象里,占用空间最大的两个结构即为Namespace和BlocksMap,当数据规模增加后,巨大的内存占用势必会制约NameNode进程的服务能力,尤其对JVM的内存管理会带来极大的挑战。

据了解业界在NameNode上JVM最大使用到180G,结合前面的计算可以得知,元数据总量700M基本上是服务的上限。

三、结论

1、NameNode内存使用量预估模型:Total=218 * num(INode) + 168 * num(Blocks);
2、受JVM可管理内存上限等物理因素,180G内存下,NameNode服务上限的元数据量约700M。

HDFS升级过程中重启方式选择

一、背景

集群在运行过程中,由于升级等原因难免会遇到重启NameNode或整个集群节点的情况,不同的重启方式会影响到整个运维操作的效率。
刚接触HDFS时的某次全集群内DataNode升级,遇到一次非预期内的NameNode重启。升级时,NameNode首先进入Safemode模式,全集群禁止写操作。DataNode数据包和配置更新后操作了所有DataNode一次性重启,之后NameNode间歇性不能响应,持续高负载达~45min,之后不得不通过重启NameNode,之后~35min全集群启动完成,服务恢复正常。

由此引出问题:
1、造成全集群DataNode重启后NameNode不能正常响应的根本原因是什么?
2、重启NameNode为什么能够实现恢复服务?

二、原因分析

1、现场还原

在集群重启过程中,不管以什么方式进行重启,避免不了DataNode向NameNode进行BlockReport的交互,从NameNode现场截取两个时间段里部分BlockReport日志。

表1 不同重启方式NameNode处理BR时间统计

DN Restart Only - - With NN Restart - -
Date Blocks ProcessTime Date Blocks ProcessTime
2015-04-28 13:15:51 49428 646 msecs 2015-04-28 14:00:57 66392 73 msecs
2015-04-28 13:15:51 49428 646 msecs 2015-04-28 14:00:57 66392 73 msecs
2015-04-28 13:15:51 49068 644 msecs 2015-04-28 14:00:57 69978 84 msecs
2015-04-28 13:15:52 51822 638 msecs 2015-04-28 14:00:57 78222 98 msecs
2015-04-28 13:15:53 83131 1214 msecs 2015-04-28 14:00:57 64663 71 msecs
2015-04-28 13:15:53 90088 169 msecs 2015-04-28 14:00:57 85106 99 msecs
2015-04-28 13:15:54 82024 1107 msecs 2015-04-28 14:00:57 87346 96 msecs
2015-04-28 13:15:55 48114 637 msecs 2015-04-28 14:00:57 87802 96 msecs
2015-04-28 13:15:55 49457 84 msecs 2015-04-28 14:00:57 65646 71 msecs
2015-04-28 13:15:56 82989 457 msecs 2015-04-28 14:00:57 71025 86 msecs
2015-04-28 13:15:57 84634 1181 msecs 2015-04-28 14:00:57 66144 73 msecs
2015-04-28 13:15:58 67321 885 msecs 2015-04-28 14:00:57 72652 90 msecs
2015-04-28 13:15:59 70668 924 msecs 2015-04-28 14:00:57 66118 76 msecs
2015-04-28 13:15:59 73114 138 msecs 2015-04-28 14:00:58 67011 74 msecs
2015-04-28 13:15:59 28215 692 msecs 2015-04-28 14:00:58 78216 84 msecs
2015-04-28 13:16:00 30080 321 msecs 2015-04-28 14:00:58 60988 66 msecs
2015-04-28 13:16:00 30435 329 msecs 2015-04-28 14:00:58 52376 58 msecs
2015-04-28 13:16:00 34350 360 msecs 2015-04-28 14:00:58 66801 73 msecs
2015-04-28 13:16:01 32487 344 msecs 2015-04-28 14:00:58 49134 53 msecs
2015-04-28 13:16:01 28244 308 msecs 2015-04-28 14:00:58 66928 73 msecs
2015-04-28 13:16:01 29138 308 msecs 2015-04-28 14:00:58 75560 82 msecs
2015-04-28 13:16:02 29765 301 msecs 2015-04-28 14:00:58 83880 92 msecs
2015-04-28 13:16:02 28699 309 msecs 2015-04-28 14:00:58 82989 93 msecs
2015-04-28 13:16:02 35377 370 msecs 2015-04-28 14:00:58 56210 60 msecs
2015-04-28 13:16:03 49204 626 msecs 2015-04-28 14:00:58 65517 78 msecs
2015-04-28 13:16:03 27554 438 msecs 2015-04-28 14:00:58 76159 78 msecs
2015-04-28 13:16:04 27285 326 msecs 2015-04-28 14:00:58 59725 58 msecs

左半部分记录的是重启全集群DataNode后,NameNode处理单个BlockReport请求耗时,右半部分为重启NameNode后,处理单个BlockReport请求耗时。这里只列了部分数据,虽不具统计意义,但是在处理时间的量级上可信。

从数据上可以看到,对于BlockReport类型的RPC请求,不同的重启方式,RPC的处理时间有明显差异。

2、深度分析

前面也提到从数据上看,对于BlockReport类型的RPC请求,重启全集群DataNode与重启NameNode,RPC处理时间有一个数量级的差别。这种差别通过代码得到验证。

BlockManager.javaGithub
1
2
3
4
5
6
7
if (storageInfo.getBlockReportCount() == 0) {
  // The first block report can be processed a lot more efficiently than
  // ordinary block reports.  This shortens restart times.
  processFirstBlockReport(node, storage.getStorageID(), newReport);
} else {
  invalidatedBlocks = processReport(node, storage, newReport);
}

可以看到NameNode对BlockReport的处理方式仅区别于是否为初次BlockReport。初次BlockReport显然只发生在NameNode重启期间。
processFirstBlockReport:对Standby节点(NameNode重启期间均为Standby),如果汇报的数据块相关元数据还没有加载,会将报告的块信息暂存队列,当Standby节点完成加载相关元数据后,再处理该消息队列; 对第一次块汇报的处理比较特别,为提高处理效率,仅验证块是否损坏,然后判断块状态是否为FINALIZED状态,如果是建立块与DN节点的映射,其他信息一概暂不处理。
processReport:对于非初次块汇报,处理逻辑要复杂很多;对报告的每个块信息,不仅会建立块与DN的映射,还会检查是否损坏,是否无效,是否需要删除,是否为UC状态等等。

初次块汇报的处理逻辑单独拿出来,主要原因有两方面:
1、加快NameNode的启动时间;统计数据也能说明,初次块汇报的处理时间比正常块汇报的处理时间能节省约一个数量级的时间。
2、由于启动过程中,不提供正常读写服务,所以只要确保正常数据(整个Namespace和所有FINALIZED状态Blocks)无误,无效和冗余数据处理完全可以延后到IBR或next BR。
说明:
1、是否选择processFirstBlockReport处理逻辑不会因为NameNode当前为safemode或者standby发生变化,仅NameNode重启生效;
2、BlockReport的处理时间与DataNode数据规模正相关,当前DataNode中Block数处于:200,000 ~ 1,000,000。
如果不操作NameNode重启,BlockReport处理时间会因为处理逻辑复杂带来额外的处理时间,统计数据显示,约一个数量级的差别。

NameNode对非第一次BlockReport的复杂处理逻辑只是NameNode负载持续处于高位的诱因,在其诱发下发生了一系列“滚雪球”式的异常放大。
1、所有DataNode进程被关闭后,NameNode的CallQueue(默认大小:3200)会被快速消费完;


2、所有DataNode进程被重启后,NameNode的CallQueue会被迅速填充,主要来自DataNode重启后正常流程里的VersionRequest和registerDataNode两类RPC请求,由于均较轻量,所以也会被迅速消费完;


3、之后DataNode进入BlockReport流程,NameNode的CallQueue填充内容开始从VersionRequest和registerDataNode向BlockReport过渡;


直到CallQueue里几乎被所有BlockReport填充满。


前面的统计数据显示,NameNode不重启对BlockReport的处理时间~500ms,另一个关键数据是Client看到的RPC超时时间,默认为60s;在默认的RPC超时时间范围内,CallQueue里最多可能被处理的BlockReport数~120个,其它均会发生超时。 当发生超时后,Client端(DataNode)会尝试重试,所以NameNode的CallQueue会被持续打满;另一方面,如果NameNode发现RPC Request出现超时会被忽略(可以从日志证实),直到存在未超时的请求,此时从CallQueue拿出来的BlockReport请求虽未超时,但也处于即将超时的边缘,即使处理完成其中的少数几个,CallQueue中的剩余大部分也会出现超时。

namenode.lgo
1
2
3
4
5
6
7
8
9
2015-04-28 13:14:48,709 INFO org.apache.hadoop.ipc.Server: IPC Server handler 9 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.45.38:37649 Call#650 Retry#0
2015-04-28 13:14:48,709 INFO org.apache.hadoop.ipc.Server: IPC Server handler 9 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.53.5:14839 Call#659 Retry#0
2015-04-28 13:14:48,709 INFO org.apache.hadoop.ipc.Server: IPC Server handler 9 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.62.5:55833 Call#702 Retry#0
2015-04-28 13:14:48,709 INFO org.apache.hadoop.ipc.Server: IPC Server handler 9 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.51.31:41016 Call#655 Retry#0
2015-04-28 13:14:48,709 INFO org.apache.hadoop.ipc.Server: IPC Server handler 29 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.62.36:53163 Call#702 Retry#0
2015-04-28 13:14:48,709 INFO org.apache.hadoop.ipc.Server: IPC Server handler 9 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.46.32:53530 Call#662 Retry#0
2015-04-28 13:14:48,710 INFO org.apache.hadoop.ipc.Server: IPC Server handler 29 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.55.11:52372 Call#662 Retry#0
2015-04-28 13:14:48,710 INFO org.apache.hadoop.ipc.Server: IPC Server handler 9 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.55.44:30295 Call#666 Retry#0
2015-04-28 13:14:48,710 INFO org.apache.hadoop.ipc.Server: IPC Server handler 29 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.16.50.4:37880 Call#674 Retry#0

通过前面的分析,从整个Timeline上看,NameNode长期处于满负荷运行状态,但是有效处理能力非常低(仅针对BlockReport)。这也是为什么1000+ DataNode(每一个DataNode管理的Block数均未超过1,000,000),也即1000+有效BlockReport请求,在~50min内依然没有被处理完成。

如果DataNode进程处于正常运行状态下,重启NameNode后会发生完全不同的情况。
1、NameNode重启后,首先加载FsImage,此时,除Namespace外NameNode的元数据几乎为空,此后开始接收DataNode过来的RPC请求(绝大多数为Heartbeat);


2、NameNode接收到Heartbeat后由于在初始状态会要求DataNode重新注册;由于Heartbeat间隔是3s,所以从NameNode的角度看,所有DataNode的后续一系列RPC请求会被散列到3s时间线上;


3、DataNode向NameNode注册完成后立即开始BlockReport;由于步骤2里提到的3s时间线散列关系,队列里后半部分BlockReport请求和VersionRequest/registerDataNode请求会出现相互交叉的情况;


4、如前述,处理BlockReport时部分RPC请求一样会发生超时;


5、由于超时重试,所以部分BlockReport和registerDataNode需要重试;可以发现不同于重启所有DataNode时重试的RPC几乎都是BlockReport,这里重试的RPC包括了VersionRequest/registerDataNode(可以从日志证实),这就大幅降低了NameNode的负载,避免了“滚雪球”式高负载RPC堆积,使异常有效收敛。

namenode.log
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.32.73.39:16329 Call#2893 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.32.20.15:54831 Call#2889 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.16.62.38:10818 Call#2835 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.16.52.18:59462 Call#2818 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.registerDatanode from 10.16.39.24:13728 Call#2864 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.32.27.8:58789 Call#2883 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.registerDatanode from 10.32.73.40:56606 Call#2889 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.16.40.21:19961 Call#2843 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.16.43.13:22644 Call#2870 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.registerDatanode from 10.16.43.26:16289 Call#2876 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.16.61.30:31968 Call#2825 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.32.21.5:47752 Call#2879 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.blockReport from 10.32.49.11:46892 Call#2904 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.16.36.24:12326 Call#2859 Retry#0
2015-04-28 14:01:19,302 INFO org.apache.hadoop.ipc.Server: IPC Server handler 31 on 8020: skipped org.apache.hadoop.hdfs.server.protocol.DatanodeProtocol.versionRequest from 10.16.56.4:55321 Call#2833 Retry#0

3、避免重启大量DataNode时雪崩

从前面的分析过程,可以得出两个结论:
(1)NameNode对正常BlockReport处理效率是造成可能雪崩的根本原因;
(2)BlockReport的堆积让问题完全失控;

从这两个结论出发可以推导出相应的解决办法:

1、解决效率问题:
(1)优化代码逻辑;这块代码相对成熟,可优化的空间不大,另外所需的时间成本较高,暂可不考虑;
(2)降低BlockReport时数据规模;NameNode处理BR的效率低主要原因还是每次BR所带的Block规模过大造成,所以可以通过调整Block数量阈值,将一次BlockReport分成多盘分别汇报,提高NameNode处理效率。可参考的参数为:dfs.blockreport.split.threshold,默认为1,000,000,当前集群DataNode上Block规模数处于240,000 ~ 940,000,建议调整为500,000;另一方面,可以通过在同一个物理节点上部署多个DataNode实例,分散数据,达到缩小规模的目的,但是这种方案仅能解决当前问题,长期来看依然不能避免,且影响范围比较大,需要多方面权衡。

2、解决堆积问题:
(1)控制重启DataNode的数量;按照当前节点数据规模,如果大规模重启DataNode,可采取滚动方式,以~15/单位间隔~1min滚动重启,如果数据规模增长,需要适当调整实例个数;
(2)定期清空CallQueue;如前述,当大规模DataNode实例被同时重启后,如果不采取措施一定会发生“雪崩”,若确实存在类似需求或场景,可以通过定期清空CallQueue(dfsadmin -refreshCallQueue)的方式,避免堆积效应;这种方案的弊端在于不能有选择的清空RPC Request,所以当线上服务期时,存在数据读写请求超时、作业失败的风险。

3、选择合适的重启方式:
(1)当需要对全集群的DataNode重启操作,且规模较大(包括集群规模和数据规模)时,建议在重启DataNode进程之后将NameNode重启,避免前面的“雪崩”问题;
(2)当灰度操作部分DataNode或者集群规模和数据规模均较小时,可采取滚动重启DataNode进程的方式;

三、总结

1、重启所有DataNode时,由于处理BlockReport逻辑不同,及由此诱发的“雪崩式”效应,导致重启进度极度缓慢;
2、在数据规模达到10K~100K,重启一台DataNode都会给NameNode的正常服务造成瞬时抖动;
3、在数据规模到100K量级时,同时重启~15以内DataNode不会对集群造成雪崩式灾难,但是可能出现短时间内服务不可用状态;
4、全集群升级时,建议NameNode和DataNode均重启,在预期时间内可恢复服务。

NameNode内存全景

一、概述

从整个HDFS系统架构上看,NameNode是其中最重要、最复杂也是最容易出现问题的地方,而且一旦NameNode出现故障,整个Hadoop集群就将处于不可服务的状态,同时随着数据规模和集群规模的持续增长,很多小量级时被隐藏的问题逐渐暴露出来。所以,从更高层次掌握NameNode的内部结构和运行机制尤其重要。除特别说明外,本文主要基于社区版本Hadoop-2.4.1[1][2]。

NameNode管理着整个HDFS文件系统的元数据。从架构设计上看,元数据大致分成两个层次:Namespace管理层,负责管理文件系统中的树状目录结构以及文件与数据块的映射关系;块管理层,负责管理文件系统中文件的物理块与实际存储位置的映射关系BlocksMap,如图1所示[1]。Namespace管理的元数据除内存常驻外,也会周期Flush到持久化设备上FsImage文件;BlocksMap元数据只在内存中存在;当NameNode发生重启,首先从持久化设备中读取FsImage构建Namespace,之后根据DataNode的汇报信息重新构造BlocksMap。这两部分数据结构是占据了NameNode大部分JVM Heap空间。

HDFS结构图

除了对文件系统本身元数据的管理之外,NameNode还需要维护整个集群的机架及DataNode的信息、Lease管理以及集中式缓存引入的缓存管理等等。这几部分数据结构空间占用相对固定,且占用较小。

测试数据显示,Namespace目录和文件总量到2亿,数据块总量到3亿后,常驻内存使用量超过90GB。

二、内存全景

如前述,NameNode整个内存结构大致可以分成四大部分:Namespace,BlocksMap,NetworkTopology,LeaseManager & SnapshotManager & CacheManager及其他,图2为各数据结构内存逻辑分布图示。

namenode内存全景图

Namespace:维护整个文件系统的目录树结构,及目录树上的状态变化;
BlocksManager:维护整个文件系统中与数据块相关的信息,及数据块的状态变化;
NetworkTopology:维护机架拓扑及DataNode信息,机架感知的基础;
LeaseManager:读写的互斥同步就是靠Lease实现,支持HDFS的Write-Once-Read-Many的核心数据结构;
CacheManager:Hadoop 2.3.0引入的集中式缓存新特性,支持集中式缓存的管理,实现memory-locality提升读性能;
SnapshotManager:Hadoop 2.1.0引入的Snapshot新特性,用于数据备份、回滚,以防止因用户误操作导致集群出现数据问题;
DelegationTokenSecretManager:管理HDFS的安全访问;
其他:临时数据信息、统计信息metrics等等。

NameNode常驻内存主要被Namespace和BlockManager使用,二者使用占比分别接近50%。其他部分内存开销较小且相对固定,与Namespace和BlockManager相比基本可以忽略。

三、内存分析

3.1 Namespace

与单机文件系统相似,HDFS对文件系统的目录结构也是按照树状结构维护,Namespace保存了目录树及每个目录/文件节点的属性。除在内存常驻外,这部分数据会定期flush到持久化设备上,生成一个新的FsImage文件,方便NameNode发生重启时,从FsImage及时恢复整个Namespace。图3所示为Namespace内存结构。前述集群中目录和文件总量即整个Namespace目录树中包含的节点总数,可见Namespace本身其实是一棵非常巨大的树。

Namespace内存结构

在整个Namespace目录树中存在两种不同类型的INode数据结构:INodeDirectory和INodeFile。其中INodeDirectory标识的是目录树中的目录,INodeFile标识的是目录树中的文件。由于二者均继承自INode,所以具备大部分相同的公共信息INodeWithAdditionalFields,除常用基础属性外,其中还提供了扩展属性features,如Quota,Snapshot等均通过Feature增加,如果以后出现新属性也可通过Feature方便扩展。不同的是,INodeFile特有的标识副本数和数据块大小组合的header(2.6.1之后又新增了标识存储策略ID的信息)及该文件包含的有序Blocks数组;INodeDirectory则特有子节点的列表children。这里需要特别说明children是默认大小为5的ArrayList,按照子节点name有序存储,虽然在插入时会损失一部分写性能,但是可以方便后续快速二分查找提高读性能,对一般存储系统,读操作比写操作占比要高。具体的继承关系见图4所示。

INode继承关系

3.2 BlockManager

BlocksMap在NameNode内存空间占据很大比例,由BlockManager统一管理,相比Namespace,BlockManager管理的这部分数据要复杂的多。Namespace与BlockManager之间通过前面提到的INodeFile有序Blocks数组关联到一起。图5所示BlockManager管理的内存结构。

BlockManager管理的内存结构

每一个INodeFile都会包含数量不等的Block,具体数量由文件大小及每一个Block大小(默认为64M)比值决定,这些Block按照所在文件的先后顺序组成BlockInfo数组,如图5所示的BlockInfo[A~K],BlockInfo维护的是Block的元数据,结构如图6所示,数据本身是由DataNode管理,所以BlockInfo需要包含实际数据到底由哪些DataNode管理的信息,这里的核心是名为triplets的Object数组,大小为3*replicas,其中replicas是Block副本数量。triplets包含的信息:

triplets[i]:Block所在的DataNode;
triplets[i+1]:该DataNode上前一个Block;
triplets[i+2]:该DataNode上后一个Block;

其中i表示的是Block的第i个副本,i取值[0,replicas)。

BlockInfo继承关系

从前面描述可以看到BlockInfo几块重要信息:文件包含了哪些Block,这些Block分别被实际存储在哪些DataNode上,DataNode上所有Block前后链表关系。

如果从信息完整度来看,以上数据足够支持所有关于HDFS文件系统的正常操作,但还存在一个使用场景较多的问题:不能通过blockid快速定位Block,所以引入了BlocksMap。

BlocksMap底层通过LightWeightGSet实现,本质是一个链式解决冲突的哈希表。为了避免rehash过程带来的性能开销,初始化时,索引空间直接给到了整个JVM可用内存的2%,并且不再变化。集群启动过程,DataNode会进行BR(BlockReport),根据BR的每一个Block计算其HashCode,之后将对应的BlockInfo插入到相应位置逐渐构建起来巨大的BlocksMap。前面在INodeFile里也提到的BlockInfo集合,如果我们将BlocksMap里的BlockInfo与所有INodeFile里的BlockInfo分别收集起来,可以发现两个集合完全相同,事实上BlocksMap里所有的BlockInfo就是INodeFile中对应BlockInfo的引用;通过Block查找对应BlockInfo时,也是先对Block计算HashCode,根据结果快速定位到对应的BlockInfo信息。至此涉及到HDFS文件系统本身元数据的问题基本上已经解决了。

前面提到部分都属于静态数据部分,NameNode内存中所有数据都要随读写情况发生变化,BlockManager当然也需要管理这部分动态数据。主要是当Block发生变化不符合预期时需要及时调整Blocks的分布。这里涉及几个核心的数据结构:

excessReplicateMap:若某个Block实际存储的副本数多于预设副本数,这时候需要删除多余副本,这里多余副本会被置于excessReplicateMap中。excessReplicateMap是从DataNode的StorageID到Block集合的映射集。 neededReplications:若某个Block实际存储的副本数少于预设副本数,这时候需要补充缺少副本,这里哪些Block缺少多少个副本都统一存在neededReplications里,本质上neededReplications是一个优先级队列,缺少副本数越多的Block之后越会被优先处理。 invalidateBlocks:若某个Block即将被删除,会被置于invalidateBlocks中。invalidateBlocks是从DataNode的StorageID到Block集合的映射集。如某个文件被客户端执行了删除操作,该文件所属的所有Block会先被置于invalidateBlocks中。 corruptReplicas:有些场景Block由于时间戳/长度不匹配等等造成Block不可用,会被暂存在corruptReplicas中,之后再做处理。

前面几个涉及到Block分布情况动态变化的核心数据结构,这里的数据实际上是过渡性质的,BlocksManager内部的ReplicationMonitor线程(图5标识Thread/Monitor)会持续从其中取出数据并通过逻辑处理后分发给具体的DatanodeDescriptor对应数据结构(3.3 NetworkTopology里会有简单介绍),当对应DataNode的心跳过来之后,NameNode会遍历DatanodeDescriptor里暂存的数据,将其转换成对应指令返回给DataNode,DataNode收到任务并执行完成后再反馈回NameNode,之后DatanodeDescriptor里对应信息被清除。如BlockB预设副本数为3,由于某种原因实际副本变成4(如之前下线的DataNode D重新上线,其中B正好有BlockB的一个副本数据),BlockManager能及时发现副本变化,并将多余的DataNode D上BlockB副本放置到excessReplicateMap中,ReplicationMonitor线程定期检查时发现excessReplicateMap中数据后将其移到DataNode D对应DatanodeDescriptor中invalidateBlocks里,当DataNode D下次心跳过来后,随心跳返回删除Block B的指令,DataNode D收到指令实际删除其上的Block B数据并反馈回NameNode,此后BlockManager将DataNode D上的Block B从内存中清除,至此Block B的副本符合预期,整个流程如图7所示。

副本数异常时处理过程

3.3 NetworkTopology

前面多次提到Block与DataNode之间的关联关系,事实上NameNode确实还需要管理所有DataNode,不仅如此,由于数据写入前需要确定数据块写入位置,NameNode还维护着整个机架拓扑NetworkTopology。图8所示内存中机架拓扑图。

NetworkTopology内存结构

从图8可以看出这里包含两个部分:机架拓扑结构NetworkTopology和DataNode节点信息。其中树状的机架拓扑是根据机架感知(一般都是外部脚本计算得到)在集群启动完成后建立起来,整个机架的拓扑结构在NameNode的生命周期内一般不会发生变化;另一部分是比较关键的DataNode信息,BlockManager已经提到每一个DataNode上的Blocks集合都会形成一个双向链表,更准确的应该是DataNode的每一个存储单元DatanodeStorageInfo上的所有Blocks集合会形成一个双向链表,这个链表的入口就是机架拓扑结构叶子节点即DataNode管理的DatanodeStorageInfo。此外由于上层应用对数据的增删查随时发生变化,随之DatanodeStorageInfo上的Blocks也会动态变化,所以NetworkTopology上的DataNode对象还会管理这些动态变化的数据结构,如replicateBlocks/recoverBlocks/invalidateBlocks,这些数据结构正好和BlockManager管理的动态数据结构对应,实现了数据的动态变化由BlockManager传达到DataNode内存对象最后通过指令下达到物理DataNode实际执行的流动过程,流程在3.2 BlockManager已经介绍。

这里存在一个问题,为什么DatanodeStorageInfo下所有Block之间会以双向链表组织,而不是其他数据结构?如果结合实际场景就不难发现,对每一个DatanodeStorageInfo下Block的操作集中在快速增加/删除(Block动态增减变化)及顺序遍历(BlockReport期间),所以双向链表是非常合适的数据结构。

3.4 LeaseManager

Lease 机制是重要的分布式协议,广泛应用于各种实际的分布式系统中。HDFS支持Write-Once-Read-Many,对文件写操作的互斥同步靠Lease实现。Lease实际上是时间约束锁,其主要特点是排他性。客户端写文件时需要先申请一个Lease,一旦有客户端持有了某个文件的Lease,其他客户端就不可能再申请到该文件的Lease,这就保证了同一时刻对一个文件的写操作只能发生在一个客户端。NameNode的LeaseManager是Lease机制的核心,维护了文件与Lease、客户端与Lease的对应关系,这类信息会随写数据的变化实时发生对应改变。

LeaseManager的内存数据结构

图9所示为LeaseManager内存结构,包括以下三个主要核心数据结构:

sortedLeases:Lease集合,按照时间先后有序组织,便于检查Lease是否超时;
leases:客户端到Lease的映射关系;
sortedLeasesByPath:文件路径到Lease的映射关系;

其中每一个写数据的客户端会对应一个Lease,每个Lease里包含至少一个标识文件路径的Path。Lease本身已经维护了其持有者(客户端)及该Lease正在操作的文件路径集合,之所以增加了leases和sortedLeasesByPath为提高通过Lease持有者或文件路径快速索引到Lease的性能。

由于Lease本身的时间约束特性,当Lease发生超时后需要强制回收,内存中与该Lease相关的内容要被及时清除。超时检查及超时后的处理逻辑由LeaseManager.Monitor统一执行。LeaseManager中维护了两个与Lease相关的超时时间:软超时(softLimit)和硬超时(hardLimit),使用场景稍有不同。

正常情况下,客户端向集群写文件前需要向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。

NameNode内存数据结构非常丰富,这里对几个重要的数据结构进行了简单的描述,除了前面罗列之外,其实还有如SnapShotManager/CacheManager等,由于其内存占用有限且有一些特性还尚未稳定,这里不再展开。

四、问题

随着集群中数据规模的不断积累,NameNode内存占用随之成比例增长。不可避免的NameNode内存将逐渐成为集群发展的瓶颈,并开始暴漏诸多问题。

1、启动时间变长。NameNode的启动过程可以分成FsImage数据加载、editlogs回放、Checkpoint、DataNode的BlockReport几个阶段。数据规模较小时,启动时间可以控制在~10min以内,当元数据规模达到5亿(Namespace中INode数超过2亿,Block数接近3亿),FsImage文件大小将接近到20GB,加载FsImage数据就需要~14min,Checkpoint需要~6min,再加上其他阶段整个重启过程将持续~50min,极端情况甚至超过60min,虽然经过多轮优化重启过程已经能够稳定在~30min,但也非常耗时。如果数据规模继续增加,启动过程将同步增加。

2、性能开始下降。HDFS文件系统的所有元数据相关操作基本上均在NameNode端完成,当数据规模的增加致内存占用变大后,元数据的增删改查性能会出现下降,且这种下降趋势会因规模效应及复杂的处理逻辑被放大,相对复杂的RPC请求(如addblock)性能下降更加明显。

3、NameNode JVM FGC(Full GC)风险较高。主要体现在两个方面:(1)FGC频率增加;(2)FGC时间增加且风险不可控。针对NameNode的应用场景,目前看CMS内存回收算法比较主流,正常情况下,对超过100GB内存进行回收处理时,可以控制到秒级别的停顿时间,但是如果回收失败被降级到串行内存回收时,应用的停顿时间将达到数百秒,这对应用本身是致命的。

4、超大JVM Heap Size调试问题。如果线上集群性能表现变差,不得不通过分析内存才能得到结论时,会成为一件异常困难的事情。且不说Dump本身极其费时费力,Dump超大内存时存在极大概率使NameNode不可服务。

针对NameNode内存增长带来的诸多问题,社区和业界都在持续关注并尝试不同的解决方案。整体上两个思路:(1)扩展NameNode分散单点负载;(2)引入外部系统支持NameNode内存数据。

从2010年开始社区就投入大量精力持续解决,Federation方案[3]通过对NameNode进行水平扩展分散单点负载的方式解决NameNode的问题,经过几年的发展该方案逐渐稳定,目前已经被业界广泛使用。除此之外,社区也在尝试将Namespace存储值外部的KV存储系统如LevelDB[4],从而降低NameNode内存负载。

除社区外,业界也在尝试自己的解决方案。Baidu HDFS2[5]将元数据管理通过主从架构的集群形式提供服务,本质上是将原生NameNode管理的Namespace和BlockManagement进行物理拆分。其中Namespace负责管理整个文件系统的目录树及文件到BlockID集合的映射关系,BlockID到DataNode的映射关系是按照一定的规则分到多个服务节点分布式管理,这种方案与Lustre有相似之处(Hash-based Partition)。Taobao HDFS2[6]尝试过采用另外的思路,借助高速存储设备,将元数据通过外存设备进行持久化存储,保持NameNode完全无状态,实现NameNode无限扩展的可能。其他类似的诸多方案不一而足。

尽管社区和业界均对NameNode内存瓶颈有成熟的解决方案,但是不一定适用所有的场景,尤其是中小规模集群。结合实践过程和集群规模发展期可能遇到的NameNode内存相关问题这里有几点建议:

1、合并小文件。正如前面提到,目录/文件和Block均会占用NameNode内存空间,大量小文件会降低内存使用效率;另外,小文件的读写性能远远低于大文件的读写,主要原因对小文件读写需要在多个数据源切换,严重影响性能。

2、调整合适的BlockSize。主要针对集群内文件较大的业务场景,可以通过调整默认的Block Size大小(参数:dfs.blocksize,默认128M),降低NameNode的内存增长趋势。

3、HDFS Federation方案。当集群和数据均达到一定规模时,仅通过垂直扩展NameNode已不能很好的支持业务发展,可以考虑HDFS Federation方案实现对NameNode的水平扩展,在解决NameNode的内存问题的同时通过Federation可以达到良好的隔离性,不会因为单一应用压垮整集群。

五、总结

NameNode在整个HDFS系统架构中占据举足轻重的位置,内部数据和处理逻辑相对复杂,本文简单梳理了NameNode的内存全景及对其中几个关键数据结构,从NameNode内存核心数据视角对NameNode进行了简单的解读,并结合实际场景介绍了随着数据规模的增加NameNode内存可能遇到的问题及业界各种可借鉴的解决方案。

六、参考

[1] Apache Hadoop. https://hadoop.apache.org/.
[2] Apache Hadoop Source Code. https://github.com/apache/hadoop/tree/branch-2.4.1/.
[3] HDFS Federation. https://issues.apache.org/jira/browse/HDFS-1052.
[4] NemeNode Scalability. https://issues.apache.org/jira/browse/HDFS-5389.
[5] Baidu HDFS2. http://static.zhizuzhefu.com/wordpress_cp/uploads/2013/04/a9.pdf.
[6] Taobao HDFS2. https://github.com/taobao/ADFS.