一次无脑的 `SELECT *` 报表导出,是如何引发线上 JVM 疯狂 STW 的?
2026/4/27 15:00:40 网站建设 项目流程


案发时间:月底财务结算日,下午 15:00。
案发现场:后台管理与报表微服务。
灾难表现
前端群里炸锅了:“报表系统怎么一抽一抽的?点个查询,平时 50 毫秒,现在要转圈 8 秒钟才出来!”
你看了一眼监控大盘:

  • CPU 呈锯齿状:每隔 3 分钟,CPU 就瞬间飙到 100%,持续几秒钟后掉下来。
  • 接口响应时间 (RT):平时是一条平滑的直线,现在变成了极其规律的“心电图”,每隔几分钟就刺出一根长达 8000ms 的长刺。

这是极其典型的JVM Stop-The-World (STW)症状!整个世界被强行暂停了。

🚨 一、现场封锁:用jstat确认犯罪类型

遇到这种规律性的卡顿,不要去查什么数据库慢 SQL,第一时间查 JVM 的垃圾回收状态。

登录服务器,敲下这行极其关键的监控命令(每隔 1000 毫秒打印一次 19890 进程的 GC 状况):

jstat-gcutil198901000

终端开始疯狂滚动,你死死盯住其中的两列:O(Old,老年代使用率)FGC(Full GC 次数)

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 12.45 89.34 98.12 96.43 93.11 4532 42.123 125 189.432 231.555 0.00 12.45 95.44 99.99 96.43 93.11 4532 42.123 125 189.432 231.555 0.00 0.00 5.12 1.23 96.43 93.11 4533 42.155 126 194.231 236.386

心肺骤停的时刻出现了:

  1. 老年代 (O) 的内存使用率在短短几秒内,像坐火箭一样从 20% 飙升到了 99.99%!
  2. 紧接着,触发了一次全量垃圾回收。FGC从 125 变成了 126。
  3. FGCT(Full GC 总耗时) 增加了将近 5 秒!
  4. 回收完之后,老年代 (O) 瞬间跌回了 1.23%。

案情定性
有极其庞大的**“大对象 (Large Object)”**在被疯狂创建!
JVM 有个规矩:如果一个对象太大,新生代 (Eden) 根本装不下,它会直接绕过年轻代的温柔乡,直接砸进老年代 (Old Gen)
老年代迅速被塞满,触发最重度的 Full GC,JVM 强行暂停所有业务线程(也就是系统卡顿的那 5 秒),把这些大对象清理掉。3 分钟后,大对象再次来袭,悲剧循环上演。

🔬 二、法医取证:冒死执行jmap提取尸体

既然是内存里有脏东西,我们就必须把它导出来看。

【高危警告】jmap导出堆内存快照,会让 JVM 彻底冻结!如果你的堆内存有 8G,导出过程可能长达十几秒,此时应用处于“真死”状态。
标准操作:立刻去负载均衡(如 Nginx 或 K8s)摘掉这台机器的流量,让它处于隔离状态,然后再动手!

# 提取案发现场的全量内存快照jmap-dump:format=b,file=heap_dump_1500.bin19890

几分钟后,你拿到了一份重达 4GB 的heap_dump_1500.bin尸检报告。

💻 三、解剖室惊魂:MAT (Memory Analyzer Tool) 里的怪物

把这 4GB 的文件下载到本地,打开程序员的终极验尸工具:Eclipse MAT

点击Leak Suspects(内存泄漏嫌疑人报告)。MAT 的饼图直接给出了致命一击:
一个名为java.lang.Object[]的大怪物,吃掉了整个堆内存的85%

我们顺着**“支配树 (Dominator Tree)”**一层层往下扒它的皮:
java.lang.Thread->http-nio-8080-exec-12->java.util.ArrayList->Object[]->com.example.finance.dto.UserOrderExportDTO

破案了!这个占用了几个 G 内存的怪物,是一个装满了UserOrderExportDTO(用户订单导出实体) 的超大ArrayList

🔪 四、还原犯罪现场:没有LIMIT的血案

顺着UserOrderExportDTO,我们打开了FinanceReportService.java的第 210 行。
这是一个名为“导出近半年所有支付订单”的接口,主要给财务人员月底对账用。

publicvoidexportAllPaidOrders(HttpServletResponseresponse){// 致命的第 212 行:没有任何分页,没有任何 LIMIT!// 财务小姐姐点了一下按钮,直接从数据库捞出了 200 万条数据!List<UserOrderExportDTO>exportList=orderMapper.selectAllPaidOrdersLast6Months();// 使用传统的 POI 框架,把这 200 万个对象塞进内存里构建 ExcelWorkbookworkbook=newXSSFWorkbook();Sheetsheet=workbook.createSheet("订单明细");for(inti=0;i<exportList.size();i++){Rowrow=sheet.createRow(i);// ... 将 DTO 数据写入 Row 的极其消耗内存的过程}workbook.write(response.getOutputStream());}

死亡回放:

  1. 月底了,财务部门的几十个员工同时登录系统,点击了【全量导出】按钮。
  2. 数据库由于命中索引,极其努力地把 200 万条数据返回给了 Java 进程。
  3. MyBatis 将这 200 万条数据映射成了 200 万个 Java 对象,塞进一个巨大的ArrayList中。
  4. 这个ArrayList太大了,高达 2GB!新生代根本放不下,直接砸入老年代。
  5. 此时 Apache POI 框架更狠,它在内存里构建 XML 树状结构的 Workbook,又吃掉了 1.5GB 内存。
  6. 老年代瞬间被撑爆。JVM 启动 Full GC。
  7. 在长达几秒的 STW 停顿后,如果财务还在下载,GC 发现这些对象“还活着(被引用)”,根本回收不掉,最后直接报OutOfMemoryError: Java heap space宕机!如果下载刚好中断了,GC 勉强把这 3GB 垃圾清掉,等待下一个财务人员点击导出。

🛠️ 五、紧急抢救与架构升级

初级抢救(止血):
立刻在 SQL 层面加上强制的分页和时间限制限制,绝不允许一次性拉取超过 1 万条数据。

架构师的终极手术(彻底治愈):

  1. 废弃传统 POI,引入阿里 EasyExcel
    传统 POI 导出是一次性把所有数据加载到内存,而 EasyExcel 底层使用的是流式写入 (Streaming)。它一边从数据库读数据,一边往磁盘或网络流里写,内存里永远只保留当前正在处理的几十行数据。就算导出 1000 万行,内存占用也只有区区几十 MB!
  2. 异步化改造(大厂标配)
    凡是超过 1 万条的报表导出,绝不允许用户在网页上“转圈”等!
    改成异步任务:用户点击导出 -> 后端返回“导出任务已提交,请稍后去下载中心查看” -> 把任务扔给上期讲过的 ElasticJob 定时任务集群去后台慢慢跑 -> 跑完后上传至 OSS,并给用户发个钉钉消息提醒下载。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询