在某个工作日,突然收到线上的服务告警,有大量的请求延时产生,查看线上服务发现基本上都是获取数据库连接超时,而且影响时间只有3~4秒钟,服务又恢复了正常。隔了几分钟之后,又出现了大量的告警,还是影响3~4秒后又恢复正常。 由于我们是底层服务,被重多的上层服务所依赖,这么频繁的异常波动已经严重影响到了业务使用。开始排查问题
DB的影响?
容器或JVM的影响?
排除了DB的影响之后,再往上排查容器的影响 我们再次回过头看异常告警,发现在每一波告警的时间段内,基本上都是同一个容器IP所产生,这个时候基本上已经有80%的概率是GC的问题了。 查询告警时间段内的容器CPU负载正常。再看JVM的内存和GC情况,发现整个内存使用曲线是像下面这样:
Heap
Old Gen
从上图可以发现内存中存在长时间被引用,无法被YongGC所回收的对象,并且对象大小一直在增长。直到Old Gen被堆满之后触发Full GC后对象才会回收。
临时措施
现在问题已经找到了,到目前为止只是3台实例触发了FullGC,但是在查看其它实例内存使用情况时,发现基本上所有的实例Old Gen都快到达临界点了。所以临时解决方案是保留一台实例现场,滚动重启其它所有的实例,避免大量的实例同时进行FullGC。否则很可能导致服务雪崩。
原本服务是有设置jvm监控告警的,理论上来说当内存使用率达到一定值时会有告警通知,但是由于一次服务迁移导致告警配置失效,没有提前发现问题。
什么对象没有被回收?
目前了解到的情况: 内存无法被YoungGC回收,且无限增加,只有FullGC才能够回收这批对象
jmap -histo:live pid
先简单在线上观察了一波,排第2的HashMap$Node看起来比较异常,但是看不出更详细的情况了。最好的办法还是将内存快照dump出来,使用MAT分析一波
jmap -dump:format=b,file=filename pid
使用MAT打开之后,可以发现很明显的问题:
class com.mysql.cj.jdbc.AbandonedConnectionCleanupThread
这个类占用了80%以上的内存,那么这个类是干嘛的呢? 看类名就知道,应该是MySQL Driver中用来清理过期连接的一个线程。让我们看一下源码:
这个类是一个单例,会且仅会开一个线程,用来清理那些没有被显式的关闭的数据库连接。
可以看到这个类里面维护了一个Set
private static final Set connectionFinalizerPhantomRefs = ConcurrentHashMap.newKeySet();
对应我们上面看到的内存占用率排第二的HashMap$Node,基本上可以确定大概率是这里存在内存泄露了。在MAT上使用list_object确认一发:
果然没错,罪魁祸首找到了! 那么它里面存的是啥东西呢? 为什么一直增长且无法被YoungGC回收?看名字
ConnectionFinalizerPhantomReference 我们可以猜到它里面保存的应该是数据库连接的phantom引用
什么是phantom reference? 当一个对象只有phantom reference引用时,则会在虚拟机GC时被回收,同时会将phantom reference的对象放入一个referenceQueue中。
让我们来跟踪源码确认一下
果然是PhantomReference,里面存放的是创建的MySQL连接,看一下是在哪里被放进来的:
可以看到,每次创建一个新的数据库连接时,都会将创建的连接包装成PhantomReference后放入
connectionFinalizerPhantomRefs中,然后这个清理线程会在一个无限循环中,获取referenceQueue中的连接并关闭。
只有在 connection对象 没有其它的引用,仅存在phantom reference时,才能够被GC,并且放入referenceQueue中
为什么Connection会无限增长?
现在问题找到了,数据库连接被创建之后,则会放入
connectionFinalizerPhantomRefs中,但是由于某种原因,连接前期正常使用,经过了多次minor GC都没有被回收,晋升到了老年代。但是一段时间过后,由于某种原因连接失效,导致连接池又新建了连接。
我们项目用的数据库连接池是Druid,以下为连接池配置:
可以看到是设置了keepAlive,且
minEvictableIdleTimeMillis设置的是5分钟,连接初始化之后,在DB请求数没有频繁的波动时,连接池应该都是维护着最小的30个连接,且会在连接空闲时间超过5分钟时进行一次keepAlive操作:
理论上来说,连接池是不会频繁的创建连接的,除非有活跃连接很少,且存在波动,并且keepAlive操作没有生效,在连接池进行keepAlive操作时,MySQL连接就已经失效,那么则会丢弃这个无效连接,下次再重建。
下面就是验证这个猜想,我们首先查看我们的活跃连接数,发现在大部分时候,单实例的数据库的活跃连接数都在3~20个左右波动,并且业务上还存在定时任务,每隔30分钟~1个小时会有大量的DB请求。 Druid既然有每隔5分钟有心跳行为,那为什么连接还会失效? 最大的可能是MySQL服务端的操作,MySQL默认服务端的wait_timeout是8小时,难道是有变更对应的配置?
show global variables like '%timeout%'
果然,数据库的超时时间被设置成了5分钟!那么问题就很明显了。
知道问题的产生原因,要解决就很简单了,将
minEvictableIdleTimeMillis设置为3分钟,保证keepAlive的有效性,避免一直重建连接即可。