1 现象
某后端系统,处于整个调用链路偏后的位置,对接口性能有着比较严格的要求。因此对外承诺的三个 9 响应时间为 200 多毫秒。
然而,从某天开始一到上午流量高峰,服务耗时就报警。随机从集群内的某些机器上报了出来,过了流量高峰就好很多……
一般排查接口耗时,基本都习惯从外部流量、相关内部接口、逻辑块耗时、底层存储耗时等接口层面来观察。
如果能在接口层面就找到了瓶颈是最理想的情况,否则就要从 JVM 层面来排查。比如线程运行异常、GC 异常等,势必要耗费更多精力了。
虽然这类问题发生的概率比较小,但是看到几十台机器中只有几台在报。感觉还是有必要排查一下。
2.2 是不是有业务重逻辑导致耗时增加
交互框架本身的限制
大型服务调用,基本都采用异步响应式调用方式,相比同步调用可以更好的利用资源。然而,异步响应式调用虽然节省了等待资源,但是也需要分出响应资源进行结果接收和返回。这里如果线程资源设置不合理,很有可能造成响应式阻塞。
Future异步调用然而,服务响应资源还算充足,没有发现拒绝,排队等异常日志,排除。
业务逻辑本身的耗时
做业务开发的兄弟应该都有感触,有的业务逻辑那是真的复杂。如果其中还涉及外部调用,那绝对是妥妥的耗时大户。流量小的时候还好,随着流量范围的扩大,影响就会凸显出来了。
好巧,正好有一个业务刚做了运营动作。于是我们梳理了该业务场景涉及的执行链路。发现某个排序操作非常耗时,赶紧分析优化并上线。
我们以为发现了事情的真相,然而并无卵用。报警短信第二天又按时上班了。
既然不是流量问题,那就要从服务本身来看了。
在 JVM 层面,可能造成服务耗时增加的最大可能:一是异常 GC,二是线程资源阻塞或竞争。比较容易的应该是 GC 问题,因为有现成的日志和监控数据可循,优先怀疑 GC 的原因。
先来整理一下有哪些方面可能导致 GC:
FGC (Full GC)耗时异常场景汇总
诸多猜想
从上述罗列的可能性,其实会有很多猜想:
2.4 问题分析
GC 日志分析
2022-01-06T11:12:46.035+0800: 52873.818: [Full GC (Allocation Failure) 4511M->4419M(10G), 11.3422935 secs]
[Eden: 8192.0K(4496.0M)->0.0B(4624.0M) Survivors: 64.0M->0.0B Heap: 4511.4M(10.0G)->4419.4M(10.0G)], [Metaspace: 308300K->308289K(1310720K)]
[Times: user=14.09 sys=0.00, real=11.34 secs]
2022-01-06T11:12:57.378+0800: 52885.160: [Full GC (Allocation Failure) 4419M->4419M(10G), 11.3581878 secs]
[Eden: 0.0B(4624.0M)->0.0B(4624.0M) Survivors: 0.0B->0.0B Heap: 4419.4M(10.0G)->4419.4M(10.0G)], [Metaspace: 308289K->308237K(1310720K)]
[Times: user=14.17 sys=0.00, real=11.36 secs]
看日志,只能看到现象是发生了连续两次 FGC。
第一次堆内存有减少,第二次几乎没有什么效果;另外,看 user 和 real 时间稍有点问题。按理说,FGC 也是多线程并行的,real 时间应该要小一些,但现在并非如此。
当时的 VM 参数如下:
-XX:+UseG1GC -Xmx10g -Xms10g
-XX:ParallelGCThreads=16 -XX:ConcGCThreads=8 -XX:GCTimeRatio=39
-XX:G1HeapRegionSize=8m -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=1024M
-XX:MaxGCPauseMillis=40 -XX:G1RSetUpdatingPauseTimePercent=40
-XX:MaxTenuringThreshold=15 -XX:InitiatingHeapOccupancyPercent=70 -XX:-UseBiasedLocking
16 核 16G 的机器规格,设置了 10G 的堆大小、16 个并行 GC 线程、8 个并发线程。GC 耗时占比 2.5%,期望最大停顿时间 40 毫秒,禁用偏向锁。单看参数本身,可以发现一些问题:
啥也别说了。反正报了好几天了,先调调参数看下:
-XX:+UseG1GC -Xmx10g -Xms10g -Xmn6g
-XX:ParallelGCThreads=10
如上,主要增加了年轻代大小设置,减小了并行线程数。为的是让 Young 区尽量大,存放更多的存活对象。
然而,结果表现更差了。YGC 时间不受控,FGC 也没减少,耗时倒是稍微好一点。
为啥呢?
主要是因为 G1 的 GC 时间预估功能,会动态的调整 Young 区的大小。现在 Young 区固定,导致了 GC 耗时控制失效。
所以,原因应该不是新生对象的快速晋升导致的了。
其他可能性分析
回看 JVM 监控,发现老年代有很大一部分根本 GC 不掉。
老年代内存监控再看发生问题时的 GC 日志,发现之前的 GC 日志查看有所遗漏:
2022-01-06T11:12:46.004+0800: 52873.787: [GC concurrent-root-region-scan-start]
2022-01-06T11:12:46.007+0800: 52873.790: [GC pause (G1 Humongous Allocation) (young)
2022-01-06T11:12:46.017+0800: 52873.799: [GC concurrent-root-region-scan-end, 0.0121935 secs]
2022-01-06T11:12:46.017+0800: 52873.799: [GC concurrent-mark-start], 0.0249766 secs]
[Root Region Scan Waiting: 9.6 ms]
[Parallel Time: 13.6 ms, GC Workers: 10]
...
看到了 G1 Humongous Allocation 是大对象分配。我们的 region 块设置的是 8M,感觉不小了,但还有大对象。
啥对象能占超过 8M 的内存呢?
这个 Object 是什么鬼?
如果是基本数据类型占这么多我觉得还能理解,但 Object 占这么多就不太正常。杀手锏,来 dump 一下吧。
结果悲剧了。
10G 内存,发生异常时最多占比有 90% 多。本地 MAT 根本打不开,就算修改了 MAT 的最大最小内存也不行。要不是扫描的数据只有几百兆,要么是直接卡死。
没办法,只能单独申请了一台超大内存的 Linux 服务器。安装 MAT 进行处理,然后再将结果文件导出到本地用浏览器分析。
Are you joking me ? 谁能帮数下,这是多少个对象。
问题很显然了,内存泄漏。剩下的就是找对应的操作这个对象的代码了。
最后发现,原来是在代码中设置了静态集合存储业务规则。又因为配置问题,触发了代码隐藏的异常。流量每次命中该规则时,就会将全量名单再次添加一次到集合列表,导致列表一再扩容,直到内存达到阈值。
有同学可能会问,为啥没有 OOM 呢?嗯,问得好,你猜……
总结
因为配置错误触发了代码的隐藏 Bug,造成了大量的配置项被重复多次添加到了静态变量,导致内存分配不足。进而触发 FGC,导致服务耗时。
流量大的时候,命中规则的次数大,导致内存增长快;流量小的时候,内存增长慢。这也就解释了为啥总是上午高峰期报。不过也给问题排查带来了不便,需要抓住上午的短暂异常时间来保存现场(关乎收入的服务,有问题得马上恢复)。也是因为规则的原因,命中的不那么多,不然可能早就 OOM 了。那样的话,可能就直接 dump 内存,不会这样来回来去的猜了。
归根结底一句话,出现了突发的系统异常,去找近期的代码改动和配置改动。一般没跑。
另外,关于 ParallelGCThreads 的那个计算公式,个人猜测,由于 G1 为大内存场景设计,在 GC 时,会有诸多关联操作,如更新 RS 缓存、计算 region 价值得分、运行时间预估算法等等,8 个线程可能正好把这些任务分个差不多。因此,当少于 8 核时,每个核都要参与;而大于 8 核时,增加一些辅助线程来参与,但线程过多又有可能造成资源抢占、上下文切换等开销。
- EOF -
1、一个不错的线上故障排查案例
2、一次压测引出的线上Full GC排查
3、JAVA 线上故障排查完整套路,从 CPU、磁盘、内存、网络、GC 一条龙!
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
点赞和在看就是最大的支持❤️