上个月排查线上一个导出接口的故障,印象特别深。订单表数据逼近千万量级后,导出模块一次性把全量结果集捞进Java堆内存,直接触发了OOM,服务半夜重启两次。后来我把查询改成MySQL游标配合JDBC流式读取的写法,堆内存稳定控制在200MB以内,同样规模的导出任务从原来的七八分钟压到三分半,GC也平稳了。这篇文章就把这次优化涉及到的游标原理、两种落地方式、参数配置和踩过的坑完整整理一遍。如果你是做Java后端、经常要处理大结果集同步、导出、批处理的场景,这篇文章值得看完再动手改代码。
1. 项目背景与问题本质:大结果集一次性加载为什么扛不住
很多团队一开始写大查询都是最直觉的方式,执行一个select把结果全部装进List,然后foreach处理。数据量在几十万条的时候问题不大,一旦到了几百万、上千万,这个写法的所有隐患都会集中爆发。
1.1 从一次OOM事故说起
当时导出模块的伪代码大概长这样:
java复制public void exportOrders(LocalDate start, LocalDate end) {
List<OrderDO> orders = orderMapper.selectByTime(start, end);
for (OrderDO order : orders) {
// 生成Excel行 / 推送下游 / 写临时文件...
}
}
selectByTime这个查询在MySQL里能正常执行,索引也没问题。问题出在结果集本身:一千万订单不是一次性全部返回,但MySQL JDBC驱动默认行为是等查询执行完毕后,把全部结果一次性从服务端拉到客户端内存里。JVM堆给了2G,光这一个List就可能吃掉1.5G,再叠加其他接口流量,OOM几乎是必然的。
服务端不停Full GC、接口响应越来越慢、最后直接OutOfMemoryError,这个故障链路在监控里看得清清楚楚。事后复盘时我们意识到,问题不在SQL本身,而在“数据搬运方式”——一次性搬运的管道太粗,内存这个蓄水池装不下。
1.2 为什么分页查询替代不了游标
有人会说,既然一次性加载不行,那把大结果集拆成 limit 分页不就行了?我在项目里确实先试过这个方案,但很快发现了几个更深层的坑。
第一个坑是深度翻页性能退化。LIMIT 1000000, 1000 这类深分页,MySQL需要先扫描一千万行,再丢弃前面九百九十九万九千行,才能返回最后那一千行。这个成本随着页码变大线性上升,导出任务越跑越慢。线上实测50万条数据分页导出,用offset方式跑了近12分钟,其中有8分钟都耗在深翻页扫描上。
第二个坑是一致性问题。导出任务往往要好几分钟,如果业务表在导出过程中有新增、删除、状态变更,基于偏移量的分页很容易出现重复行或者漏数据。比如排序列上有新数据插入,分页游标会偏移错位,某些行就被重复导出或跳过。要解决一致性问题就得开启事务并锁表或快照,代价反而更大。
第三个坑是连接和事务占用。分页方案虽然不占内存,但长时间占用一个数据库连接反复执行查询,连接池里的连接被“长期持有”,在高并发场景下很容易拖垮整个连接池。
所以分页方案只适合“给用户看的前台分页”这种小结果集场景,不适合“后台批量处理大数据集”的场景。这时候游标方案的价值就体现出来了:服务端一边查,客户端一边读,内存里永远只保留一小批数据,数据一致性靠一个事务getSnapshot完成,连接占用也只是一次查询的时长。用生活中的例子理解,一次性加载像用一个大水缸把整条河的水先存放好再慢慢用,游标像把水管直接接到客户端,开水龙头边流边接,水缸只需要小小一个就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 游标方案设计:MySQL游标的工作机制与适用边界
MySQL里的“游标”这个词其实会在两个层面出现:一个是存储过程里的CURSOR,另一个是JDBC驱动通过服务端游标实现的流式读取。两者底层机制相通,但使用场景差别很大。新手容易混淆,我先把原理讲清楚。
2.1 游标的工作机制
游标本质上是“客户端与MySQL服务端之间的一条数据流水线”。服务端执行完查询后,并不会拼命把所有结果一口气丢给客户端,而是维护一个结果集游标指针,客户端按需调用fetch指令,服务端才把游标指向的下一批数据发送过来。
可以类比成外卖配送:一次性加载是商家把一百份餐全塞进你的后座,后座装不下就洒了;游标是配送员一份一份(或一批一批)递给你,你手里始终只有一两份餐的量,全吃完再接下一份。
MySQL中游标有几个特性需要注意:只读(不能通过游标修改数据)、不可滚动(只能向前遍历,不能回退到上一行)、敏感度取决于实现(JDBC流式读取的敏感度是ASENSITIVE,结果集快照是否实时取决于语句类型)。这些特性决定了游标适合顺序遍历批处理,不适合随机访问。
2.2 使用原则:什么时候该用游标,什么时候别用
我自己总结了一个简单的判断标准:如果结果集可能超过JVM堆可用内存的十分之一,或者单条记录较大且总条数超过几十万,就别再想着一次性加载了。 具体适合游标的场景包括:
- 大数据量导出文件(Excel/CSV)
- 异构数据同步(从MySQL搬到ES、HBase、另一个库)
- 批量数据清洗、校验、补偿任务
- 历史数据归档或清理
- 存储过程内部的逐行计算或状态流转
相反,用游标反而是负优化的场景也有不少。比如前台列表页的分页展示,本身只需要当前页几十条数据,完全用不到游标;比如小结果集的简单查询,结果集本身就在驱动的一两个包内返回,强行开流式读取反而增加网络交互次数,性能更差。
还有一点必须在设计阶段就想清楚:游标方案是“每一行都走一次业务代码”,如果业务处理本身耗时很高(比如每行都调一次外部接口),那么瓶颈会转移到业务处理上,游标带来的数据层优化再大也被外部IO吃掉。我后来优化的导出逻辑里,业务侧改成批量攒够1000行一起写文件,效果远比单纯换游标更明显。
2.3 事务与锁对游标的影响
游标方案的隐含前提是“结果集在遍历期间保持一致”。MySQL默认的REPEATABLE READ隔离级别下,普通SELECT走的是MVCC快照读,游标打开后看到的是同一个快照,数据在遍历过程中不会受到其他事务提交的影响,这是它比分页方案好在一致性的原因。
但要注意,如果SQL写成了SELECT ... FOR UPDATE,游标遍历期间每一行都会持有锁,直到事务提交才释放。结果集越大、遍历越慢,锁持有的时间就越长,很容易引发其他写入请求堆积甚至死锁。我有一次做批量回滚任务就踩过这个坑,FOR UPDATE配合流式读取把一张核心表的写操作整整堵了三分钟,后来改成不在游标里加锁、用乐观锁做条件更新才解决。如果你的场景必须加锁,尽量把事务范围压缩到最小,或者改用单行更新+条件判断,避免大范围持锁。
3. 两种落地实操:存储过程游标与JDBC流式读取
实际干活的时候,游标优化有两种常见实现方式。一种是纯数据库侧处理,把逻辑写在存储过程里,用MySQL的CURSOR语法;另一种是Java侧处理,用JDBC的流式读取能力,让数据像流水一样流进Java代码。我分别说完整步骤和关键细节。
3.1 存储过程游标的完整写法
存储过程游标适合“不需要把数据拿回Java,只想在数据库内部做批量计算、归档、清理”的场景。下面是一个完整的示例:把一年前的订单数据批量搬运到历史表。
sql复制DELIMITER $$
DROP PROCEDURE IF EXISTS sp_archive_orders$$
CREATE PROCEDURE sp_archive_orders()
BEGIN
DECLARE v_done INT DEFAULT FALSE;
DECLARE v_order_id BIGINT;
DECLARE v_order_amount DECIMAL(12,2);
-- 1. 声明游标:注意必须声明在变量之后
DECLARE cur_orders CURSOR FOR
SELECT id, amount
FROM tb_order
WHERE create_time < DATE_SUB(NOW(), INTERVAL 1 YEAR);
-- 2. 声明NOT FOUND处理器,这是循环终止的关键
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
OPEN cur_orders;
read_loop: LOOP
FETCH cur_orders INTO v_order_id, v_order_amount;
IF v_done THEN
LEAVE read_loop;
END IF;
-- 3. 核心处理逻辑:归档插入 + 软删除原数据
INSERT INTO tb_order_archive (id, amount, archived_at)
VALUES (v_order_id, v_order_amount, NOW());
UPDATE tb_order SET deleted = 1
WHERE id = v_order_id;
END LOOP;
CLOSE cur_orders;
END$$
DELIMITER ;
这里有几个新手必踩的雷:
- DECLARE的顺序不能乱:变量声明必须在最前面,然后才是游标声明,最后是HANDLER声明。顺序写反了MySQL直接报语法错误。
CONTINUE HANDLER FOR NOT FOUND必须要有,不然FETCH到结果集末尾后会抛“No data - zero rows fetched”异常,循环无法正常退出。IF v_done THEN LEAVE read_loop;必须放在FETCH之后立刻判断,否则最后一行会被重复处理一次。- 遍历结束后一定要CLOSE,否则连接上的游标不会自动释放,长时间运行会堆积资源。
- MySQL游标不支持直接遍历动态SQL的结果集,也不支持SCROLLABLE回退,只能单调向前。
调用存储过程在Java里没什么特殊处理,直接用JDBC的CallableStatement即可:
java复制try (CallableStatement cs = connection.prepareCall("{CALL sp_archive_orders()}")) {
cs.execute();
}
3.2 JDBC流式读取才是日常主力
大多数业务场景还是需要把数据拿回Java做处理,比如生成Excel、同步到Redis、拼接报文推送MQ。这时候最合适的方案是JDBC流式读取。核心就三句话:URL加参数、Statement指定游标类型、设置fetchSize。
java复制// 连接URL必须追加游标支持参数
// jdbc:mysql://127.0.0.1:3306/testdb?useCursorFetch=true&useServerPrepStmts=true&rewriteBatchedStatements=true
String sql = "SELECT id, order_no, amount, status FROM tb_order WHERE create_time >= ? AND create_time < ? ORDER BY id";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
sql,
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY)) {
ps.setObject(1, startTime);
ps.setObject(2, endTime);
// fetchSize控制服务端每批传输的行数
ps.setFetchSize(1000);
try (ResultSet rs = ps.executeQuery()) {
// 逐行或逐批消费
while (rs.next()) {
// 处理单行数据
}
}
}
关键配置说明:
useCursorFetch=true是打开服务端游标的总开关。这个参数不设置的话,你调setFetchSize(1000)实际上是无效的,MySQL驱动默认仍然会一次性加载全部结果集到客户端内存。useServerPrepStmts=true必须同步打开。服务端游标依赖服务端预编译语句(Server-Side Prepared Statement),MySQL驱动需要用它来创建一个真正的server-side cursor。很多网上教程只说设置第一个参数,漏掉第二个就怎么调都不生效。ResultSet.TYPE_FORWARD_ONLY和ResultSet.CONCUR_READ_ONLY是固定搭档。服务端游标结果集必须只支持向前和只读,你要是为了省事写了TYPE_SCROLL_INSENSITIVE,驱动会直接降级回一次性加载。- fetchSize不是越大越好,也不是越小越好。设置太小(比如1)会导致网络往返次数爆炸,设置太大(比如50000)内存又上去了,失去流式读取的意义。我实测下来单条记录宽度在几百字节时,500到2000是比较合理的区间。
老版本Connector/J还有一种流式读取的偏方:把fetchSize设为Integer.MIN_VALUE,驱动会自动进入流式模式,但是是按一行一行从服务端拉取,性能很差,而且服务端状态管理更复杂,属于历史遗留做法。现在新项目直接用useCursorFetch=true+合理fetchSize才是正路。
3.3 MyBatis项目里怎么用流式游标
很多Java项目用MyBatis,Mapper接口的方法返回值可以直接写Cursor<T>,MyBatis底层会自动帮你走JDBC流式读取。这个方法改造起来非常轻量:
java复制public interface OrderMapper {
// 返回Cursor,MyBatis自动启用流式读取
@Select("SELECT id, order_no, amount FROM tb_order WHERE create_time >= #{start} AND create_time < #{end}")
Cursor<OrderDO> scanOrders(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
}
调用方代码:
java复制// 注意:Cursor必须在SqlSession没有关闭的范围内消费
try (SqlSession sqlSession = sqlSessionFactory.openSession();
Cursor<OrderDO> cursor = orderMapper.scanOrders(start, end)) {
for (OrderDO order : cursor) {
// 逐条处理
}
}
注意这里有个隐藏约束:一个SqlSession同一时间只能开一个游标,如果你在遍历Cursor的循环里再去执行其他查询(哪怕是同一个Mapper里的方法),会报"Can not open a new cursor when another open cursor is active"这类错误。解决方案是先把需要的数据按主键收集成批,循环结束后再执行其他操作,或者开多个独立的SqlSession。整体来说,MyBatis的Cursor方案代码最少,适合快速改造存量项目。
4. 性能实测与参数调优:游标方案到底快在哪、快了多少
光说原理不贴数据不太好说服自己。下面这组数据来自我在测试环境做的一组对比实验,样本量100万行,每行约200字节,MySQL 8.0,JVM堆内存设置512MB,测试服务器8C16G。
4.1 三种查询方式的实测对比
| 方案 | 堆内存峰值 | 全程耗时 | 备注 |
|---|---|---|---|
| 一次性加载到List | 超过1.2GB,直接OOM | 未完成 | 堆外分配后触发Full GC过频繁 |
| LIMIT分页逐页查询 | 约90MB | 约11分钟 | 深翻页扫描耗时严重 |
| 游标流式读取(fetchSize=1000) | 约180MB | 约4分半 | 内存稳定,无STW明显波动 |
一次性加载方式在100万行时已经触顶512MB堆,还没跑到数据处理部分就OOM了。分页方案内存是下来了,但深翻页的时间成本是肉眼可见地高。游标流式读取全程内存曲线基本是一条直线,而且因为处理逻辑是按批写的,网络传输和本地处理还能有一些流水线重叠,最终总耗时反而最短。
这里要强调一点:游标方案真正的核心收益是“稳定”而不是“绝对最快”。在数据量小的时候,一次性加载反而是最快的(省去了多次网络交互)。游标的价值在于数据量变大时性能曲线依然平稳,不会出现某个阈值附近突然崩掉的现象。
4.2 fetchSize到底设置多少合适
fetchSize的选择可以这么估算。假设单条结果集大小平均是R字节,网络往返一次RTT是T毫秒,服务端每批传输行数N,那么整批拉取的耗时大约为:
code复制单批传输耗时 ≈ 网络传输时间 + 应用处理时间
网络传输时间 ≈ (R × N) / 可用带宽 + T
如果N太小,比如1,那么100万行就有100万次网络往返,哪怕RTT只有0.2毫秒,光网络往返就要200秒,这还没算服务端准备和解析的CPU开销。如果N太大,比如50000,一次拉取到客户端的行数太多,堆内存峰值飙升,又违背了流式读取的初衷。
我一般按“批数据体积不超过2MB”来初选N:N = 2MB / R。比如单行200字节,2MB/200 = 10000,但我实际用1000也足够好,因为还要给同时运行的其他线程留内存余量。核心原则是:内存允许范围内,N越大,总耗时越短;但N超过处理速度能消费的范围后,继续增大收益趋近于零。
4.3 网络与数据库侧参数配合
大数据量流式读取时还有几个参数值得一起调:
useCompression=true:结果集在网络传输时压缩,对文本类字段多的场景能省30%到70%带宽。CPU换带宽,值得开。rewriteBatchedStatements=true:如果处理逻辑里还要批量写回数据库(比如批量更新状态),这个参数能把很多条INSERT/UPDATE合并成一条,大幅减少网络往返。- MySQL服务端的
max_allowed_packet和net_buffer_length适当调大,避免大批次传输时出现PacketTooBig异常。线上我一般调到64MB以上。 - 数据库连接的超时时间要留意:
wait_timeout和interactive_timeout。流式读取如果处理太慢,连接长时间空闲也可能被服务端切断,SQL执行就会中断报“Communications link failure”。处理逻辑耗时超过一两分钟的场景,要么调大超时,要么保持每批处理节奏紧凑,别在循环里做无谓的等待。
5. 常见问题与排查技巧实录:游标实战里的坑和避坑方法
这部分内容是我在一线排查过程中积累的“踩坑清单”。每个问题都有真实的生产事故背景,建议收藏起来当速查表用。
5.1 fetchSize设置了却不生效
症状:明明设置了ps.setFetchSize(1000),观察堆内存还是疯狂上涨,和一次性加载没有区别。
排查思路:先检查连接URL是否有useCursorFetch=true和useServerPrepStmts=true;再检查Statement是否用了TYPE_FORWARD_ONLY和CONCUR_READ_ONLY;最后确认MySQL版本和Connector/J版本匹配。MySQL 8.0对应驱动版本不低于8.0.13,老版本驱动对游标支持的Bug不少。
还有一个特殊情况:如果SQL里包含了GROUP BY、ORDER BY、DISTINCT等服务端无法流式返回的语法,服务端可能先要创建临时表完成排序/聚合,再基于临时表建游标。这个过程中临时表数据量依然可能很大,内存压力转移到了服务端而不是客户端,客户端看起来“好像没生效”。解决思路:尽量不要在流式查询里做排序聚合,必要时把排序放到下游处理。
| 现象 | 原因 | 处理方式 |
|---|---|---|
| fetchSize设置后内存仍暴涨 | URL缺useCursorFetch | 补全连接参数 |
| 游标查询报语法错误 | Statement类型不是FORWARD_ONLY | 改成TYPE_FORWARD_ONLY |
| 查询极慢且服务端磁盘IO高 | ORDER BY触发临时表 | 去掉服务端排序,下游处理 |
| 连接被中途断开 | 处理太慢超时 | 调大超时或优化处理批量节奏 |
5.2 流式读取时连接被连接池提前回收
症状:好不容易拿到的ResultSet遍历到一半,突然抛Connection is closed或者Operation not allowed after ResultSet closed。
原因:连接池(HikariCP、Druid等)默认开启了连接空闲回收机制,比如HikariCP的idleTimeout默认10分钟,maxLifetime默认30分钟。流式读取期间连接看似“空闲”(没有SQL执行,只有fetch操作),连接池后台线程可能误判为闲置连接而回收,导致连接底层socket被关闭。
解决方式:把流式读取任务的连接从连接池里“隔离”出来,或者适当调大idleTimeout和maxLifetime;更稳妥的做法是给流式任务单独配一个连接池实例,不和其他高频接口混用,避免长时间占用连接导致连接池被耗尽。
5.3 游标遍历期间其他查询全部变慢
症状:游标任务跑起来后,线上其他接口响应时间明显上涨。
原因:游标查询在服务端打开一个大型结果集,占用了服务端排序缓冲区、临时表空间、网络发送缓冲等资源;如果查询还带有FOR UPDATE,行锁范围大,其他事务的写入会被长时间阻塞。
解决方式:控制游标任务的最大并发数(我用独立线程池限定并发不超过1到2个);错峰执行,把这类大任务放在业务低峰期;尽量不用FOR UPDATE,改为乐观锁或单行条件更新;查询条件务必命中索引,让服务端不必拖拽全表。
5.4 长事务内使用游标导致死锁
症状:游标批处理任务里,如果每条记录都执行UPDATE,偶尔会出现Deadlock死锁报错。
原因:游标遍历是长事务,事务里先SELECT后UPDATE的间隙锁区间可能会和其他事务的写入操作产生循环等待。MySQL InnoDB死锁检测开启时,会直接回滚其中一个事务,业务日志里就能看到Deadlock found错误。
解决方式:降低事务隔离级别到READ COMMITTED(减少间隙锁);或者把“读取”和“更新”拆成两个阶段,先流式读取拿主键列表,提交事务,再按主键分批更新。这个改造把事务粒度从“全部结果集”拆成“一批结果集”,死锁概率大幅下降。
5.5 游标任务并发太高拖垮数据库
这是我踩过最狠的一次坑。当时上了游标方案后觉得自己写出了“内存友好”的代码,就把并发调到了16个线程跑批处理,结果每个线程都打开一个百万级结果集的server-side cursor,数据库服务端内存和临时空间瞬间被打满,整个库响应变慢。游标方案不是“免资源方案”,它只是把内存压力从客户端挪到了服务端和网络层。服务端的游标状态需要维护,结果集数据也要在服务端留存,并发一高,照样雪崩。
正确姿势是给游标任务加信号量控制并发,一般一个实例1到2个并发就够了;如果数据量特别大,考虑按时间范围或主键范围拆成多个小任务排队执行,既能保证内存稳定,也能保护数据库。
最后分享一个真实体会
游标优化做了这么久,我最大的感受是:游标不是银弹,它的核心价值是让你在处理大结果集时“内存可控、行为可预判”。真正把这次优化做成功的,不光是换了个读取方式,同时配套做了三件事:处理逻辑改成批量攒批、任务并发限流、流式连接独立池管理。三件事叠加在一起,才换来线上稳定的效果。如果你也准备在项目里引入游标优化,建议不要只改一个读取方式就完事,先把“数据产生方”和“消费方”的速率匹配起来,把故障边界想清楚,再动手写代码。这样优化完的系统,才是真的能睡得着觉的系统。
