尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
一次查 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跑一遍大数据量看看老年代曲线会不会从尖峰变成一条平线。
RELATED

相关推荐

免费开源的WPS AI插件 察元AI助手:从 Ribbon 加载到 AI 对话框 URL 拼装

免费开源的WPS AI插件 察元AI助手:从 Ribbon 加载到 AI 对话框 URL 拼装

摘要OnAddinLoad 在加载项生命周期早期注册 ApiEvent、恢复模型索引并异步 loadModelList。openAIAssistantDialog 使用 Util.GetUrlPath 与 GetRouterHash 拼接 SPA 地址,是与 WPS ShowDialog 强耦合的关键路径。关键词OnAddinLoad;ShowDialog;ribbon扩展阅读与维护…

📅 2026/9/1 10:54:55
C++模板元编程现代化:六大降维手段提升代码可读性与维护性

C++模板元编程现代化:六大降维手段提升代码可读性与维护性

1. 项目概述:为什么我们需要“降维打击”模板元编程?如果你在C领域摸爬滚打有些年头,尤其是涉足过性能敏感或框架开发,那么“模板元编程”这个词对你来说,可能既是神兵利器,也是梦魇之源。它能在编译期完成…

📅 2026/9/1 15:53:43
文档智能解析:从非结构化文档到结构化数据的自动化处理

文档智能解析:从非结构化文档到结构化数据的自动化处理

1. 从“文档沼泽”到“数据金矿”:为什么我们需要Docling这样的工具 在任何一个与技术、数据或知识管理打交道的团队里,文档处理都是一个绕不开的“脏活累活”。想象一下这个场景:你手头有一份客户发来的PDF合同,一份同事用Word写…

📅 2026/9/17 17:20:06
MORE NEWS

更多资讯

📰

冒险岛083服务端搭建与排坑实战:从环境配置到源码改造

简介:盛大083完美修复源码是《冒险岛》083版本的一套经深度优化的服务端/客户端源代码,主要面向游戏开发爱好者、私服架设者以及想学习早期端游架构的Java程序员。它以修复已知bug、优化运行效率、增强反作弊能力为主线,为社区提供一份可编译…

📰

Docker部署OpenClaw实战:容器化AI Agent的安装、配置与避坑指南

简介:一份面向开发者的容器化 OpenClaw 安装配置实操指南,着力解决快速迭代环境中部署该人工智能系统时的环境搭建与版本管理难题。内容涵盖手动安装完整步骤、通过 Dockerfile 构建自定义镜像的优化做法,并覆盖网关配置、插件冲突规避、网络…

📰

OpenCV频率域滤波实战:DFT与理想、高斯、巴特沃斯低通滤波器实现

简介:面向图像处理学习者和OpenCV初学者的频率域滤波示例工程,演示高斯、理想、巴特沃斯三种低通滤波器的实现与效果,帮助读者理解频域变换、滤波核构造与频谱操作等核心概念。资源共8个文件,核心为一个C源文件(Low_Pa…

📰

SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0教学资源库系统开发实战

拿到这个项目标题的第一反应,是“经典中的经典”。SpringBoot2 Vue3 MyBatis-Plus MySQL8.0,这套组合在当前的 Java Web 课程设计、毕业设计乃至小型企业级项目中,出镜率实在太高了。标题里带了“含文档”三个字,说明不是光有代…

📰

ThinkPad X1 Carbon Gen 10 CPU选型指南:i5-1240P与i7-1280P实测对比

1. 为什么2022款X1 Carbon的CPU选择这么让人纠结ThinkPad X1 Carbon 2022款(也就是Gen 10)这一代,联想在CPU配置上做了一件让很多人头疼的事:同一台机器,国行和海外版本、不同批次之间,混用了四颗看起来差不…

📰

Flutter BackdropFilter 鸿蒙适配实战:毛玻璃效果与性能优化

1. 先说结论:为什么鸿蒙应用需要毛玻璃效果Flutter 的跨平台能力已经不是什么新鲜话题,但真正把 Flutter 工程跑在鸿蒙设备上、还要做出足够惊艳的视觉效果,这中间的路其实比很多人想象得要长。最近我在做鸿蒙端的 Flutter 应用改造时&#x…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬