文章 557
评论 5
浏览 200215
批处理任务内存 OOM:一次性加载百万数据?流式查询+分片处理+游标分批提交

批处理任务内存 OOM:一次性加载百万数据?流式查询+分片处理+游标分批提交

公司的日报任务每天半夜 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 就够了。再加上分片和游标,批处理从"半夜炸内存"变成了"每天默默跑完"。



标题:批处理任务内存 OOM:一次性加载百万数据?流式查询+分片处理+游标分批提交
作者:jiangyi
地址:http://jiangyi.space/articles/2026/07/04/1783148087019.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消