ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

踩坑记:MySQL 连接 URL 缺失 useCursorFetch 参数引发的 Java 内存溢出惨案——TaoToken 统一 Key 通道下的 JDBC 配置排查实录

踩坑记:MySQL 连接 URL 缺失 useCursorFetch 参数引发的 Java 内存溢出惨案——TaoToken 统一 Key 通道下的 JDBC 配置排查实录 1. 从一次线上 OOM 说起千万级结果集把 4G 堆直接打满先说结论MySQL JDBC 默认会把整个结果集一次性拉到 JVM 内存里useCursorFetchtrue没配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 堆只给了 4GexecuteQuery()还没返回就已经在往堆里塞数据OOM 是必然的。更隐蔽的是这种 OOM 往往不是立刻发生——数据量小的时候完全正常等表涨到某个量级才突然爆发很容易被误判成最近没改代码怎么出问题了。2.2 useCursorFetch 与 defaultFetchSize 的配合关系这两个参数是绑定使用的单独配一个都没用参数作用不配的后果useCursorFetchtrue启用服务端游标驱动分批向 MySQL 拉取数据驱动走全量加载defaultFetchSize被忽略defaultFetchSize10000指定每批拉取的行数即使开了游标也可能按驱动默认值走批次不合理开启游标后客户端内存里只保留当前批次比如 10000 行的数据遍历完这批再拉下一批。内存占用从结果集总大小变成单批次大小这是质的变化。注意useCursorFetchtrue需要服务端支持游标MySQL 5.0 都没问题。另外它和useServerPrepStmtstrue搭配时行为更可控建议一起开。3. 可复制的 JDBC URL 配置骨架3.1 优化前后的 URL 对比先看踩坑时的配置很多项目模板里就是这么写的# 优化前缺失游标参数大结果集必炸 jdbc.druid.urljdbc:mysql://127.0.0.1:3306/dbname?useUnicodetruecharacterEncodingUTF-8autoReconnecttrue修复后的完整骨架# 优化后开启游标 指定批次大小 jdbc.druid.urljdbc:mysql://127.0.0.1:3306/dbname?useUnicodetruecharacterEncodingUTF-8autoReconnecttrueuseCursorFetchtruedefaultFetchSize10000useServerPrepStmtstrue3.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_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewriteCoding Plan长期编码/Agent 场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewriteAPI 基础地址是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?useUnicodetruecharacterEncodingUTF-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?useUnicodetruecharacterEncodingUTF-8useCursorFetchtruedefaultFetchSize10000useServerPrepStmtstrue结果千万行数据能平稳遍历完堆占用稳定在批次大小对应的水位不再随总行数线性增长。如果想进一步确认可以在运行中导出堆转储jmap -dump:formatb,fileheap.hprof pid用分析工具打开对比修复前后byte[]和结果集相关对象的占比修复后不会再出现一个巨大的结果集缓冲区。4.3 参数对比小结配置项修复前修复后useCursorFetch未配置truedefaultFetchSize未配置10000useServerPrepStmts未配置true内存峰值随结果集线性增长稳定在单批次水位千万行表现OOM正常遍历5. 本篇常见错排查配了 defaultFetchSize 但没开 useCursorFetch这是最常见的误配。defaultFetchSize只有在游标模式下才生效单独配它等于没配驱动照样全量拉取。两个参数必须成对出现。以为加了 LIMIT 就安全LIMIT能限制单次返回行数但如果业务逻辑是循环分页查询再拼装内存里累积的中间结果照样可能撑爆堆。游标解决的是单次查询的内存问题两者场景不同。fetchSize 设成 Integer.MIN_VALUEMySQL 驱动里setFetchSize(Integer.MIN_VALUE)是一种流式读取的特殊写法但它和useCursorFetch是两条不同的路径混用容易出意外。统一用useCursorFetchtrue 正数defaultFetchSize更稳。连接池把参数吃掉了Druid、HikariCP 等连接池如果自己拼 URL要确认参数确实透传到了底层驱动。排查时可以在获取连接后打印conn.getMetaData().getURL()核对。只改配置没重启连接池里的旧连接还带着老参数改完 URL 要重启应用或让连接池重建连接否则你测的还是旧行为。堆转储文件太大打不开jmap -dump出来的 hprof 可能几个 G本地分析工具内存不够。可以先用jhat或轻量工具看摘要或者只 dump 存活对象jmap -dump:live。6. 收尾把参数写进模板别靠记忆这类问题的麻烦之处在于它有潜伏期——数据量小的时候一切正常等量级上来才爆发而那时候你往往已经忘了连接 URL 里少了什么。我的做法是把useCursorFetchtruedefaultFetchSize10000useServerPrepStmtstrue直接写进项目的 JDBC URL 模板新服务默认带上省得下次再踩。排查思路上jstack看线程卡在哪、jmap看堆里谁在占内存这两个动作基本能定位到是不是结果集全量加载。确认后改 URL、重启、用同样的大表复跑一遍内存曲线平了就说明对了。需要模型辅助读栈或生成排查脚本时从 API Keys 页面拿 Key 走统一通道就行配置本身还是落在你的 JDBC URL 上。
返回列表