避免一次性将所有数据加载到内存中,我们需要采用流式处理或分批写入的方式,将数据“流”向 Excel 文件,而不是在内存中构建一个巨大的数据集。

下面我将从问题根源、主流解决方案、代码示例和最佳实践四个方面,详细讲解如何处理大数据导出。
问题根源:为什么大数据导出会 OOM?
当我们使用像 Apache POI 这样的库时,如果不加选择,很容易踩坑。
-
HSSFWorkbook(.xls格式):- 工作原理:所有数据都存储在内存中,当数据量很大时,会瞬间消耗大量内存,极易导致 OOM。
- 限制:
.xls格式本身最多支持 65536 行、256 列,对于“大数据”场景根本不适用。
-
XSSFWorkbook(.xlsx格式):
(图片来源网络,侵删)- 工作原理:早期版本(如 3.x)与
HSSFWorkbook类似,也是全内存操作,虽然.xlsx格式本身支持百万行数据,但XSSFWorkbook在写入时,会先将整个文档结构(包括样式、公式等)都加载到内存中,再填充数据,数据量巨大时依然会 OOM。 - 改进:新版本的 POI(如 4.x+)对
XSSFWorkbook做了优化,引入了SXSSFWorkbook,这是解决大数据问题的关键。
- 工作原理:早期版本(如 3.x)与
直接使用 HSSFWorkbook 或旧版 XSSFWorkbook 来处理大数据是导致 OOM 的主要原因。
主流解决方案
Apache POI 的 SXSSFWorkbook (推荐)
这是目前处理大数据导出最主流、最高效的方案。
-
核心原理:
SXSSFWorkbook是XSSFWorkbook的一个流式实现,它结合了XSSF和SXSSF的优点。- 它使用 临时文件 来存储不常访问的数据(已经写入到 Excel 但屏幕滚动后就看不见的数据行)。
- 它只保留一个固定大小的 “行访问窗口” 在内存中(只保留最近写入的 100 行)。
- 当新数据写入导致窗口溢出时,最旧的数据就会被“刷”(
flush)到硬盘中,从而释放内存。 - 注意:
SXSSFWorkbook生成的.xlsx文件是 只读的,因为它依赖临时文件,你无法在生成后立即修改它(比如添加公式、图表等复杂操作),但作为数据导出功能,这完全足够。
-
优点:
- 内存占用极低:与数据量大小基本无关,只取决于窗口大小。
- 性能高:写入速度快,适合百万甚至千万级数据导出。
- API 兼容:使用方式与
XSSFWorkbook非常相似,学习成本低。
-
缺点:
生成的文件是只读的。
代码示例 (使用 SXSSFWorkbook)
下面是一个完整的、可运行的示例,演示如何使用 SXSSFWorkbook 导出百万条数据。
添加 Maven 依赖
确保你的 pom.xml 中有 POI 依赖,推荐使用较新版本。
<dependencies>
<!-- POI Core -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.3</version> <!-- 使用较新版本 -->
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency>
<!-- 为了方便生成测试数据,使用 Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
</dependencies>
Java 代码实现
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import java.io.FileOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class BigDataExportDemo {
public static void main(String[] args) {
// 1. 模拟从数据库或其他数据源获取数据
// 在实际项目中,这里应该是一个分页查询的循环
List<User> allUsers = generateMockUsers(1_000_000); // 生成100万条模拟数据
// 2. 定义文件输出路径
String filePath = "D:/temp/big_data_export.xlsx";
// 3. 开始导出
long startTime = System.currentTimeMillis();
System.out.println("开始导出 Excel...");
try {
exportWithSXSSFWorkbook(allUsers, filePath);
} catch (IOException e) {
e.printStackTrace();
System.err.println("导出失败!");
return;
}
long endTime = System.currentTimeMillis();
System.out.println("导出成功!文件路径: " + filePath);
System.out.println("总耗时: " + (endTime - startTime) / 1000.0 + " 秒");
}
/**
* 使用 SXSSFWorkbook 导出大数据
* @param dataList 数据列表
* @param filePath 文件路径
*/
public static void exportWithSXSSFWorkbook(List<User> dataList, String filePath) throws IOException {
// 1. 创建 SXSSFWorkbook 对象
// 参数 100 是指在内存中保留的行数,超过100行的数据会被写入临时文件
// 这个值可以根据你的内存大小和实际情况调整
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
// 2. 创建工作表
Sheet sheet = workbook.createSheet("用户数据");
// 3. 创建样式(可选,但推荐)
CellStyle headerStyle = createHeaderStyle(workbook);
CellStyle dateStyle = createDateStyle(workbook);
// 4. 创建表头
Row headerRow = sheet.createRow(0);
headerRow.createCell(0).setCellValue("ID");
headerRow.createCell(1).setCellValue("姓名");
headerRow.createCell(2).setCellValue("年龄");
headerRow.createCell(3).setCellValue("创建时间");
headerRow.createCell(4).setCellValue("账户余额");
// 应用表头样式
for (int i = 0; i < 5; i++) {
headerRow.getCell(i).setCellStyle(headerStyle);
}
// 5. 写入数据
AtomicInteger rowNum = new AtomicInteger(1); // 从第2行开始写数据
dataList.forEach(user -> {
Row row = sheet.createRow(rowNum.getAndIncrement());
row.createCell(0).setCellValue(user.getId());
row.createCell(1).setCellValue(user.getName());
row.createCell(2).setCellValue(user.getAge());
Cell dateCell = row.createCell(3);
dateCell.setCellValue(user.getCreateTime());
dateCell.setCellStyle(dateStyle);
row.createCell(4).setCellValue(user.getAccountBalance().doubleValue());
// 每写入1000行,可以手动调用一下 flush(),让数据更早地写入磁盘,减少内存压力
// 但通常在 finally 或 try-with-resources 中自动 flush 就足够了
if (rowNum.get() % 1000 == 0) {
System.out.println("已处理 " + rowNum.get() + " 行数据...");
}
});
// 6. 自动调整列宽(可选)
// 注意:自动调整列宽在数据量巨大时可能比较耗时
for (int i = 0; i < 5; i++) {
sheet.autoSizeColumn(i);
}
// 7. 写入到文件
// 使用 try-with-resources 确保 FileOutputStream 被关闭
try (FileOutputStream out = new FileOutputStream(filePath)) {
workbook.write(out);
}
// 8. 清理临时文件
// 当 workbook 被关闭时(try-with-resources 会自动调用),临时文件会被自动删除
// 如果没有使用 try-with-resources,则需要手动调用 workbook.dispose()
// workbook.dispose();
}
}
// 创建表头样式
private static CellStyle createHeaderStyle(SXSSFWorkbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
return style;
}
// 创建日期样式
private static CellStyle createDateStyle(SXSSFWorkbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setDataFormat(workbook.createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss"));
return style;
}
// 模拟生成大量数据
private static List<User> generateMockUsers(int count) {
List<User> users = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
users.add(new User((long) i, "用户_" + i, 18 + (i % 50), new Date(), new BigDecimal("1000." + (i % 100))));
}
return users;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
static class User {
private Long id;
private String name;
private Integer age;
private Date createTime;
private BigDecimal accountBalance;
}
}
其他解决方案及最佳实践
EasyExcel (阿里巴巴开源)
如果你不想用 POI,或者觉得 POI 的 API 有些繁琐,阿里巴巴的 EasyExcel 是一个绝佳的选择。
- 核心原理:同样是基于
SXSSFWorkbook,但做了更进一步的封装和优化,它通过AnalysisEventListener监听器模式,将读取和写入都变成了事件驱动,内存控制得非常好。 - 优点:
- API 极简:代码量比 POI 少很多,可读性高。
- 内存占用同样极低。
- 功能强大:支持复杂的表头、动态表头、数据转换等。
- 社区活跃:国内使用广泛,文档和案例丰富。
EasyExcel 写入示例(简化版):
// 1. 准备数据(可以是任何集合,甚至直接从数据库分页查询)
List<User> data = ...;
// 2. 写入文件
String fileName = "D:/temp/easyexcel_export.xlsx";
// 通过 write() 和 sheet() 指定文件和工作表
// 通过 doWrite() 写入数据
EasyExcel.write(fileName, User.class).sheet("用户数据").doWrite(data);
可以看到,EasyExcel 的代码量大大减少,非常适合快速开发。
CSV 文件
如果你的需求只是将数据导出为表格形式,不关心 Excel 的复杂格式(如合并单元格、公式、图表等),CSV 是最轻量、最高效的选择。
- 核心原理:CSV 是纯文本格式,每一行就是一条记录,用逗号分隔字段,它不涉及任何复杂的文件结构,写入过程就是简单的字符串拼接和文件写入。
- 优点:
- 性能最高,内存占用最小:几乎可以忽略不计。
- 兼容性好:可以被 Excel、WPS、文本编辑器等几乎所有软件打开。
- 实现简单:几行代码就能搞定。
- 缺点:
- 无格式:无法设置字体、颜色、边框等。
- 数据类型问题:所有数据都是字符串,需要自行处理。
Java 写入 CSV 示例:
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class CsvExportDemo {
public static void main(String[] args) throws IOException {
List<User> users = generateMockUsers(1_000_000);
String filePath = "D:/temp/big_data_export.csv";
try (BufferedWriter writer = new BufferedWriter(new FileWriter(filePath, StandardCharsets.UTF_8))) {
// 写入表头
writer.write("ID,姓名,年龄,创建时间,账户余额");
writer.newLine();
// 写入数据
for (User user : users) {
String line = String.format("%d,%s,%d,%s,%.2f",
user.getId(),
user.getName(),
user.getAge(),
user.getCreateTime(), // 注意:日期需要格式化
user.getAccountBalance());
writer.write(line);
writer.newLine();
}
}
System.out.println("CSV 导出完成!");
}
// ... generateMockUsers 和 User 类同上 ...
}
最佳实践总结
-
首选方案:
- 需要
.xlsx格式和基本样式:使用SXSSFWorkbook(POI) 或 EasyExcel,EasyExcel 更推荐,因为它更简单。 - 只需要纯数据表格,对格式无要求:使用 CSV,性能和效率最优。
- 需要
-
数据源处理:
- 不要一次性查询所有数据! 这是最重要的原则,如果你的数据来自数据库,一定要使用 分页查询,在循环中,每次只查询一页数据(1000 或 5000 条),然后写入 Excel,再查询下一页。
- 伪代码示例:
int pageSize = 5000; int pageNum = 1; List<User> pageData; do { pageData = userMapper.selectPage(pageNum, pageSize); // 分页查询 writeToExcel(pageData); // 将当前页数据写入 Excel pageNum++; } while (!pageData.isEmpty());
-
性能优化:
- 减少样式操作:频繁地创建和应用样式会消耗性能,尽量复用
CellStyle对象。 - 禁用自动列宽:
sheet.autoSizeColumn()在数据量大时非常耗时,如果可以,手动设置一个固定的列宽。 - 使用
try-with-resources:确保Workbook和FileOutputStream等资源被正确关闭,避免文件句柄泄漏。 - 考虑多线程:如果数据预处理耗时较长,可以将数据预处理和 Excel 写入放到两个线程中,使用队列进行数据传递,实现流水线作业。
- 减少样式操作:频繁地创建和应用样式会消耗性能,尽量复用
-
用户体验:
对于超大数据导出,可以考虑提供一个异步任务接口,用户点击导出后,立即返回一个任务 ID,让用户可以继续其他操作,后台通过一个定时任务或消息队列来执行导出,完成后通过邮件或 WebSocket 通知用户下载,这能避免因导出时间过长导致 HTTP 请求超时。
