政务大数据导出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 配置获取,不是写死的

前端传过来的数据里包含 headerdecoder 两个 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 依赖 StatementStatement 依赖 Connection。如果在这里关闭了 StatementResultSet 就失效了,write2file() 里调用 result.next() 会报错。资源的管理必须由调用方统一控制。


决策原则

该绕的时候必须绕——不要被框架的"标准流程"绑架。

MyBatis 的标准流程适合 CRUD 和分页查询,但在大数据导出场景下,它的全量加载模型是致命的。我的做法是保留 MyBatis 的 SQL 管理能力(XML 定义 + 参数解析),但绕开它的结果集映射,直接用 JDBC 游标。这样 SQL 的维护还是集中的,但内存问题彻底解决了。


你在项目中遇到过大数据导出 OOM 吗?是怎么解决的?欢迎评论区聊聊。


作者:许彰午 | 非科班野生程序员,深耕政务信息化20年

标签: #Java #MyBatis #JDBC #大数据导出 #OOM #游标 #性能优化 #政务信息化

更多推荐