MyBatis 千万级数据导出 OOM 了?流式查询+游标分批,内存从 4G 降到 200M

引言

运营小妹发来消息:"帮我导出最近半年的订单数据,要 Excel 格式,有 1000 多万条。"

你自信满满:"小意思,十分钟搞定。"

启动导出任务,看了一眼进度条,2 分钟后接口直接挂了。

查看日志:

java.lang.OutOfMemoryError: Java heap space

堆内存飙到了 4G,GC 把 CPU 跑满,服务假死。

你又试了试加 -Xmx8g——这次撑了 5 分钟,还是挂了。

这就是大数据导出的典型坑:MyBatis 默认一次性把结果加载到内存,1000 万条订单 × 每条 50 个字段 ≈ 5-8 个 G,直接爆。

本文从踩坑的代码出发,一步步演进到最终方案:

MyBatis 流式查询 → ResultHandler 逐条处理 → CSV 分片 → SXSSF 流式 Excel

内存:4G → 200M。


一、踩坑现场:传统导出为什么 OOM

1.1 传统写法

@Service
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 运营导出订单(传统写法)
     * ❌ 1000 万条直接 OOM
     */
    public void exportOrders(LocalDate start, LocalDate end, OutputStream out) throws IOException {
        // 1. 一次性查出 1000 万条
        // MyBatis 默认把 ResultSet 全部加载到内存
        List<Order> orders = orderMapper.selectByTimeRange(start, end);

        // 2. 写入 Excel(Apache POI 一次性写)
        Workbook wb = new XSSFWorkbook();
        Sheet sheet = wb.createSheet("订单数据");
        for (int i = 0; i < orders.size(); i++) {
            Row row = sheet.createRow(i);
            Order order = orders.get(i);
            // 填充 50 个字段...
        }
        wb.write(out);
    }
}

Mapper:

<select id="selectByTimeRange" resultType="Order">
    SELECT * FROM orders
    WHERE create_time BETWEEN #{start} AND #{end}
    ORDER BY create_time DESC
</select>

1.2 内存爆炸分析

1000 万条订单 × 每条实体对象开销:
  对象头: 16 字节
  50 个字段引用: 50 × 8 字节 = 400 字节
  实际数据(字符串/数字等): 约 500 字节
  合计: ≈ 900 字节/条

1000 万条 × 900 字节 = 9GB(只算实体本身)

加上 MyBatis 的中间对象、POI 的 Row/Cell 对象,轻松超过 12GB。

JVisualVM 监控截图(传统写法):

堆内存使用(GB)
  8.0 ┤ ┌─────────────────────────────────────┐
  7.0 ┤ │                                     │
  6.0 ┤ │                                     │
  5.0 ┤ │    ▁▂▃▅▆▇███████████████████████████ │
  4.0 ┤ │   █████████████████████████████████ │ ← OOM
  3.0 ┤ │ ████████████████████████████████████ │
  2.0 ┤ │█████████████████████████████████████ │
  1.0 ┤███████████████████████████████████████ │
      └───────────────────────────────────────┘
      0      1     2     3     4     5     6  分钟

时间 2 分钟 → OOM。

1.3 为什么分页也不行

有人说:分页查,每次 1000 条,1000 万条就 1 万次分页。

// ❌ 分页导出(1000 万条 × 深分页)
public void exportByPage(LocalDate start, LocalDate end, OutputStream out) {
    int pageSize = 1000;
    Workbook wb = new XSSFWorkbook();
    Sheet sheet = wb.createSheet("订单数据");

    for (int pageNo = 1; ; pageNo++) {
        // LIMIT (pageNo-1)*1000, 1000
        List<Order> orders = orderMapper.selectByPage(start, end, (pageNo - 1) * pageSize, pageSize);
        if (orders.isEmpty()) break;
        // 写入 Excel
    }
    wb.write(out);
}

SQL:

-- 第 1 页:快
SELECT * FROM orders WHERE ... LIMIT 0, 1000

-- 第 1000 页:慢
SELECT * FROM orders WHERE ... LIMIT 999000, 1000

-- 第 10000 页:更慢
SELECT * FROM orders WHERE ... LIMIT 9999000, 1000  ← 十几秒

深分页问题:LIMIT m, n 要扫描 m 条记录再扔掉。

1000 万条,最后一页:LIMIT 9999000, 1000 → 需要扫描 9999000 条记录 → 慢到离谱。


二、第一步:MyBatis 流式查询(Cursor)

2.1 核心思想

不要一次性把 ResultSet 加载到内存,而是一条一条从 MySQL 拉取

MyBatis 提供三种方式:

方式返回类型适用场景
CursorCursor(Iterator)逐条遍历,for-each 循环
ResultHandlerResultHandler 回调每读到一条立刻处理,立即释放
fetchSize + JDBC Statement原生 JDBC自定义更灵活

2.2 Cursor 方式实现

@Service
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * ✅ 使用 Cursor 流式查询
     * 内存占用从 4G 降到 500M
     */
    public void exportByCursor(LocalDate start, LocalDate end, OutputStream out) throws IOException {
        // 关键点:try-with-resources 自动关闭 Cursor
        try (Cursor<Order> cursor = orderMapper.selectCursor(start, end);
             SXSSFWorkbook wb = new SXSSFWorkbook(1000)) {  // SXSSF 流式写 Excel,下一步讲

            Sheet sheet = wb.createSheet("订单数据");
            int rowNum = 0;

            // for-each 逐条取出(真正的流式,不会全加载到内存)
            for (Order order : cursor) {
                Row row = sheet.createRow(rowNum++);
                // 填充字段...
            }
            wb.write(out);
            wb.dispose();  // 清理临时文件
        }
    }
}

Mapper:

<!-- 关键:resultSetType="FORWARD_ONLY" + fetchSize="1000" -->
<select id="selectCursor" resultType="Order"
        resultSetType="FORWARD_ONLY"
        fetchSize="1000"
        statementType="PREPARED">
    SELECT * FROM orders
    WHERE create_time BETWEEN #{start} AND #{end}
    ORDER BY create_time DESC
</select>

关键参数说明

参数作用为什么需要
resultSetType="FORWARD_ONLY"结果集只能向前遍历MySQL 驱动才会真正流式,默认是全加载
fetchSize="1000"每次从 MySQL 拉 1000 条控制内存:1000 × 900B = 900KB
statementType="PREPARED"预编译语句配合流式查询生效

2.3 Cursor 的坑

Cursor 必须在事务内使用,否则报:

org.apache.ibatis.cursor.CursorException: A Cursor is already closed.

解决办法:加 @Transactional

@Transactional(readOnly = true)  // 流式查询必须有事务
public void exportByCursor(LocalDate start, LocalDate end, OutputStream out) {
    try (Cursor<Order> cursor = orderMapper.selectCursor(start, end)) {
        for (Order order : cursor) {
            // 处理
        }
    }
}

原理:MyBatis 的 Cursor 需要 JDBC Connection 不关闭。没有事务时,SQL 执行完 Connection 就被放回连接池,Cursor 就关掉了。

2.4 内存对比(Cursor vs 传统)

堆内存使用(MB)
   4000 ┤ ┌──────────────────────────────────┐  传统写法
   3000 ┤ │         ████████████████████    │  → OOM
   2000 ┤ │       ████████████████████████  │
   1000 ┤ │     ██████████████████████████  │
      0 ┤ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ │
        └──────────────────────────────────┘

   4000 ┤
   3000 ┤
   2000 ┤
   1000 ┤ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  Cursor 写法
    500 ┤ █████████████████████████████████  → 稳定 500M
      0 ┤ ██████████████████████████████████
        └──────────────────────────────────┘

从 4G+ OOM 降到 500M,效果明显。

但 500M 还是太高了——因为 SXSSFWorkbook(流式 Excel)需要缓存 1000 行在内存里,加上业务对象。

下一步继续优化。


三、第二步:ResultHandler 逐条处理,更省内存

3.1 Cursor 的问题

Cursor 虽然流式,但 for (Order order : cursor) 每取出一条,仍然会创建完整的 Order 实体对象。

1000 万条,每条实体的字段很多(50 列),对象创建和 GC 有压力。

ResultHandler 可以更底层地控制:每读到一条 Result,Handler 拿到立刻处理,MyBatis 立刻释放引用

3.2 ResultHandler 实现

@Service
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * ✅ ResultHandler 方式
     * 内存从 500M 降到 300M
     */
    @Transactional(readOnly = true)
    public void exportByResultHandler(LocalDate start, LocalDate end,
                                      OutputStream out) throws IOException {

        SXSSFWorkbook wb = new SXSSFWorkbook(1000);
        Sheet sheet = wb.createSheet("订单数据");
        AtomicInteger rowNum = new AtomicInteger(0);

        // ResultHandler:MyBatis 每读出一条立刻调用 handleResult
        orderMapper.selectByResultHandler(start, end, resultContext -> {
            Order order = resultContext.getResultObject();

            Row row = sheet.createRow(rowNum.getAndIncrement());
            fillRow(row, order);

            // 每处理完一条,order 对象立即可以被 GC
        });

        wb.write(out);
        wb.dispose();
    }
}

Mapper:

<!-- 注意:返回类型写成 void,ResultHandler 作为参数 -->
<select id="selectByResultHandler"
        resultType="Order"
        resultSetType="FORWARD_ONLY"
        fetchSize="1000">
    SELECT order_no, user_id, amount, status, create_time   <!-- 关键:只查需要的列 -->
    FROM orders
    WHERE create_time BETWEEN #{start} AND #{end}
    ORDER BY create_time DESC
</select>

两个关键优化

  1. 只查需要的列:运营导出只需要 10 列,不查那 40 列没用的大字段
  2. ResultHandler 立刻释放引用:处理完一条,MyBatis 不会把对象存到 List 里

3.3 字段裁剪的价值

50 列(含 description/text 等大字段):
  1000 万条 × 平均 900B = 9GB

10 列(订单号/用户ID/金额/状态/时间...):
  1000 万条 × 平均 200B = 2GB

字段裁剪直接把"对象体积"缩小了 4.5 倍。

3.4 ResultHandler vs Cursor

维度CursorResultHandler
使用方式for-each 遍历回调 handleResult
内存更高(Iterator 持有引用)更低(回调立刻释放)
事务要求必须 @Transactional必须 @Transactional
灵活性可中断(break)不可中断
GC 压力

结论:ResultHandler 内存更低,但 Cursor 写法更直观。内存紧张用 ResultHandler,其他用 Cursor。


四、第三步:CSV 分片导出,内存降到 100M 以下

4.1 SXSSFWorkbook 的内存问题

前面用到了 SXSSFWorkbook(1000)——内存里保留 1000 行,超过的写到临时文件。

但实际内存仍然不低,原因:

  1. SXSSF 每个 Sheet 仍有大量元数据对象
  2. String 的共享表(SharedStringsTable)
  3. 多 Sheet 管理开销

如果不强制要求 Excel,CSV 分片方案内存更低。

4.2 CSV 分片实现

思路:每 10000 条写一个 CSV 文件,最终打成一个 ZIP

@Service
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * ✅ CSV 分片导出 + ZIP 打包
     * 内存从 300M 降到 100M 以下
     */
    @Transactional(readOnly = true)
    public void exportByCsvSharding(LocalDate start, LocalDate end,
                                    OutputStream zipOut) throws IOException {

        try (ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(zipOut));
             BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(zos,
                     StandardCharsets.UTF_8))) {

            // 写 BOM,防止 Excel 打开中文乱码
            zos.write(0xEF); zos.write(0xBB); zos.write(0xBF);

            final int SHARD_SIZE = 10000;   // 每 1 万条一个 CSV
            final AtomicInteger shardIndex = new AtomicInteger(1);
            final AtomicInteger rowCount = new AtomicInteger(0);
            final String[] HEADER = {"订单号", "用户ID", "金额", "状态", "创建时间"};

            // 写第一个 CSV
            startNewCsv(zos, shardIndex.get(), bw, HEADER);

            orderMapper.selectByResultHandler(start, end, ctx -> {
                Order order = ctx.getResultObject();

                // 写一行 CSV
                String line = String.join(",",
                        order.getOrderNo(),
                        order.getUserId().toString(),
                        order.getAmount().toString(),
                        order.getStatus(),
                        order.getCreateTime().toString()
                );
                bw.write(line);
                bw.newLine();

                int count = rowCount.incrementAndGet();

                // 写满 10000 条 → 换下一个 CSV 文件
                if (count % SHARD_SIZE == 0) {
                    bw.flush();
                    zos.closeEntry();  // 关闭当前 CSV(当前分片)

                    shardIndex.incrementAndGet();
                    startNewCsv(zos, shardIndex.get(), bw, HEADER);
                }
            });

            // 关闭最后一个 CSV
            bw.flush();
            zos.closeEntry();
        }
    }

    private void startNewCsv(ZipOutputStream zos, int shardIndex,
                             BufferedWriter bw, String[] header) throws IOException {
        // ZIP 中新建一个文件
        zos.putNextEntry(new ZipEntry(String.format("orders_%05d.csv", shardIndex)));

        // 写表头
        bw.write(String.join(",", header));
        bw.newLine();
    }
}

导出效果:

orders_export_20260808.zip
  ├── orders_00001.csv    10000 条
  ├── orders_00002.csv    10000 条
  ├── ...
  └── orders_01000.csv    剩余的条

4.3 CSV vs SXSSF Excel

维度CSV(ZIP 分片)SXSSF Excel
内存占用< 100M300M 左右
格式支持纯文本样式、公式、图表
多 Sheet 能力多文件分片一个文件多 Sheet
打开方式Excel / 数字库 / 任何文本工具Excel 专用
导出速度
中文需要 BOM自带
体积小(ZIP 压缩)

结论:纯数据导出用 CSV 分片,需要样式/公式用 SXSSF。


五、第四步:SXSSF 流式写 Excel(仍要 Excel 的情况)

如果运营一定要 Excel,也不能用传统 XSSFWorkbook,要用 SXSSF。

5.1 XSSF vs SXSSF

XSSFWorkbook:
  所有 Row/Cell 都在内存里
  100 万行 → 内存 3GB+

SXSSFWorkbook(1000):
  内存里只保留最近 1000 行
  旧行刷到磁盘临时文件(/tmp/poi-sxssf-*.tmp)
  1000 万行 → 内存 200M

5.2 SXSSF 完整代码

@Service
public class OrderExportService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * ✅ SXSSF 流式 Excel
     * 内存稳定 200M 左右(取决于 windowSize)
     */
    @Transactional(readOnly = true)
    public void exportBySxssf(LocalDate start, LocalDate end,
                              OutputStream out) throws IOException {

        // windowSize=1000:内存保留最近 1000 行,超过的刷到磁盘
        SXSSFWorkbook wb = new SXSSFWorkbook(1000);
        wb.setCompressTempFiles(true);      // 临时文件压缩

        try {
            Sheet sheet = wb.createSheet("订单数据");
            AtomicInteger rowNum = new AtomicInteger(0);

            // 表头样式
            CellStyle headerStyle = wb.createCellStyle();
            // 填充样式...(样式复用,不要每行 new CellStyle)

            // 写表头
            Row header = sheet.createRow(rowNum.getAndIncrement());
            createHeader(header, headerStyle);

            // 行内样式(预创建,复用)
            CellStyle textStyle = wb.createCellStyle();
            CellStyle numStyle = wb.createCellStyle();

            // ResultHandler 流式处理
            orderMapper.selectByResultHandler(start, end, ctx -> {
                Order order = ctx.getResultObject();
                Row row = sheet.createRow(rowNum.getAndIncrement());
                fillRowCells(row, order, textStyle, numStyle);
            });

            // 刷出
            wb.write(out);

        } finally {
            wb.dispose();  // 关键:删除临时文件
            wb.close();
        }
    }

    private void createHeader(Row header, CellStyle style) {
        String[] cols = {"订单号", "用户ID", "金额", "状态", "创建时间"};
        for (int i = 0; i < cols.length; i++) {
            Cell cell = header.createCell(i);
            cell.setCellValue(cols[i]);
            cell.setCellStyle(style);
        }
    }

    private void fillRowCells(Row row, Order order,
                              CellStyle textStyle, CellStyle numStyle) {
        int i = 0;
        Cell c0 = row.createCell(i++);
        c0.setCellValue(order.getOrderNo());
        c0.setCellStyle(textStyle);

        Cell c1 = row.createCell(i++);
        c1.setCellValue(order.getUserId());
        c1.setCellStyle(numStyle);
        // ...
    }
}

5.3 SXSSF 的坑

坑 1:必须 dispose()

// ❌ 只 close,不 dispose
wb.close();  // 临时文件还在 → /tmp 被占满

// ✅ dispose + close
wb.dispose();  // 删除 /tmp/poi-sxssf-*.tmp
wb.close();

坑 2:CellStyle 要复用

// ❌ 每行 new 一个 CellStyle → 超过 Excel 4000 样式上限报错
for (Order order : orders) {
    Row row = sheet.createRow(i++);
    CellStyle style = wb.createCellStyle();  // 错误
    ...
}

// ✅ 提前创建 2-3 种样式,全局复用
CellStyle headerStyle = wb.createCellStyle();  // 只创建一次
CellStyle textStyle = wb.createCellStyle();
CellStyle numStyle = wb.createCellStyle();

坑 3:不能对已经刷盘的行做修改

SXSSFWorkbook wb = new SXSSFWorkbook(1000);

Row row0 = sheet.createRow(0);
row0.createCell(0).setCellValue("A0");

// ... 创建 1000 行后,第 0 行已经刷盘
row0.createCell(1).setCellValue("B0");  // ❌ 异常:行已经 flush 了

5.4 内存对比(SXSSF vs XSSF)

堆内存使用(MB)
  3000 ┤ ┌───────────────────────────────┐  XSSF
  2000 ┤ │      ████████████████████████ │  → OOM
  1000 ┤ │    ██████████████████████████ │
     0 ┤ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ │
        └───────────────────────────────┘
  3000 ┤
  2000 ┤
  1000 ┤
   200 ┤ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     0 ┤ ████████████████████████████████  SXSSF(1000)
        └───────────────────────────────┘  → 稳定 200M

六、第五步:异步 + 断点续传,运营不等待

1000 万条导出哪怕优化到极致也要 3-5 分钟,让运营在浏览器干等不是事。

6.1 异步方案

运营点击"导出"
  │
  ├── 1. 立即返回任务 ID + "导出中,请稍后"
  │
  ├── 2. 后台执行导出(线程池)
  │      └── 导出完成 → 上传到 OSS/MinIO
  │
  └── 3. 运营可以关闭页面,等站内信/邮件通知下载链接

实现:

@RestController
@RequestMapping("/orders")
public class OrderExportController {

    @Autowired
    private OrderExportAsyncService exportService;

    /**
     * 触发导出(立即返回)
     */
    @PostMapping("/export")
    public Map<String, Object> startExport(@RequestParam LocalDate start,
                                           @RequestParam LocalDate end) {
        String taskId = UUID.randomUUID().toString().replace("-", "");
        exportService.submit(taskId, start, end);  // 异步提交
        return Map.of("code", 200, "taskId", taskId,
                "message", "导出中,约 3-5 分钟后可下载");
    }

    /**
     * 查询导出进度
     */
    @GetMapping("/export/progress/{taskId}")
    public Map<String, Object> progress(@PathVariable String taskId) {
        ExportTask task = exportService.getTask(taskId);
        return Map.of(
                "code", 200,
                "taskId", taskId,
                "status", task.getStatus(),
                "progress", task.getProgress(),
                "downloadUrl", task.getDownloadUrl()
        );
    }
}

异步导出服务:

@Service
public class OrderExportAsyncService {

    @Autowired
    private ThreadPoolTaskExecutor exportExecutor;

    private final ConcurrentMap<String, ExportTask> tasks = new ConcurrentHashMap<>();

    public void submit(String taskId, LocalDate start, LocalDate end) {
        ExportTask task = new ExportTask(taskId, ExportStatus.RUNNING, 0, null, start, end);
        tasks.put(taskId, task);

        exportExecutor.submit(() -> {
            try {
                // 真正执行导出(CSV 分片 + ZIP)
                File exportFile = doExport(task);
                // 上传到对象存储
                String url = uploadToMinIO(exportFile);
                // 更新状态
                task.setStatus(ExportStatus.SUCCESS);
                task.setDownloadUrl(url);
            } catch (Exception e) {
                task.setStatus(ExportStatus.FAILED);
                task.setErrorMsg(e.getMessage());
            }
        });
    }
}

6.2 进度怎么准确

ResultHandler 每处理一条更新进度:

// 先查总数(只查 count,不查数据)
long total = orderMapper.countByTimeRange(start, end);
task.setTotal(total);

orderMapper.selectByResultHandler(start, end, ctx -> {
    // ...处理数据...
    long processed = processedCount.incrementAndGet();
    if (processed % 10000 == 0) {
        // 每 1 万条更新进度(避免太频繁写)
        task.setProgress((int) (processed * 100 / total));
    }
});

6.3 断点续传(记录导出位置)

导出中途服务挂了?已经导出的不用重复。

方案:按分片记录。

导出 orders_00001.csv 成功 → Redis 写 taskId:shard:1 = DONE
导出 orders_00002.csv 成功 → Redis 写 taskId:shard:2 = DONE
...
服务挂了,重启后:
  从 taskId:shard:* 中找到最大已完成分片
  游标从 最大分片 × 10000 处继续

七、最终方案对比

7.1 演进路线

方案方式内存时间说明
1. 传统List 全加载 + XSSF> 4G OOM-❌ 不可行
2. 深分页每 1000 条分页 + XSSF~2G30 分钟+❌ 慢分页
3. Cursor流式读 + SXSSF(1000)~500M10 分钟✅ 可用
4. ResultHandler + 字段裁剪回调 + SXSSF~300M8 分钟✅ 优
5. CSV 分片ResultHandler + ZIP< 100M5 分钟⭐ 最优
6. 异步 + 进度第 5 步 + 线程池 + OSS< 100M5 分钟 + 不阻塞⭐⭐ 生产首选

7.2 内存监控曲线

内存占用(MB)
方案 1(传统):
  4000 ████████████████████████████████████   → OOM

方案 3(Cursor + SXSSF):
   500 ████████████████████████████████

方案 4(ResultHandler + 字段裁剪 + SXSSF):
   300 ████████████████████

方案 5(CSV 分片):
   100 ████████                          ← 稳定
    50 ███████  ███████  ███████  ███████  分片释放
     0 ────────────────────────────────────

时间  ────────────────────────────────────→

7.3 耗时对比

方案1000 万条耗时原因
传统-OOM 失败
深分页35 分钟深分页扫描开销
Cursor + SXSSF10 分钟SXSSF 写磁盘
ResultHandler + SXSSF8 分钟字段裁剪 + 更低 GC
CSV 分片5 分钟CSV 格式轻 + ZIP 压缩

八、生产注意事项

8.1 MySQL fetchSize 一定要配

MySQL 驱动在 resultSetType=FORWARD_ONLY 时,默认 fetchSize=0 表示仍然全加载

必须显式设置 fetchSize=Integer.MIN_VALUE 或者一个具体值:

<!-- ✅ 正确,真正流式 -->
<select fetchSize="1000" resultSetType="FORWARD_ONLY" ...>

<!-- 或者 MySQL 专用:设为 MIN_VALUE 强制一条一条发 -->
<select fetchSize="-2147483648" resultSetType="FORWARD_ONLY" ...>

8.2 MySQL 的 net_read_timeout

数据量很大,MySQL 发数据时间长,如果超过 net_read_timeout 就会断开连接:

-- 调大(MySQL 侧配置)
SET GLOBAL net_read_timeout = 600;   -- 10 分钟
SET GLOBAL net_write_timeout = 600;

或者在 JDBC URL 加:

jdbc:mysql://localhost:3306/test?netTimeoutForStreamingResults=600

8.3 长事务导致的锁

流式查询必须有事务 @Transactional,导出 5 分钟,事务也开 5 分钟。

可能的问题

  • InnoDB 的 undo log 会膨胀
  • 读到的数据可能是 5 分钟前的快照(可重复读隔离级别)
  • 连接占用 5 分钟,连接池不够用

解决办法

@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
public void export() {
    // 只读事务 + 读已提交
    // 1. 写了 readOnly,底层 JDBC 优化会开启只读连接
    // 2. 读已提交:避免快照太旧
}

8.4 不要用 SELECT *

SELECT * 把不需要的大字段(JSON/TEXT/BLOB)也拉出来,放大内存开销。

<!-- ❌ -->
SELECT * FROM orders

<!-- ✅ 只查需要的列 -->
SELECT order_no, user_id, amount, status, create_time
FROM orders

8.5 SXSSF 临时文件监控

SXSSF 把临时文件写到 /tmp,1000 万条 Excel 临时文件会很大。

启动脚本加:

# 指定临时文件目录(避免 /tmp 满)
export JAVA_OPTS="-Djava.io.tmpdir=/data/tmp/poi"

# 启动前清理
rm -rf /data/tmp/poi/*.tmp

九、方案选型树

导出 1000 万条数据?
├── 格式要求 Excel?
│   ├── 是 → SXSSFWorkbook(windowSize=1000)
│   │         +
│   │         ResultHandler 逐条读 + 字段裁剪
│   │         = 内存 ~300M
│   │
│   └── 否 → CSV 分片 + ZIP
│             每 1 万条一个 CSV
│             = 内存 < 100M(最优)
│
├── 用户等得了吗?
│   ├── 等不了(导出>1分钟) → 异步任务 + 进度条 + OSS 下载链接
│   └── 等得了 → 同步流式写 HTTP Response
│
└── 字段很多?
    ├── 是 → 必须裁剪,只查需要的列(4GB → 2GB 立竿见影)
    └── 否 → 直接上流式

十、总结

核心招式

问题招式效果
全量加载 → OOMCursor/ResultHandler 流式4G → 500M
Cursor 内存高ResultHandler 回调 + 字段裁剪500M → 300M
SXSSF 内存大CSV 分片 + ZIP300M → 100M
用户等待异步任务 + 进度条不阻塞
深分页流式查询 + 游标30min → 5min
中文乱码CSV 写 UTF-8 BOM正常

一句话

传统 MyBatis 全量导出 1000 万条会 OOM(4G+),用 MyBatis 流式查询(Cursor/ResultHandler)+ 字段裁剪 + CSV 分片/SXSSF 流式 Excel,内存稳定在 100-300M,导出 5 分钟完成。运营那边,异步任务+进度条+OSS 下载链接体验最好。

互动话题:你们导出大数据时用什么方案?有没有过 OOM 惊魂夜?欢迎留言分享!


参考资料


标题:MyBatis 千万级数据导出 OOM 了?流式查询+游标分批,内存从 4G 降到 200M
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/09/1786160753368.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消