上周运营要导出全量物流记录做月度对账,点了导出按钮,后台直接抛 OutOfMemoryError,服务线程也挂了。查代码发现导出逻辑是先把 10 万行全查出来放进 List,再循环写 Excel,内存直接爆了。
前后花了两天把导出改成流式 + 异步任务,记录下来。
一、为什么全量查询会 OOM
传统写法的问题在于:
// 错误写法:一次性查全部
List<Log> all = logMapper.selectAll(); // 10万行对象全在内存
ExcelWriter writer = new ExcelWriter();
for (Log l : all) {
writer.writeRow(l);
}
10 万行对象,每行几十字段,加上 MyBatis 的映射开销,JVM 堆直接撑爆。就算不 OOM,Full GC 也会把服务拖卡。
二、流式查询:游标滚动,不一次性加载
MyBatis 提供流式查询(Cursor),数据库游标滚动拉取,每次只在内存里保留一行:
// Mapper接口
@Select("SELECT * FROM logistics_log WHERE create_time > #{start}")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
Cursor<Log> streamByDate(Date start);
// Service
try (Cursor<Log> cursor = logMapper.streamByDate(start)) {
ExcelWriter writer = new ExcelWriter(outputStream);
for (Log l : cursor) {
writer.writeRow(l); // 每行写完就丢,不保留
}
writer.finish();
}
关键点:
- fetchSize 设 Integer.MIN_VALUE,MySQL 驱动会用流式模式,逐行从数据库拉数据。
- Cursor 要 try-with-resources 关闭,否则数据库连接不释放。
- 每行写完直接 flush,不攒在内存里。
三、异步任务:别让用户等,导出完给下载链接
10 万行导出要 30 秒以上,HTTP 请求一直挂着用户会以为卡死。改成异步任务模式:
// 用户点导出,立即返回任务ID
@PostMapping("/export")
public Result submitExport(@RequestBody ExportQuery q) {
String taskId = UUID.randomUUID().toString();
asyncTaskExecutor.submit(() -> {
String file = doExport(q); // 流式查+写文件
taskDao.save(taskId, "DONE", file);
});
return Result.success(taskId); // 立即返回
}
// 用户轮询任务状态
@GetMapping("/export/status/{taskId}")
public Result status(@PathVariable String taskId) {
Task t = taskDao.get(taskId);
if ("DONE".equals(t.getStatus())) {
return Result.success(Map.of("status", "DONE", "downloadUrl", t.getFile()));
}
return Result.success(Map.of("status", t.getStatus()));
}
流程:用户点导出 → 后台开任务 → 前端轮询状态 → 完成后给下载链接。
四、文件下载:生成临时文件,定时清理
导出文件写到服务器临时目录,生成一个带签名的下载链接,24 小时后自动删:
// 导出完成后存记录
taskDao.save(taskId, "DONE", "/data/export/物流记录_202610.xlsx");
// 定时任务清理超过24小时的导出文件
@Scheduled(fixedRate = 3600000)
public void cleanExpiredFiles() {
List<Task> expired = taskDao.findExpiredBefore(nowMinusHours(24));
for (Task t : expired) {
fileService.delete(t.getFile());
taskDao.delete(t.getId());
}
}
五、踩坑清单(5 条实际踩过的)
- 流式查询的 fetchSize 必须设 Integer.MIN_VALUE,普通 fetchSize 不生效,MySQL 还是会一次性拉全量。
- Cursor 必须在数据库连接关闭前读完,try-with-resources 里别再开新事务,否则连接释放了游标也关了。
- 异步任务要在独立线程池跑,别占 Web 容器线程,否则导出多了 Tomcat 线程会被占满。
- Excel 写大文件用 SXSSF(POI 的流式写入),别用普通 XSSF,普通模式内存占用巨大。
- 导出文件要设过期清理,不然后磁盘会被几百个临时文件塞满。
| 方案 | 内存占用 | 用户体验 | 适合数据量 |
|---|---|---|---|
| 全量查+内存写 | 极高(OOM) | 等待 | <1000行 |
| 流式查询+同步写 | 低 | 等待 | <1万行 |
| 流式+异步任务+下载链接 | 低 | 轮询通知 | >1万行 |
这次物流记录导出的改造是在固乔快递批量查询助手的后台导出模块上做的,我主要负责接 MyBatis 流式游标、改异步任务和写文件清理那几段,改完后 10 万行导出内存稳定在 200MB 以内,再也没 OOM。
复盘
- 大数据量导出千万别全量查内存,用流式游标逐行处理。
- 导出耗时超过 10 秒就走异步任务+下载链接模式,别让 HTTP 请求挂着。
- 流式查询的 fetchSize 和 Cursor 关闭是最容易踩的两个坑。
以上是个人实践记录,各平台具体功能以官方实时信息为准。
你们做大批量导出时,是同步等待还是异步任务模式?