从OOM到根治的完整排查过程——导出大数据的应急、根因与最终方案
背景
政务系统里,"导出Excel"是高频需求。某天线上开始出现导出时系统卡顿,偶尔直接OOM崩溃。
第一步:现象——导出OOM
看日志,OOM发生在导出Servlet里。当时的导出流程很简单:
- 业务方法查询数据库,结果集全部加载到DataStore
- DataStore转成Excel,写入ByteArrayOutputStream
- 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+原生支持,不用绕) ↓ 锁删掉:根因解决了,应急措施没用了关键认知
应急措施一定要标注"这是应急"——代码里加注释说明为什么加这个锁,找到根因后必须删。不然后来的人不知道为什么有这个锁,不敢删,就一直烂在代码里。
锁防并发,防不住单次大数据——加锁只是争取排查时间,不是解决方案。
MyBatis版本决定技术方案——3.1.1只能用JDBC游标绕开它,3.5+直接用Cursor。技术方案要跟着工具版本走。
写文件用Tab分隔不用POI——POI的内存模型和ByteArrayOutputStream是同一个问题。Tab分隔的文本文件Excel能打开,内存占用极低。
不要在内存里做格式化——如果需要真正的xlsx格式,用SXSSFWorkbook(流式写入)而不是XSSFWorkbook。原理一样:逐行写,不在内存里攒。