一次线上环境内存问题分析和解决过程:tomcat(JVM参数配置)和vertx的hazelcast集群处理(长连接分配、内存大对象分析、异步存储日志方案)

文章目录

  • 总览
  • 应用情况
  • tomcat应用分析与解决
    • 问题描述
    • 问题分析
    • 问题思考
  • vertx应用分析与解决
    • 问题描述
    • 应用区别
    • 问题分类
      • 长连接分配问题
      • 长连接分配问题的解决思路
      • 长连接分配问题的思考
      • 内存问题又来了
      • 该怎么分析大对象呢?
      • 发挥极端的想象
      • 验证你的想法并去实践
      • 解决问题的时刻到了
      • 简单反思

总览

最近这段时间,线上应用老是出问题,主要还是内存问题,莫名其妙的内存飙升。

应用情况

  • tomcat:两个war包,倒没啥特殊的。部署了6个。
  • vertx:使用了vertx写的一个维护长连接的服务,使用hazelcast做集群管理,有4个节点,然后长连接数据大概在4000左右。客户端-服务端,客户端与服务端建立长连接,但不是前端应用,是后台java应用。

tomcat应用分析与解决

问题描述

服务器的内存是16G,应用配置的是最大内存是6G(-Xmx),然后大概是跑上一天左右,内存就会飙升到10G左右,加上服务器上面的其它内容,服务器总内存使用就超过85%了,然后就开始疯狂报警了。

问题分析

既然内存那么大,肯定首先想的是dump下来,看看有没有什么问题。结果,好几天下来,一直dump失败,反正就是部署。后面使用了jmap查看内存里面的对象,也分析了不少,但是并没有发现异常的对象,所以并没有收获。还有一个事实就是,这个应用本身已经跑了很多年了,所以我当时其实并不怀疑一些代码问题导致的内存上升。

最后看到内存参数最小堆内存(-Xms)分配的也是6G,也就是说最大和最小是一样的。当时我可能感觉有点问题,启动内存怎么能配成这么大呢?虽然配置的大一些可能会对内存分配那边提供一些效率,但这也太大了。所以就把最小的直接给调成了1G。重新启动,结果好了。稳定运行了很久了。

问题思考

问题解决了,留给我们的就是一些思考了。配置这些参数的时候要慎重。同时还留了一个问题,配成一样大以后到底带了的问题是什么?为什么就飙升了呢?可以作为后续的一个学习研究内容吧。

vertx应用分析与解决

问题描述

服务器的内存是8G,应用配置的是最大内存是6G(-Xmx),然后大概是跑上一天左右,内存就会飙升到6G左右,加上服务器上面的其它内容,服务器总内存使用就超过90%了,然后就开始疯狂报警了。这个比较奇怪的是,内存飙升的那个服务器是随机的,而且其余3个的内存都很正常,代码是完全一样的,而且有时候飙升了,自己能降下来,有的时候不行,必须重启才行。

应用区别

主要说一下这个和tomcat本质的一些区别。

  • tomcat的集群,前面挂nginx,后面节点之间其实没有任何关系,所以它的内存问题,是所有节点都有。而vertx集群,它所有节点内部是互相通信的,一个请求进来,由nginx打到了A上面,但实际上A可能会转发到A、B、C、D上面任何一个节点去。
  • tomcat只处理了普通的http请求,而vertx集群维护了大量的长连接。

问题分类

长连接分配问题

4个节点,要维护大约4000个长连接,只要平均分配到每个机器上面,其实就压力也不是很大。但是实际上,从我们启动的效果来看(服务端重启,客户端会自动重新连接),节点不管怎么一起启动,好像都不能平均,根本做不到。而且比较严重的现象是:几乎所有的请求只会和一台机器保持长连接,其它机器几乎没有。所以第一个怀疑点来了,长连接多的那个内存就会高一些?(其实这个判断起来比较简单,内存高的时候对比一下就行了,看看是否有直接关系,答案其实是否定的,内存高的机器并不是长连接最多的那个,因为它不是主要因素,但也会有影响,是次要因素)。

长连接分配问题的解决思路

既然它那么不平均,那就让它不平均的彻底一些,专门找一个节点,机器配置扩大到其它的两倍,专门用来维护长连接。那要怎么启动呢?先起配置大的节点,然后让客户端重连的时候,仅仅打到指定的服务器即可。结果也并不如此,先启动第一个,等几十秒(远超出客户端的重连时间),再启动别的;结果长连接又打在了任意一个节点上面,并不是我们想要的。

最后分析了一下,应该是因为nginx,nginx那边配置了4个节点,所以在重连的时候,尽管你只有1个起来了,但Nginx不知道,所以它会轮询分配嘛,虽然会失败。所以考虑之后,最正确的重启方案就是:

  1. 重启前,把nginx的流量切到指定的节点。
  2. 节点全部停止。
  3. 然后全部启动。这个可以一起启动,反正有nginx前面拦着。
  4. 用命令查看指定服务器节点的长连接数目,如果达到了目标值,就把nginx流量恢复一下。

就这样,做到了把长连接数量打到了指定一个配置比较高的服务器上面。而且也确实解决了内存的问题。最起码在短时间内。

长连接分配问题的思考

长连接的数量和内存使用,有没有直接的关系?能证明么?
目前来看,并没有。因为当它稳定运行一段时间后,内存最大的并不是长连接数量最多的节点。
但从实际效果来看,它应该是有用的。当然可能相对于下一个问题,就不是特别重要了。

内存问题又来了

用上述方案,跑了几天,内存又上去了。
这次的分析的话,就使用的jmap命令了,有两个关键命令吧:
里面的数字是进程ID。

查看堆使用情况:
jmap -heap 16474 > heap.txt

查询对象占用内存的情况,每一个对象有多少个,总共占了多少内存,排序等:
jmap -histo:live 16474 > object.txt
(网上的说法是,这个打的存活的对象)
jmap -histo 16474 > object.txt
(网上的说法是,这个打的是历史所有对象)

其实之前一直用的是live那个命令,看了很多次,都没有发现任何一个有用的信息,所有的类的实例数量都很正常。那天灵机一动,干脆去掉live吧,结果就发现了一个关键的一个信息:

  83:          11235         832480  com.test.Command

有一个非常熟悉的对象,实例数超过1W,但是我去掉了live,所以这个打印出来的可能就是曾经所有的,但是1W个,就是所有的了吗?按照我们的流量来估算,肯定不是。同时我做了另外一件事情来证明这个1W一定是异常的,当时在其余内存节点正常的上面,也敲了这个命令,结果发现只有1千个。所以这个命令的结果一定不是历史的,具体含义我没有过多去研究,因为不影响我分析问题,我倒愿意给它定义是:带live的是查询的当前活跃的对象,否则是当前所有的对象。当然怎么定义活跃,不是很清楚,而且这种定义我估计80%也是错的。

但是上面的结果,绝对能证明,1W多个对象,一定是不正常的。这种不正常现象在内存分析里面,其实是非常关键的,最起码,有了明确的方向。因为使用jmap看了很多次了,每次都带live,所以根本发现不了比较多的对象。

该怎么分析大对象呢?

我觉得应该具备一下几点要求:

  • 你应该对代码很熟,基本上看见类名甚至说包名都应该联想到它是干什么的。或者最低档次也得通过类名翻代码找到是干什么的吧。
  • 感觉。这种感觉很重要,没法描述,就是看见实例数,类名全路径这些字符串以后,就够自动联想到很多事情。
  • 要会极端的思考问题。要去思考一段看着没有任何问题的代码,在某些极端情况下会发生什么。想法要大胆,但同时也得有方式来证明和验证,最好别再测试环境上面去验证。

我解决这个问题,基本上就是依靠着上面几点。
分析大对象的常规思路,其实很简单,就是看这个对象在哪里被引用了,然后紧接着思考在什么情况下会累积大量的对象而没有被回收。最基本的原则就是,大对象,一定不要关注最底层的,那些肯定是最大的,一定要找我们自己写的那些最大的对象,当然也不完全仅仅只看我们写的,把握一个度就行。

之前处理过内存大对象问题,所以这次给了我一些经验。
我发现的大对象,其实是特别熟悉的一个类。是我们代码里面最基础的一个类,然后呢?要去找它在哪里被引用了吗?对于这次而已,千万不行,因为它是基础类,它遍布在代码的角角落落里面,找它的引用,没有任何意义,虽然也是我们写的。这个就好比,排名前几位的对象一定是:

   1:       1624460     1377162952  [B
   2:        115673      630918840  [I
   3:       2945043      610433992  [C

byte数组,int数组,char数组。不可能去分析这些的。
所以记下来还得往下找,我又找到了两个值得引起我注意的两个对象,找的过程的话,基本原则就是:一看大小,二就是凭借你对代码的熟悉程度迅速得出一个结论,需要不需要进一步看。

 353:          1628          78144  com.test.HttpRequestSender$$Lambda$295/2121626763
 375:          2779          66696  com.test.SysMsgReceiveHandler$$Lambda$330/1984814112

我很快就锁定了这两个对象,它那个是lambda表达式,其实就算是一个内部匿名类吧,懂1.8的人看着就比较清晰,同时我迅速排除了第一个,还是那句话,要对代码熟悉,我直接判断出来,这个类比较多,是正常的。所以把重点聚焦在了下面这个类。
翻了一下源码,非常简单。就是一个存储日志的一个类,消息过来,直接保存,然后结束。
每保存一条日志,就会有创建这个匿名类的一个实例。
接下来得发挥你的想象力了,就一个保存数据库的动作,你想想有没有可能会出事?出事了会怎么样?

发挥极端的想象

极端点吧,数据库性能迅速下降的时候,查库会不会特别慢,导致所有的请求回不来,再回来之前,它本身的内存是不会被回收的,因为还有引用。再极端点,数据库挂了,会怎么样?仅仅报错失败吗?如果只是保存失败的话,应该不会导致慢,它会直接失败吧,应该连接拒绝,应该很快啊,不可能同时又很多对象不能被及时回收。

如果分析问题停在这边的话,估计肯定解决不了这个问题,因为解释不了的,还是需要进一步再想。

好像还有其它可能,因为连接数据库,都会用到连接池,当数据库挂掉的时候,大量的请求进来,连接很快就被占了,而且得不到处理,同时连接池后面会有一个队列,后面来得就会在队列排队等待获取下一个可以用的连接,只要数据库不恢复,那会一直排下去直到超过了设置的最大时间会直接失败。我迅速看了一下配置,从连接池取连接的最大超时时间是1分钟,如果请求量够大+数据库挂了,这1分钟,简直是要命的。同时我还知道一件事情,我们的存日志的mongo库,经常会挂,几天一次吧。

其实我已经得到了结论了,或者是一种猜想吧:数据库挂,有效的连接被占满,也一直处理不了,所以一直没有空闲的且可用的连接,所以后续的请求进来以后,只能去队列排队,然后排队时间1分钟呐,已经是非常长了,所以会累积大量的请求,不能回收掉,内存飙升。同时我们的数据库因为经常挂,而且没有办法,所以有自动启动程序,也就很快重启了,重启后,那些日志也就迅速消费掉了,内存也会下降,只要能抗得住那会儿(其实也遇见过内存扛不住了,被操作系统给杀掉的情况)。

验证你的想法并去实践

想了那么多,那到底对不对呢?在去生产环境测试之前,应该想办法证明一下猜测吧。
我想到了两个主意,一个是查看我们的报警消息群,这个群里面加了各种监控,对服务器硬件的,对应用的各种监控,还有中间件的。另外一个就是内存飙升的时候的日志。
无非就是想证明一件事情,内存飙升和数据库宕机有关系。
我发现了3个现象:

  1. 数据库的宕机,必然伴随了节点的内存飙升。报警信息是刚好挨着,非常多。
  2. 在某些情况下,当数据库恢复后,内存的报警也恢复。倒并不是全部,因为大多数情况下,为了防止被操作杀掉,我们宁愿选择亲手重启,感觉这样会稳妥一些。有的情况下,飙升不是太多,就没有重启,而且随着数据库恢复,内存也降下来了。
  3. 当内存飙升的时候,我在日志里面发现了大量的存储日志消息的报错信息。

都这么明显了?这还是猜测嘛,不是了。当我发现上面的3点的时候,这就是结论了。
接下来就直接干呗。那要怎么做呢?

解决问题的时刻到了

数据库一定会挂,我们要做的就是,数据库挂了,日志爱存不存,反正不能影响正常使用就行了。往往存储日志都是这种思路,但是只能说不够好,异步本身只是和你当前的主要业务做了分割,但是存储的过程始终都在一个JVM了,那就必须要解决后续存储的问题。
和数据库交互那边,其实有很多参数,连接池大小、数据库最大响应时间、排队队列大小以及排队的最大等待时间等待。仔细想了一下,只需要改一个参数即可,其余都不用动,动了可能会影响到正常情况的使用。把排队的最大等待时间由60秒直接调小到3秒,结束。数据库挂了,要的效果就是,我不仅希望别存了,而且还得直接结束了,赶紧垃圾回收。

上正式环境,改了,重启。
稳定跑了一个礼拜了,之前顶多撑一天,看着报警群里面数据库经常挂,但是后面在也没有其它消息了。
哇,真香,终于解决了。

简单反思

这种问题,能提前处理吗?有了这份经验,也许能,在写异步处理日志代码的时候,也许可以避免掉。
没有相关经验,而且也没有任何问题的情况下,看代码,分析,真的很难。因为,存日志,也确实异步了,存的代码,也非常简单。
这次也是线上环境的问题驱动吧,才能够进一步想那么多吧。
这让我想到之前tomcat应用也出过一次问题,也是日志异步存储问题,问题的现象是:异步的时候,是提交给了一个线程池,然后提交的时候失败了,被拒绝了,线程满了,而且队列也满了;那个异常并没有捕获,而且提交的代码是在同步代码里面,所以影响的范围超级大,几乎瘫痪了。最后的方案是,提交的异常,捕获一下,吃掉,就行了,解决了。但是有一个问题所有人都没有想明白:为什么数据库挂掉以后,用来异步处理日志的那个线程池,会被一直占着?为什么没有直接失败,线程结束,给另外一个提交用。
现在,或许有了答案。

你可能感兴趣的:(数据库,Java,软件工程,内存分析,JVM,异步处理日志)