问题排查
在公司某次重大活动的保障中,某个组件突然收到了CPU过高的预警(超过80%),收到预警以后,立刻查看监控,发现现网一共10个节点,其中3个节点cpu超过80%,剩余节点cpu正常(cpu不超过60%),并且问题节点的cpu中断数,突然陡增。‘
疑点一:在同样级别的请求量下,只有3台服务器
疑点二:CPU中断数,在某个时间点突然陡增,但上下文切换没有明显变化 (如果上下文切换数,和中断数两个同时上升,很有可能是GC的原因导致的)
这边首先怀疑是不是6个服务节点,收到的请求量,不一致。但是,在查了日志后,发现各节点的请求量,基本上是一致的,甚至正常节点的请求量要略高于问题节点(分析,由于cpu高了,导致处理能力下降了)。
第一个怀疑的是GC问题,通过 jstat 命令查看:
jstat -gcutil [PID] 2000
结果发现 GC 是正常的,full gc次数为0 , yong gc 的频率也不是很高。
继续分析问题,先到问题节点上查看占用cpu比较高的线程:
top -Hp [PID]
这时候发现新的疑点
疑点三:占用cpu较高的线程不止一个,并且是轮流变化的
不管怎么样,有了占用cpu较高的线程,就可以结合栈快照进行分析,但这里碰到一个问题,就是top5的线程,一直在变化,通过jstack打出的快照,由于是手动打得,可能要晚于top上显示线程运行的时刻。
这时候,就用到了排查神器 Arthas 启动 arthas,并attach 上服务进程以后, 输入命令:
thread -n 3 -i 5000
查看到一个堆栈信息非常频繁的出现:
终于找到方向了,继续分析,这个堆栈信息是发生在 Spring MVC 对请求进行拦截的时候。起初怀疑是filter中的代码,导致的,但对比了10台服务器的代码,md5值是一摸一样的。如果是这段代码的问题,那在同样级别的请求量下,应该是都会有问题。
经过网上查证,找到了相同的问题:
https://github.com/spring-projects/spring-framework/issues/24886
https://github.com/spring-projects/spring-framework/issues/24671
https://github.com/spring-projects/spring-framework/commit/39536acf9f28ddcae59bf89ae2c31b8e6a615494
原来 Spring MVC 在处理请求时,会缓存请求头中的 Content-Type , Spring 构造了一个 LRU 缓冲结构(内部采用ConcurrentLinkedQueue 实现),按作者正常的设计,该队列,最多只能存储64个元素,超过64个,就会删除最不常用的,但由于jdk1.8低版本的bug, 导致在高并发的情况下,会产生内存泄露的问题,导致链表变长。而remove由于需要遍历链表,所以当链表长度达到一定程度以后,遍历就会非常耗费CPU
主要逻辑代码:
这里就解释了疑问一
为什么只在3台服务器上,出现问题,由于该bug只在频繁的调用add和remove方法时,才会导致链表变长(实际没有删掉),所以说,这里存在一个阈值,只有当已缓存的数量达到32个时候,才会触发。
这里要说一个后面的反思,由于当时是cpu过高,并且没有gc问题, 所以在保留现场的时候,只做了栈快照,没有留堆快照,这样对于后续故障定位,和现场复原就失去了第一手的证据。如果有堆快照的话,就可以对象进一步佐证(问题节点的content-type,超过了32个)
参考:
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8137185
https://blog.51cto.com/u_15127641/2874815
测试环境复现
- 在测试环境,我们使用jemter,进行压测,并且在请求中,随机添加40个不同的content-type,成功复现问题,压了30分钟以后,cpu飙高到95%,后续,即使减少了并发量,cpu也很难降下来
- 在将jdk 升级到升级到1.8.0_201 后,问题解决,无法复现
疑问点解答
疑问一
:
为什么只在3台服务器上,出现问题?
由于该bug只在频繁的调用add和remove方法时,才会导致链表变长(实际没有删掉),所以说,这里存在一个阈值,只有当已缓存的数量达到32个时候,才会触发。
疑问二
:
CPU中断数,在某个时间点突然陡增,但上下文切换没有明显变化
问题主要是由于,多个线程一直在遍历链表,查找到元素remove, 一直占用着cpu时间,本身上下文切换,不多,但由于长期占用cpu的时间,当别的进程需要cpu资源时,只能中断cpu, 所以现象就是,CPU中断数,在某个时间点突然陡增,但上下文切换没有明显变化。
疑问三
:
占用cpu较高的线程不止一个,并且是轮流变化的
根据代码可知,需要LRU Cache中缓存的元素达到,32个以上,才会触发add() 和 remove 方法,来移动元素位置,将操作频繁的元素移动到头部,淘汰在尾部的预算。
基于堆栈的思考
在分析堆栈的时候,发现一个问题,代码中读取和更新逻辑,采用的是读锁,按道理是可以并发执行的,但从栈快照中却发现,也有很多线程wait 在435行
ReentrantReadWriteLock 底层使用的是AQS, 于是我抱疑问去查了一下资料
ReentrantReadWriteLock.ReadLock 的实现机制 :
获取共享锁的时候 先去同步队列看看 有没有阻塞的写锁 如果有就阻塞自己 把自己也加入到同步队列
当第一个获取共享锁的线程不释放共享锁时 后续的 写锁和 排在写锁后面的线程 都会被阻塞
写锁阻塞是因为 有读锁获取到了锁
后续的读锁阻塞是 因为前面有了写锁在等待锁
启示
- 在使用ConcurrentLinkedQueue队列时,如果长度非常大的话,需要谨慎使用remove,size等需要遍历元素的方法,还有就是,在采用ConcurrentLinkedQueue的迭代器时,要注意他是弱一致性的。
- 在使用任何一个新的框架或者api时,一定要仔细阅读,官方的文档,和相关的issue bug, 避免睬坑。