# 从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。原理一样:逐行写,不在内存里攒。

相关推荐
eLIN TECE1 小时前
nacos2.3.0 接入pgsql或其他数据库
数据库
上弦月-编程1 小时前
C语言指针超详细教程——从入门到精通(面向初学者)
java·数据结构·算法
ANnianStriver1 小时前
Java中的stream流的用法
java
1104.北光c°1 小时前
【AI核心概念讲解】一口气搞懂 Agent:干翻传统后端!自主循环决策的秘密,ReAct 与 Plan-and-Execute 范式
java·人工智能·程序人生·ai·agent·react·智能体
Jul1en_2 小时前
Claude 迁移 Codex 工作流迁移与更新
java·服务器·前端·后端·ai编程
曾几何时`2 小时前
MySQL(七)索引
数据库·mysql
未若君雅裁2 小时前
Spring Statemachine 实战入门:从零实现一个订单状态流转 Demo
java·spring·状态模式
Justice Young2 小时前
Flink第四章:运行架构
大数据·flink
早日退休!!!2 小时前
操作系统锁
java·开发语言