MySQL环境下,mybatis使用Cursor:从配置到验证的完整实践
1. 为什么大结果集查询会把内存打爆先说结论MySQL 环境下用 MyBatis 做普通查询默认会把整个结果集一次性读进 JVM 内存几百万行数据直接 OOM。Cursor游标就是解决这个问题的——它让 JDBC 逐批从服务端拉数据而不是一口气全拿回来。我在一个订单对账项目里踩过这个坑单表 800 万行用ListOrder selectAll()查出来做遍历堆内存 4G 直接撑爆GC 日志全是 Full GC。后来改成 Cursor 流式读取内存曲线立刻平了稳定在几百 MB。Cursor 适合谁适合需要遍历大结果集做批处理、导出、对账、数据迁移的场景。不适合的场景是你只需要查几条数据或者需要把结果集反复随机访问——那种情况用普通 List 更省事。这里有个关键前提MySQL 的流式查询不是随便设个fetchSize就生效的。它有一套特定的触发条件配错了就是伪 Cursor数据照样全量加载。下面我把配置骨架、验证方法、常见坑一次讲清楚。2. TaoToken 前置把模型对话和编码辅助接进来在动手改 MyBatis 配置之前我习惯先把调试辅助工具准备好。排查 Cursor 问题时经常需要问模型这段 JDBC 源码为什么这样判断或者让模型帮我 review Mapper 写法。TaoToken 在这里的作用是提供一个统一的模型调用入口省去到处找 Key 的麻烦。官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content如果你只是想快速验证某个模型对 MyBatis 源码的理解直接用模型对话页最方便 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite如果你在做长期的编码工作比如反复调试 Cursor 配置、让 Agent 帮你改 Mapper那 Coding Plan 更合适 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite需要自己管理调用凭证的话去控制台和 API Keys 页面 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteAPI 地址统一是https://taotoken.net/api接入文档在这里 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite用 Claude Code 做编码辅助的Anthropic 兼容入口 https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite这些工具不是必须的但排查 Cursor 这种涉及 JDBC 底层源码的问题时有个能随时问的模型确实省时间。3. 可复制的 MyBatis Cursor 配置骨架3.1 环境与依赖版本先确认版本Cursor 的行为在不同版本间有差异组件版本说明JDK8Cursor 接口在 JDK8 可用MyBatis3.5.2低版本 Cursor 支持不完整mysql-connector-java8.0.228.x 系列流式判断逻辑一致MySQL Server5.7 / 8.0都支持流式结果集Maven 依赖dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.22/version /dependency3.2 Mapper XML 写法核心是fetchSize-2147483648也就是Integer.MIN_VALUE。这个值不是随便写的后面会解释原因。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.demo.mapper.MenuMapper select idcursor resultTypecom.demo.entity.Menu fetchSize-2147483648 select menu_id as menuId, menu_name as menuName from t_menu where menu_id lt; #{menuId} /select /mapper注意fetchSize写在select标签上不是写在全局 settings 里。全局 settings 里没有这个配置项别找错地方。3.3 Mapper 接口返回类型必须是org.apache.ibatis.cursor.Cursor不能是Listpackage com.demo.mapper; import com.demo.entity.Menu; import org.apache.ibatis.cursor.Cursor; public interface MenuMapper { CursorMenu cursor(Integer menuId); }3.4 Service 层SqlSession 类型是关键这一步最容易出错。必须用ExecutorType.REUSE打开 SqlSession用 BATCH 或 SIMPLE 都会导致结果集被提前关闭。package com.demo.service; import com.demo.entity.Menu; import com.demo.mapper.MenuMapper; import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.session.ExecutorType; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Iterator; Service public class MenuService { Autowired private SqlSessionFactory sessionFactory; public void streamMenu() { SqlSession sqlSession sessionFactory.openSession(ExecutorType.REUSE); try { MenuMapper mapper sqlSession.getMapper(MenuMapper.class); CursorMenu cursor mapper.cursor(1000000); IteratorMenu iterator cursor.iterator(); while (iterator.hasNext()) { Menu menu iterator.next(); System.out.println(menu.getMenuId() - menu.getMenuName()); } } finally { sqlSession.close(); } } }如果你用 Spring 的Transactional注意事务边界。Cursor 必须在同一个 SqlSession 生命周期内消费完跨事务会报 Cursor is closed。4. 验证请求与成功结果4.1 为什么必须是 Integer.MIN_VALUEMySQL Connector/J 判断是否启用流式结果集的逻辑在com.mysql.cj.jdbc.StatementImpl#createStreamingResultSetprotected boolean createStreamingResultSet() { return ((this.query.getResultType() Type.FORWARD_ONLY) (this.resultSetConcurrency java.sql.ResultSet.CONCUR_READ_ONLY) (this.query.getResultFetchSize() Integer.MIN_VALUE)); }三个条件同时满足才走流式结果集类型是FORWARD_ONLY、并发模式是CONCUR_READ_ONLY、fetchSize 等于Integer.MIN_VALUE。所以-2147483648不是魔法数字是 MySQL 驱动约定的开关值。4.2 验证游标是否真的生效光看代码跑通不够要确认数据是分批拉的。两个验证方法方法一看内存曲线。用jconsole或jvisualvm挂到进程上跑一个百万行查询。如果内存平稳说明流式生效如果内存阶梯式上涨到峰值说明是伪 Cursor。方法二加日志看拉取节奏。在遍历循环里每 10000 条打一次时间戳int count 0; while (iterator.hasNext()) { Menu menu iterator.next(); if (count % 10000 0) { System.out.println(已处理 count 条, 时间: System.currentTimeMillis()); } }真 Cursor 的时间戳是均匀递增的伪 Cursor 会在开头卡很久全量加载然后瞬间打印完。4.3 成功结果对照跑通后你应该看到控制台逐条打印菜单数据不是一次性刷屏内存占用稳定不随数据量线性增长处理 100 万行耗时比普通查询略长因为网络往返多了但内存安全5. 本篇常见错误排查5.1 报 Cursor is closed原因SqlSession 被提前关闭或者用了ExecutorType.BATCH/SIMPLE。BatchExecutor#doQueryCursor里查询完就关了结果集SimpleExecutor同理。只有ReuseExecutor不关闭能持续从服务端取数据。改成ExecutorType.REUSE即可。5.2 内存还是爆了检查三处fetchSize是否写成-2147483648写成1000无效resultType是否误写成List是否在遍历前把 Cursor 转成了 List。5.3 遍历时报连接超时流式查询会长时间占用连接MySQL 的wait_timeout默认 8 小时一般够用。但如果处理逻辑很慢建议调大net_write_timeout和net_read_timeout。5.4 Spring 事务里 Cursor 失效Transactional默认用 SIMPLE executor。要么手动开 REUSE 的 SqlSession要么在事务方法里用SqlSessionTemplate指定 executorType。注意Cursor 消费完必须关闭 SqlSession否则连接泄漏。用 try-finally 包住。6. 接入与排障资源Cursor 配置本身不复杂难的是排查为什么没生效。遇到 JDBC 源码层面的疑问或者需要模型帮你 review Mapper 和 Service 写法可以用这些入口API Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite接入文档含各语言 SDK 示例https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite快速验证模型对源码的理解用模型对话https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite长期做编码调试和 Agent 辅助用 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteAPI 基础地址https://taotoken.net/api最后补一个实战技巧生产环境用 Cursor 时给遍历循环加个批量提交的节奏控制比如每 5000 条做一次业务侧落库或缓存写入避免长时间持有连接。另外 Cursor 不支持RowBounds分页要分页就自己在 SQL 里加limit别指望 RowBounds 和 Cursor 混用。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →