# 政务大数据导出OOM?绕开MyBatis,JDBC游标逐行写文件
政务大数据导出OOM?绕开MyBatis,JDBC游标逐行写文件
非科班野生程序员,深耕政务信息化20年,这套自研Java Web框架支撑过省级新农保、全国跨省医保结算等核心民生系统,18年稳定运行至今。这篇复盘大数据导出OOM的处理过程,全是政务场景踩坑后的实用解法,不求优雅但求落地。最后感谢豆包、智谱、OpenCode,决策是我做的,代码是我搓的,文字是他们总结的。
文章目录
背景
政务系统有一个高频需求:数据导出。社保参保人员名单、医保结算记录、新农保缴费明细——这些表动辄几十万甚至上百万行。
最开始,我用的是框架里标准的 getDao() 方法查数据,然后写到文件。getDao() 底层走的是 MyBatis 的标准流程:session.getMapper() → 反射调用 Mapper 方法 → MyBatis 把结果集映射成 Java 对象 → 返回 List。
问题是:当数据量达到几十万行时,这个 List 直接把 JVM 堆撑爆了。
原因分析
OOM 的根本原因不复杂,但值得拆清楚:
1. MyBatis 的结果集处理方式
MyBatis 默认把查询结果全部加载到内存。它内部用 DefaultResultSetHandler 逐行读取 ResultSet,每行映射成一个 Java 对象,全部放进 List 再返回。
假设一张表有 50 万行,每行 20 个字段,平均每行映射成 Java 对象后约占 1~2 KB。50 万 × 2 KB = 约 1 GB 的堆内存。这还没算 MyBatis 内部处理过程中产生的临时对象。
2. 对象映射的内存开销
原始的 ResultSet 里,一个字段值就是一个字符串引用。但经过 MyBatis 映射后:
- 每行变成一个 Dao 对象(包含 HashMap、属性数组等)
- 字符串会被复制
- 如果有嵌套映射,开销更大
从 ResultSet 到 Dao 对象,内存膨胀了 3~5 倍。
3. 字符串拼接的二次内存占用
即使数据查出来了,写到文件的过程中还有问题。常见写法:
String row = "";
for (int i = 0; i < count; i++) {
row = row + String.valueOf(val) + "\t";
}
row = row + "\n";
bw.write(row);
每次 row = row + ... 都会产生一个新的字符串对象。50 万行 × 20 列,就是 1000 万次字符串拼接,产生大量临时对象,加剧 GC 压力。
4. 总结
MyBatis 全量加载 → 50万行×3~5倍内存膨胀 → 堆撑爆
+
文件写入时字符串拼接 → 大量临时对象 → GC风暴
=
OutOfMemoryError: Java heap space
处理思路
核心思路就两步:绕开 MyBatis 的全量加载,直接用 JDBC 游标逐行处理。
第一步:getBigResult() — 绕开 MyBatis,只借它的 SQL 解析
MyBatis 的好处是 SQL 和参数都写在 XML 里,管理方便。我不想放弃这个好处,但又不想要 MyBatis 的结果集映射。
我的做法是:只借用 MyBatis 的 SQL 解析能力,拿到最终的 SQL 和参数值,然后自己用 JDBC 执行。
public static ResultSet getBigResult(Class calss, String MethodNmae, Object... params) throws utilException {
ResultSet result = null;
bSession session = null;
PreparedStatement stmt = null;
try {
AppContext context = AppContextContainer.getAppContext();
session = context.getSeesion();
if (session == null) {
BeginTrans(false);
session = AppContextContainer.getAppContext().getSeesion();
}
String id = calss.getName() + "." + MethodNmae;
IbatisSql ibatisSql = getIbatisSql(null, id, params[0]);
String sql = ibatisSql.getSql();
if (!ibatisSql.isSelectSql()) {
throw new utilException("非Select语句!sql内容:" + sql, -9999);
}
Connection conn = session.getSession().getConnection();
stmt = conn.prepareStatement(sql);
Object[] value = ibatisSql.getValue();
if (value != null && value.length > 0) {
for (int i = 0; i < value.length; i++) {
stmt.setObject(i + 1, value[i]);
}
}
result = stmt.executeQuery();
} catch (Exception e) {
throw new utilException(e.getMessage(), -8998);
}
return result;
}
关键点拆解:
1. 借用 getIbatisSql() 解析 SQL 和参数
getIbatisSql() 方法做了什么?它通过 MyBatis 的 Configuration.getMappedStatement(id) 拿到 XML 里定义的 SQL 语句,然后解析出:
- 最终的 SQL 字符串(含
?占位符) - 按顺序排列的参数值数组
public static IbatisSql getIbatisSql(SqlSessionFactory sqlSessionFactory, String id, Object parameterObject) {
IbatisSql ibatisSql = new IbatisSql();
if (sqlSessionFactory == null) {
sqlSessionFactory = sc;
}
MappedStatement ms = sqlSessionFactory.getConfiguration().getMappedStatement(id);
BoundSql boundSql = ms.getBoundSql(parameterObject);
SqlCommandType sqltype = ms.getSqlCommandType();
ibatisSql.setSqlType(sqltype);
ibatisSql.setSql(boundSql.getSql());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
ibatisSql.setParameters(parameterMappings);
if (parameterMappings != null) {
Object[] parameterArray = new Object[parameterMappings.size()];
MetaObject metaObject = parameterObject == null ? null : MetaObject.forObject(parameterObject);
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
PropertyTokenizer prop = new PropertyTokenizer(propertyName);
if (parameterObject == null) {
value = null;
} else if (ms.getConfiguration().getTypeHandlerRegistry()
.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (propertyName.startsWith(ForEachSqlNode.ITEM_PREFIX)
&& boundSql.hasAdditionalParameter(prop.getName())) {
value = boundSql.getAdditionalParameter(prop.getName());
if (value != null) {
value = MetaObject.forObject(value)
.getValue(propertyName.substring(prop.getName().length()));
}
} else {
value = metaObject == null ? null : metaObject.getValue(propertyName);
}
parameterArray[i] = value;
}
}
ibatisSql.setValue(parameterArray);
}
return ibatisSql;
}
这样,SQL 和参数都是从 MyBatis 的 XML 配置里来的,开发人员只需要维护 XML,不需要在 Java 代码里拼接 SQL。
2. 自己创建 PreparedStatement 执行查询
拿到 SQL 和参数后,不走 MyBatis 的执行流程,而是直接从当前连接创建 PreparedStatement:
Connection conn = session.getSession().getConnection();
stmt = conn.prepareStatement(sql);
Object[] value = ibatisSql.getValue();
if (value != null && value.length > 0) {
for (int i = 0; i < value.length; i++) {
stmt.setObject(i + 1, value[i]);
}
}
result = stmt.executeQuery();
注意 finally 块里没有关闭 stmt——这是刻意的。因为 ResultSet 必须保持打开状态,交给后面的 write2file() 逐行消费。资源的关闭在写入完成后由调用方负责。
3. getBigResult() 有两个重载版本
一个从 AppContext 取连接,一个接收外部传入的 bSession。这是因为有些场景下连接已经在事务里了,不能重新开。
第二步:write2file() — 游标逐行读取,边读边写文件
public static void write2file(String filename, ResultSet result) throws Exception {
ResultSetMetaData meta = result.getMetaData();
FileWriter fw = new FileWriter(new File(filename));
BufferedWriter bw = new BufferedWriter(fw);
HashMap<String, DataStore> stmap = AppContextContainer.getAppContext().getStoreMap();
DataStore dsHeader = stmap.get("header");
DataStore dsDecoder = stmap.get("decoder");
HashMap<String, Object> decKeys = new HashMap<String, Object>();
for (int a = 0; a < dsDecoder.getRowset().getPrimary().size(); a++) {
String key = (String) dsDecoder.getRowset().getrow(a).getItemValue("name");
decKeys.put(key, key);
}
String rowHeader = "";
List<String> keys = new ArrayList<String>();
if (dsHeader != null) {
int count = dsHeader.getRowset().getPrimary().size();
for (int hh = 0; hh < count; hh++) {
rowHeader = rowHeader + String.valueOf(dsHeader.getRowset().getrow(hh).getItemValue("label"));
if (hh < count - 1) {
rowHeader = rowHeader + "\t";
}
String key = String.valueOf(dsHeader.getRowset().getrow(hh).getItemValue("name"));
keys.add(key);
}
rowHeader = rowHeader + "\n";
bw.write(rowHeader);
} else {
int count = meta.getColumnCount();
for (int i = 1; i <= count; i++) {
rowHeader = rowHeader + meta.getColumnName(i);
if (i < count - 1) {
rowHeader = rowHeader + "\t";
}
String key = meta.getColumnName(i);
keys.add(key);
}
rowHeader = rowHeader + rowHeader + "\n";
bw.write(rowHeader);
}
while (result.next()) {
String row = "";
int count = keys.size();
for (int i = 0; i < count; i++) {
String key = keys.get(i);
Object val = result.getObject(key);
Object dckey = decKeys.get(key);
if (val == null) {
val = "";
}
try {
if (dsDecoder != null && dckey != null) {
for (int a = 0; a < dsDecoder.getRowset().getPrimary().size(); a++) {
if (key.equals(dsDecoder.getRowset().getrow(a).getItemValue("name"))
&& val.equals(dsDecoder.getRowset().getrow(a).getItemValue("col_value"))) {
val = dsDecoder.getRowset().getrow(a).getItemValue("col_name");
if ((val == null)) {
val = "";
}
break;
}
}
}
} catch (Exception e) {
val = "";
}
row = row + String.valueOf(val);
if (i < count) {
row = row + "\t";
}
}
row = row + "\n";
bw.write(row);
}
bw.close();
fw.close();
AppContextContainer.getAppContext().setTextFileName(filename);
}
关键点拆解:
1. 表头从 DataStore 配置获取,不是写死的
前端传过来的数据里包含 header 和 decoder 两个 DataStore:
header:定义了导出列的name(字段名)和label(显示名)decoder:定义了代码翻译规则(比如"1"→"男"、"2"→"女")
这样用户在界面上选择导出哪些列、列名是什么,后端完全不用改。
2. 游标逐行读取 + 逐行写入
while (result.next()) {
// 从 ResultSet 取一行
// 拼成 tab 分隔的字符串
// 写入文件
bw.write(row);
}
result.next() 是 JDBC 游标在数据库端逐行推进,不会把所有数据加载到内存。每读一行,处理一行,写一行。内存中同时只存在一行的数据。
3. 代码翻译(decoder)
政务系统的数据表里大量使用代码值(性别 "1"/"2"、状态 "0"/"1"/"9" 等),导出时需要翻译成中文。decoder DataStore 保存了翻译规则,逐行翻译:
if (dsDecoder != null && dckey != null) {
for (int a = 0; a < dsDecoder.getRowset().getPrimary().size(); a++) {
if (key.equals(dsDecoder.getRowset().getrow(a).getItemValue("name"))
&& val.equals(dsDecoder.getRowset().getrow(a).getItemValue("col_value"))) {
val = dsDecoder.getRowset().getrow(a).getItemValue("col_name");
break;
}
}
}
4. Tab 分隔格式,直接能被 Excel 打开
输出的文件是 Tab 分隔的文本文件,扩展名用 .xls,Excel 可以直接打开。不需要用 POI 或 jxl 生成真正的 Excel 文件,避免了大 Excel 文件的内存开销。
完整调用流程
// 业务代码中的调用方式
ResultSet result = DBUtil.getBigResult(XxxMapper.class, "selectAllData", dao);
String filename = request.getSession().getServletContext().getRealPath("/")
+ "export/" + System.currentTimeMillis() + ".xls";
DBUtil.write2file(filename, result);
// 前端通过 AppContext 中存储的 filename 下载文件
整个过程中:
getBigResult()返回的是 JDBC 的ResultSet游标,数据还在数据库端write2file()逐行从游标读取,逐行写入文件- 内存中始终只有一行的数据量
和标准 getDao() 的对比
| 对比项 | getDao()(标准方式) | getBigResult() + write2file() |
|---|---|---|
| SQL 来源 | MyBatis XML | 同样是 MyBatis XML |
| SQL 解析 | MyBatis 内部处理 | getIbatisSql() 手动解析 |
| 执行方式 | session.getMapper() → 反射 |
JDBC PreparedStatement |
| 结果处理 | 全量映射成 List<Dao> |
ResultSet 游标逐行读取 |
| 内存占用 | 50万行 ≈ 1~2 GB | 始终只有一行数据 |
| 写入方式 | 拿到 List 后再遍历写文件 | 边读边写,零积压 |
| 适用场景 | 常规查询、分页查询 | 大数据量导出 |
为什么这样做
为什么不直接用 MyBatis 的流式查询?
MyBatis 其实支持流式查询(ResultHandler),但在我写这段代码的年代(大约 2012 年前后),这个特性不太好用,而且我的框架在 getDao() 里做了很多额外的事情(MongoDB 路由、分页拦截、加解密等),直接改 getDao() 会影响其他功能。
所以我的选择是:单独写一个 getBigResult() 方法,专门处理大数据导出的场景,和标准的 getDao() 完全隔离,互不影响。
为什么不导出真正的 Excel?
POI 的 SXSSFWorkbook 可以流式写 Excel,但它是后来才加入 POI 的,而且我对它的稳定性没有把握。Tab 分隔文本是最简单、最可靠的方式,所有版本的 Excel 都能打开,导出速度也最快。
为什么 getBigResult() 不关闭 Statement?
因为 ResultSet 依赖 Statement,Statement 依赖 Connection。如果在这里关闭了 Statement,ResultSet 就失效了,write2file() 里调用 result.next() 会报错。资源的管理必须由调用方统一控制。
决策原则
该绕的时候必须绕——不要被框架的"标准流程"绑架。
MyBatis 的标准流程适合 CRUD 和分页查询,但在大数据导出场景下,它的全量加载模型是致命的。我的做法是保留 MyBatis 的 SQL 管理能力(XML 定义 + 参数解析),但绕开它的结果集映射,直接用 JDBC 游标。这样 SQL 的维护还是集中的,但内存问题彻底解决了。
你在项目中遇到过大数据导出 OOM 吗?是怎么解决的?欢迎评论区聊聊。
作者:许彰午 | 非科班野生程序员,深耕政务信息化20年
标签: #Java #MyBatis #JDBC #大数据导出 #OOM #游标 #性能优化 #政务信息化
更多推荐
所有评论(0)