本文目录导读:

- 📚 目录导读
- 问题背景:你还在用Workbook导出?小心OOM!
- 核心方案对比:三大武器如何选型?
- 实战案例解剖:500万订单导出重构记
- 性能调优四板斧
- 企业级高可用设计:导出中心化
- 常见问题FAQ
- 总结与最佳实践
📚 目录导读
- 问题背景:为什么大数据量导出会OOM?传统POI为何不堪重负?
- 核心方案对比:SXSSFWorkbook(流式Excel) vs CSV流式输出 vs 异步任务+分页查询
- 实战案例解剖:一个真实订单系统(500万行)的导出重构全过程
- 性能调优四板斧:内存控制、GC优化、IO瓶颈、前端接收策略
- 企业级高可用设计:导出中心化 + 任务队列 + 文件管理
- 常见问题FAQ:针对开发者的高频疑问解答
- 总结与最佳实践
问题背景:你还在用Workbook导出?小心OOM!
很多Java开发者在首次面对“导出10万条以上数据”时,第一反应是使用Apache POI的XSSFWorkbook,但在数据量达到百万级时,XSSFWorkbook会将所有Sheet、单元格、样式全部驻留内存,导致java.lang.OutOfMemoryError: Java heap space。
真实案例:某电商后台导出3个月订单(约200万行),使用
XSSFWorkbook直接导致生产环境频繁Full GC,响应超时达到20秒,最后应用崩溃。
根本原因:POI的XSSFWorkbook采用DOM模型,一个单元格约占用1KB内存,100万行×10列×1KB = 10GB内存,远超常规堆设置。
核心方案对比:三大武器如何选型?
方案1:SXSSFWorkbook(POI 3.8+)— 流式Excel
- 原理:滑动窗口机制,仅保留指定行数(
rowAccessWindowSize)在内存,超出部分刷入临时文件。 - 优点:保留Excel格式,支持多Sheet,样式丰富。
- 缺点:只能生成
.xlsx,且临时文件占磁盘;不支持读取(仅写入)。 - 适用:需要复杂格式、多Sheet、必须Excel文件。
// 核心代码片段
SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 保留100行在内存
Sheet sheet = workbook.createSheet("订单");
for (int i = 0; i < 5000000; i++) {
Row row = sheet.createRow(i);
// 填充数据...
if (i % 1000 == 0) {
((SXSSFSheet) sheet).flushRows(100); // 手动刷盘
}
}
方案2:CSV流式输出(性能之王)
- 原理:纯文本,按行拼接字符串,直接写入
HttpServletResponse.getOutputStream()。 - 优点:内存占用几乎为0,速度最快,支持无限大。
- 缺点:无格式(不能合并单元格、加粗),需注意编码(UTF-8 BOM防乱码)。
- 适用:大数据量、格式要求低、B端后台导出。
response.setContentType("text/csv;charset=UTF-8");
response.setHeader("Content-Disposition", "attachment;filename=orders.csv");
PrintWriter writer = response.getWriter();
// 写入BOM防止Excel打开乱码
writer.write('\ufeff');
// 逐行查询并写入
try (Stream<Order> stream = orderMapper.streamAll()) {
Iterator<Order> it = stream.iterator();
while (it.hasNext()) {
writer.write(convertToCsvLine(it.next()));
}
}
方案3:异步任务+分页查询(最稳妥)
- 原理:导出请求提交到
ThreadPoolExecutor,后台任务以游标或ID分页方式分批查询数据库,写入本地文件,完成后异步通知下载。 - 优点:完全不阻塞Web线程,不会OOM,支持超大(亿级)。
- 缺点:实现复杂度高,需要任务状态管理。
实战案例解剖:500万订单导出重构记
业务场景:财务系统需要导出近一年所有订单,行数约500万,字段20列。
问题初现:原系统用XSSFWorkbook,导出耗时40分钟,且多次OOM。
重构方案:采用方案1+方案3的组合——异步任务中,分页查询(每页5000条),数据实时转为CSV写文件,最后提供下载链接。
关键代码架构:
@Service
public class ExportService {
@Async("exportExecutor")
public void exportLargeData(Long taskId) {
ExportTask task = taskMapper.selectById(taskId);
Path tempFile = Files.createTempFile("export_" + taskId, ".csv");
try (BufferedWriter bw = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) {
bw.write('\ufeff'); // BOM
long lastId = 0;
while (true) {
List<Order> page = orderMapper.findByPage(lastId, 5000);
if (page.isEmpty()) break;
page.forEach(order -> writeLine(bw, order));
lastId = page.get(page.size() - 1).getId(); // 用ID游标翻页
}
}
// 更新任务状态为完成,记录文件路径
}
}
性能对比: | 指标 | 原方案(XSSF) | 新方案(SXSSF+异步) | 新方案(CSV+异步) | |------|--------------|-------------------|------------------| | 耗时 | 40min | 12min | 3.5min | | 峰值内存 | 8GB(OOM) | 800MB | 150MB | | 用户等待 | 40min阻塞 | 立即返回任务ID | 立即返回任务ID |
性能调优四板斧
1️⃣ 内存控制
- 使用
SXSSFWorkbook时,windowSize不宜过大,推荐100-500。 - 避免在循环中创建
CellStyle,应复用样式对象。
2️⃣ GC与JVM调优
- 建议使用G1垃圾收集器,并调整
MaxGCPauseMillis。 - 大导出任务建议使用独立的线程池,避免占满Tomcat公共线程。
3️⃣ IO瓶颈
- 使用
BufferedOutputStream或BufferedWriter,批量写缓存(如每1000条flush一次)。 - 数据库查询使用流式游标(
MyBatis的fetchSize设置为Integer.MIN_VALUE,配合ResultHandler配合Oracle/MySQL流式读取)。
4️⃣ 前端接收策略
- 前端不要用
window.location.href直接下载大文件,容易超时,应使用axios请求获取Blob,或采用任务轮询方式。 - 超大文件建议前端显示下载进度条(通过WebSocket推送进度)。
企业级高可用设计:导出中心化
当平台有多个业务需要导出功能,建议抽象一个导出中心:
- 任务表:
task_id, type, params, status, file_url, created_at - 统一调度:Quartz或ShedLock定时扫描待执行任务。
- 文件存储:生成文件后上传至OSS/MinIO,生成临时下载URL(有效期1小时)。
- 失败重试:任务失败自动重试2次,并记录错误日志。
常见问题FAQ
Q1:CSV文件用Excel打开中文乱码怎么办?
答:写文件之前输出UTF-8 BOM(
\ufeff),Excel即能正确识别。
Q2:SXSSFWorkbook生成多个Sheet性能差,怎么办?
答:多Sheet会导致频繁刷盘,尽量合并为单Sheet,或每Sheet控制在50万行内,最后用
workbook.dispose()清理临时文件。
Q3:流式查询(游标)会不会一直占着数据库连接?
答:会的,务必在
finally块中关闭流和SqlSession,对于MySQL,记得设置useCursorFetch=true,并调整fetchSize。
Q4:导出1000万行,CSV文件超过5GB,用户下载不了怎么办?
答:超过2GB建议直接生成压缩包(.zip),多个CSV文件分卷,前端解压工具自动处理。
Q5:如何处理导出期间数据库新增数据?
答:导出开始前,用
SELECT MAX(id)切分快照,或使用FOR UPDATE锁表(不推荐),最常用的是按时间范围筛选,容忍轻微不一致。
总结与最佳实践
- 首选方案:大数据量(>50万行)优先选择 CSV流式输出 + 异步任务。
- 格式敏感:需要Excel格式且行数<200万,用
SXSSFWorkbook。 - 核心口诀:不一次性查全量,不一次性写全部,不占用Web线程。
- 监控:导出任务必须记录耗时、内存、成功行数,便于持续优化。
建议收藏本文,当你的系统下次面对百万级导出需求,不再手足无措,架构演进永远从最简单的方案开始,当性能瓶颈出现时,再逐步升级,祝你的代码永不OOM!