当前位置: 首页 > 科技观察

数据库连接池引起的FullGC问题,看我如何一步步排查、分析、解决

时间:2023-03-22 01:23:01 科技观察

数据库连接池引起的FullGC问题,看我一步步排查分析解决发现基本是访问数据库连接超时,影响时间只有3到4秒,服务恢复正常。几分钟后,再次出现大量告警,但依然受到影响3~4秒后恢复正常。由于我们是一个底层服务,被很多上层服务所依赖,如此频繁的异常波动严重影响了业务的使用。开始故障排除故障排除过程DB影响?第一次告警产生时,第一反应是上层服务可能有大量的接口调用,涉及到一些复杂的SQL查询,导致数据库连接不足。但是通过对接口调用的分析,发现异常前后的请求并没有明显的变化,排除了突发流量带来的影响。查询DB情况,负载良好,无慢查询,排除DB造成的影响。容器或JVM的影响?在排除了DB的影响之后,我们接着检查容器的影响。我们再次回头查看异常告警,发现在每一波告警的时间段内,基本都是同一个容器IP产生的。这时候基本有80%的概率是GC的问题。查询报警期间容器CPU负载是否正常。查看JVM的内存和GC,发现整个内存使用曲线如下:无法被YongGC回收,对象的体积一直在增长。直到OldGen满后触发FullGC,对象才会被回收。临时措施现在问题已经找到了。到目前为止,只有3个实例触发了FullGC,但是在查看其他实例的内存使用情况时,发现几乎所有实例的OldGen都快到了临界点。所以暂时的解决办法是,现场保留一个实例,滚动重启所有其他实例,避免大量实例同时进行FullGC。否则很可能造成服务雪崩。本来服务是设置了jvm监控告警的。理论上,当内存使用达到一定值时,就会有报警通知。但是由于业务迁移,告警配置失败,没有提前发现问题。问题分析哪些对象没有被回收?到目前为止我学到的是:内存不能被YoungGC回收,它会无限增加。只有FullGC才能回收这批对象。但看不到更多细节。最好的办法是dump内存快照,用MAT分析一波jmap-dump:format=b,file=filenamepid用MAT打开后,可以发现明显的问题:classcom.mysql.cj.jdbc.AbandonedConnectionCleanupThread这个class占用了80%以上的内存,那么这个class是干什么用的呢?从类名可以看出应该是MySQLDriver中用于清理过期连接的线程。我们看一下源码:这个类是一个单例,只会开启一个线程来清理那些没有显式关闭的数据库连接。可以看到这个类维护了一个SetprivatestaticfinalSetconnectionFinalizerPhantomRefs=ConcurrentHashMap.newKeySet();对应我们上面看到的内存占用排名第二的HashMap$Node,基本可以确定这里大概率存在内存泄漏。在MAT上用list_object确认一个帖子:果然,罪魁祸首找到了!那么里面存的是什么呢?为什么一直在增长,不能被YoungGC回收?看ConnectionFinalizerPhantomReference这个名字,我们可以猜测其中存放的幻影引用应该是数据库连接的幻影引用。进入一个引用队列。我们跟踪源码确认一下,确实是一个PhantomReference,里面存放的是创建的MySQL连接,看看放在哪里:可以看到每次创建新的数据库连接时,创建的连接都会被打包成一个PhantomReference,放入connectionFinalizerPhantomRefs中,然后清理线程会在referenceQueue中获取连接,无限循环关闭。只有当connection对象没有其他引用,只有幻象引用存在时,才会被GC,放入referenceQueue中。为什么Connection会无限增长?现在问题找到了。数据库连接创建后,会放入connectionFinalizerPhantomRefs中。但是由于某些原因,前期connection使用正常,多次minorGC后没有被回收,被提升到oldgeneration。但是过了一段时间后,由于某种原因连接失败,导致连接池重新创建了一个连接。我们项目中使用的数据库连接池是Druid。下面是连接池的配置:可以看到设置了keepAlive,minEvictableIdleTimeMillis设置为5分钟。连接初始化后,当DB请求数不频繁波动时,连接池应该全部保持最少30个连接,当连接空闲超过5分钟时会执行keepAlive操作:理论上,连接池不会频繁创建连接,除非活跃连接很少,有波动。并且keepAlive操作不生效。当在连接池中进行keepAlive操作时,MySQL连接已经过期,所以失效的连接会被丢弃,下次重建。下面来验证这个猜想。我们先查看活跃连接数,发现大多数时候,单实例数据库的活跃连接数在3到20左右波动,而且业务中有定时任务,每30分钟就会有很多大约1小时的数据库请求。既然Druid每5分钟心跳一次,为什么还是连接失败?最大的可能是MySQL服务器的运行。MySQL服务器默认的wait_timeout是8小时。是否有相应的配置更改?showglobalvariableslike'%timeout%'果然数据库的超时时间设置为5分钟!那么问题就很明显了。结论Idleconnections依赖Druid的keepAlive定时任务进行心跳检测和keepAlive。定时任务默认每60秒检测一次,只有当连接空闲时间大于minEvictableIdleTimeMillis时才会进行心跳检测。由于minEvictableIdleTimeMillis设置为5分钟,理论上空闲连接会在5分钟±60秒的时间间隔内进行心跳检测。但是由于MySQL服务器的超时时间只有5分钟,所以在Druid执行keepAlive操作时,很大概率会出现连接失败的情况。由于数据库的活跃连接有波动,min-idle设置为30,当活跃连接达到高峰时,需要在连接池中创建和维护大量的连接。但是当活跃度下降到低谷时,由于keepAlive失败,大量连接从连接池中被移除。反复。每次创建connection时,Connection对象都会被放入connectionFinalizerPhantomRefs中,由于connection在创建后处于active状态,短时间内不会被miniorGC回收,直到晋升到老年代。导致这个SET越来越大。解决知道了问题的原因,解决起来就很简单了。设置minEvictableIdleTimeMillis为3分钟,保证kee??pAlive的有效性,避免一直重连。