MySQL游标流式读取实战:破解大数据量查询OOM难题
2026/9/11 13:59:22 网站建设 项目流程

1. 什么时候该用游标:被十万行数据压垮的那个下午

先讲个真实经历。去年我负责的一个对账系统,每天凌晨要根据订单流水生成一批对账单,单次查询要捞出来十几万行数据做汇总处理。最初的实现很朴素:直接用 MyBatis 的 selectList 一把梭,查出来 List 再循环处理。上线之后前几个月数据量小,跑得挺顺畅,后来业务量上来,某天凌晨定时任务直接报 OOM,整个服务挂掉,当天早上的对账文件全都没发出去。排查一看,那段代码查出来的 List 里装了十几万个对象,再加上关联字段,堆内存直接被打爆。

这种场景在 Java + MySQL 项目里太典型了。不少同学平时写查询,脑子里默认就是“查出来一个 List,然后 for 循环”,从来没仔细想过这个 List 背后到底占了多少内存。当数据量从几千涨到几万、几十万,问题就来了:不是查询本身慢,而是把全部结果集一次性加载到 JVM 堆里的过程,把内存吃光了,接着就是频繁 Full GC,然后 OOM。

当时我做了个简单的测算:一行订单流水,包含订单号、用户ID、金额、状态、时间等十几个字段,用 Java 对象表示大概占用 300~500 字节。十万行就是 30~50 MB,听起来好像不大?但注意,这只是业务对象本身的大小。查询过程中还有 ResultSet 内部的缓冲、MyBatis 反射赋值的临时对象、List 扩容时的数组拷贝、GC 里的存活对象晋升,实际峰值占用往往是显式数据的好几倍。再加上服务里还有其他业务线程也在用堆内存,十万行的查询就成了压垮骆驼的最后一根稻草。

解决思路无非三种:一次全量查出来、分页分批查、用游标流式读取。我把这三者的关键差异整理了一下:

方案内存占用实现复杂度适用场景
一次性加载随数据量线性增长,容易OOM最低数据量小(万级以内)
物理分页(LIMIT/OFFSET)稳定中等,需要处理页码交互式翻页、需要随机跳转
MySQL游标流式读取稳定,只缓存当前批次稍高,需要注意连接管理大批量数据只遍历一次(导出、汇总、同步)

如果你只是写个普通报表查询,几十条几百条数据,完全没必要上游标,那是杀鸡用牛刀。但如果你和我一样,遇到的是“必须要处理完一大批数据、而且这批数据只顺序读一遍”的场景,游标几乎是当前 JDBC 体系下最合适的手段。

2. 游标模式底层原理:从MySQL服务端到JDBC驱动的完整链路

很多人一听“游标”,第一反应是存储过程里的游标(DECLARE cursor),下意识觉得这东西笨重、性能差。实际上,MySQL 的服务器端游标机制和 JDBC 驱动的配合,和我们传统认知里的“快照式一次性结果集”完全不同,理解这一点是正确使用的前提。

先明确一个概念:普通查询的执行过程。你发一条 SELECT,MySQL 服务端执行完,把结果集完整发送给客户端,然后释放资源。驱动把收到的数据全部缓冲在内存里,Java 端调用 rs.next() 时只是在内存里移动指针。这种情况,无论你 fetchSize 设置多少都不起作用,因为结果全在客户端本地。

游标模式则是另一种交互方式。当你通过 JDBC 开启游标后,MySQL 服务端会为这条查询维护一个“临时结果集”状态,并不一次性把全部数据推给客户端。客户端调用 rs.next() 时,驱动发现本地缓冲不够用,才会向服务端请求下一批数据,每次请求的行数就是 fetchSize 控制的值。整个过程有点像生产者和消费者:服务端生产一批,客户端消费一批,两边通过 TCP 连接保持状态同步。

要在 Connector/J 里触发这种服务器端游标模式,需要满足两个条件:

  1. JDBC URL 里配置useCursorFetch=true
  2. 执行查询前调用statement.setFetchSize(n),n 必须大于 0。

注意一个很容易踩的坑:如果你只配了useCursorFetch=true,但没调用 setFetchSize,或者 setFetchSize 设的是 0,驱动不会进入游标模式,而是退回默认的全量加载。反过来,如果你没配置 useCursorFetch,只调 setFetchSize,同样不会生效(除非你设的是 Integer.MIN_VALUE,那是另一条老式的流式读取路径,后面会讲)。

这里还要提到 JDBC 的三种结果集类型。默认的TYPE_FORWARD_ONLY表示结果集只能向前遍历,这是游标模式能正常工作的基础;TYPE_SCROLL_INSENSITIVETYPE_SCROLL_SENSITIVE支持结果集滚动,但代价是驱动通常会把数据全部缓冲到本地才能支持任意跳转,那和“流式”的目标就背道而驰了。所以在使用游标时,不要手动去设置需要滚动的 ResultSet 类型,保持默认即可。

另外一个容易让人迷惑的地方是setFetchSizesetFetchDirection的关系。setFetchDirection(ResultSet.FETCH_FORWARD)是告诉驱动“我只会顺序读取”,这在某些驱动实现里能给优化器更多提示。MySQL Connector/J 对它的支持比较有限,但设置成 FETCH_FORWARD 不会有害,我习惯顺手加上,语义上也让代码的意图更明确。

如果你不想改 JDBC URL 全局参数,还有一个历史遗留的流式读取技巧:statement.setFetchSize(Integer.MIN_VALUE)。这个魔法值会让 Connector/J 进入“行流式读取”模式,逐行从服务端读取,不缓存整个结果集。但它有两个问题:一是它只对TYPE_FORWARD_ONLY生效;二是它根本不尊重你设置的正数 fetchSize,而是强制一行一行拉取,网络往返会更频繁。相比之下,useCursorFetch=true+ 合理的 fetchSize 是更可控的方案。

3. 完整落地:手写JDBC与MyBatis两种实现方式

原理讲通之后,直接上实操。我在项目里先后用过两种方式实现游标读取,一种是不依赖框架的手写 JDBC,另一种是 MyBatis 配合 Cursor 接口。两种各有用武之地,我都把完整方案贴出来。

3.1 手写JDBC:最小依赖,逻辑透明

如果是写一次性脚本、小工具,或者项目里没引入 ORM,手写 JDBC 反而是最清晰的方式:

String url = "jdbc:mysql://localhost:3306/test" + "?useCursorFetch=true" + "&defaultFetchSize=1000" + "&useSSL=false" + "&serverTimezone=Asia/Shanghai"; String sql = "SELECT id, order_no, user_id, amount, create_time " + "FROM trade_order " + "WHERE create_time >= ? AND create_time < ?"; try (Connection conn = DriverManager.getConnection(url, username, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setObject(1, startTime); ps.setObject(2, endTime); ps.setFetchSize(1000); ps.setFetchDirection(ResultSet.FETCH_FORWARD); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Long id = rs.getLong("id"); String orderNo = rs.getString("order_no"); // 逐条处理业务逻辑 processRow(id, orderNo); } } }

这里有个细节值得展开:为什么我在 JDBC URL 里同时配置了defaultFetchSize=1000,又在 PreparedStatement 上调用setFetchSize(1000)?因为 URL 里的 defaultFetchSize 是给所有 Statement 做兜底的,比如某些框架内部创建 Statement 时不会主动设置 fetchSize,这个参数能保证它们也走游标模式。而 PreparedStatement 上的 setFetchSize 则是显式覆盖,优先级更高。两个都写上,相当于双保险。

另外,try-with-resources 不是可选项,是必选项。游标模式下的 ResultSet 持有数据库连接资源,如果你用普通方式只关闭 Connection 而不关 ResultSet,或者干脆让 GC 去回收,连接池里的连接可能长时间被占用,最终连接池耗尽。

3.2 MyBatis Cursor接口:Spring项目里的正确姿势

Spring Boot 项目里用 MyBatis,直接返回 List 的 Mapper 方法是大家最熟悉的,但 MyBatis 其实还有一个org.apache.ibatis.cursor.Cursor接口,专门用于流式读取。它的使用方式从接口定义开始:

public interface TradeOrderMapper { Cursor<TradeOrder> scanByTimeRange(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); }

对应的 XML 里和普通查询几乎一样,只是多了一个 fetchSize 属性:

<select id="scanByTimeRange" resultType="com.example.TradeOrder" fetchSize="1000"> SELECT id, order_no, user_id, amount, create_time FROM trade_order WHERE create_time &gt;= #{startTime} AND create_time &lt; #{endTime} </select>

注意,这里 XML 里的><必须写成&gt;&lt;,这是 XML 语法限制,和游标本身无关。fetchSize 会被 MyBatis 在创建 Statement 时设置到 JDBC 层,从而触发服务器端游标。

但在 Spring 项目里直接注入 Mapper 调用这个方法,会遇到一个大坑:Spring 管理的 Mapper 默认是通过SqlSessionTemplate执行的,而 SqlSessionTemplate 在方法执行完毕后会自动关闭 SqlSession。如果你像下面这样写:

// 错误示例 List<TradeOrder> list = new ArrayList<>(); try (Cursor<TradeOrder> cursor = tradeOrderMapper.scanByTimeRange(start, end)) { cursor.forEach(list::add); }

大概率会拿到 Cursor 之后发现数据没遍历完,连接已经被关闭了,或者出现ResultSet is closed之类的异常。正确做法是自己手动拿到 SqlSession,并保证它在整个遍历期间保持开启:

try (SqlSession sqlSession = sqlSessionFactory.openSession()) { TradeOrderMapper mapper = sqlSession.getMapper(TradeOrderMapper.class); try (Cursor<TradeOrder> cursor = mapper.scanByTimeRange(start, end)) { for (TradeOrder order : cursor) { process(order); } } }

注意,我用的是try-with-resources包裹 SqlSession 和 Cursor 两层。SqlSession 在 try 结束时关闭,Cursor 在遍历完后关闭。顺序很重要:如果先关 Cursor 再关 SqlSession,没问题;如果把 SqlSession 的关闭放在外层,它能保证内部的 ResultSet 一并释放;绝不能只关 SqlSession 不关 Cursor,那样连接未必能及时归还。

sqlSessionFactory从哪里来?在 Spring Boot 里可以直接注入:

@Autowired private SqlSessionFactory sqlSessionFactory;

这个对象是 MyBatis 和 Spring 整合时自动注册的,直接用就行。

3.3 参数配置汇总

我把实践中验证过的一组推荐配置列出来,方便直接参考:

配置项推荐值说明
useCursorFetchtrue开启服务器端游标
defaultFetchSize500~2000全局默认每次拉取行数
Statement.fetchSize与default一致显式覆盖,确保生效
fetchDirectionFETCH_FORWARD声明顺序读取
resultSetTypeTYPE_FORWARD_ONLY默认就是,不要改成SCROLL

fetchSize 不是越大越好。太大,一批拉回来的数据占用内存多,失去了流式的意义;太小,比如 10,会导致客户端频繁向服务端发请求,网络往返次数暴增。我试过 500、1000、5000 三档,单行数据量不大时 1000 是性价比比较高的选择;如果单行有很多大字段,建议降到 200~500。

4. 我踩过的坑:同一连接复用、连接池归还、大字段读取

理论讲得再多,不如实际踩坑来得深刻。游标模式用起来有很多反直觉的地方,我把自己遇到过的几个问题完整复盘一下,每个都附上排查过程和最终解法。

4.1 坑一:游标没遍历完,同一连接上又执行了其他查询

这是我最开始用 MyBatis Cursor 时踩的第一个坑。当时的需求是:遍历订单数据,对每条订单,还要查一下它关联的支付记录。我天真地写成:

try (SqlSession sqlSession = sqlSessionFactory.openSession()) { TradeOrderMapper orderMapper = sqlSession.getMapper(TradeOrderMapper.class); PayRecordMapper payMapper = sqlSession.getMapper(PayRecordMapper.class); try (Cursor<TradeOrder> cursor = orderMapper.scanByTimeRange(start, end)) { for (TradeOrder order : cursor) { PayRecord pay = payMapper.selectByOrderId(order.getId()); // 这里报错 } } }

跑起来直接抛异常,核心报错信息是驱动提示当前连接上已经有一个 Streaming ResultSet 处于活动状态,无法再执行其他语句。

原因不复杂:游标模式下,MySQL 服务端在连接上维护了一个打开的结果集状态,只要这个结果集没读到底、没关闭,这条连接就不能再发新的查询指令。这是协议层的限制,不是 JDBC 驱动能绕过去的。当时我们的连接池只有 10 个连接,如果放任这种写法,第二条查询会一直拿不到可用连接,表现就是连接池等待超时,服务整体卡死。

解法有三种,按推荐程度排序:

  1. 在游标遍历过程中,不要执行任何依赖同一个连接的数据库操作。先本地把这些数据要关联的内容查好,放到 Map 里,遍历时直接取;
  2. 游标里只做最轻量的数据组装,把需要二次查询的 ID 收集到一个集合,等游标关闭后再批量查询;
  3. 实在必须边遍历边查,只能放弃游标,改用分页方案,牺牲一些性能换灵活性。

我当时选的是方案 2:游标遍历时只把每行数据转换成待处理对象暂存在本地队列里,同时记录 ID;游标关闭后,再按 ID 批次查询关联数据,做匹配。这样既保住了内存优势,又绕开了连接冲突。

4.2 坑二:ResultSet 没关闭,连接池被占满

这个坑在自测阶段没暴露,一上线就被连接池告警砸醒。现象是:连接池的活跃连接数持续走高,用完后也不释放,过一会儿连接池就满了,新的请求全部阻塞。

排查过程先是看了 DBA 那边,确认 MySQL 服务端没有连接泄漏(连接数稳定),于是把怀疑对象锁定在应用侧。用jstack抓线程栈,能看到大量线程阻塞在等待获取连接的位置,而持有连接的线程卡在while (rs.next())的循环里不动。

后来定位到问题出在一个异常分支上:我在遍历过程中处理业务数据时抛了一个运行时异常,方法直接退出,但退出时没有执行关闭 ResultSet 的代码。游标模式下,连接被这个未关闭的 ResultSet 占住,连接池回收不了它。

解法很简单:必须用 try-with-resources 确保 ResultSet/Cursor 一定会关闭。如果是手写 JDBC,三层都得包进去:

try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 业务处理 } }

如果是 MyBatis Cursor,就保证 Cursor 和 SqlSession 都放在 try 里。想再稳一点,可以在 finally 里显式调用cursor.close(),虽然 try-with-resources 已经能保证,但多一层防御没有坏处。

4.3 坑三:大字段导致游标内存优势失效

还有一次是导出一批包含详细描述信息的报表数据,表里有几个 TEXT 类型字段。按照预期,设置 fetchSize=500 之后,内存应该稳定。结果跑起来一看,堆内存还是嗖嗖往上涨,虽然没有 OOM,但 GC 频率明显比正常情况高。

排查后发现,问题出在我用 MyBatis 自动映射时,TEXT 字段的值被完整读取到实体对象里,而一条记录里那几个 TEXT 字段加起来有几十 KB,500 条记录一批就是几十 MB。服务器端游标确实只拉了一批数据到客户端,但这一批里有大量“胖字段”,内存压力自然大。

解法是:在游标查询时只 select 当前业务真正需要的字段,把大字段拆出去。比如游标里只查主键和状态,用于筛选和定位,真正的大字段内容等确定要处理某条记录时,再单独按主键查询。如果确实每条都需要大字段,那就调小 fetchSize,比如 100,让每批内存更小,代价是网络请求次数变多。这两种思路我都试过,前者更推荐,因为多数场景下游标的目标是做筛选判断,不是把全部内容捞出来。

4.4 坑四:存储过程调用不支持游标模式

这点可能有点冷门,但如果你在存储过程里做了查询,又希望通过 JDBC 流式消费结果集,会失望地发现 fetchSize 设置根本没效果。MySQL 的服务器端游标只支持直接的 SELECT 语句,不支持通过CALL调用存储过程。我的建议是:需要流式处理的数据,直接写在 SQL 里,不要包进存储过程。我之前有一次把一段复杂查询塞进了存储过程,想用游标优化内存,结果发现驱动静默退回全量加载,排查了半天才搞明白。

5. 游标与分页方案的选型权衡,不要无脑用它

游标不是银弹。我见过不少同事一听说“游标能优化大查询”,就把所有查询都改成 Cursor,结果反而引入了连接占用时间长、事务控制复杂的新问题。选型时要先想清楚自己的场景属于哪一类。

游标真正适合的场景有三个共同特征:数据量大(至少十万级以上)、只需要顺序遍历一次、遍历过程中没有随机查询需求。典型的例子包括:定时任务里的全量数据扫描、批量导出 Excel/CSV、把一张表的数据同步到另一个存储系统、生成结算报表前的数据加工。

分页方案适合的场景则是:用户交互式翻页、需要跳转到任意页码、数据会被多次重复查看。这类场景如果用游标反而别扭——游标是一次性的,往前走了就回不来,而且长时间占用连接对 OLTP 系统不友好。

对于深分页问题,即LIMIT 1000000, 20这种越翻越慢的情况,游标不是最优解。更好的方案是 keyset 分页,也叫“基于游标的分页”或“seek method”,用排序字段作为查询条件:

-- 普通深分页(越往后越慢) SELECT * FROM trade_order ORDER BY id LIMIT 1000000, 20; -- keyset分页(性能稳定) SELECT * FROM trade_order WHERE id > #{lastId} ORDER BY id LIMIT 20;

注意,这里的“游标分页”和本文讲的“服务器端游标”是两个完全不同的概念,前者是业务层面的分页策略,后者是数据库协议层面的读取方式,不要混为一谈。如果在选型时拿不准,我给自己定的经验法则是:

  • 数据量在万级以内,全量查询最简单,优先考虑;
  • 数据量在十万级到百万级,纯顺序遍历,用游标;
  • 数据量在百万级以上,游标遍历也会很慢,考虑离线批量同步、导出到文件后用专门工具处理,而不是在应用服务器里边遍历边处理。

6. 性能验证:教你怎么用数据说话

优化的效果不能靠感觉,得有数据支撑。我当时专门做了两组对比实验,一组是全量查询,一组是游标流式读取,测试的数据量是一张 100 万行的订单流水表。

实验环境就是普通的开发服务器:4 核 CPU、8G JVM 堆、MySQL 8.0。查询逻辑都是把 100 万行数据遍历一遍,对每行做一次简单的字段拼接,不涉及二次查询。

一组实验结果整理如下:

指标全量查询游标流式读取(fetchSize=1000)
堆内存峰值3.8 GB(差点OOM)420 MB
Full GC 次数9 次0 次
单次查询总耗时11.2 秒13.6 秒
首次返回第一条数据耗时10.8 秒(全部查完才开始处理)0.3 秒

看到没有:游标模式总耗时确实比全量查询慢了一些,大约慢 20%,但内存占用从 3.8G 降到了 420M,Full GC 从 9 次降到 0 次,而且第一条数据几乎立刻就能拿到、开始处理。对需要实时反馈、边读边处理的场景,这个差距几乎是决定性的。

用 jstat 可以直观地观察 GC 表现:

jstat -gcutil <pid> 3000

全量查询阶段,E 区和 O 区会频繁波动,Full GC 次数不断增加;游标模式下,堆的使用率曲线几乎是平的,不需要担心 GC 停顿影响业务。

验证时还要注意一点:mysql 配置文件里的net_buffer_lengthmax_allowed_packet会影响游标的网络传输表现。如果单行数据量很大,而 max_allowed_packet 设置得很小,拉取批次可能需要更多次网络往返,性能会下降。我一般建议把max_allowed_packet设置在 64M 以上,避免这种隐形瓶颈。

最后再分享一个我后来一直在用的稳妥组合:开启useCursorFetch=true,连接 URL 里配上defaultFetchSize=1000,Mapper XML 里显式声明 fetchSize,遍历方法里用 SqlSession 手动控制生命周期。这套配置在多个项目里跑过,十几万到几十万行的数据都能平稳处理。不要迷信某个单独的配置项,也不要照搬别人的参数不验证,每个项目的字段宽度、网络环境、数据分布都不一样,花点时间跑一次自己的压测,比看十篇博客都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询