一不小心写了个OOM

业务背景

image.png

  1. 生产消费模型
  2. 生产者通过异步http的方式定时拉取外围系统数据,在回调中将数据推送到queue
  3. 消费者从queue中take数据,然后解析,每个元素可能会被解析成一个或多个metric meta对象
  4. 通过监控系统提供的SDK,将metric meta对象推送到Metric Queue
  5. 监控系统SDK将数据上报到监控平台
  6. 因为目前只是在拿几个外围系统测试,数据量不大,每分钟大概2000数据

问题现象

  1. 收到应用 内存CPU使用率过高告警,看了一下,两者都接近100%
  2. 先重启快速恢复服务,然后密切观察服务资源状况
  3. 随着服务运行时长的不断增加,内存使用率也在不停的增加
  4. 随着内存使用率的不断增加,CPU的使用率也在不断增加。在刚开始启动服务的时候,CPU使用率很低,10%不到。但就拉取得数据量来说,不存在说数据越来越大得情况,基本是均与的

问题排查

根据目前已知的问题现象,可以发现有几个点很值得关注:

  1. 内存在不停的缓慢增长。一般什么问题会导致这种现象呢?
    • 内存泄漏:该回收的对象没有被回收,随着时间的推移,脏对象越来越多,直到OOM。这是BUG,得修复
    • 请求量太大:假如堆容量为1000单位,系统每秒可以处理(回收:执行完业务逻辑之后才会回收)90单位,每秒请求量为100单位。如果应用一直这样运行,总有一个是时间点会把堆撑爆,然后OOM。这种情况得话,我们就需要提高系统得吞吐量,得优化
  2. CPU使用率随着内存使用率得增加而不断增加。这个我第一想到的原因就是GC,实际上就是GC,这个我们只要验证一下就可以了

上面两个分析,其实已经提供了很多有用的信息了,接下来我们只需要结合业务背景,去验证我们的猜想,并最终修复问题就可以了

问题猜想

我们先不管CPU使用率过高的问题,这就是因为GC导致的,这不是问题的关键,关键是为什么会频繁GC?结合内存使用率不停增长业务背景,做出以下猜想

生产速度大于消费速度

也就是说生产速度太快,消费速度太慢。假如是这种情况,会有哪些现象?

  1. 随着时间的推移,在Queue中堆积的对象越来越多
  2. 随着不可回收的对象越来越多,堆可用空间越来越少
  3. 生产速度和消费速度保持不变,意味着在单位时间内,需要的堆空间保持不变
  4. JVM为了保证有足够的可用可空间分配对象,需要根据情况进行GC
  5. 结合上面4点,随着堆可用空间的越来越小,单位时间内需要的可用空间保持不变,意味着GC的间隔时间会越来越小,即GC会越来越频繁
  6. 随着GC越来越频繁,CPU使用率会越来越高
  7. 最终的表现:系统瘫痪,CPU一直在进行无效GC,因为每次GC都无法回收有效对象

从问题表现来看,和我们出现的问题很像。但结合业务背景就发现不太可能:

  1. 数据量很小,系统完全可以处理的过来,也不存在什么耗时操作
  2. 要验证也可以简单,可以直接观察消费情况。如果是这种情况,消费一定会有很大的延迟的。或者可以把生产消费的速率打印出来,对比一下就知道了

内存泄漏

如果是内存泄漏的话,那么需要借助一些工具来排查,大致思路如下:

  1. 通过jmap查看堆中大对象,大对象主要包括两方面:占空间大的数量多的
  2. 结合步骤1,思考可能是哪部分代码有问题
  3. 如果步骤2还是没有思绪,可以借助 MATjvisualvm等工具分析一下

验证猜想

CPU使用率过高猜想验证

上面我们已经猜想了是频繁GC导致的CPU使用率过高,下面验证一下:

  1. jstat -gcutil pid
  2. top -Hp pid
  3. jstack pid |grep 0xxx
  4. 发现就是几个GC线程CPU使用率比较高,验证通过

内存泄漏猜想验证

  1. jmap -histo pid |more
  2. 定位到关键对象xxEvent,发现它的数量超过了100W
  3. 检查相关的业务代码,主要有会从这几个方面检查:创建的源头持有者销毁点。有时候可能不太好找,需要一点耐心

根据上面3点,最终找到的xxEvent相关源头就是:生产者消费者Queue

上面有一点很值得注意,xxEvent对象超过了100W。根据我们每分钟处理2000条数据的速度来算,Queue中还存在超过100W对象明显不合理:一个小时才产生12W条数据,100W就意味着至少8个小时,并且这8个小时内,消费者完全不消费,根据实际情况来看,这不可能

所以,这铁定是一个内存泄漏问题

问题原因

  1. 项目中用到了Disruptor框架
  2. 创建Disruptor对象的时候,需要我们传入一个ringBufferSize,表示该队列的最大长度
  3. Disruptor对象在初始的时候,会创建一个ringBufferSize大小的数组,同时根据我们传入的eventFactory创建ringBufferSize个对象,这里对应的就是xxEvent
  4. 代码中ringBufferSize大小为1024 * 1024,也就是大概100W。也就是说,在Disruptor对象初始的时候,就完成了100WxxEvent对象的创建
  5. Disruptor底层是基于数组,内部持有消费指针和生产指针,消费者消费之后,并不会直接将该元素从数组中移除,而是改变消费指针的位置。如果生产指针到了数组尾部,结论下来会覆盖前面的元素
  6. 结合上面5点再来分析:Disruptor对象创建的时候,就完成了100WxxEvent对象的创建,但因为此时xxEvent对象是空对象,里面没数据,所以此时也不会占用很多内存。随着生产者在持续生产元素,队列中的xxEvent不停的被填充,被填充的xxEvent对象越来越多。因为这些对象被队列引用,即使消费完了之后也不会被回收。所以,可用内存越来越少,最终导致不可回收的对象装满了新生代和老年代,然后JVM一直在无效GC

问题解决

  1. ringBufferSize设置为512,部署并观察现象
  2. 用Java原生BlockingQueue其实完全可以满足需求

补充

1、有关于上面说的异步pull数据

  1. 通过观察代码,发现有一个每10s一次的定时任务,通过异步http的方式拉取外围系统数据。可以先认为一个异步线程对应一个外围系统,当然线程数肯定有限制
  2. 在该任务中,会通过异步方式依次从所有外围系统拉取数据。这里想到个问题:假如现在有100个外围系统,因为是异步请求,我们可能在0.1s之内就把100个请求发出去了,然后这个任务就结束了,10s之后再次执行。多个异步的请求结果可能会同时到达,然后将数据丢到queue中。假如100个请求的结果消费者3s可以消费完,剩下的7s钟将无事可做,在这3s内,消费逻辑可能会占用大量的CPU,从而导致系统中的其他业务耗时增加。如果匀速消费,将消费请求分摊到剩下的7s内,是不是会好一点?不过这样就会导致消费延时更大了,还是要结合具体业务场景来做吧
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享