公司的日报任务每天半夜 2 点跑,统计前一天所有订单的销售额。代码逻辑很简单——
SELECT * FROM orders WHERE date = '2026-06-30',然后for循环逐条累加。平时几万条订单没问题,大促那天 300 万条订单,SELECT *把 300 万行全部加载到了 JVM 堆里,直接 OOM。运维半夜被电话叫醒,手动分批 SQL 跑了一个小时才完成。
批处理的 OOM 跟文件下载的 OOM 原因一样——把全部数据一把梭进内存。 数据库能存那么大的表,不代表你的 JVM 堆能装下它。今天聊聊三种让批处理不再 OOM 的方法:流式查询、分片处理、游标分批提交。
问题:JDBC 默认是一次性加载全部结果集
MySQL 的 JDBC 驱动默认行为是:执行 SELECT 后,把所有结果行都拉到客户端内存里。你的代码写的虽然是 while (rs.next()),但在 next() 之前,数据已经在内存里了。
JDBC 默认行为:
→ 执行 SELECT
→ 100 万行全部加载到 ResultSet(JVM 堆)
→ while (rs.next()) 遍历
→ OOM
方案一:流式查询(Streaming ResultSet)
MySQL JDBC 支持流式查询,让数据一行一行从数据库传过来,而不是一次性全拉:
// 流式查询的关键配置
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM orders WHERE date = ?")) {
// ★ 三个关键设置
stmt.setFetchSize(Integer.MIN_VALUE); // MySQL 流式读
// 或:stmt.setFetchSize(1000); // 每次拉 1000 行(非 MySQL)
stmt.setString(1, "2026-06-30");
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
// 逐行处理,内存里只有当前这行
process(rs);
}
}
}
setFetchSize(Integer.MIN_VALUE) 告诉 MySQL 驱动"逐条返回",不要一次性全拉到客户端。
内存占用从"全量数据大小"变成了"单行数据大小"。300 万条订单,之前要 2GB 内存,现在只需要几 KB。
注意事项:流式查询期间,数据库连接一直被占用,不能释放。 处理完结果集之前,不能在同一连接上执行其他 SQL。
方案二:分片查询(WHERE id BETWEEN)
流式查询解决的是"内存不爆",但没解决"单线程跑得慢"的问题。300 万条订单逐条处理,单线程也要跑很久。
分片查询让多个线程并行处理:
// 按 ID 范围分片,多线程并行处理
long minId = getMinId("2026-06-30");
long maxId = getMaxId("2026-06-30");
long shardSize = 100_000;
ExecutorService executor = Executors.newFixedThreadPool(5);
for (long start = minId; start <= maxId; start += shardSize) {
long end = Math.min(start + shardSize - 1, maxId);
executor.submit(() -> processShard(start, end));
}
每个分片查询自己那一段:
SELECT * FROM orders WHERE date = '2026-06-30'
AND id BETWEEN 1000000 AND 1099999
5 个线程,300 万条订单,每人处理 60 万条。时间从单线程的 30 分钟降到 6 分钟。
分片的关键是要有连续的主键。如果主键是 UUID(离散的),不能这么分。这时候用时间分片也行——WHERE date = ? AND create_time BETWEEN ? AND ?。
方案三:游标分批提交
即使流式查询不爆内存,如果处理过程中出错了,已经处理完的那一半数据怎么办?重头再来?
游标分批提交的思路:每处理 1000 条就记一次"我处理到哪了",出错了就从上次的位置继续。
// 游标分批提交
long cursor = restoreCursor("2026-06-30"); // 从断点恢复
final int BATCH_SIZE = 1000;
int batchCount = 0;
try (ResultSet rs = executeStreamingQuery(cursor)) {
while (rs.next()) {
process(rs);
batchCount++;
if (batchCount >= BATCH_SIZE) {
cursor = rs.getLong("id");
saveCursor(cursor); // 持久化游标
batchCount = 0;
}
}
}
游标存在 Redis 或本地文件里。任务启动时先查"上次处理到哪了",从那继续。任务跑了 80% 被 kill 了,重启后从 80% 继续,不需要重新跑前面的。
三方案组合使用
三个方案不是互斥的,是递进的:
流式查询(不爆内存)
→ 分片处理(多线程加速)
→ 游标提交(断点续跑)
一个完整的生产级批处理:
long cursor = getCursor(taskName); // 游标恢复
ExecutorService pool = newFixedThreadPool(5);
long endId = getMaxId();
while (cursor < endId) {
long shardEnd = Math.min(cursor + 100_000, endId);
pool.submit(() -> {
try (StreamingResultSet rs = execute(cursor, shardEnd)) {
int count = 0;
while (rs.next()) {
process(rs);
if (++count % 1000 == 0) saveCursor(rs.getLong("id"));
}
}
});
cursor = shardEnd + 1;
}
流式查 → 分片跑 → 每千行记游标。内存不爆、速度快、能断点续跑。
总结
批处理 OOM 三件套:
- 流式查询 ——
setFetchSize(Integer.MIN_VALUE),逐行取不积压 - 分片处理 ——
WHERE id BETWEEN ? AND ?,多线程并行 - 游标提交 —— 每 1000 条记一次位置,断了能续跑
不用流式,300 万条订单 2GB 内存起步。用了流式,几个 MB 就够了。再加上分片和游标,批处理从"半夜炸内存"变成了"每天默默跑完"。
