1. 从一次线上 OOM 说起:千万级结果集把 4G 堆直接打满
先说结论:MySQL JDBC 默认会把整个结果集一次性拉到 JVM 内存里,useCursorFetch=true没配,defaultFetchSize就是摆设。这个坑我在一个数据同步服务上踩得很实在——服务跑了半年没事,数据量从百万涨到千万后,Full GC 越来越频繁,最后进程被系统直接 kill。
现象很有代表性:老年代占用一路飙升,GC 后几乎不回落;jstack抓下来一堆线程卡在com.mysql.jdbc.MysqlIO.readSingleRowSet和unpackBinaryResultSetRow上。代码逻辑本身没改,SQL 就是一句SELECT * FROM big_table,用ResultSet遍历,没分页。问题不在 SQL 写法,而在连接 URL 少了两个参数。
这篇文章面向正在用 Java + JDBC 连 MySQL 的后端同学,尤其是做数据同步、报表导出、批量清洗这类会碰大结果集的场景。我会把故障链路拆开讲清楚:为什么默认行为会 OOM、useCursorFetch和defaultFetchSize到底怎么配合、连接 URL 骨架怎么写、怎么用jstack和堆转储验证修复效果,最后顺带说下在 TaoToken 统一 Key 通道下接入模型辅助排查时 JDBC 配置要注意什么。
2. 根因拆解:MySQL JDBC 默认的"全量拉取"机制
2.1 默认模式为什么危险
MySQL Connector/J 在不开启游标的情况下,执行查询后会把服务端返回的所有行一次性读进客户端内存,然后ResultSet.next()只是在这块已经加载好的数据上移动指针。也就是说,内存峰值出现在executeQuery()返回的那一刻,而不是你遍历的时候。
算一笔账:单行 1KB,一千万行就是约 10GB。JVM 堆只给了 4G,executeQuery()还没返回就已经在往堆里塞数据,OOM 是必然的。更隐蔽的是,这种 OOM 往往不是立刻发生——数据量小的时候完全正常,等表涨到某个量级才突然爆发,很容易被误判成"最近没改代码怎么出问题了"。
2.2 useCursorFetch 与 defaultFetchSize 的配合关系
这两个参数是绑定使用的,单独配一个都没用:
| 参数 | 作用 | 不配的后果 |
|---|---|---|
useCursorFetch=true | 启用服务端游标,驱动分批向 MySQL 拉取数据 | 驱动走全量加载,defaultFetchSize被忽略 |
defaultFetchSize=10000 | 指定每批拉取的行数 | 即使开了游标,也可能按驱动默认值走,批次不合理 |
开启游标后,客户端内存里只保留当前批次(比如 10000 行)的数据,遍历完这批再拉下一批。内存占用从"结果集总大小"变成"单批次大小",这是质的变化。
注意:
useCursorFetch=true需要服务端支持游标,MySQL 5.0+ 都没问题。另外它和useServerPrepStmts=true搭配时行为更可控,建议一起开。
3. 可复制的 JDBC URL 配置骨架
3.1 优化前后的 URL 对比
先看踩坑时的配置,很多项目模板里就是这么写的:
# 优化前:缺失游标参数,大结果集必炸 jdbc.druid.url=jdbc:mysql://127.0.0.1:3306/dbname?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true修复后的完整骨架:
# 优化后:开启游标 + 指定批次大小 jdbc.druid.url=jdbc:mysql://127.0.0.1:3306/dbname?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useCursorFetch=true&defaultFetchSize=10000&useServerPrepStmts=true3.2 fetchSize 取值怎么定
defaultFetchSize不是越大越好,也不是越小越好:
- 太小(如 100):数据库往返次数暴增,网络和解析开销拖慢整体速度;
- 太大(如 10 万):单批就占不少内存,等于把问题缩小了但没解决;
- 建议区间:1000 到 10000,按单行大小调整。单行宽(比如带大文本字段)就往 1000 靠,单行窄就往 10000 靠。
如果不想改全局默认值,也可以在代码里对特定 Statement 单独设置:
PreparedStatement ps = conn.prepareStatement(sql); // 针对这条大查询单独指定批次,覆盖 URL 里的 defaultFetchSize ps.setFetchSize(5000); ResultSet rs = ps.executeQuery(); while (rs.next()) { // 逐行处理,内存里只有当前批次 }3.3 在 TaoToken 统一 Key 通道下接入排查辅助
排查这类 OOM 时,我习惯用模型帮忙读堆转储摘要、分析线程栈。TaoToken 提供统一 Key 通道,把模型对话、Coding Plan、API Keys 收敛到一个入口,省得在多个平台之间切来切去。接入方式很简单,拿到 Key 后按文档配置即可:
- 模型对话入口:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- Coding Plan(长期编码/Agent 场景):https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
API 基础地址是https://taotoken.net/api,注意这个不带 UTM 参数。需要说明的是,TaoToken 在这里的角色是辅助排查和编码提效的通道,JDBC 参数该配还得配,它不会替你改连接 URL。
4. 验证修复:jstack、堆转储与参数对比
4.1 复现与观察
先写一个最小复现类,故意用全量模式跑大表:
public class OomRepro { public static void main(String[] args) throws Exception { String url = "jdbc:mysql://127.0.0.1:3306/dbname?useUnicode=true&characterEncoding=UTF-8"; try (Connection conn = DriverManager.getConnection(url, "user", "pwd"); PreparedStatement ps = conn.prepareStatement("SELECT * FROM big_table"); ResultSet rs = ps.executeQuery()) { int count = 0; while (rs.next()) { rs.getInt("id"); count++; } System.out.println("rows=" + count); } } }用-Xmx512m启动,很快就能看到 OOM。此时抓线程栈:
jps -l jstack <pid> > stack.txt栈里会大量出现MysqlIO.readSingleRowSet、readAllResults这类调用,说明驱动正在一次性读取全部结果。
4.2 修复后对比
把 URL 换成带游标参数的版本,同样-Xmx512m再跑:
jdbc:mysql://127.0.0.1:3306/dbname?useUnicode=true&characterEncoding=UTF-8&useCursorFetch=true&defaultFetchSize=10000&useServerPrepStmts=true结果:千万行数据能平稳遍历完,堆占用稳定在批次大小对应的水位,不再随总行数线性增长。如果想进一步确认,可以在运行中导出堆转储:
jmap -dump:format=b,file=heap.hprof <pid>用分析工具打开,对比修复前后byte[]和结果集相关对象的占比,修复后不会再出现一个巨大的结果集缓冲区。
4.3 参数对比小结
| 配置项 | 修复前 | 修复后 |
|---|---|---|
| useCursorFetch | 未配置 | true |
| defaultFetchSize | 未配置 | 10000 |
| useServerPrepStmts | 未配置 | true |
| 内存峰值 | 随结果集线性增长 | 稳定在单批次水位 |
| 千万行表现 | OOM | 正常遍历 |
5. 本篇常见错排查
配了 defaultFetchSize 但没开 useCursorFetch:这是最常见的误配。defaultFetchSize只有在游标模式下才生效,单独配它等于没配,驱动照样全量拉取。两个参数必须成对出现。
以为加了 LIMIT 就安全:LIMIT能限制单次返回行数,但如果业务逻辑是循环分页查询再拼装,内存里累积的中间结果照样可能撑爆堆。游标解决的是单次查询的内存问题,两者场景不同。
fetchSize 设成 Integer.MIN_VALUE:MySQL 驱动里setFetchSize(Integer.MIN_VALUE)是一种流式读取的特殊写法,但它和useCursorFetch是两条不同的路径,混用容易出意外。统一用useCursorFetch=true+ 正数defaultFetchSize更稳。
连接池把参数吃掉了:Druid、HikariCP 等连接池如果自己拼 URL,要确认参数确实透传到了底层驱动。排查时可以在获取连接后打印conn.getMetaData().getURL()核对。
只改配置没重启:连接池里的旧连接还带着老参数,改完 URL 要重启应用或让连接池重建连接,否则你测的还是旧行为。
堆转储文件太大打不开:jmap -dump出来的 hprof 可能几个 G,本地分析工具内存不够。可以先用jhat或轻量工具看摘要,或者只 dump 存活对象(jmap -dump:live)。
6. 收尾:把参数写进模板,别靠记忆
这类问题的麻烦之处在于它有潜伏期——数据量小的时候一切正常,等量级上来才爆发,而那时候你往往已经忘了连接 URL 里少了什么。我的做法是把useCursorFetch=true&defaultFetchSize=10000&useServerPrepStmts=true直接写进项目的 JDBC URL 模板,新服务默认带上,省得下次再踩。
排查思路上,jstack看线程卡在哪、jmap看堆里谁在占内存,这两个动作基本能定位到是不是结果集全量加载。确认后改 URL、重启、用同样的大表复跑一遍,内存曲线平了就说明对了。需要模型辅助读栈或生成排查脚本时,从 API Keys 页面拿 Key 走统一通道就行,配置本身还是落在你的 JDBC URL 上。