周五晚上十点半,手机开始疯狂震动。
运维群里甩出来一张监控截图——我们那个跑了大半年的订单服务,堆内存使用率从40%直接飙到98%,GC频率高得像抽风,Full GC一轮接一轮,每次回收完只能释放掉不到5%的内存。服务没挂,但接口响应时间从平时的30ms飙到了3秒以上,用户侧已经开始报超时了。
我当时的第一反应是:谁又上了什么新代码?
翻了一下最近的发布记录,最近一次上线是周三,改了一个批量导出Excel的功能。我当时心里就咯噔了一下——批量导出,大数据量,内存……这几个关键词凑在一起,大概率就是它。
但光靠猜没用,得看证据。
第一步:先止血
线上服务不能一直这么半死不活地挂着。先做了两件事:
- 把批量导出的入口临时下掉,防止新的请求继续往内存里塞数据
- 对服务做了一次滚动重启,先把内存释放掉,让服务恢复正常
重启之后内存果然降下来了,但我知道这只是治标。根因没找到,下次上线还可能炸。
第二步:拿到现场
好在我们之前配了JVM启动参数,开了OOM时自动dump:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump/但这次比较尴尬——服务还没真正抛OOM,只是GC压力巨大,堆还没完全打满。所以没有自动dump文件。
只能手动来。先登录机器,用jmap打一份堆快照:
jmap -dump:format=b,file=/data/logs/heapdump/manual_dump.hprof <pid>这里有个坑要提一下:jmap在堆很大的时候会触发一次STW(Stop The World),当时服务本来就快挂了,这一锤子下去差点直接把它打死。所以生产环境打dump一定要小心,最好先把流量切走,或者在备节点上操作。
dump文件大概1.2G,scp拉到本地,用MAT(Eclipse Memory Analyzer)打开。
第三步:分析dump
MAT打开之后,第一眼看的是Dominator Tree(支配树),这个视图能直接告诉你哪些对象占了最多的内存。
结果很明显——排名第一的是一个byte[]数组,占了将近600MB。再往下看Histogram,大量的byte[]和char[]对象,加起来占了堆的70%以上。
顺着引用链往上追(右键 → List Objects → with incoming references),最终定位到了一个类:
public class OrderExportService { public byte[] exportOrders(Date startTime, Date endTime) { List<Order> orders = orderMapper.selectByDateRange(startTime, endTime); // 问题就出在这里 ByteArrayOutputStream baos = new ByteArrayOutputStream(); Workbook workbook = new XSSFWorkbook(); Sheet sheet = workbook.createSheet("订单数据"); for (int i = 0; i < orders.size(); i++) { Row row = sheet.createRow(i); row.createCell(0).setCellValue(orders.get(i).getOrderNo()); row.createCell(1).setCellValue(orders.get(i).getAmount().toString()); // ... 还有十几个字段 } workbook.write(baos); return baos.toByteArray(); } }问题一目了然:
selectByDateRange没有分页,时间范围一大,直接查出几十万条数据全塞进内存XSSFWorkbook(xlsx格式)本身就是把整个文档加载到内存里的,不像SXSSFWorkbook支持流式写入- 最终
baos.toByteArray()又复制了一份完整的byte数组
也就是说,一份导出数据在内存里至少存在了三份拷贝:原始查询结果List、Workbook对象、最终的byte数组。如果查出来10万条数据,每条数据序列化后大概1KB,那光数据就是100MB,乘以3就是300MB。再加上GC来不及回收,堆直接被打爆。
第四步:修复
改了两个地方:
1. 查询加分页,流式处理
public void exportOrders(Date startTime, Date endTime, OutputStream outputStream) { int pageSize = 5000; int pageNum = 1; SXSSFWorkbook workbook = new SXSSFWorkbook(500); // 只保留500行在内存 Sheet sheet = workbook.createSheet("订单数据"); int rowIndex = 0; while (true) { List<Order> orders = orderMapper.selectByDateRange( startTime, endTime, (pageNum - 1) * pageSize, pageSize ); if (orders.isEmpty()) break; for (Order order : orders) { Row row = sheet.createRow(rowIndex++); row.createCell(0).setCellValue(order.getOrderNo()); row.createCell(1).setCellValue(order.getAmount().toString()); } pageNum++; } workbook.write(outputStream); workbook.dispose(); // 清理临时文件 }2. 不再把结果存成byte[]返回,而是直接写到response的OutputStream里
@GetMapping("/export") public void exportOrders(@RequestParam Date startTime, @RequestParam Date endTime, HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=orders.xlsx"); orderExportService.exportOrders(startTime, endTime, response.getOutputStream()); }这样改完之后,不管导出多少数据,内存中最多只保留5000条记录 + 500行的Workbook滑动窗口,内存占用基本是恒定的。
复盘和反思
回过头来看,这个问题其实不复杂,但有几个点值得注意:
1. 不要相信"数据量不大"的假设
开发的时候测试环境就那么几百条数据,怎么测都不会出问题。但线上用户一选就是三个月的时间范围,数据量直接翻了几百倍。做批量操作的时候,一定要考虑最坏情况。
2. POI的xlsx模式是个内存杀手
XSSFWorkbook底层用的是DOM模型,整个文档都在内存里。如果要导出大数据量的Excel,要么用SXSSFWorkbook(流式写入),要么考虑换成EasyExcel这类专门优化过大文件导出的库。
3. 监控告警要配好
这次是运维看到内存飙了才通知的,如果告警阈值设得合理(比如堆使用率超过80%就报警),能更早发现问题。我们后来把告警阈值从90%调到了80%,并且加了GC频率的告警。
4. 生产环境的dump操作要谨慎
jmap打dump会STW,堆越大影响越大。更好的做法是用jcmd或者配好自动dump参数,或者在预发环境复现问题后再dump。
写这篇主要是给自己做个记录,顺便也希望能帮到遇到类似问题的朋友。如果你也有过线上OOM的排查经历,欢迎在评论区聊聊你的故事,尤其是那些"排查到最后发现原因很离谱"的案例,我最喜欢听这种了。