前言
在一个平静的上午,我坐在工位上,用聊天软件跟我对象斗图正欢,突然飞书上开始了一阵弹窗,比过年红包群消息还密集的阿里云监控告警通知蜂拥而来,点开监控平台一看,一台机器CPU直接达到90%了,到底发生了什么?废话不多说,开讲!!!
1 生产现象
- 首先是连续收到阿里云监控告警通知;
- 查看阿里云监控平台发现一台混布的机器CPU飙高;
3. 登上服务器,查看机器CPU使用情况,显示机器上总共有三个java进程,其中PID:3454383的java进程占用的CPU飙高,确认后原来是我们的会员任务服务;
4. 查看skywalking中对应机器的JVM参数指标:old gc频繁、单次GC时间达到3s+;
- 查看数据库监控:CPU飙高、InnoDB读吞吐率严重降低、InnoDB写吞吐率飙高;
- MQ云消息平台也来凑热闹:百万消息积压。
2 问题分析
2.1 业务背景:会员日=消息海啸
- 每到会员日,业务就会向会员发送一些福利,这次是给会员派发一个促销任务;
- 某个任务在大数据侧触发会员人群圈选消息,直接往MQ中撒花般地丢了上百万的MQ消息;
- 会员任务服务在消息消费时会根据消息中相应的会员id,向任务圈选人群表中给该会员插入一条当前任务的记录;
- 若插入的该会员任务在任务派发时间段,则会当即向该会员派发该任务(向会员任务表中插入一条会员id与任务id的绑定记录)。
2.2 技术原因梳理逻辑
结合SLS日志,我们很快锁定元凶 — 大数据的那波消息海啸。推理链如下:
- 百万消息涌入 → 数据库写爆了 → 写入超时;
- 数据库写入超时 → MQ消息消费速率降低 → 消息积压;
- 大量消息消费超时 → 触发重试;
- 消息重试 → 消息积压 → 恶性循环;
- 暂时预测原因:大量消息消费 → 业务处理对象爆炸 → JVM频繁GC;
- 十多分钟后数据库写入和MQ消息确实如预期回复正常,但是机器内存和CPU仍旧居高不下!!!
烧退了,人怎么还在萎靡 — 肯定有别的问题!!!
为了保住线上业务,我们做了下面这几个措施:
- 先请运维大佬拉下dump文件 (保留案发现场)
- 问题机器摘机下线
- 重新开一台机器顶上
3 问题排查定位流程
3.1 dump文件解析流程
- 找运维大佬拉下dump文件
- 我们使用VisualVM作为分析工具,加载hprof文件,以下是dump文件解析内容:
VisualVM使用指南:服务器挂了,日志看不出原因!别慌,VisualVM带你"开棺验尸"
3. 查看总览,定位泄漏对象:从支配对象深堆大小模块(Dominators by Retained Size)可以看到
org.redisson.pubsub.PublishSubscribeService对象作为支配者,它的深堆大小达到880+MB大小,太能吃了!
- 点击这个
org.redisson.pubsub.PublishSubscribeService对象后,可以查看到这个对象具体的内部结构。
5. 从中可以看出
PublishSubscribeService#name2entry属性才是导致内存泄漏的真凶,作为一个ConcurrentHashMap对象,其中有足足200+w个节点对象;
- 我们从中找到单个entry,找到它的key:
【redisson_lock__channel:{memberTask:taskLock:123456:7891234}】
3.2 问题代码定位
3.2.1 业务代码定位
顺着key里的业务标识,我们在代码里找到了它的老巢 — 会员任务派发方法。
3.2.2 业务代码问题原因
- 在
assign方法整体逻辑很简单:- 根据
memberId+taskId创建Redisson分布式锁 - 加锁,操作数据库
- 解锁
- 根据
- 问题来了:消息量太大 → 任务派发太多 → 数据库扛不住 → 消费超时 → 同个会员的消息重试
- 重试就意味着:同一个
memberId+taskId的锁,开始疯狂竞争; - 项目中使用的是
redisson.3.20.1版本,当发生锁的等待,会本地内存维护一个key的等待队列(PublishSubscribeService#name2entry); - redisson关键代码就三行:
getLock、tryLock、unLock看起来人畜无害,没想到隐藏着内存泄漏的定时炸弹! - 从dump文件解析中可以看到,发生内存泄漏的是redisson中的对象,根据这点,基本可以断定是redisson的原因;
- redisson作为开源代码平台的热门项目,Memory leak通常是开源代码比较严重的问题,大都会在相邻版本修复&发布;
- 打开GitHub,在release note 中定位到redisson.3.20.1版本,往后寻找memory leak字眼;
9. 果然,在redisson 3.21.2版本有被提及,终于松了一口气;
> GitHub修复记录链接: redisson/redisson/releases/tag/redisson-3.21.2
3.2.3 redisson源码剖析
- 先研究下redisson的tryLock到底做了哪些事情,不用每一行都看,只要看几个关键步骤就行: 正常流程:没有获取到锁,会在本地内存中subscribe订阅该锁的释放通知,并且不管怎样最终都会unsubscribe取消订阅。
2. subscribe订阅时会创建一个当前业务key的entry放入到
PublishSubscribeService#name2entry(细节代码这里不带大家看了,有兴趣可以研究一下);
3. 从dump文件分析时情况,可以看到内存泄漏的对象是
name2entry没有释放,也就是说在3.20.1版本里,unsubscribe虽然做了取消订阅,但是没把map里的entry删掉!
- 我们把redisson依赖更新到3.21.2以后的版本3.25.0,可以看到
PublishSubscribeService#name2entry属性的引用处,3.25.0比3.20.1多了两处,有一处正是remove方法,就是这个小小的remove操作,解放了880MB的内存泄漏!
5. 用idea打开两个项目,一个用redisson3.20.1,一个用redisson3.25.0,可以看到在3.25.0中unsubscribe取消订阅时,新增了remove方法。
3.2.4 根因总结
Redisson低版本有个bug:
- 锁在竞争时,会在本地map里给每个key建个队列
- 等锁结束了,队列清空了,但map里的entry没删
- 百万级锁竞争 → map里留下百万个空队列 → 内存爆炸
就像你点外卖:
- 每次等餐时都在门口放个空箱子(subscribe)
- 餐到了,箱子空了,但你不扔掉(没remove)
- 点了一百万次外卖,门口堆满空箱子 → 你家(JVM)被塞爆了!
4 修复措施
- 升级Redisson版本 (治本)从3.20.1 → 3.25.0+,把那行要命的
remove补上 - 优化任务处理流程 (治标)MQ大任务消费加个缓冲池,别让消息重试把系统搞崩
- 服务器配置调整 (增强体质)原来是混布机器,现在独立出来,升配!升配!升配!
写在最后
这次事故告诉我们:
- 开源虽好,版本要新 — 特别是带“memory leak fix”字样的,看到了赶紧升!
- dump文件是个好东西 — 关键时刻,VisualVM yyds!
再次附上VisualVM使用指南:服务器挂了,日志看不出原因!别慌,VisualVM带你"开棺验尸"
最后轻声问一句 :你们项目里的Redisson,是什么版本?