优化Hadoop NameNode:拆分锁机制与升级日志框架
优化Hadoop NameNode:拆分锁机制与升级日志框架
在大数据处理领域,Hadoop的性能优化一直是技术团队关注的重点。本文详细介绍了针对Hadoop NameNode的深度优化方案,通过拆分全局锁和优化日志框架等措施,实现了显著的性能提升。这些优化不仅解决了NameNode的内存占用和读写性能问题,还在实际生产环境中取得了令人瞩目的效果。
1. HDFS 架构
Hadoop分布式文件系统(HDFS)被设计成适合运行在通用硬件(commodity hardware)上的分布式文件系统。它和现有的分布式文件系统有很多共同点。但同时,它和其他的分布式文件系统的区别也是很明显的。HDFS是一个高度容错性的系统,适合部署在廉价的机器上。HDFS能提供高吞吐量的数据访问,非常适合大规模数据集上的应用。HDFS放宽了一部分POSIX约束,来实现流式读取文件系统数据的目的。HDFS在最开始是作为Apache Nutch搜索引擎项目的基础架构而开发的。HDFS是Apache Hadoop Core项目的一部分。
HDFS采用master/slave架构。一个HDFS集群是由一个NameNode和一定数目的DataNodes组成。NameNode是一个中心服务器,负责管理文件系统的名字空间(namespace)以及客户端对文件的访问。集群中的DataNode一般是一个节点一个,负责管理它所在节点上的存储。HDFS暴露了文件系统的名字空间,用户能够以文件的形式在上面存储数据。从内部看,一个文件其实被分成一个或多个数据块,这些块存储在一组DataNode上。NameNode执行文件系统的名字空间操作,比如打开、关闭、重命名文件或目录。它也负责确定数据块到具体DataNode节点的映射。DataNode负责处理文件系统客户端的读写请求。在NameNode的统一调度下进行数据块的创建、删除和复制。
2. 性能瓶颈
2.1. DataNode
在HDFS架构中,DataNode负责存储用户数据信息,可以通过横向扩展DataNode节点来实现性能提升。在现有硬件条件下,数据I/O链路相对精简,当遇到DataNode性能瓶颈时,通常的解决方法是增加DataNode节点。我们团队已有成员针对HDFS DataNode进行过一次深度优化(深度优化Hadoop DataNode读写性能)。然而,在不增加硬件成本的前提下,继续提升单个DataNode节点性能已变得相当具有挑战性。
2.2. NameNode
在一个HDFS集群中,通常存在多个NameNode节点(一般为3个节点或更多)。在同一时刻,只有一个Active的NameNode节点会对外提供元数据的读写服务,而其它节点作为standby节点,随时准备在Active的NameNode节点故障之后接管元数据服务。这意味着单个NameNode需要存储集群中所有文件和目录的元数据信息。
单点NameNode将会产生两个主要问题:
内存空间占用过大引发的问题。由于NameNode是基于JVM运行的,当存储的文件和目录达到一定量级时(亿级以上),单点的NameNode将会占用数百GB的内存空间,如果所有NameNode重启,恢复时间较长,将会影响业务体验。为了解决过大内存占用带来的影响,通常会部署多个standby 节点避免多点同时故障引发的影响,同时通过使用联邦HDFS方案,横向扩展NameNode节点,每个NameNode节点管理命名空间的一部分。
读写服务性能不足的问题。通过分析NameNode节点运行时的堆栈信息以及火焰图,我们很容易发现NameNode在读写服务上受到一把全局锁(FSNamesystemLock)的影响。处理读写请求的过程中都会优先加这把全局锁,虽然是读写分开的,且有部分流程对该锁的持有范围进行了优化,但是依然是最大的问题。同时FSNamesystem内部的FSDirectory(Inode树)还存在一把单独的锁(dirLock),用来保护整棵树以及BlockMap的访问和修改。
下面两个图分别是我们在环境中截取的NameNode中的CallQueueLength指标和NameNode节点CPU利用率指标。
其中CallQueueLength指标表示NameNode接收到了大量请求或者NameNode处理请求耗时很长,导致请求队列堆积。综合这两个指标来看NameNode有大量请求堆积,但是CPU利用率却不高。也更加印证了NameNode中的FSNamesystemLock这把全局锁对读写性能带来的影响。
2.3. 优化点
经过对上述提到的NameNode的两个问题进行深入分析,我们发现可以做两方面的优化:
将元数据存放到外部,可以使用RocksDB这种本地存储,也可以使用提供集群服务的SQL数据库或者NoSQL数据库,这种优化方式对HDFS本身架构调整比较大,开发周期比较长。
对NameNode的全局锁(FSNamesystemLock)进行优化,减少锁对读写服务的影响,这种优化方式对HDFS本身架构调整比较小,开发周期相对较短。
考虑到上线周期和系统稳定性,我们在第一阶段选择了消除NameNode的全局锁(FSNamesystemLock)的优化方案。预计在优化完成后,NameNode在读写混合场景下的性能将提升至少4倍。
3. 全局锁拆分
HDFS原生架构中,FSNamesystemLock全局锁主要保护的内容有:
- 命名空间FSDirecory的内容
- 是BlockManager管理的数据块信息
- Lease 内容
- cache 内容
- snapshot内容等
NameNode的主要模块类图如下:
如果想要去掉FSNamesystemLock全局锁,就需要使用更细粒度的锁来进行取代,详细参考下表。
注:路径锁是ozone团队提出的,我们在此基础上做了更细致的优化。
原生架构 | 优化后 |
---|---|
目录或文件操作 | FSNamesystemLock全局锁 |
block操作 | FSNamesystemLock全局锁 |
进程控制 | FSNamesystemLock全局锁 |
Lease操作 | FSNamesystemLock全局锁 |
cache操作 | FSNamesystemLock全局锁 |
snapshot操作 | FSNamesystemLock全局锁 |
3.1. 锁的设计原则
- 锁需要用来保护数据,而不是保护流程
- 对于有transaction语义的流程,需要用单独的锁来保护,而且尽量保证这样的流程没有高耗时的操作
- 每个INode都用一把锁保护,操作该INode时需要加相应读写锁。使用LockPool可以避免锁对象空间浪费
3.2. 锁的设计限制
由于NameNode内部的锁使用非常复杂,大规模的改动会带来极高的风险,因此需要测试驱动以及充分的测试来保证数据的正确性。涉及多INode同时加锁情况:
- rename: 操作需要同时锁2个path(srcPath,distPath),因此需要设计一个PairLock,同时锁住两个path
- delete: 操作时候会自上而下递归加INode写锁,被操作的inode和其父INode一定会同时加锁。
- ContentSummary: 操作需要自上而下加读锁,被操作的INode和其父INode一定会同时加锁,对于已经计算完毕summary的INode,需要立刻释放,才能访问下一个。
- concat: 和rename一致,concat会访问到多个Path,所以也要求同时锁住两个Path
3.3. 路径锁
所谓路径锁,就是在需要操作某个路径上的文件或者目录时,会从根节点开始加对应INode的加读写锁,一直到需要目标的INode节点为止。下表描述了几个比较有代表的操作对应的加锁情况。(其中蓝色表示读锁,红色表示写锁)
Operation | LockList | Description |
---|---|---|
getFileInfo /a/b/f1 | /, a , b, f1 | 对/,a,b,f1 都上读锁其中如果f1不存在,只锁/, a , b如果b不存在,只锁/, a |
create /a/b/f1 | /, a , b, f1 | 对/, a , b上读锁,对f1上写锁*注:hdfs在create文件时,可能会牵扯到上级目录INode的改动:如quota,因此在可能需要给上级目录也加写锁。 |
rm -R /a/b/(b有children b1,b2) | /, a , b/, a ,b, b1/, a ,b, b2 | 对/, a 加读锁 b加写锁,对所有b的后代加写锁。检查是否对b有写权限检查是否对b的所有后代有写权限。删除节点b,b的后代b1,b2,b3 |
rename src deste.g.rename /a/b /c/b | /, a, b/, c | 对/,a,c上读锁src的b上写锁,dst的b若存在加读锁,此次加锁操作失败。若两个url同时需对公共路径的某个节点加读锁,则加读锁,否则加写锁。 |
3.4. LockPool 的设计
增加路径锁之后,加锁时每个INode都会关联一个INode lock。当同时存在大量操作请求某些路径时,将会由于INode lock引发内存膨胀的问题,当内存中存在10亿INode,每个锁对象大小100B,预计将会占用90GB堆内存。根据“二八定律”,少数文件会在短时间内被大量访问,短时间内不会存在10亿INode都被访问的场景。因此,我们设计了一个LockPool来动态的维护lock的创建和回收。如果某个线程想要获取一个INode的lock,可以使用INode中的inodeid做为key,在LockPool中进行检查是否可以获取锁,因为INode的inodeid在集群中是唯一表示。具体流程如下:
- 某个线程使用inodeid作为key在LockPool进行查找。
- 如果LockPool存在对应的key,refCount自增1,直接退出;
- 如果LockPool不存在对应的key,创建一个lock实例,存放到LockPool中。
- 判断poolSize是否超过高水位,如果超过高水位,发送evict信号。
- 另外存在单独的Evictor线程循环检查poolSize是否超过高水位,如果没有操作,await等待signal或者超时重新判断。
- 当poolSIze超过高水位,则进行evit操作,去除没有引用的lock,直到抵达低水位。
3.5. 其他功能的锁
- Lease 租约。原本受到FSNamesystemLock全局锁保护的操作,目前也被单独的lease 锁代替。
- BlockManager 块管理。新的优化方案下,BlockManager相关的操作都在单独的block锁范围下进行操作。BlockManager相关操作可以分为两类:一类是只操作block信息。单独的block锁就可以避免死锁和数据不一致的问题。另一类是先操作命名空间,再操作block信息。为了避免死锁,优先加命名空间的路径锁之后操作命名空间,并且在路径锁的范围下再加block锁之后再操作block信息。避免在并发场景下出现数据不一致的情况。
- 进程控制。新的优化方案下,在进程控制方面,将会拆分为路径锁(加root节点读写锁)和block锁。比如初始化NameNode节点的场景,初始化部分内容时,按照顺序先加路径锁(加root节点读写锁),然后再加block锁。
4. 测试效果
4.1. 优化审计日志
测试结果显示我们优化的版本性能提升接近2倍,但是没有达到我们的预期,经过抓取NameNode的CPU火焰图和堆栈信息,我们分析发现,HDFS集群默认是开启auditlog功能的,并且读写操作将较多的时间耗费在了logAuditEvent调用上,即使我们开启日志的异步功能(dfs.NameNode.audit.log.async),也没有更进一步的效果提升。
当我们把auditlog功能关闭后,我们发现有较明显的性能提升。读写(4:1)的场景下,优化版本的性能是原生架构的7.12倍;读写(8:1)的场景下,优化版本的性能是原生架构的6.27倍。
4.2. 优化log4j框架
因为审计日志(auditlog)信息可以作为审计使用,实际环境中无法关闭。在生产环境中,审计日志功能默认是开启的。经过深入分析,我们发现:在HDFS原生架构下,NameNode的性能瓶颈在FSNamesystemLock全局锁上。当我们将全局锁细化拆分后,读写元数据性能得到了提升,但是性能瓶颈转移到了审计日志上。HDFS原生架构中,审计日志使用log4j框架输出,但由于log4j内部多处使用 synchronized 关键字, 大量并发请求调用log4j接口打印审计日志时,将会竞争 synchronized 影响性能。同时,官方不再维护log4j框架。log4j2框架是log4j的升级版本,日志打印性能有数倍的提升。我们发现社区解决此问题进展缓慢,为此我们先于社区对日志输出框架进行改造适配,将log4j升级到log4j2。(备注:log4j2 v2.17.2版本存在功能调整,极大影响性能,建议使用更高的版本)
经过测试,读写(4:1)的场景下有5.55倍的性能提升,读写(8:1)的场景下有5.07倍的性能提升。
4.3. 线上效果
根据监控数据显示(下图),优化方案取得了显著成果:每个RPC请求的平均延时从原先的数百ms以上降低至10毫秒以内,请求队列中排队请求数从原先平均5000以上降低至100以内。截至2023年5月31日,该优化方案持续为各集群带来显著的性能提升。
- RPC请求平均延时在上线拆锁版本之后的监控数据对比
2.请求队列中排队请求数在上线拆锁版本之后的监控数据对比
5.未来的优化方向
- NameNode节点在文件数较多(亿级以上)时,还是会存在内存占用过多导致的问题。因此将元数据存放到外部,比如可以使用RocksDB这种本地存储,也可以使用提供集群服务的SQL数据库或者NoSQL数据库。如果使用外部分布式存储(比如TiKV或者是MySQL集群版等)来持久化元数据内容,NameNode节点可以更加专注提供元数据读写服务。
- 另外也可以参考分布式存储CEPH的实现,将集群命名空间拆分成多个子树,使用不同的NameNode节点管理不同的子树对外提供服务,将会极大的提升集群整体服务能力(拆分多个集群或者使用联邦模式)