java.lang.OutOfMemoryError: Java heap space
日志里只剩这一句,前面那条 SQL 很朴素:
select * from user_event_log;
表不复杂,1000 万行。 代码也不复杂,甚至看着还挺“正常”。
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"select * from user_event_log"
);
这种代码我第一眼就不太信。不是 MySQL 多脆弱,是你把 1000 万行一次性塞进 Java 堆里,谁来都扛不住。
select * from 一个 1000 万行的表,内存会不会飙升?
得分开看。
MySQL 服务器那边,不是说你一执行select *,它就把 1000 万行全部一次性加载到内存里,然后再慢慢返回给你。InnoDB 会按页读取数据,读到的数据页会进 buffer pool,热点页会留下,冷数据会被淘汰。
所以 MySQL 的内存可能上涨,但通常不会因为一条普通全表查询直接把所有行“完整塞进内存”。
真正容易炸的,反而是 Java 应用。
尤其是这种写法:
public List<UserEvent> dumpAll() {
return jdbcTemplate.query(
"select * from user_event_log",
(rs, rowNum) -> {
UserEvent e = new UserEvent();
e.setId(rs.getLong("id"));
e.setUserId(rs.getLong("user_id"));
e.setAction(rs.getString("action"));
e.setPayload(rs.getString("payload"));
e.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return e;
}
);
}
这段代码的问题不在 SQL 语法,在返回值。
List<UserEvent>。
1000 万个对象,加上对象头、字段引用、字符串、时间对象、List 扩容数组,再加上 JDBC driver 内部缓冲。你以为查的是 1000 万行,JVM 看到的是一堆对象森林。
线上如果开了 GC 日志,差不多会看到这种味道:
[GC pause Young 1800M->1600M]
[Full GC 3900M->3850M]
java.lang.OutOfMemoryError: Java heap space
这时候别急着调大-Xmx。调大也只是晚死一点。
还有一个坑,很多人以为自己写了ResultSet就一定是流式读取。未必。
这种代码看起来比queryForList老实一点:
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"select * from user_event_log");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
handle(rs);
}
}
但在 MySQL JDBC 下,如果连接参数和 fetchSize 没配对,驱动可能还是会把结果集拉到客户端缓存起来。你代码是一行一行处理,底层不一定真是一行一行取。
我一般会这么写导出类任务,别追求优雅,先让它别炸。
public void exportUserEvent(Path file) throws Exception {
String sql = """
select id, user_id, action, created_at
from user_event_log
where id > ?
order by id
limit ?
""";
long lastId = 0L;
int size = 5000;
try (BufferedWriter writer = Files.newBufferedWriter(file)) {
while (true) {
int count = 0;
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, lastId);
ps.setInt(2, size);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
lastId = rs.getLong("id");
writer.write(lastId + ","
+ rs.getLong("user_id") + ","
+ rs.getString("action") + ","
+ rs.getTimestamp("created_at"));
writer.newLine();
count++;
}
}
}
if (count < size) {
break;
}
}
}
}
这里我不用limit offset。
select * from user_event_log limit 9000000, 5000;
这种分页越往后越慢。MySQL 得先跳过前面 900 万行,再给你 5000 行。你让它干这活,它当然喘。
按主键往后扫,至少执行计划比较干净:
explain
select id, user_id, action, created_at
from user_event_log
where id > 8800000
order by id
limit 5000;
看到type: range,走主键或索引,我心里才踏实一点。
如果你真要流式读,也可以这么干,但连接串要配合:
jdbc:mysql://127.0.0.1:3306/app
?useCursorFetch=true
&useServerPrepStmts=true
代码里再控制 fetchSize:
public void scanSlowly() throws SQLException {
String sql = """
select id, user_id, action, payload
from user_event_log
order by id
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
sql,
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY)) {
ps.setFetchSize(1000);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
consumeOneRow(
rs.getLong("id"),
rs.getLong("user_id"),
rs.getString("action"),
rs.getString("payload")
);
}
}
}
}
不过我不太喜欢在业务接口里这么扫。
因为一个大查询挂在那里,连接一直占着,事务如果没处理好,还可能拖住 undo 清理。你以为只是导个数据,数据库那边看着可能是一条长时间不结束的查询。
再说select *。
这个星号很讨厌。表一开始只有 5 个字段,大家都觉得无所谓。过几个月加了remark、extra_json、content_blob,老代码一点没改,查询的数据量翻几倍。
你查列表页,只要 4 个字段,就别写:
select * from user_event_log;
写清楚:
select id, user_id, action, created_at
from user_event_log
where created_at >= ?
order by id
limit 5000;
还有一种情况,MySQL 服务器内存也会明显动。
比如你查 1000 万行,还来个没索引的排序:
select *
from user_event_log
order by created_at desc;
如果created_at没合适索引,执行计划里可能出现:
Using filesort
这类 SQL 我一般先停一下,不急着跑。因为它不只是扫表,还要排序。内存不够就落磁盘临时文件,慢得很稳定。
比较靠谱的是加索引,或者把查询条件压窄:
create index idx_event_created_id
on user_event_log(created_at, id);
然后查的时候别贪:
select id, user_id, action, created_at
from user_event_log
where created_at >= '2026-05-01 00:00:00'
order by created_at, id
limit 5000;
所以这个问题最后不是一句“会”或者“不会”。
如果你在 MySQL 命令行里执行select * from 大表,MySQL 会边读边返回,客户端慢慢接,服务端不一定爆。
如果你在 Java 里用queryForList、MyBatis 直接返回List,那内存大概率会飙。不是 MySQL 飙,是你的 JVM 飙。
如果 SQL 里带没索引的排序、分组、大字段、临时表,那 MySQL 服务器也可能跟着难受。
我自己的处理顺序一般就三步:
先砍掉select *,只查需要的列。
再看是不是一次性装进 List,能分批就分批,能流式就流式。
最后看执行计划,尤其盯着全表扫描、filesort、临时表、大 offset。
1000 万行不可怕,可怕的是你把它当 1000 行写。