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