核心是切断数据库端生成巨型中间结果的源头,再配合JDBC流式消费:改用JOIN替代IN子查询、物化子查询到带索引临时表、游标分页(基于主键/时间戳)替代OFFSET、JDBC启用useCursorFetch=true及setFetchSize(Integer.MIN_VALUE),并监控Created_tmp_disk_tables等指标定位瓶颈。

Java 中 MySQL 处理大量子查询导致的临时表内存溢出,核心不是“让 Java 多扛一点”,而是**切断数据库端生成巨型中间结果的源头**,再配合 JDBC 层合理消费。重点在 SQL 设计和连接行为协同优化,而非单靠分页或调大 JVM。
先砍掉子查询膨胀源:避免 IN (SELECT ...) 全量加载
最常见 OOM 场景是 WHERE id IN (SELECT id FROM huge_table WHERE ...) —— MySQL 会把子查询结果全塞进内存建哈希表。这不是 Java 的错,是 SQL 写法触发了数据库的低效执行路径。
- 改用 JOIN 替代 IN 子查询:让优化器走关联而非物化,例如:
SELECT t1.* FROM t1 JOIN t2 ON t1.id = t2.id WHERE t2.status = 'active' - 若必须用子查询结果做筛选,优先物化到临时表 + 加索引:
CREATE TEMPORARY TABLE tmp_ids AS SELECT id FROM t2 WHERE ...; ALTER TABLE tmp_ids ADD PRIMARY KEY (id);
再SELECT * FROM t1 WHERE t1.id IN (SELECT id FROM tmp_ids),避免重复计算且支持索引查找 - 确认
t2上有高效过滤条件(如 status、date 范围)并已建索引,否则子查询本身就在扫全表
真要分批,别用 OFFSET 深分页
如果业务逻辑允许分段处理(如后台导出、ETL),用 LIMIT 分批比硬扛一个超大子查询更稳,但必须避开 OFFSET 性能陷阱。
- 用游标式分页(基于主键/时间戳),例如:
SELECT id FROM t2 WHERE status = 'active' AND id > ? ORDER BY id LIMIT 5000
每次拿上一批最大 id 当作下一批起点,不跳过前 N 行 - 每批拿到 ID 列表后,在 Java 侧拼成
IN (1,2,...,5000)执行主查询;注意控制列表长度(MySQL 默认max_allowed_packet限制参数大小) - 避免在应用层用
OFFSET累加,百万级数据时第 1000 页要跳过 999 万行,CPU 和 I/O 都吃不消
JDBC 层启用真正的流式读取
即使 SQL 改好了,Java 默认仍会把整结果集缓存在 JVM 堆里——这是另一个独立的 OOM 来源。
立即学习“Java免费学习笔记(深入)”;
- 连接 URL 必须含
useCursorFetch=true,例如:jdbc:mysql://host:3306/db?useCursorFetch=true&serverTimezone=UTC - 创建 Statement 时指定类型:
conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY) - 执行前设
setFetchSize(Integer.MIN_VALUE)(不是 -1 或 1000),这是触发服务端游标的唯一有效值 - MyBatis 用户需:
• XML 中写<select fetchSize="-2147483648">
• Mapper 方法返回void,用ResultHandler逐条处理
• 关闭二级缓存、禁用自动映射,防止框架偷偷缓存全量结果
查清到底是谁在占内存:监控关键指标
别猜,用数据定位瓶颈点:
- 查 MySQL 状态:
SHOW GLOBAL STATUS LIKE 'Created_tmp%';
重点关注Created_tmp_disk_tables—— 数值飙升说明大量操作落盘,直指临时表空间压力 - 看配置是否匹配:
SELECT @@tmp_table_size, @@max_heap_table_size, @@temptable_max_ram;
三者需协调:后两者必须相等,temptable_max_ram(MySQL 8.0.16+)需在配置文件中显式设置并重启生效 - Java 侧用
jstat -gc <pid>观察老年代增长趋势,确认是否真由 ResultSet 导致堆溢出,而非其他对象泄漏


















