记一次OOM问题排查以及引出的GC优化
一)介绍
我们有一个服务,主要是给一些商家或者供应商查看售卖的票信息,例如报表下载和一些售票的查询等等。
因为平时用的人比较少,而且我们这边可以配置自动扩容的阈值,所以机器配置是 2C8G,两个机房分别 3 台。
项目很少发版本,最近一次的发版本还是前一个月。
二)问题出现
在某一个下午,突然系统告警,16:52 显示有一个机器 OOM 了,因为我们的运维平台会自动重启实例,所以这个出问题的实例当时的容器快照没有保存下来,不过比较好的是,发生 OOM 时的堆快照被保留下来了。
于是直接登录机器准备下载快照分析,下载比较耗时,在这段时间正好也没事,顺带就看了下容器的整体指标
三)分析
1)QPS 上升导致的?
因为我们是 16:52 出现的告警,因此首先去看的是 16:40 以后的请求 QPS 有不有上升,发现非常平稳,Redis 和 MySQL 也没有出现告警,所以初步怀疑是某一个接口的 bug 导致内存泄漏。
2)容器指标
在排查 QPS 无异常以后,直接查看容器的 JVM 情况,发现非常不乐观,在将时间向前选择到 16:30 以后,发现各个指标均出现异常。
可以看到在 16:30 到 16:35 之间,突然出现了 4 次 FullGC,同时 JVM 的老年代在 16:30 以后直接突然上升。
在 JVM 触发 FullGC 以后,老年代也会被回收,但是看老年代的内存,不减反而一直在增加,那就说明一个问题,有大量的对象直接晋升到了老年代。
排查到这里以后,基本确定是代码问题,首先因为大量对象晋升,触发 FullGC,引起 CPU 使用率上升,被宿主机限流。
3)error 日志
从监控看发生时间应该是 16:30 附近,所以将日志查询时间提前到 16:30,查看到如下异常:
问题基本确定在某一个报表导出接口上,顺着日志逆推接口,很快就定位到响应的接口了。
4)堆快照
此时堆的快照文件已经下载下来了,用 mat 查看以后,按照堆内存使用情况排序,可以看到大量的 com.mysql.cj
包的对象,初步猜测是数据库查询量太多。
至此,问题大致应该已经定位到了,应该是某一个接口在查询 MySQL 的大量数据,然后将其生成 Excel 供用户下载。
四)修复
通过之前的报错日志,反推接口伪代码如下:
1 | public void export(Args args) { |
本着优先解决线上问题的要求,第一时间先加限制,如下:
经过我们的对 DB 数据量的评估,最多只能查询一个月比较合理,因此第一步限制了接口的查询范围。
对机器进行扩容,由原来的 8G 直接扩容至 16G。
五)复盘
1)业务上
1.1)接口的限制
对于需要从 DB 通过日期查询的数据,必须加限制,同时限制做成可以动态配置的,最好是全局限制,防止业务人员变动导致后续忘记在某一个接口添加限制了。
如果有额外的需求,那么就通过数据异构的方式生成专门用于报表的表,或者直接将此类需求转给 BI。
2)技术上
2.1)对 Stream 进行优化
在 Java 中,由于 Stream 具有不可变性,因此 Stream 的每次操作都是会生成一个新的 Stream,同时原来的 Stream 不会被释放。
但是对于我们的业务来说,list1 中的每一个元素,在转变为 list2 中的一个元素以后,就不会再被使用了,因此可以直接设置为 null,提前 GC。
测试如下:
首先设置 JVM 的参数为:-Xmx120M
这段代码首先从 getFromDB
方法里面获取一个 List,大小为 100M,而堆的最大内存是 120M,在将 list1 转化为 list2 的过程中,可以看到 list1 中的每一个元素在使用后就可以被 GC 回收。但是使用 stream 无法提前释放从而触发 OOM。
于是进行如下优化:
可以看到 OOM 已经不会出现了。
这是因为在 GC 回收的时候,list1 是一个可达对象,所以 list1 中的元素是无法被回收的,那么经典八股文来袭,那些对象是可以作为可达对象?
- 被 system class loader 所加载的类
- 方法区中静态变量或者引用
- 活跃的线程
- 方法中的本地变量或者参数
- JNI handles:全局和本地的
- 对象被加 synchronized 锁
- 被 JVM 所持有的对象:基本数据类型等等
所以 list1 是一个活跃线程中的虚拟机栈中的一个本地变量,因此可以作为 GC Root,那么 list1 中的所有元素就无法被释放。
2.2)关于help gc
这个注释其实在 JDK 中也有出现,通过全局搜索 help gc
可以看到很多核心类库都有提前设置 null 的操作。
以 LinkedList 为例,在 unlinkFirst
和 unlinkLast
的方法内部,均有 help GC 的操作,可以看看 LinkedList 的做法。
LinedList
那么假设有一个 List 如下,初始的时候有三个 Node,然后经过两次调用以后,first 指针指向了 node3,如果每一次的调用都没有设置 next 为 null,那么就有可能会导致内存泄漏。
假设现在 node2 已经使用完了,但是由于 node1 的 next 还是持有它的强引用,如果说外部还持有 node1 的强引用,那么 node2 也算一个可达对象,因此无法被释放。
既然 JDK 中这么写,那么肯定有他的原因的,那什么地方持有 f 的引用呢?答案是 listIterator
方法。代码如下:
可以看到,如果 index 给的是0,那么 ListItr 的 next 指针还是会持有 node1 的引用,因此 node1 的 next 如果不设置 null,那么就会导致一个引用链出现。
所以这里的 f.next = null
还是有点用的。计算有引用,那也就是 node1,不会导致很大的问题。
好了,继续说回到优化上。
2.2)替换 poi 为其他优秀的excel导出工具
这部分目前还在调研测试中
2.3)配置 DB 慢查询告警
这种大量数据查询,而且又是时间范围的,一般都比较慢,配置慢查询告警可以在提前发现一些问题。
六)其他
在看容器的情况的时候,出现了很多 close wait 的连接。
看时间也和 FGC 触发的时间吻合,但是具体是什么原因暂时还未知,因为很多现场都已经没了,等待后续复现抓包分析。
猜测是某种情况下,对方主动关闭了 socket 连接,但是这台机器上由于某些原因没有回复 FIN。
记一次OOM问题排查以及引出的GC优化