政务大数据导出OOM绕开MyBatisJDBC游标逐行写文件非科班野生程序员深耕政务信息化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. 字符串拼接的二次内存占用即使数据查出来了写到文件的过程中还有问题。常见写法Stringrow;for(inti0;icount;i){rowrowString.valueOf(val)\t;}rowrow\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 执行。publicstaticResultSetgetBigResult(Classcalss,StringMethodNmae,Object...params)throwsutilException{ResultSetresultnull;bSession sessionnull;PreparedStatementstmtnull;try{AppContextcontextAppContextContainer.getAppContext();sessioncontext.getSeesion();if(sessionnull){BeginTrans(false);sessionAppContextContainer.getAppContext().getSeesion();}Stringidcalss.getName().MethodNmae;IbatisSqlibatisSqlgetIbatisSql(null,id,params[0]);StringsqlibatisSql.getSql();if(!ibatisSql.isSelectSql()){thrownewutilException(非Select语句sql内容sql,-9999);}Connectionconnsession.getSession().getConnection();stmtconn.prepareStatement(sql);Object[]valueibatisSql.getValue();if(value!nullvalue.length0){for(inti0;ivalue.length;i){stmt.setObject(i1,value[i]);}}resultstmt.executeQuery();}catch(Exceptione){thrownewutilException(e.getMessage(),-8998);}returnresult;}关键点拆解1. 借用getIbatisSql()解析 SQL 和参数getIbatisSql()方法做了什么它通过 MyBatis 的Configuration.getMappedStatement(id)拿到 XML 里定义的 SQL 语句然后解析出最终的 SQL 字符串含?占位符按顺序排列的参数值数组publicstaticIbatisSqlgetIbatisSql(SqlSessionFactorysqlSessionFactory,Stringid,ObjectparameterObject){IbatisSqlibatisSqlnewIbatisSql();if(sqlSessionFactorynull){sqlSessionFactorysc;}MappedStatementmssqlSessionFactory.getConfiguration().getMappedStatement(id);BoundSqlboundSqlms.getBoundSql(parameterObject);SqlCommandTypesqltypems.getSqlCommandType();ibatisSql.setSqlType(sqltype);ibatisSql.setSql(boundSql.getSql());ListParameterMappingparameterMappingsboundSql.getParameterMappings();ibatisSql.setParameters(parameterMappings);if(parameterMappings!null){Object[]parameterArraynewObject[parameterMappings.size()];MetaObjectmetaObjectparameterObjectnull?null:MetaObject.forObject(parameterObject);for(inti0;iparameterMappings.size();i){ParameterMappingparameterMappingparameterMappings.get(i);if(parameterMapping.getMode()!ParameterMode.OUT){Objectvalue;StringpropertyNameparameterMapping.getProperty();PropertyTokenizerpropnewPropertyTokenizer(propertyName);if(parameterObjectnull){valuenull;}elseif(ms.getConfiguration().getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass())){valueparameterObject;}elseif(boundSql.hasAdditionalParameter(propertyName)){valueboundSql.getAdditionalParameter(propertyName);}elseif(propertyName.startsWith(ForEachSqlNode.ITEM_PREFIX)boundSql.hasAdditionalParameter(prop.getName())){valueboundSql.getAdditionalParameter(prop.getName());if(value!null){valueMetaObject.forObject(value).getValue(propertyName.substring(prop.getName().length()));}}else{valuemetaObjectnull?null:metaObject.getValue(propertyName);}parameterArray[i]value;}}ibatisSql.setValue(parameterArray);}returnibatisSql;}这样SQL 和参数都是从 MyBatis 的 XML 配置里来的开发人员只需要维护 XML不需要在 Java 代码里拼接 SQL。2. 自己创建PreparedStatement执行查询拿到 SQL 和参数后不走 MyBatis 的执行流程而是直接从当前连接创建PreparedStatementConnectionconnsession.getSession().getConnection();stmtconn.prepareStatement(sql);Object[]valueibatisSql.getValue();if(value!nullvalue.length0){for(inti0;ivalue.length;i){stmt.setObject(i1,value[i]);}}resultstmt.executeQuery();注意finally块里没有关闭stmt——这是刻意的。因为ResultSet必须保持打开状态交给后面的write2file()逐行消费。资源的关闭在写入完成后由调用方负责。3.getBigResult()有两个重载版本一个从AppContext取连接一个接收外部传入的bSession。这是因为有些场景下连接已经在事务里了不能重新开。第二步write2file() — 游标逐行读取边读边写文件publicstaticvoidwrite2file(Stringfilename,ResultSetresult)throwsException{ResultSetMetaDatametaresult.getMetaData();FileWriterfwnewFileWriter(newFile(filename));BufferedWriterbwnewBufferedWriter(fw);HashMapString,DataStorestmapAppContextContainer.getAppContext().getStoreMap();DataStoredsHeaderstmap.get(header);DataStoredsDecoderstmap.get(decoder);HashMapString,ObjectdecKeysnewHashMapString,Object();for(inta0;adsDecoder.getRowset().getPrimary().size();a){Stringkey(String)dsDecoder.getRowset().getrow(a).getItemValue(name);decKeys.put(key,key);}StringrowHeader;ListStringkeysnewArrayListString();if(dsHeader!null){intcountdsHeader.getRowset().getPrimary().size();for(inthh0;hhcount;hh){rowHeaderrowHeaderString.valueOf(dsHeader.getRowset().getrow(hh).getItemValue(label));if(hhcount-1){rowHeaderrowHeader\t;}StringkeyString.valueOf(dsHeader.getRowset().getrow(hh).getItemValue(name));keys.add(key);}rowHeaderrowHeader\n;bw.write(rowHeader);}else{intcountmeta.getColumnCount();for(inti1;icount;i){rowHeaderrowHeadermeta.getColumnName(i);if(icount-1){rowHeaderrowHeader\t;}Stringkeymeta.getColumnName(i);keys.add(key);}rowHeaderrowHeaderrowHeader\n;bw.write(rowHeader);}while(result.next()){Stringrow;intcountkeys.size();for(inti0;icount;i){Stringkeykeys.get(i);Objectvalresult.getObject(key);ObjectdckeydecKeys.get(key);if(valnull){val;}try{if(dsDecoder!nulldckey!null){for(inta0;adsDecoder.getRowset().getPrimary().size();a){if(key.equals(dsDecoder.getRowset().getrow(a).getItemValue(name))val.equals(dsDecoder.getRowset().getrow(a).getItemValue(col_value))){valdsDecoder.getRowset().getrow(a).getItemValue(col_name);if((valnull)){val;}break;}}}}catch(Exceptione){val;}rowrowString.valueOf(val);if(icount){rowrow\t;}}rowrow\n;bw.write(row);}bw.close();fw.close();AppContextContainer.getAppContext().setTextFileName(filename);}关键点拆解1. 表头从 DataStore 配置获取不是写死的前端传过来的数据里包含header和decoder两个 DataStoreheader定义了导出列的name字段名和label显示名decoder定义了代码翻译规则比如1→男、2→女这样用户在界面上选择导出哪些列、列名是什么后端完全不用改。2. 游标逐行读取 逐行写入while(result.next()){// 从 ResultSet 取一行// 拼成 tab 分隔的字符串// 写入文件bw.write(row);}result.next()是 JDBC 游标在数据库端逐行推进不会把所有数据加载到内存。每读一行处理一行写一行。内存中同时只存在一行的数据。3. 代码翻译decoder政务系统的数据表里大量使用代码值性别1/2、状态0/1/9等导出时需要翻译成中文。decoderDataStore 保存了翻译规则逐行翻译if(dsDecoder!nulldckey!null){for(inta0;adsDecoder.getRowset().getPrimary().size();a){if(key.equals(dsDecoder.getRowset().getrow(a).getItemValue(name))val.equals(dsDecoder.getRowset().getrow(a).getItemValue(col_value))){valdsDecoder.getRowset().getrow(a).getItemValue(col_name);break;}}}4. Tab 分隔格式直接能被 Excel 打开输出的文件是 Tab 分隔的文本文件扩展名用.xlsExcel 可以直接打开。不需要用 POI 或 jxl 生成真正的 Excel 文件避免了大 Excel 文件的内存开销。完整调用流程// 业务代码中的调用方式ResultSetresultDBUtil.getBigResult(XxxMapper.class,selectAllData,dao);Stringfilenamerequest.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 XMLSQL 解析MyBatis 内部处理getIbatisSql()手动解析执行方式session.getMapper()→ 反射JDBCPreparedStatement结果处理全量映射成ListDaoResultSet游标逐行读取内存占用50万行 ≈ 1~2 GB始终只有一行数据写入方式拿到 List 后再遍历写文件边读边写零积压适用场景常规查询、分页查询大数据量导出为什么这样做为什么不直接用 MyBatis 的流式查询MyBatis 其实支持流式查询ResultHandler但在我写这段代码的年代大约 2012 年前后这个特性不太好用而且我的框架在getDao()里做了很多额外的事情MongoDB 路由、分页拦截、加解密等直接改getDao()会影响其他功能。所以我的选择是单独写一个getBigResult()方法专门处理大数据导出的场景和标准的getDao()完全隔离互不影响。为什么不导出真正的 ExcelPOI 的SXSSFWorkbook可以流式写 Excel但它是后来才加入 POI 的而且我对它的稳定性没有把握。Tab 分隔文本是最简单、最可靠的方式所有版本的 Excel 都能打开导出速度也最快。为什么getBigResult()不关闭 Statement因为ResultSet依赖StatementStatement依赖Connection。如果在这里关闭了StatementResultSet就失效了write2file()里调用result.next()会报错。资源的管理必须由调用方统一控制。决策原则该绕的时候必须绕——不要被框架的标准流程绑架。MyBatis 的标准流程适合 CRUD 和分页查询但在大数据导出场景下它的全量加载模型是致命的。我的做法是保留 MyBatis 的 SQL 管理能力XML 定义 参数解析但绕开它的结果集映射直接用 JDBC 游标。这样 SQL 的维护还是集中的但内存问题彻底解决了。你在项目中遇到过大数据导出 OOM 吗是怎么解决的欢迎评论区聊聊。作者许彰午| 非科班野生程序员深耕政务信息化20年标签#Java #MyBatis #JDBC #大数据导出 #OOM #游标 #性能优化 #政务信息化