Namenode内存分析

最近在学习JVM内存调优,顺便对于Namenode的内存分析了一把,发现还是有很多有意思但是危险的地方。

1 结论

1.1 Namenode内存使用和趋势

  • 截止2016年12月22号14点,集群的文件和文件夹总数量为293444427,block数量为348566581,堆内存总大小为148GB,Old区大小为130GB,堆内存使用为110GB~126GB

    目前Namenode常驻内存(详细定义请参考2.1章节)使用为110GB,Old区使用率84%,即将突破90%,需要采取扩大内存(降低文件数量)等措施来规避CMS GC降级为压缩式GC带来的漫长STW风险。

  • 依据我们的集群监控系统数据,从12月1号到22号,集群文件数增长了293444427 - 275847816=17596611,大约1千7百万,常驻内存增长了110GB - 103GB = 7GB
    按照这个速度,一个月内堆内存常驻使用将会突破90%警戒线。

  • 如果堆内存加大到180GB,全部给Old区的话增加30GB可用内存,按照估算(估算方法参考2.3章节)可以容纳的文件数在362804019左右,还可以增长的文件数需要控制在69359591以内,大约7千万文件量。

1.2 Namenode内存配置修改建议

1.2.1 提升堆内存大小,降低Old区使用比例,规避压缩式GC的STW风险。

从2016年8月份至今,老年代的最大使用曾经达到122.25g(94%),目前使用110g(84%),平均使用达到116.1g(89%)
堆内存增加到180g后能容纳的文件数在362804019左右,增长的文件数需要控制在69359591以内,大约7千万文件量。

目前Namenode常驻内存内存使用为110GB,Old区使用率84%,按照这个月的增长速度估算,一个月内堆内存使用将会突破90%警戒线。
依据监控系统数据,从12月1号到22号,集群文件数从275847816增长到293444427大约增长了1千7百万,
堆内存常驻内存增长了从103GB增长到110GB,增长了7GB

详细分析请参考2. Name内存利用分析章节。

修改:

1
-Xms180g -Xmx180g

1.2.2 增加ParNew和CMS GC使用的线程数量,目前是10,可以提升到18(24核下的默认值)。

提升ParNew和CMS的GC效率,降低ParNew STW时间,
缓解在临时对象数量激增时发生ParNew GC频繁导致服务延迟增加,吞吐量降低问题。

详细分析请参考3. Namenode启动参数分析章节。

修改或者去掉以使用默认值:

1
-XX:ParallelGCThreads=18

1.2.3 设置当Old区域使用达到80%时候,提前启动CMS GC。降低压缩式GC的STW风险。

目前GC配置缺少UseCMSInitiatingOccupancyOnly这项,
所以CMS GC启动是根据JVM自动确定,时机不确定而且有缺陷(来源于社区)。

目前我们的Namenode Old区常驻内存使用率高,所以不适合设置比较低的值,常驻内存使用率高于这个值会造成频繁无效的CMS GC。
而如果设置过高的话,会造成Full GC风险升高。

详细分析请参考3. Namenode启动参数分析章节。

修改和增加:

1
2
-XX:CMSInitiatingOccupancyFraction=80
-XX:+UseCMSInitiatingOccupancyOnly

1.2.4 设置Java8的永生代初始值MetaspaceSize为较大的值512m,避免MetaSpace用满需要增长而引发的Full GC。

参考文献[8],Java8采用MetaSpace替代PermGen,MetaSpace对于64bit Server模式的JVM默认初始大小21m,比较小。
另外,Java8的永生代默认没有上限,在用户代码不规范时候有可能发生无限增长,但是对于Namnode这种稳定运行的程序没有太大问题。

增加:

1
-XX:MetaspaceSize=512m

1.2.5 打印更多的GC日志信息:JVM启动参数,晋升分布,停顿时间等。

JVM启动参数:可以查看一些隐藏和最终被设置的参数。

晋升分布作为调整-XX:MaxTenuringThreshold参数和GC Young区域调优的重要参考。

停顿时间作为衡量JVM用户程序执行吞吐率的重要参考。

增加:

1
2
3
-XX:+PrintCommandLineFlags
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationStoppedTime

1.3 其他建议

  • 控制集群的目录树/文件数/Block数量,采用合并小文件等措施,降低Namenode内存压力。
  • 调整默认blockSize为256m,降低block数量。缺点是只能控制增量数据,修改存量数据代价较大。
  • 调研G1内存回收策略。

2 Namenode内存利用分析

2.1 Namenode常驻内存使用和估算公式

定义Namenode常驻内存使用是位于Old区,存活稳定,不可GC的内存使用部分。这部分内存使用主要随着目录/文件/Block数量的变化而变化。

根据文献[3][4]和代码阅读,Namenode常驻内存数据结构按照数据量主要包含以下两部分(节点和网络拓扑信息/LeaseManager/SnapShotManager/CacheManager等部分由于数据量较小忽略):

    1. 目录和文件树 Namespace

      a) Directory数据结构总大小: SumDir = (24+96+44+48) * CountDir + 8 * (CountDir + CountFile)

      b) File数据结构总大小: SumFile = (24+96+48) * CountFile + 8 * CountBlock

合并上述 a) 和 b) 两部分,Namespace总大小: SumNamespace = SumDir + SumFile = 220 * CountDir + 176 * CountFile + 8 * CountBlock

    1. 文件与块的映射 BlockMap

BlockMap数据结构总大小: SumBlockMap = 16 + 24 + JVMMemoryNN * 0.02 + (40 + 128) * CountBlock

  • 计算NN内存使用

合并上述 1 和 2 两部分,得到NN内存使用总大小估算公式:
MemoryUsedNN = SumNamespace + SumBlockMap = 220 * CountDir + 176 * CountFile + 176 * CountBlock + JVMMemoryNN * 0.02

假设文件数和目录数为1:1,NN内存使用总大小可以简化为:
MemoryUsedNN = SumNamespace + SumBlockMap = 198 * CountDirAndFile + 176 * CountBlock + JVMMemoryNN * 0.02

如果文件数相对目录数量数量较多,上述估算结果会偏小一些

2.2 公司集群Namenode内存使用估算

截止2016年12月22号14点,公司集群的文件和文件夹总数量为293444427,block数量为348566581,堆内存总大小为148GB,Old区大小为130GB,堆内存使用为110GB~126GB

Old区常驻使用率在84%(110/130),已经比较严重。

按照上述公式,得NN内存常驻使用总大小为:MemoryUsedNN = 198 * 293444427(54.1GB) + 176 * 348566581(57.1GB) + 0.02 * 148GB ~= 114.2GB(54.1G+57.1+3.0GB)

估算值与实际在GC日志中看到的ParNew GC和CMS GC后的内存使用110GB(主要为常驻内存使用)相比,误差3.5%,估计较为准确,误差应该主要由于文件和目录比例大于1:1导致。

2.3 内存分析结论

  1. 目前Namenode常驻内存内存使用为110GB,Old区使用率84%,即将突破90%,需要采取扩大内存(降低文件数量)等措施来规避STW风险。

  2. 依据监控系统数据,从12月1号到22号,集群文件数增长了293444427 - 275847816=17596611,大约1千7百万,堆内存常驻内存增长了110GB - 103GB = 7GB

    按照这个速度,一个月内堆内存使用将会突破90%警戒线

  3. 如果堆内存增大到180GB,Old区域增加30GB,按照上述估算公式和常驻内存使用量85%,
    能容纳的文件数在160 * 0.85 / 110 * 293444427 = 362804019左右,也就是说还可以增长的文件数需要控制在69359591以内,大约7千万文件量。

2.4 可以采取的措施

  1. 增大Namenode堆内存设置,目前是150GB,可以尝试增加到180GB。降低Old区使用比例,规避压缩式GC的STW风险。

    增大Old区域的堆内存,缺点是会使得CMS GC的Remark时间增长,目前是0.8s左右,影响不大。
    参考文献[4]推荐,再进一步加大堆内存会有JVM内存管理的额外风险。

  2. 调整blockSize为256MB,降低block数量。缺点是只能修改增量数据。

3. Namenode启动参数分析

1
/usr/local/jdk1.8.0_77/bin/java -Dproc_namenode -Xmx1024m -Djava.net.preferIPv4Stack=true -Dhadoop.log.dir=/usr/local/hadoop-2.7.2/logs -Dhadoop.log.file=hadoop.log -Dhadoop.home.dir=/usr/local/hadoop-2.7.2 -Dhadoop.id.str=hadoop -Dhadoop.root.logger=INFO,console -Djava.library.path=/usr/local/hadoop-2.7.2/lib/native -Dhadoop.policy.file=hadoop-policy.xml -Djava.net.preferIPv4Stack=true -Djava.net.preferIPv4Stack=true -Djava.net.preferIPv4Stack=true -Dhadoop.log.dir=/usr/local/hadoop-2.7.2/logs -Dhadoop.log.file=hadoop-hadoop-namenode-bigdata-hdp-apachenn01.xg01.log -Dhadoop.home.dir=/usr/local/hadoop-2.7.2 -Dhadoop.id.str=hadoop -Dhadoop.root.logger=INFO,RFA -Djava.library.path=/usr/local/hadoop-2.7.2/lib/native -Dhadoop.policy.file=hadoop-policy.xml -Djava.net.preferIPv4Stack=true -Dhadoop.security.logger=INFO,RFAS -Dhdfs.audit.logger=INFO,NullAppender -Dhadoop.security.logger=INFO,RFAS -Dhdfs.audit.logger=INFO,NullAppender -Dhadoop.security.logger=INFO,RFAS -Dhdfs.audit.logger=INFO,NullAppender -Xms150g -Xmx150g -Xmn20g -XX:SurvivorRatio=8 -XX:ParallelGCThreads=10 -XX:MaxTenuringThreshold=15 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=2 -XX:GCLogFileSize=512M -Xloggc:/usr/local/hadoop-2.7.2/logs/namenode_gc.log -Xms150g -Xmx150g -Xmn20g -XX:SurvivorRatio=8 -XX:ParallelGCThreads=10 -XX:MaxTenuringThreshold=15 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=2 -XX:GCLogFileSize=512M -Xloggc:/usr/local/hadoop-2.7.2/logs/namenode_gc.log -Xms150g -Xmx150g -Xmn20g -XX:SurvivorRatio=8 -XX:ParallelGCThreads=10 -XX:MaxTenuringThreshold=15 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=2 -XX:GCLogFileSize=512M -Xloggc:/usr/local/hadoop-2.7.2/logs/namenode_gc.log -Dhadoop.security.logger=INFO,RFAS org.apache.hadoop.hdfs.server.namenode.NameNode

其中GC相关的关键属性(由于重复配置,GC相关的属性重复了三遍,以最后为准):

1
2
3
-Xms150g -Xmx150g -Xmn20g -XX:SurvivorRatio=8 -XX:ParallelGCThreads=10 -XX:MaxTenuringThreshold=15
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=2 -XX:GCLogFileSize=512M

  • -Xms150g -Xmx150g:堆内存大小最大和最小都是150g
  • -Xmn20g:新生代大小为20g,等于eden+2*survivor,意味着老年代为150-20=130g。
  • -XX:SurvivorRatio=8:Eden和Survivor的大小比值为8,意味着两个Survivor区和一个Eden区的比值为2:8,一个Survivor占整个年轻代的1/10
  • -XX:ParallelGCThreads=10:设置ParNew GC的线程并行数,默认为8 + (Runtime.availableProcessors - 8) * 5/8,24核机器为18。这里是否需要提升?
  • -XX:MaxTenuringThreshold=15:设置对象在年轻代的最大年龄,超过这个年龄则会晋升到老年代
  • -XX:+UseParNewGC:设置新生代使用Parallel New GC
  • -XX:+UseConcMarkSweepGC:设置老年代使用CMS GC,当此项设置时候自动设置新生代为ParNew GC
  • -XX:CMSInitiatingOccupancyFraction=70
    老年代第一次占用达到该百分比时候,就会引发CMS的第一次垃圾回收周期。后继CMS GC由HotSpot自动优化计算得到。
    如果后继也想指定老年代使用达到百分比,然后就进行CMS GC参数为-XX:+UseCMSInitiatingOccupancyOnly,建议同时设置。
    是否应该同时设置-XX:+UseCMSInitiatingOccupancyOnly?

这个值如果设置的太大,则很可能避免不了Full GC;如果设置的太小,CMS GC会进行的比较频繁。

对于Namenode这样的常驻内存使用主要是稳定的目录文件数信息,而且随着文件数的增长堆内存使用稳定增长的场景,
CMS GC很多情况都是做的无用功,从log里也可以观察CMS并发清除前后的堆内存使用来验证,现象为CMS并发清除前后的堆内存使用差距很小,表明Old区域可以回收的对象基本诶呦。

目前GC配置缺少UseCMSInitiatingOccupancyOnly这项,所以CMS GC启动是根据JVM自动确定,时机不确定而且有缺陷(来源于社区)。

目前线上的Namenode常驻内存使用率高,所以不适合设置比较低的值,会造成频繁无效的CMS GC。
如果设置太高的话,会造成Full GC风险升高。
因此可以考虑将这个值设置比较大,例如80~85

4. GC log分析

4.1 Minor GC日志解析

根据上述分析,新生代大小为20g,Eden为16g,Survivor大小2g,老年代大小为130g

1
2
3
4
5
6
7
8
9
[GC (Allocation Failure) 2016-11-04T16:12:57.274+0800: 11243291.242:
[ParNew: 16831941K->60946K(18874368K), 0.1122703 secs]
126061168K->109291366K(155189248K), 0.1126967 secs]
[Times: user=1.09 sys=0.00, real=0.11 secs]
[GC (Allocation Failure) 2016-09-06T17:55:38.334+0800: 6151960.699:
[ParNew: 16844397K->85085K(18874368K), 0.1314400 secs]
116885867K->100127390K(155189248K), 0.1318411 secs]
[Times: user=1.29 sys=0.01, real=0.13 secs]

新生代与CMS配套的GC算法为多线程ParNew或者单线程DefNew。

  1. [ParNew: 16844397K->85085K(18874368K), 0.1314400 secs]
    其中,16844397K表示GC前的新生代占用量,85085K表示GC后的新生代占用量,GC后Eden和一个Survivor为空,所以85085K也是另一个Survivor的占用量。括号中的18874368K是Eden+一个被占用Survivor的总和(18g)。0.1122703 secs是新生代回收不可达对象的时间。

    可以看到每次ParNew GC后,基本对象都会被回收或者晋升,只剩下85085K=83M的数据

  2. 116885867K->100127390K(155189248K), 0.1318411 secs]
    其中,分别是Java堆在垃圾回收前后的大小,和Java堆大小。说明堆使用为116885867K=111.47g,回收大小为100127390K=95.49g,堆大小为155189248K=148g(去掉其中一个Survivor),回收了16g空间,耗时0.13秒。

    从而可以推算老年代大小为148g-18g=130g,与上述参数一致。老年代使用大小为95.49g-85085K=95.41g,使用率73.4%

  3. [Times: user=1.29 sys=0.01, real=0.13 secs]
    其中,时间=新生代垃圾收集+对象提升到老年代+垃圾清理。user指的是GC消耗的用户态度CPU时间,sys是内核态CPU时间,real指的是从开始到结束的时间计时。real包含了在GC过程中,IO或线程阻塞等待耗时。而user是CPU计算时间,在多核时通常会高于real时间。

4.2 CMS GC日志解析

利用工具分析的结果(请把本项目git clone到本地再打开文件):
GCLog分析

4.3 GC 日志相关参数

详见文献[9]

4.4 GC 日志抽取指标(未完成)

  1. 吞吐率=(程序运行时间-GC停顿时间)/程序运行时间1
  2. Old区域常驻内存使用率: 定义在CMS GC的sweep后,old区域的内存使用率
  3. 对象晋升率:定义为ParNew GC前后,Old区域内存使用差值
  4. 。。。

5. GC原理

5.1 Minor GC的触发条件

5.2 CMS GC的触发条件

5.3 CMS Full GC的触发条件

主要指的是由于某种原因CMS GS退化为单线程的Old区域压缩GC(Serial Old GC),极其耗时,一旦发生,服务即不可用。

  • System.gc()
  • 当永生代(Perm Gen)/老年代(Old Gen)/Metaspace(Java 8)使用比例100%时候
  • 老生代碎片过大无法分配空间给晋升的对象,引发concurrent mode failure错误

6. TODO工作

  • GC分析程序,接入监控。
  • 调研新的低延迟GC策略G1。
  • 如何用指标来度量GC的严重程度。
  • 提升服务吞吐量和降低延迟是否可以兼得?

7. Reference

  1. JVM调优建议
  2. NameNode Garbage Collection Configuration: Best Practices and Rationale
  3. Namenode内存分析-1
  4. Namenode内存分析-2
  5. [HDFS文件数监控]
  6. [HDFS文件和存储监控页面]
  7. Java SE 6 HotSpot[tm] Virtual Machine Garbage Collection Tuning
  8. JDK8: Metaspace
  9. JVM实用参数(八)GC日志
  10. Java性能优化权威指南