最近这段时间,线上应用老是出问题,主要还是内存问题,莫名其妙的内存飙升。
服务器的内存是16G,应用配置的是最大内存是6G(-Xmx),然后大概是跑上一天左右,内存就会飙升到10G左右,加上服务器上面的其它内容,服务器总内存使用就超过85%了,然后就开始疯狂报警了。
既然内存那么大,肯定首先想的是dump下来,看看有没有什么问题。结果,好几天下来,一直dump失败,反正就是部署。后面使用了jmap查看内存里面的对象,也分析了不少,但是并没有发现异常的对象,所以并没有收获。还有一个事实就是,这个应用本身已经跑了很多年了,所以我当时其实并不怀疑一些代码问题导致的内存上升。
最后看到内存参数最小堆内存(-Xms)分配的也是6G,也就是说最大和最小是一样的。当时我可能感觉有点问题,启动内存怎么能配成这么大呢?虽然配置的大一些可能会对内存分配那边提供一些效率,但这也太大了。所以就把最小的直接给调成了1G。重新启动,结果好了。稳定运行了很久了。
问题解决了,留给我们的就是一些思考了。配置这些参数的时候要慎重。同时还留了一个问题,配成一样大以后到底带了的问题是什么?为什么就飙升了呢?可以作为后续的一个学习研究内容吧。
服务器的内存是8G,应用配置的是最大内存是6G(-Xmx),然后大概是跑上一天左右,内存就会飙升到6G左右,加上服务器上面的其它内容,服务器总内存使用就超过90%了,然后就开始疯狂报警了。这个比较奇怪的是,内存飙升的那个服务器是随机的,而且其余3个的内存都很正常,代码是完全一样的,而且有时候飙升了,自己能降下来,有的时候不行,必须重启才行。
主要说一下这个和tomcat本质的一些区别。
4个节点,要维护大约4000个长连接,只要平均分配到每个机器上面,其实就压力也不是很大。但是实际上,从我们启动的效果来看(服务端重启,客户端会自动重新连接),节点不管怎么一起启动,好像都不能平均,根本做不到。而且比较严重的现象是:几乎所有的请求只会和一台机器保持长连接,其它机器几乎没有。所以第一个怀疑点来了,长连接多的那个内存就会高一些?(其实这个判断起来比较简单,内存高的时候对比一下就行了,看看是否有直接关系,答案其实是否定的,内存高的机器并不是长连接最多的那个,因为它不是主要因素,但也会有影响,是次要因素)。
既然它那么不平均,那就让它不平均的彻底一些,专门找一个节点,机器配置扩大到其它的两倍,专门用来维护长连接。那要怎么启动呢?先起配置大的节点,然后让客户端重连的时候,仅仅打到指定的服务器即可。结果也并不如此,先启动第一个,等几十秒(远超出客户端的重连时间),再启动别的;结果长连接又打在了任意一个节点上面,并不是我们想要的。
最后分析了一下,应该是因为nginx,nginx那边配置了4个节点,所以在重连的时候,尽管你只有1个起来了,但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个现象:
都这么明显了?这还是猜测嘛,不是了。当我发现上面的3点的时候,这就是结论了。
接下来就直接干呗。那要怎么做呢?
数据库一定会挂,我们要做的就是,数据库挂了,日志爱存不存,反正不能影响正常使用就行了。往往存储日志都是这种思路,但是只能说不够好,异步本身只是和你当前的主要业务做了分割,但是存储的过程始终都在一个JVM了,那就必须要解决后续存储的问题。
和数据库交互那边,其实有很多参数,连接池大小、数据库最大响应时间、排队队列大小以及排队的最大等待时间等待。仔细想了一下,只需要改一个参数即可,其余都不用动,动了可能会影响到正常情况的使用。把排队的最大等待时间由60秒直接调小到3秒,结束。数据库挂了,要的效果就是,我不仅希望别存了,而且还得直接结束了,赶紧垃圾回收。
上正式环境,改了,重启。
稳定跑了一个礼拜了,之前顶多撑一天,看着报警群里面数据库经常挂,但是后面在也没有其它消息了。
哇,真香,终于解决了。
这种问题,能提前处理吗?有了这份经验,也许能,在写异步处理日志代码的时候,也许可以避免掉。
没有相关经验,而且也没有任何问题的情况下,看代码,分析,真的很难。因为,存日志,也确实异步了,存的代码,也非常简单。
这次也是线上环境的问题驱动吧,才能够进一步想那么多吧。
这让我想到之前tomcat应用也出过一次问题,也是日志异步存储问题,问题的现象是:异步的时候,是提交给了一个线程池,然后提交的时候失败了,被拒绝了,线程满了,而且队列也满了;那个异常并没有捕获,而且提交的代码是在同步代码里面,所以影响的范围超级大,几乎瘫痪了。最后的方案是,提交的异常,捕获一下,吃掉,就行了,解决了。但是有一个问题所有人都没有想明白:为什么数据库挂掉以后,用来异步处理日志的那个线程池,会被一直占着?为什么没有直接失败,线程结束,给另外一个提交用。
现在,或许有了答案。