一次查 50 万行把老年代打爆:MyBatis 的 fetchSize 不生效,和 Executor 到 ResultSetHandler 的 5 段链路
title: 一次查 50 万行把老年代打爆MyBatis 的 fetchSize 不生效和 Executor 到 ResultSetHandler 的 5 段链路tags: [MyBatis, 源码分析, 性能优化, Java, 后端]category: 后端一次数据导出把生产 JVM 干到 Full GC我们有一个「对账文件导出」的定时任务每次把一张 50 万行的明细表全量捞出来按行写 CSV。代码一直跑得好好的直到有一次表数据涨到 80 万行导出任务所在的应用老年代直接打满连续 Full GC接口大面积超时。最初的写法长这样Select(SELECT id, order_no, amount, create_time FROM settle_detail WHERE biz_date #{date}) ListSettleDetail listByDate(Param(date) String date); // 调用方 ListSettleDetail rows mapper.listByDate(2026-08-01); for (SettleDetail r : rows) { writeCsv(r); // 一次性把 80 万行都装进 List再遍历 }逐行拆解这段第 1 行Select声明了一条全量查询没有分页意图是把当天明细一次性取回。第 2 行返回ListSettleDetail意味着 MyBatis 会先把所有结果行都映射成对象再整体返回给调用方。第 6 行mapper.listByDate返回时这 80 万行对象已经全部驻留在 JVM 堆里。第 7 行才遍历写文件——但此时内存峰值早已在「返回瞬间」到达GC 根本来不及在遍历阶段救场。当时组里第一反应是「加机器、扩内存」但我们没那么大的堆预算于是把 MyBatis 的查询链路从头读了一遍发现真正的问题有两个fetchSize 在我们的 JDBC 配置下根本没生效以及MyBatis 默认把结果集一次性物化到 List。第一段链路SqlSession 到 ExecutorDefaultSqlSession.selectList是最常走的入口它把活儿甩给Executor// DefaultSqlSession.selectList public E ListE selectList(String statement, Object parameter, RowBounds rowBounds) { MappedStatement ms configuration.getMappedStatement(statement); return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); }逐行看第 3 行从全局Configuration里按 statement id 取出MappedStatement这里面装着 SQL、参数映射、结果映射等全部元数据。第 4 行交给executor.query。默认是CachingExecutor包了一层SimpleExecutor或ReuseExecutor前者负责二级缓存后者负责真正的 JDBC 交互。CachingExecutor先查二级缓存没命中再委托给被包装的BaseExecutor// BaseExecutor.query public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) { BoundSql boundSql ms.getBoundSql(parameter); CacheKey key createCacheKey(ms, parameter, rowBounds, boundSql); return query(ms, parameter, rowBounds, resultHandler, key, boundSql); // 走一级缓存 真正查询 }这里要特别注意BaseExecutor维护着一级缓存localCachePerpetualCache它是SqlSession级别的。同一个 SqlSession 内两次相同查询第二次会直接返回一级缓存里的对象——这个机制本身也可能让你在测试里「改了库却查不到新值」不过那是另一个坑本文聚焦执行链路。第二段链路StatementHandler 与 fetchSize真正和 JDBC 打交道的是PreparedStatementHandlerSimpleExecutor.doQuery长这样// SimpleExecutor.doQuery public E ListE doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { Statement stmt null; try { Configuration configuration ms.getConfiguration(); StatementHandler handler configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql); stmt prepareStatement(handler, ms.getStatementLog()); // 建连接、设参数 return handler.query(stmt, resultHandler); // 执行并映射 } finally { closeStatement(stmt); } }关键点藏在prepareStatement里它最终调用 JDBC 的PreparedStatement.setFetchSize// PreparedStatementHandler 在 instantiateStatement 之后 stmt.setFetchSize(fetchSize); // fetchSize 来自 MappedStatement默认是 null逐行拆解fetchSize控制 JDBC 驱动每次从数据库网络缓冲取多少行到客户端。设成 100就分批拉内存平稳不设置MySQL 驱动默认会一次性把结果集全部取回。MyBatis 的fetchSize默认值在MappedStatement里是null最终落到 JDBC 时如果不显式配就用驱动默认——而 MySQL Connector/J 在没开游标时默认等于把整个结果集拉进内存。我们当时的mybatis-config.xml里压根没配defaultFetchSize所以 80 万行被一次性物化进堆。补上配置只是第一步因为 MySQL 还需要在连接串里开useCursorFetchtrue才能让fetchSize真正走服务端游标否则驱动会忽略它。第三段链路ResultSetHandler 把行映射成对象PreparedStatementHandler.query把结果交给DefaultResultSetHandler// DefaultResultSetHandler.handleResultSets public ListObject handleResultSets(Statement stmt) throws SQLException { final ListObject multipleResults new ArrayList(); int resultSetCount 0; ResultSetWrapper rsw getFirstResultSet(stmt); while (rsw ! null) { ResultMap resultMap resultMaps.get(resultSetCount); // 把当前 ResultSet 逐行映射全部 add 进 localResults handleResultSet(rsw, resultMap, multipleResults, null); rsw getNextResultSet(stmt); resultSetCount; } return collapseSingleResultList(multipleResults); // 最终返回一个 List }逐行看第 4 行getFirstResultSet拿到 JDBC 的ResultSet。第 7 行handleResultSet内部会循环rs.next()每一行都createResultObject生成一个 Java 对象并塞进一个List。第 10 行collapseSingleResultList把结果整体返回——这就是「一次性物化」的根源MyBatis 默认把 ResultSet 全部转成对象再给你。也就是说即便你在 JDBC 层开了fetchSize做流式拉取只要调用的是selectListMyBatis 仍然会在内部把所有行映射完才返回。80 万行对象在返回前就已经堆满了老年代。真正的解法用 ResultHandler 流式消费而不是 selectList要切断「全量物化」得绕开selectList改用ResultHandler逐行处理// 用 SqlSession 的 select 重载配合 ResultHandler 逐行处理 try (SqlSession session sqlSessionFactory.openSession(ExecutorType.SIMPLE, false)) { OrderMapper mapper session.getMapper(OrderMapper.class); mapper.listByDate(2026-08-01, resultContext - { SettleDetail row resultContext.getResultObject(); writeCsv(row); // 取一行、写一行、丢一行 }); }// Mapper 接口改成接收 ResultHandler Select(SELECT id, order_no, amount, create_time FROM settle_detail WHERE biz_date #{date}) void listByDate(Param(date) String date, ResultHandlerSettleDetail handler);逐行拆解这套写法第 3 行ResultHandler的回调在DefaultResultSetHandler每映射完一行时就被调用一次对象出来立刻进writeCsv。第 5 行writeCsv(row)处理完就失去强引用下一行映射时被 GC 回收内存峰值从「80 万行」降到「1 行」。第 10 行 Mapper 方法返回类型改成void并接收ResultHandlerMyBatis 就不会再帮你攒 List。配套的 JDBC 层还要在连接串加useCursorFetchtrue并设defaultFetchSize500让驱动也分批拉双管齐下。方案对比四种导出姿势方案内存峰值改动量风险selectList 全量返回80 万行对象全在堆最小大数据量直接 OOM/Full GCselectList 数据库分页受每页大小控制中分页查询次数多、深翻页慢游标 ResultHandler约 1 行中需改 Mapper 签名、注意事务边界直接 JDBC ResultSet 流式约 1 行大脱离 MyBatis 映射要手写我们最后选了「游标 ResultHandler」因为它既保留了 MyBatis 的结果映射又把内存压到最低。分页方案在我们这种「必须全量、顺序写文件」的场景下反而更慢。复盘数字改造前导出 80 万行老年代峰值约 1.6 GB期间触发 3 次 Full GC单次停顿 1.2~1.8 秒应用 P99 从 120ms 飙到 2.3s。改造后同样 80 万行老年代峰值稳定在 120 MB 以内全程零 Full GC导出耗时反而从 47 秒降到 31 秒少了 GC 停顿。全代码库里类似「一次性 selectList 大结果集」的写法静态扫描又找出 5 处全部在后续迭代里改成流式。我的取舍我觉得selectList是个「新手友好但暗藏上限」的 API。小数据量几千行以内用它最省事没有任何问题可一旦结果集可能涨到十万、百万级它就从「方便」变成「隐患」。我更倾向于在代码评审里给「查询返回 List」加一个隐形的规模红线超过一万行的结果强制走ResultHandler或数据库游标。别等 JVM 用 Full GC 提醒你——那个提醒的代价往往是一次线上抖动。思考题你项目里有没有「一次查全表再内存处理」的 Mapper把它的返回类型从List改成voidResultHandler跑一遍大数据量看看老年代曲线会不会从尖峰变成一条平线。