作为Java开发者,大数据量导出Excel是常见的需求,但传统的POI方式极易导致OOM。下面我将分享一套完整的解决方案,涵盖多种技术路线,帮你彻底解决这个问题。
一、问题根源分析
1.1 传统POI方式为什么OOM
// 典型的危险写法 - 一次性加载所有数据到内存
Workbook workbook = new XSSFWorkbook(); // 每个Sheet默认限制104万行
Sheet sheet = workbook.createSheet();
for (Data data : getAllDataFromDB()) { // 百万数据全部加载到内存
Row row = sheet.createRow();
// 填充数据...
}
内存消耗估算:
- 每行数据按1KB计算
- 100万行 ≈ 1GB堆内存占用
- 加上POI对象开销 ≈ 1.5GB+
1.2 JVM内存限制
- 32位JVM最大2-4GB
- 64位JVM默认1/4物理内存
- 典型线上环境:2-4GB Xmx
二、解决方案全景图
方案 | 适用场景 | 优点 | 缺点 |
SXSSF | 单机中等数据量(10-100万) | 官方实现,简单可靠 | 仍有限制 |
分页查询+分批写入 | 任何规模 | 完全可控 | 需要多次查询 |
CSV代替Excel | 无需复杂格式 | 极高性能 | 功能受限 |
消息队列+分布式 | 超大数据量 | 可水平扩展 | 架构复杂 |
三、SXSSF方案(推荐)
3.1 核心实现
// 使用SXSSFWorkbook,设置行访问窗口
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { // 保持100行在内存
Sheet sheet = workbook.createSheet();
// 分页查询
int pageSize = 5000;
for (int page = 0; ; page++) {
List<Data> batch = queryByPage(page, pageSize);
if (batch.isEmpty()) break;
for (Data data : batch) {
Row row = sheet.createRow();
// 填充数据...
}
// 手动flush已处理的行
if (page % 20 == 0) {
((SXSSFSheet)sheet).flushRows(100); // 保持100行在内存
}
}
// 写入文件
workbook.write(new FileOutputStream("large.xlsx"));
}
3.2 关键参数调优
// 创建SXSSFWorkbook时的优化配置
SXSSFWorkbook workbook = new SXSSFWorkbook(-1); // -1表示禁用临时文件压缩
workbook.setCompressTempFiles(true); // 启用压缩(CPU换内存)
// 设置临时文件存储位置
System.setProperty("org.apache.poi.util.DefaultTempFileCreationStrategy", "true");
System.setProperty("poi.keep.tmp.files", "false"); // 完成后删除临时文件
四、分页查询+分批写入(最可靠)
4.1 实现模板
try (Workbook workbook = new XSSFWorkbook();
OutputStream out = new FileOutputStream("result.xlsx")) {
Sheet sheet = workbook.createSheet();
int rowNum = 0;
int pageSize = 2000;
// 使用游标分页
String scrollId = null;
do {
PageResult<Data> page = dataService.scrollQuery(scrollId, pageSize);
scrollId = page.getScrollId();
for (Data data : page.getItems()) {
Row row = sheet.createRow(rowNum++);
// 填充数据...
}
// 每处理5万行写入磁盘一次
if (rowNum % 50_000 == 0) {
out.write(workbook.getBytes());
((XSSFSheet)sheet).flush(); // 清空内存中的数据
}
} while (!page.getItems().isEmpty());
// 写入剩余数据
out.write(workbook.getBytes());
}
4.2 性能优化技巧
- 数据库层:
-- MySQL高效分页
SELECT * FROM large_table
WHERE id > ? -- 上一批最大ID
ORDER BY id
LIMIT ?;
-- ES的scroll API
POST /_search/scroll
{
"scroll": "5m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVY..."
}
- JVM参数:
-XX:+UseG1GC -Xmx4g -XX:MaxGCPauseMillis=200
五、CSV方案(极高性能)
5.1 简单实现
try (BufferedWriter writer = Files.newBufferedWriter(Paths.get("output.csv"))) {
// 写表头
writer.write("ID,Name,Amount,Date\n");
// 流式处理
dataService.streamAllData(data -> {
String line = String.format("%d,%s,%.2f,%s\n",
data.getId(),
escapeCsv(data.getName()), // 处理CSV特殊字符
data.getAmount(),
data.getDate());
writer.write(line);
});
}
5.2 CSV转Excel
// 使用Python脚本转换(适合后台异步处理)
ProcessBuilder pb = new ProcessBuilder("python", "csv_to_excel.py", "input.csv", "output.xlsx");
pb.redirectErrorStream(true);
Process p = pb.start();
六、高级方案:分布式导出
6.1 架构设计
[客户端] --> [API网关] --> [消息队列] --> [Worker集群] --> [对象存储]
↑ ↓
[进度查询] <-- [Redis缓存] <-- [状态更新]
6.2 关键代码
// 1. 客户端提交导出任务
@PostMapping("/export")
public Response submitExportTask(@RequestBody ExportRequest request) {
String taskId = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(taskId, "QUEUED");
kafkaTemplate.send("export-tasks", new ExportTaskMessage(taskId, request));
return Response.success(taskId);
}
// 2. Worker处理
@KafkaListener(topics = "export-tasks")
public void handleExportTask(ExportTaskMessage message) {
try {
// 分片处理逻辑
exportService.processChunk(message.getTaskId(), message.getChunkRange());
redisTemplate.opsForValue().set(message.getTaskId(), "PROCESSING");
} catch (Exception e) {
redisTemplate.opsForHash().put("export-errors", message.getTaskId(), e.getMessage());
}
}
七、避坑指南
- 内存监控:
// 添加内存监控
Runtime runtime = Runtime.getRuntime();
log.info("Memory used: {}MB", (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024);
- 常见陷阱:
- 忘记关闭InputStream/OutputStream
- 使用String拼接大文本(应使用StringBuilder)
- 未处理单元格样式内存泄漏
- 兜底方案:
// 设置导出超时中断
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> exportLargeData());
try {
future.get(30, TimeUnit.MINUTES); // 30分钟超时
} catch (TimeoutException e) {
future.cancel(true);
throw new ExportTimeoutException();
}
八、性能对比
测试环境:100万行数据,16列,8核16GB服务器
方案 | 耗时 | 内存峰值 | CPU使用率 |
传统XSSF | OOM | - | - |
SXSSF | 2m15s | 1.2GB | 45% |
分页查询 | 3m40s | 800MB | 30% |
CSV | 55s | 200MB | 15% |
分布式 | 1m10s | 各节点<500MB | 60% |
九、总结建议
- 数据量级选择方案:
- <10万:常规SXSSF
- 10-100万:SXSSF+分页查询
100万:CSV或分布式
- 终极方案推荐:
graph TD
A[客户端请求] --> B{数据量>50万?}
B -->|是| C[生成CSV+异步通知]
B -->|否| D[使用SXSSF流式导出]
C --> E[后台转Excel]
E --> F[邮件/站内信通知]