# 从OOM到根治的完整过程——导出大数据的应急、根因分析与游标方案

从OOM到根治的完整排查过程------导出大数据的应急、根因与最终方案

背景

政务系统里,"导出Excel"是高频需求。某天线上开始出现导出时系统卡顿,偶尔直接OOM崩溃。

第一步:现象------导出OOM

看日志,OOM发生在导出Servlet里。当时的导出流程很简单:

  1. 业务方法查询数据库,结果集全部加载到DataStore
  2. DataStore转成Excel,写入ByteArrayOutputStream
  3. ByteArrayOutputStream一次性写到response的OutputStream

问题在第1步和第2步------120万行数据,全部加载到内存,再全部写成Excel字节流。堆直接打满。

第二步:应急------加锁限制并发

来不及改技术方案,先止血。在导出Servlet里加了一把锁,限制同时导出的请求数不超过5:

java 复制代码
private static Integer i = 0;

// 导出前
synchronized(i) {
    if(i > 4) {
        response.getWriter().print("系统繁忙,请过段时间再尝试导出");
        return;
    }
    i++;
}

// ... 执行导出 ...

// 导出后
synchronized(i) {
    i--;
}

上线后OOM频率下降,因为并发导出被堵住了。

但这个锁防不住单次大数据量。 一个人导120万行,不并发也一样OOM。锁只是争取了排查时间。

第三步:根因分析

问题不在并发,在内存模型 。ByteArrayOutputStream把整个Excel全攒在内存里,数据量和内存占用是线性关系。10万行也许没事,120万行就爆了。

根因很清楚:不该把全部数据加载到内存再写文件。

第四步:根治------游标逐行写文件

思路转变:不攒,写一行丢一行。

当时的方案:JDBC游标

当时的MyBatis版本(3.1.1)不支持游标,只能绕开MyBatis,直接用JDBC:

java 复制代码
statement = connection.createStatement(
    ResultSet.TYPE_FORWARD_ONLY,
    ResultSet.CONCUR_READ_ONLY
);
statement.setFetchSize(Integer.MIN_VALUE); // MySQL
// Oracle: statement.setFetchSize(1000);

resultSet = statement.executeQuery(sql);

while (resultSet.next()) {
    // 当前行数据写入文件,写完这行内存就释放
    writeRow(resultSet, writer);
    // 内存中始终只有一行
}

setFetchSize是关键:

  • MySQL设为Integer.MIN_VALUE,启用流式读取
  • Oracle设为合理值(1000),每次从网络缓冲区取一批

写文件的格式用Tab分隔的伪xls(Excel能打开),不引入POI等库------POI的内存模型同样会把整个Excel加载到内存。

现在的方案:MyBatis Cursor

MyBatis 3.5+原生支持游标,不用绕开MyBatis了:

java 复制代码
@Select("select * from KC22 where ...")
Cursor<Map<String, Object>> exportWithCursor();
java 复制代码
try (Cursor<Map<String, Object>> cursor = mapper.exportWithCursor()) {
    for (Map<String, Object> row : cursor) {
        writeRow(row, writer);
    }
}

效果一样:内存中始终只有一行数据,不管导出多少行都不会OOM。

但注意 :Cursor必须在事务内使用,且连接不能关闭,否则Cursor is closed。Spring的@Transactional可以保证这一点。

第五步:删掉锁

根因解决了------内存模型从"全部加载"变成"逐行读写",单次导出的内存占用从"和数据量成正比"变成"固定一行"。并发导出也不怕了。

锁删掉。应急代码不留。

完整思路总结

复制代码
现象:导出OOM
  ↓
应急:加锁限制并发(治标,防不住单次大数据)
  ↓
根因:ByteArrayOutputStream把全部数据攒在内存
  ↓
根治:游标逐行写文件,内存中只有一行
  - 早期:JDBC游标(MyBatis 3.1.1不支持游标,绕开它)
  - 现在:MyBatis Cursor(3.5+原生支持,不用绕)
  ↓
锁删掉:根因解决了,应急措施没用了

关键认知

  1. 应急措施一定要标注"这是应急"------代码里加注释说明为什么加这个锁,找到根因后必须删。不然后来的人不知道为什么有这个锁,不敢删,就一直烂在代码里。

  2. 锁防并发,防不住单次大数据------加锁只是争取排查时间,不是解决方案。

  3. MyBatis版本决定技术方案------3.1.1只能用JDBC游标绕开它,3.5+直接用Cursor。技术方案要跟着工具版本走。

  4. 写文件用Tab分隔不用POI------POI的内存模型和ByteArrayOutputStream是同一个问题。Tab分隔的文本文件Excel能打开,内存占用极低。

  5. 不要在内存里做格式化------如果需要真正的xlsx格式,用SXSSFWorkbook(流式写入)而不是XSSFWorkbook。原理一样:逐行写,不在内存里攒。

相关推荐
阳光九叶草LXGZXJ9 小时前
达梦数据库-报错-15-列【XXX】长度超出定义
linux·运维·数据库·sql·学习
字节渡客9 小时前
Redis键明明过期了,业务还在读到旧数据
数据库·redis·spring
Devlive 开源社区9 小时前
AuthX 正式更名 GrantForge:我们重新做了一遍权限管理系统
大数据·人工智能·架构
daidaidaiyu10 小时前
ThingsBoard 集群的核心逻辑源码分析
java
楚楚25110 小时前
2026企业AI办公工具选型指南:落地评估与效果衡量体系
大数据·人工智能
我可能是个假开发11 小时前
FTP Unicode 文件名导致 `550` 的问题分析与解决方案
java·开发语言·spring
caoerzhong11 小时前
中小企业上 WMS 该先上哪几块:JeeWMS 开源 Java 仓库管理系统的分批上线清单
java·python·开源
卷毛迷你猪11 小时前
快速实验篇(B16)用户级预测可行性审计(否定性意见)
大数据·hadoop·数据挖掘·聚类
OnlineProxy12 小时前
亚马逊多账号运营:如何在规避“关联封号”风险的同时实现电商规模化扩张
服务器·数据库·redis
盟接之桥12 小时前
当大模型遇见线束制造:不是通用AI,而是行业AI
大数据·网络·人工智能·安全·制造