# 从OOM到根治的完整过程——导出大数据的应急、根因分析与游标方案
2026/5/4 4:19:39 网站建设 项目流程

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

背景

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

第一步:现象——导出OOM

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

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

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

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

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

privatestaticIntegeri=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:

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了:

@Select("select * from KC22 where ...")Cursor<Map<String,Object>>exportWithCursor();
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。原理一样:逐行写,不在内存里攒。

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

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

立即咨询