🔥内存炸了!背刺我的竟然是Redisson!!

前言

在一个平静的上午,我坐在工位上,用聊天软件跟我对象斗图正欢,突然飞书上开始了一阵弹窗,比过年红包群消息还密集的阿里云监控告警通知蜂拥而来,点开监控平台一看,一台机器CPU直接达到90%了,到底发生了什么?废话不多说,开讲!!!

1 生产现象

  1. 首先是连续收到阿里云监控告警通知;
  1. 查看阿里云监控平台发现一台混布的机器CPU飙高;

3. 登上服务器,查看机器CPU使用情况,显示机器上总共有三个java进程,其中PID:3454383的java进程占用的CPU飙高,确认后原来是我们的会员任务服务;

4. 查看skywalking中对应机器的JVM参数指标:old gc频繁、单次GC时间达到3s+;

  1. 查看数据库监控:CPU飙高、InnoDB读吞吐率严重降低、InnoDB写吞吐率飙高;
  2. MQ云消息平台也来凑热闹:百万消息积压。

2 问题分析

2.1 业务背景:会员日=消息海啸

  1. 每到会员日,业务就会向会员发送一些福利,这次是给会员派发一个促销任务;
  2. 某个任务在大数据侧触发会员人群圈选消息,直接往MQ中撒花般地丢了上百万的MQ消息;
  3. 会员任务服务在消息消费时会根据消息中相应的会员id,向任务圈选人群表中给该会员插入一条当前任务的记录;
  4. 若插入的该会员任务在任务派发时间段,则会当即向该会员派发该任务(向会员任务表中插入一条会员id与任务id的绑定记录)。

2.2 技术原因梳理逻辑

结合SLS日志,我们很快锁定元凶 --- 大数据的那波消息海啸。推理链如下:

  1. 百万消息涌入 → 数据库写爆了 → 写入超时;
  2. 数据库写入超时 → MQ消息消费速率降低 → 消息积压;
  3. 大量消息消费超时 → 触发重试;
  4. 消息重试 → 消息积压 → 恶性循环;
  5. 暂时预测原因:大量消息消费 → 业务处理对象爆炸 → JVM频繁GC;
  6. 十多分钟后数据库写入和MQ消息确实如预期回复正常,但是机器内存和CPU仍旧居高不下!!!

烧退了,人怎么还在萎靡 --- 肯定有别的问题!!!

为了保住线上业务,我们做了下面这几个措施:

  1. 先请运维大佬拉下dump文件 (保留案发现场)
  2. 问题机器摘机下线
  3. 重新开一台机器顶上

3 问题排查定位流程

3.1 dump文件解析流程

  1. 找运维大佬拉下dump文件
  2. 我们使用VisualVM作为分析工具,加载hprof文件,以下是dump文件解析内容:

VisualVM使用指南:服务器挂了,日志看不出原因!别慌,VisualVM带你"开棺验尸"

3. 查看总览,定位泄漏对象:从支配对象深堆大小模块(Dominators by Retained Size)可以看到org.redisson.pubsub.PublishSubscribeService对象作为支配者,它的深堆大小达到880+MB大小,太能吃了!

  1. 点击这个org.redisson.pubsub.PublishSubscribeService对象后,可以查看到这个对象具体的内部结构。

5. 从中可以看出PublishSubscribeService#name2entry属性才是导致内存泄漏的真凶,作为一个ConcurrentHashMap对象,其中有足足200+w个节点对象;

  1. 我们从中找到单个entry,找到它的key:【redisson_lock__channel:{memberTask:taskLock:123456:7891234}】

3.2 问题代码定位

3.2.1 业务代码定位

顺着key里的业务标识,我们在代码里找到了它的老巢 --- 会员任务派发方法。

3.2.2 业务代码问题原因

  1. assign方法整体逻辑很简单:
    • 根据memberId+taskId创建Redisson分布式锁
    • 加锁,操作数据库
    • 解锁
  2. 问题来了:消息量太大 → 任务派发太多 → 数据库扛不住 → 消费超时 → 同个会员的消息重试
  3. 重试就意味着:同一个memberId+taskId的锁,开始疯狂竞争;
  4. 项目中使用的是redisson.3.20.1版本,当发生锁的等待,会本地内存维护一个key的等待队列(PublishSubscribeService#name2entry);
  5. redisson关键代码就三行:getLocktryLockunLock看起来人畜无害,没想到隐藏着内存泄漏的定时炸弹
  6. 从dump文件解析中可以看到,发生内存泄漏的是redisson中的对象,根据这点,基本可以断定是redisson的原因;
  7. redisson作为开源代码平台的热门项目,Memory leak通常是开源代码比较严重的问题,大都会在相邻版本修复&发布;
  8. 打开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源码剖析

  1. 先研究下redisson的tryLock到底做了哪些事情,不用每一行都看,只要看几个关键步骤就行: 正常流程:没有获取到锁,会在本地内存中subscribe订阅该锁的释放通知,并且不管怎样最终都会unsubscribe取消订阅。

2. subscribe订阅时会创建一个当前业务key的entry放入到PublishSubscribeService#name2entry(细节代码这里不带大家看了,有兴趣可以研究一下);

3. 从dump文件分析时情况,可以看到内存泄漏的对象是name2entry没有释放,也就是说在3.20.1版本里,unsubscribe虽然做了取消订阅,但是没把map里的entry删掉!

  1. 我们把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:

  1. 锁在竞争时,会在本地map里给每个key建个队列
  2. 等锁结束了,队列清空了,但map里的entry没删
  3. 百万级锁竞争 → map里留下百万个空队列 → 内存爆炸

就像你点外卖:

  1. 每次等餐时都在门口放个空箱子(subscribe)
  2. 餐到了,箱子空了,但你不扔掉(没remove)
  3. 点了一百万次外卖,门口堆满空箱子 → 你家(JVM)被塞爆了!

4 修复措施

  1. 升级Redisson版本 (治本)从3.20.1 → 3.25.0+,把那行要命的remove补上
  2. 优化任务处理流程 (治标)MQ大任务消费加个缓冲池,别让消息重试把系统搞崩
  3. 服务器配置调整 (增强体质)原来是混布机器,现在独立出来,升配!升配!升配!

写在最后

这次事故告诉我们:

  • 开源虽好,版本要新 --- 特别是带"memory leak fix"字样的,看到了赶紧升!
  • dump文件是个好东西 --- 关键时刻,VisualVM yyds!

再次附上VisualVM使用指南:服务器挂了,日志看不出原因!别慌,VisualVM带你"开棺验尸"

最后轻声问一句 :你们项目里的Redisson,是什么版本?

相关推荐
lizhongxuan2 小时前
AI 的底层思考
后端
Penge6662 小时前
解密 Kafka 与 RocketMQ 消费模型的核心之战
后端
小码哥_常2 小时前
Spring Boot遇上Maven依赖冲突:打怪升级全攻略
后端
用户7344028193422 小时前
Spring Boot 集成 Redis 并调用 Lua 脚本详解
后端
小码哥_常2 小时前
Spring Boot多模块项目:Parent、BOM、Starter的分工大揭秘
后端
SimonKing2 小时前
GitHub 10万星的OpenCode,正在悄悄改变我们的工作流
java·后端·程序员
Moment3 小时前
OpenClaw 从能聊到能干差的是这 50 个 Skills 😍😍😍
前端·后端·开源
怕浪猫3 小时前
第20章:Web服务实战——构建RESTful API
后端·go·编程语言
BingoGo4 小时前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php