记一次pinpoint内存占用过多故障排查

故障现象

工作中用到pinpoint实现调用链的采集监控功能,在生产系统上线前进行了压力测试,测试过程中发现系统不停的做FGC导致系统不可用,通过内存工具核查发现大量调用链相关的对象进入了永久代,导致系统频繁的做FGC,下面是故障检测的详细过程及解决方案。

调用链采集逻辑架构

本系统主要采用pinpoint1.6.0版本,但对发送消息这块做了部分改造,调用链数据采集后发送到kafka队列,所以本次故障也可能是改造导致的记一次pinpoint内存占用过多故障排查,核心发送逻辑如下所示:

记一次pinpoint内存占用过多故障排查

其中,调用链数据采用span对象来存储单次请求相关的数据,span对象又包含多个spanEvent对象,一个spanEvent对象代表一次内部方法调用。

故障定位

首先通过内存工具(jmap -histo <pid>)检测到有大量spanEvent对象,如下图所示:

记一次pinpoint内存占用过多故障排查

另外利用唯品会提供的vjmap工具可以单独打印老年代的内存情况(./vjmap.sh -old <pid> ,注意目前只支持CMS类型的分代打印),通过打印老年代的对象发现大部分spanEvent对象都存在老年代堆内存中;对象之所以进入老年代,通常情况就是对象存活时间过长或者是对象过大,在本系统中span对象并不是很大,因此推测问题主要是存活时间过长导致;从调用链的发送逻辑来看,span对象只可能在“待发送队列”或者“kafka消息缓冲池”存活,另外因为我们在调用kafkaProducer.send方法之前做了消息对象转换,进入“kafka消息缓冲池”之前已经释放了span对象,因此可以断定span对象呆在“待发送队列”的时间过长;

通过日志打印span对象在队列的时长,果然发现span对象从进入队列到出队列平均耗时5分钟,也就是在压力环境下所有的span对象都会进入老年代;那么核心问题就是消息被消费的太慢了,因为pinpoint默认只会启动一个daemon线程来做队列消费,在高并发压力下能分配到的cpu执行时间比较少,另外一个是发送线程在处理消息时也做了一些逻辑操作(比如同步打印日志等),导致消费速率过慢。

解决思路

找到了问题,我们主要通过以下几个手段来优化:

  1. 尽量提升发送线程的消费效率,比如将日志改为异步打印或开启buffer机制打印日志
  2. 可以缩小“待发送队列”的size (压测前我们调大到了5120,现在调回到默认的1024),pinpoint在队列满时会直接丢弃span对象,这些丢弃的对象在YGC阶段就会被回收,不至于引起FGC
  3. 增加了自动调节pinpoint采样率的机制,当短时间内检测到丢弃的span对象过多时将自动降低采样率,从根本上避免大量span对象的创建

通过上述优化手段后,目前压测时暂未发现内存大量占用的情况。