1. 百万数据导出为什么会 OOM:先把场景说清楚
MySQL 百万数据导出,听起来只是「查出来写 Excel」,真跑起来却经常在半夜把服务打挂。核心检索词就几个:mysql、游标查询、流式查询、分页查询、mybatis 游标查询。它们解决的是同一件事——别把一百万行一次性塞进 JVM 堆内存。
我先把场景摆出来。假设你有一张user表,数据量 120 万行,字段包括 id、username、age、password、phone、address、status、create_time、update_time。业务方要一个 Excel,运营要能直接打开筛选。你写了个最朴素的select * from user,然后List<User> list = userMapper.selectList(null),本地测试 2 万行没问题,上线跑 120 万行,堆内存直接飙到 4G,GC 疯狂 Full GC,最后java.lang.OutOfMemoryError: Java heap space。
问题出在三个地方。第一,MySQL JDBC 驱动默认会把整个 ResultSet 拉到客户端内存,这叫「全量结果集」。第二,MyBatis 的selectList会把所有行映射成对象放进List,又是一份内存。第三,EasyExcel 如果不用ExcelWriter分批写,而是EasyExcel.write(...).sheet().doWrite(list),它内部还会再缓存一份。三份叠加,百万行必炸。
所以导出的本质是流式管道:数据库一行一行吐,程序一批一批写,内存里始终只保留一个批次。下面我把普通查询、游标查询、流式查询、分页查询、MyBatis 游标查询五种方式拆开讲,每种都给可复制的配置和参数,最后用 TaoToken 统一 Key 通道接入 AI 工具做验证动作。
注意:本文所有连接串、账号密码都是本地示例,生产环境请用配置中心或环境变量注入,别硬编码。
2. TaoToken 前置:统一 Key 与 API 通道怎么准备
在写导出代码之前,先把 AI 辅助工具这条链路打通。原因很实际:百万数据导出的排障过程里,你会反复让 AI 帮你读堆栈、改 SQL、生成 MyBatis 映射,如果每个工具都要单独配 Key,切换成本很高。TaoToken 的作用就是提供一个统一的 Key 和 API 通道,让模型对话、编码计划、控制台管理走同一套凭证。
你需要准备的东西:
- 一个 TaoToken 账号,登录官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册。
- 在控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 里创建 API Key。
- 拿到 Key 后,模型对话入口在 https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,编码计划在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
API 基础地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接用于代码里的 base_url。配置方式和你平时用 OpenAI 兼容接口一样,把 base_url 指向它,api_key 填你创建的 Key 即可。
这一步不是注册教程注水,而是后面验证动作的前提。因为导出代码改完,你需要一个稳定的模型通道来跑「让 AI 检查这段 MyBatis 游标配置有没有漏事务」这类动作。Key 拿到手,我们进入正题。
3. 五种查询方式的可复制配置与参数骨架
3.1 普通查询:为什么它最先出局
普通查询就是select * from user配Statement.executeQuery,然后 while 循环读。看起来已经在「一行一行读」了,但 MySQL Connector/J 默认行为是把结果集全部加载到客户端。除非你显式设置fetchSize,否则驱动会一次性拉完。
Connection connection = JDBCUtils.getConnection(); Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("select * from user"); List<UserExcelVO> dataList = new ArrayList<>(); while (rs.next()) { // 映射字段 dataList.add(excelVO); if (dataList.size() >= 15000) { excelWriter.write(dataList, writeSheet); dataList.clear(); } }这段代码在 2 万行以内没问题,因为dataList每 15000 行清一次,内存可控。但ResultSet本身在驱动层是全量的,120 万行时驱动内部缓冲区就把堆吃满了。所以普通查询适合小数据量,百万级直接排除。
3.2 游标查询:setFetchSize 的正确用法
游标查询的关键是PreparedStatement加ResultSet.TYPE_FORWARD_ONLY、ResultSet.CONCUR_READ_ONLY,再设置setFetchSize(2000)。这样驱动会按 2000 行一批从服务端拉取,而不是一次拉完。
PreparedStatement statement = connection.prepareStatement( "select * from user", ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY ); statement.setFetchSize(2000); ResultSet rs = statement.executeQuery(); List<UserExcelVO> dataList = new ArrayList<>(); while (rs.next()) { // 映射 dataList.add(excelVO); if (dataList.size() >= 5000) { excelWriter.write(dataList, writeSheet); dataList.clear(); } }注意fetchSize和dataList批次大小是两个概念。fetchSize控制驱动从 MySQL 拉多少行到客户端缓冲区,dataList控制你写 Excel 的批次。两者都设小一点,内存曲线会很平。实测 120 万行,fetchSize=2000、批次 5000,堆内存稳定在 300M 以内。
3.3 流式查询:Integer.MIN_VALUE 的坑
流式查询和游标查询很像,区别在setFetchSize(Integer.MIN_VALUE)。这是 MySQL Connector/J 的一个特殊约定,表示「逐行流式读取」,驱动不会在客户端缓存结果集。
Statement statement = connection.createStatement( ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY ); statement.setFetchSize(Integer.MIN_VALUE); ResultSet rs = statement.executeQuery("select * from user");这里有个坑:Integer.MIN_VALUE只对Statement生效,如果你用PreparedStatement并传了Integer.MIN_VALUE,某些驱动版本会抛异常或行为不一致。所以流式查询建议用Statement,游标查询用PreparedStatement配正数fetchSize。另外流式读取期间不能在同一连接上执行其他查询,否则会报「Streaming result set is still active」。
3.4 分页查询:limit offset 的深分页问题
分页查询是最容易想到的方案,Page<User> page = new Page<>(current, size),每页 20000 行,循环查。MyBatis-Plus 的写法:
List<UserExcelVO> dataList = new ArrayList<>(20000); int current = 1, size = 20000; Page<User> page = new Page<>(current, size); page.setSearchCount(false); do { dataList.clear(); page.setCurrent(current); IPage<User> iPage = userService.page(page, null); if (CollectionUtils.isEmpty(iPage.getRecords())) { break; } dataList = iPage.getRecords().stream().map(user -> { UserExcelVO excelVO = new UserExcelVO(); BeanUtils.copyProperties(user, excelVO); return excelVO; }).collect(Collectors.toList()); excelWriter.write(dataList, writeSheet); current++; } while (dataList.size() >= size);setSearchCount(false)很重要,否则每页都会跑一次count(*),120 万行分 60 页就是 60 次 count,白白浪费。但分页查询有个致命问题:limit 1000000, 20000这种深分页,MySQL 要先扫描前 100 万行再丢弃,越翻越慢。所以分页适合数据量几十万以内,或者用游标式分页(记住上一页最大 id,where id > lastId limit 20000)。
3.5 MyBatis 游标查询:Cursor 配事务
MyBatis 的Cursor<T>是对 JDBC 游标的封装,写法最优雅,但有两个硬性条件:连接串加useCursorFetch=true,方法加@Transactional。
连接串:
jdbc:mysql://192.168.159.100:3306/ssm?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&useCursorFetch=trueMapper XML:
<select id="findUsers" resultType="com.linging.easyexcel.pojo.User" fetchSize="2000"> select * from user </select>Service 层:
@Transactional @Override public List<User> listMybatisCursorUser() { List<User> list = new ArrayList<>(); try (Cursor<User> cursor = userMapper.findUsers()) { for (User user : cursor) { System.out.println(user); } } catch (IOException e) { throw new RuntimeException(e); } return list; }Controller 里导出:
@Transactional @GetMapping("/exportMpCursor") public void exportMpCursor(HttpServletResponse response) { ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), UserExcelVO.class) .autoCloseStream(true).build(); WriteSheet writeSheet = EasyExcel.writerSheet("Sheet1").build(); List<UserExcelVO> dataList = new ArrayList<>(); try (Cursor<User> cursor = userMapper.findUsers()) { for (User user : cursor) { UserExcelVO excelVO = new UserExcelVO(); BeanUtils.copyProperties(user, excelVO); dataList.add(excelVO); if (dataList.size() >= 5000) { excelWriter.write(dataList, writeSheet); dataList.clear(); } } } catch (Exception e) { throw new RuntimeException(e); } finally { if (excelWriter != null) { excelWriter.finish(); } } }@Transactional不能省,因为 MyBatis 游标依赖连接保持打开状态,事务提交前连接不能归还连接池。fetchSize="2000"写在 XML 里,配合连接串的useCursorFetch=true才生效。实测 120 万行,MyBatis 游标方式堆内存稳定在 250M 左右,比手写 JDBC 游标还省一点,因为 MyBatis 的映射复用做得更好。
3.6 五种方式对照
| 方式 | 关键参数 | 内存表现 | 适用量级 | 主要坑 |
|---|---|---|---|---|
| 普通查询 | 无 | 全量加载,易 OOM | 万级 | 驱动默认全量 |
| 游标查询 | fetchSize=2000 | 稳定 | 百万级 | 需 PreparedStatement |
| 流式查询 | fetchSize=Integer.MIN_VALUE | 最省 | 百万级 | 连接独占 |
| 分页查询 | setSearchCount(false) | 稳定 | 几十万 | 深分页慢 |
| MyBatis 游标 | useCursorFetch=true + @Transactional | 稳定 | 百万级 | 必须加事务 |
4. 验证请求与成功结果:跑一遍看内存曲线
配置写完,怎么验证真的不 OOM?我一般分三步。
第一步,本地起服务,用jconsole或jvisualvm挂上去,看堆内存曲线。跑/user/exportMpCursor,观察堆内存是否在 300M 以内波动,而不是一路爬升到 4G。如果曲线平稳,说明流式管道生效了。
第二步,看日志里的耗时。每种方式在finally里都打了耗时,比如「单线程 mybatis 游标查询导出:xxxxms」。120 万行、9 个字段,MyBatis 游标方式大概在 40 到 70 秒之间,取决于磁盘和网络。如果超过 3 分钟,检查fetchSize是不是设太小导致往返次数过多。
第三步,用 TaoToken 的模型对话通道做一次代码审查验证。把exportMpCursor方法贴进 https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,让它检查「事务注解是否遗漏、Cursor 是否在 try-with-resources 里关闭、fetchSize 是否与连接串匹配」。这一步能帮你抓出肉眼容易漏的配置问题。如果你在长期做编码和 Agent 任务,可以用 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 把这类审查动作固化下来。
成功的结果长这样:Excel 文件生成完整,120 万行数据无缺失,服务堆内存峰值不超过 400M,GC 次数正常,没有OutOfMemoryError,也没有Streaming result set is still active这类报错。
5. 本篇常见错排查
5.1 useCursorFetch=true 没加,Cursor 退化成全量
现象:MyBatis 游标查询跑起来还是 OOM。原因:连接串漏了useCursorFetch=true,驱动不认fetchSize,Cursor内部还是全量结果集。排查:打印连接串确认参数存在,或者用SHOW VARIABLES LIKE 'have_query_cache'之类的方式确认连接属性。修复:在 JDBC URL 里补上useCursorFetch=true。
5.2 @Transactional 漏加,报连接已关闭
现象:java.sql.SQLException: Operation not allowed after ResultSet closed或Connection is closed。原因:MyBatis 游标遍历过程中连接被归还连接池。排查:看 Service 或 Controller 方法上有没有@Transactional。修复:加上@Transactional,并确保遍历在事务内完成。
5.3 fetchSize 设成 Integer.MIN_VALUE 却用了 PreparedStatement
现象:抛异常或行为异常。原因:Integer.MIN_VALUE是Statement的流式约定,PreparedStatement应使用正数fetchSize配useCursorFetch=true。排查:看代码里是Statement还是PreparedStatement。修复:流式用Statement,游标用PreparedStatement。
5.4 分页查询深分页越来越慢
现象:前几页很快,翻到后面每页要几十秒。原因:limit offset, size的 offset 越大,MySQL 扫描丢弃的行越多。排查:看慢查询日志里rows_examined是否远大于rows_sent。修复:改用游标式分页,where id > lastMaxId order by id limit 20000,或者直接用 MyBatis 游标查询。
5.5 ExcelWriter 没 finish,文件损坏
现象:下载的 Excel 打不开或数据不全。原因:excelWriter.finish()没调用,或者异常路径下没走到。排查:看finally块里有没有finish()。修复:把finish()放在finally里,确保任何路径都执行。
5.6 流式读取期间执行其他查询
现象:Streaming result set is still active。原因:同一个连接上,流式 ResultSet 没读完就执行了别的 SQL。排查:看代码里是否在 while 循环内调用了其他 mapper 方法。修复:流式读取期间不要复用同一连接做其他查询,或者把其他查询放到独立连接。
6. 接入文档与 Key 管理:把验证动作固化
排障和接入相关的动作,统一走 API Keys 和接入文档这条线。Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。API 基础地址 https://taotoken.net/api 直接填到你的 HTTP 客户端 base_url 里。
如果你要验证模型输出,用模型对话 https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。如果你在做长期编码或 Agent 任务,用 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 用来看用量和 Key 状态。
最后给一个我踩过的坑:MyBatis 游标查询的fetchSize不要设太大,2000 到 5000 之间比较稳。设成 50000 虽然往返次数少,但驱动缓冲区又会变大,内存曲线会抬头。导出百万数据这件事,核心就一句话——让数据流起来,别让它堆起来。