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 提供三种方式:
| 方式 | 返回类型 | 适用场景 |
|---|---|---|
| Cursor | Cursor(Iterator) | 逐条遍历,for-each 循环 |
| ResultHandler | ResultHandler 回调 | 每读到一条立刻处理,立即释放 |
| 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>
两个关键优化:
- 只查需要的列:运营导出只需要 10 列,不查那 40 列没用的大字段
- ResultHandler 立刻释放引用:处理完一条,MyBatis 不会把对象存到 List 里
3.3 字段裁剪的价值
50 列(含 description/text 等大字段):
1000 万条 × 平均 900B = 9GB
10 列(订单号/用户ID/金额/状态/时间...):
1000 万条 × 平均 200B = 2GB
字段裁剪直接把"对象体积"缩小了 4.5 倍。
3.4 ResultHandler vs Cursor
| 维度 | Cursor | ResultHandler |
|---|---|---|
| 使用方式 | for-each 遍历 | 回调 handleResult |
| 内存 | 更高(Iterator 持有引用) | 更低(回调立刻释放) |
| 事务要求 | 必须 @Transactional | 必须 @Transactional |
| 灵活性 | 可中断(break) | 不可中断 |
| GC 压力 | 中 | 低 |
结论:ResultHandler 内存更低,但 Cursor 写法更直观。内存紧张用 ResultHandler,其他用 Cursor。
四、第三步:CSV 分片导出,内存降到 100M 以下
4.1 SXSSFWorkbook 的内存问题
前面用到了 SXSSFWorkbook(1000)——内存里保留 1000 行,超过的写到临时文件。
但实际内存仍然不低,原因:
- SXSSF 每个 Sheet 仍有大量元数据对象
- String 的共享表(SharedStringsTable)
- 多 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 |
|---|---|---|
| 内存占用 | < 100M | 300M 左右 |
| 格式支持 | 纯文本 | 样式、公式、图表 |
| 多 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 | ~2G | 30 分钟+ | ❌ 慢分页 |
| 3. Cursor | 流式读 + SXSSF(1000) | ~500M | 10 分钟 | ✅ 可用 |
| 4. ResultHandler + 字段裁剪 | 回调 + SXSSF | ~300M | 8 分钟 | ✅ 优 |
| 5. CSV 分片 | ResultHandler + ZIP | < 100M | 5 分钟 | ⭐ 最优 |
| 6. 异步 + 进度 | 第 5 步 + 线程池 + OSS | < 100M | 5 分钟 + 不阻塞 | ⭐⭐ 生产首选 |
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 + SXSSF | 10 分钟 | SXSSF 写磁盘 |
| ResultHandler + SXSSF | 8 分钟 | 字段裁剪 + 更低 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 立竿见影)
└── 否 → 直接上流式
十、总结
核心招式
| 问题 | 招式 | 效果 |
|---|---|---|
| 全量加载 → OOM | Cursor/ResultHandler 流式 | 4G → 500M |
| Cursor 内存高 | ResultHandler 回调 + 字段裁剪 | 500M → 300M |
| SXSSF 内存大 | CSV 分片 + ZIP | 300M → 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://www.jiangyi.space/articles/2026/08/09/1786160753368.html
公众号:服务端技术精选
- 引言
- 一、踩坑现场:传统导出为什么 OOM
- 1.1 传统写法
- 1.2 内存爆炸分析
- 1.3 为什么分页也不行
- 二、第一步:MyBatis 流式查询(Cursor)
- 2.1 核心思想
- 2.2 Cursor 方式实现
- 2.3 Cursor 的坑
- 2.4 内存对比(Cursor vs 传统)
- 三、第二步:ResultHandler 逐条处理,更省内存
- 3.1 Cursor 的问题
- 3.2 ResultHandler 实现
- 3.3 字段裁剪的价值
- 3.4 ResultHandler vs Cursor
- 四、第三步:CSV 分片导出,内存降到 100M 以下
- 4.1 SXSSFWorkbook 的内存问题
- 4.2 CSV 分片实现
- 4.3 CSV vs SXSSF Excel
- 五、第四步:SXSSF 流式写 Excel(仍要 Excel 的情况)
- 5.1 XSSF vs SXSSF
- 5.2 SXSSF 完整代码
- 5.3 SXSSF 的坑
- 5.4 内存对比(SXSSF vs XSSF)
- 六、第五步:异步 + 断点续传,运营不等待
- 6.1 异步方案
- 6.2 进度怎么准确
- 6.3 断点续传(记录导出位置)
- 七、最终方案对比
- 7.1 演进路线
- 7.2 内存监控曲线
- 7.3 耗时对比
- 八、生产注意事项
- 8.1 MySQL fetchSize 一定要配
- 8.2 MySQL 的 net_read_timeout
- 8.3 长事务导致的锁
- 8.4 不要用 SELECT *
- 8.5 SXSSF 临时文件监控
- 九、方案选型树
- 十、总结
- 核心招式
- 一句话
- 参考资料
评论