EasyExcel 百万级数据导出
一、工具介绍
本文档提供的工具类基于阿里 EasyExcel 封装,专门解决百万级大数据量 Excel 导出场景下的 OOM(内存溢出) 问题,支持:
-
小数据量:全量写入(简单高效)
-
大数据量:分页写入(核心方案,避免内存溢出)
-
异步导出:支持任务进度追踪、结果查询
二、核心依赖
使用前需引入 EasyExcel 核心依赖(Maven 示例):
<!-- EasyExcel 核心依赖 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version> <!-- 建议使用最新稳定版 -->
</dependency>
<!-- Servlet API (web项目必加) -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
三、核心工具类说明
3.1 ExcelExporter(对外统一入口)
封装了分页/普通导出的统一逻辑,对外提供简洁API,无需关注底层细节:
| 方法 | 适用场景 | 核心参数 |
|---|---|---|
exportSimple |
小数据量(<1万条) | 响应对象、文件名、Sheet名、数据模型、全量数据List |
exportByPage |
大数据量(≥1万条) | 响应对象、文件名、Sheet名、数据模型、页大小、总条数、分页查询逻辑 |
- 代码如下:
/**
* 【开箱即用】EasyExcel 导出增强工具类 (支持普通/分页模式)
*/
public class ExcelExporter {
// ============== 【1. 分页写入 (大数据量首选)】 ==============
public static <T> void exportByPage(HttpServletResponse response,
String fileName, // 下载文件名
String sheetName, // Sheet名称
Class<T> dataModel, // 数据类 (User.class)
int pageSize, // 每页条数
int totalCount, // 总条数
PageQuerySupplier<T> pageSupplier) { // 分页查询逻辑
setupResponse(response, fileName); // 设置响应头
try (OutputStream out = response.getOutputStream()) {
// 🎯 委托给核心分页工具执行
PageWriteExcelHelper.writeByPage(out, dataModel, sheetName, pageSize, totalCount, pageSupplier);
} catch (Exception e) {
throw new RuntimeException("导出失败: " + e.getMessage(), e); // 统一异常处理
}
}
// ============== 【2. 普通导出 (小数据量)】 ==============
public static <T> void exportSimple(HttpServletResponse response, String fileName, String sheetName,
Class<T> dataModel, List<T> dataList) { // 全量数据List
setupResponse(response, fileName);
try (OutputStream out = response.getOutputStream()) {
// 3️⃣ EasyExcel 一键写入
EasyExcel.write(out, dataModel)
.sheet(sheetName)
.doWrite(dataList); // 全量写入
} catch (Exception e) {
throw new RuntimeException("导出失败: " + e.getMessage(), e);
}
}
// ============== 【私有方法:响应头设置 (复用)】 ==============
private static void setupResponse(HttpServletResponse response, String fileName) {
try {
// 2️⃣ 设置响应头 (固定套路)
//response.setContentType("application/vnd.ms-excel");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8");
String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20"); // 处理空格
response.setHeader("Content-disposition", "attachment;filename=" + encodedFileName + ".xlsx");
} catch (Exception e) {
throw new RuntimeException("设置响应头失败", e);
}
}
// ============== 【内部接口:分页查询供应商】 ==============
@FunctionalInterface
public interface PageQuerySupplier<T> {
List<T> getPage(int pageNum, int pageSize); // 函数式接口
}
}
3.2 PageWriteExcelHelper(分页写入核心)
处理分页写入的核心逻辑,核心能力:
-
分批次查询数据,避免一次性加载全量数据到内存
-
写入后立即清空当前页数据,释放内存
-
自动计算总页数,循环写入
-
确保ExcelWriter资源释放,防止内存泄漏
/**
* 【核心武器】分页写入Excel工具 - 专治各种不服(OOM)
*/
public class PageWriteExcelHelper<T> {
// 🎯 关键接口:定义如何分页获取数据 (由调用方实现)
public interface PageQuerySupplier<T> {
List<T> getPage(int pageNum, int pageSize); // 第几页? 每页几条?
}
/**
* 执行分页写入
* @param outputStream 输出流 (响应OutputStream)
* @param head 数据模型Class (如 User.class)
* @param pageSize 【重要】每批次处理条数 (建议 1000~5000)
* @param totalCount 总数据量 (用于计算总页数)
* @param supplier 分页数据提供器 (你的业务查询逻辑)
*/
public static <T> void writeByPage(OutputStream outputStream, Class<T> head, String sheetName,
int pageSize, int totalCount, PageQuerySupplier<T> supplier) {
// 🔧 1. 初始化 ExcelWriter (EasyExcel 核心写入器)
ExcelWriter excelWriter = EasyExcel.write(outputStream, head).build();
WriteSheet writeSheet = EasyExcel.writerSheet(sheetName).build(); // 默认Sheet
try {
// 📐 2. 计算总页数 (小心除0)
int totalPage = totalCount > 0 ? (int) Math.ceil((double) totalCount / pageSize) : 1;
// 🔁 3. 分页循环:查询 -> 写入 -> 释放
for (int pageNum = 1; pageNum <= totalPage; pageNum++) {
// 🚚 3.1 获取当前页数据 (你的分页查询)
List<T> pageData = supplier.getPage(pageNum, pageSize);
// ✍️ 3.2 写入当前页到 Excel
excelWriter.write(pageData, writeSheet);
// 🗑️ 3.3 【关键】立即清空释放当前页内存!
pageData.clear();
}
} finally {
// 🔒 4. 【务必关闭】释放资源 (防止内存泄漏)
if (excelWriter != null) {
excelWriter.finish(); // 重要!!!
}
}
}
}
四、快速使用指南
4.1 步骤1:定义数据模型(Excel列映射)
@Data
public class User {
@ExcelProperty("用户ID") // Excel列名
private Long id;
@ExcelProperty("用户名")
private String username;
@ExcelProperty("手机号")
private String phone;
@ExcelProperty("创建时间")
private String createTime;
}
4.2 步骤2:编写Service层(分页查询逻辑)
@Service
public class UserService {
/**
* 全量查询(仅小数据量使用)
*/
public List<User> findAllUsers() {
// 实际业务中替换为Mapper/DAO查询
return new ArrayList<>();
}
/**
* 分页查询(大数据量核心)
* @param pageNum 页码(从1开始)
* @param pageSize 页大小
*/
public List<User> findByPage(int pageNum, int pageSize) {
// 计算偏移量:(pageNum-1)*pageSize
int offset = (pageNum - 1) * pageSize;
// 实际业务中替换为Mapper分页查询:select * from user limit #{offset}, #{pageSize}
return new ArrayList<>();
}
/**
* 查询总条数(用于计算分页)
*/
public int countTotalUsers() {
// 实际业务中替换为:select count(1) from user
return 0;
}
}
4.3 步骤3:Controller层接入导出接口
@RestController
@RequestMapping("/export")
@Slf4j
public class ExportController {
@Resource
private UserService userService;
@Resource
private ExportTaskService exportTaskService; // 任务进度服务(异步导出用)
@Resource
private ThreadPoolExecutor asyncTaskExecutor; // 自定义线程池
// ========== 场景1:小数据量导出(<1万条) ==========
@GetMapping("/users/small")
public void exportSmallUserList(HttpServletResponse response) {
// 1. 全量查询数据(仅小数据量使用),数据量大必OOM!
List<User> smallList = userService.findAllUsers();
// 2. 调用工具类导出
ExcelExporter.exportSimple(
response, // 响应对象
"最近用户列表", // 下载文件名(无需.xlsx后缀)
"用户数据", // Sheet名称
User.class, // 数据模型类
smallList // 全量数据
);
}
// ========== 场景2:大数据量导出(10万+条) ==========
@GetMapping("/users/large")
public void exportLargeUserList(HttpServletResponse response) {
// 1. 查询总条数
int total = userService.countTotalUsers();
// 2. 分页导出(每页3000条,建议1000-5000)
ExcelExporter.exportByPage(
response, // 响应对象
"全量用户数据", // 下载文件名
"用户清单", // Sheet名称
User.class, // 数据模型类
3000, // 每页条数(核心参数,根据服务器内存调整)
total, // 总条数
(pageNum, pageSize) -> userService.findByPage(pageNum, pageSize) // 分页查询逻辑
);
}
// ========== 场景3:异步导出(百万级/超大数据量) ==========
/**
* 触发异步导出(返回任务ID,前端轮询进度)
*/
@GetMapping("/users/async")
public ResponseEntity<String> triggerAsyncExport() {
// 1. 生成唯一任务ID
String taskId = "EXPORT_" + System.currentTimeMillis();
// 2. 提交异步任务(避免阻塞请求线程)
asyncTaskExecutor.execute(() -> doExportTask(taskId));
// 3. 返回任务ID给前端
return ResponseEntity.ok("导出任务已提交,任务ID:" + taskId + ",请稍后查询进度");
}
/**
* 实际执行异步导出任务
*/
private void doExportTask(String taskId) {
try {
// 1. 初始化任务状态(进度0%)
exportTaskService.save(new ExportTask(taskId, "PROCESSING", 0));
// 2. 查询总条数
int total = userService.countTotalUsers();
AtomicInteger exported = new AtomicInteger(0); // 已导出条数
// 3. 构建临时文件(异步导出不能直接写Response,需先写本地/OSS)
String tempFilePath = "/tmp/export/" + taskId + ".xlsx";
File tempFile = new File(tempFilePath);
if (!tempFile.getParentFile().exists()) {
tempFile.getParentFile().mkdirs();
}
// 4. 分页写入到临时文件
try (FileOutputStream out = new FileOutputStream(tempFile)) {
PageWriteExcelHelper.writeByPage(
out,
User.class,
"用户清单",
3000,
total,
(pageNum, pageSize) -> {
// 分页查询数据
List<User> page = userService.findByPage(pageNum, pageSize);
// 更新进度
int currentExported = exported.addAndGet(page.size());
int progress = (int) ((currentExported / (double) total) * 100);
exportTaskService.updateProgress(taskId, progress);
return page;
}
);
}
// 5. 任务完成(更新状态+存储文件路径)
exportTaskService.updateStatus(taskId, "SUCCESS", 100, tempFilePath);
log.info("异步导出完成,任务ID:{},文件路径:{}", taskId, tempFilePath);
} catch (Exception e) {
// 6. 任务失败(更新状态+错误信息)
exportTaskService.updateStatus(taskId, "FAILED", 0, e.getMessage());
log.error("异步导出失败,任务ID:{}", taskId, e);
}
}
/**
* 查询导出进度
*/
@GetMapping("/progress/{taskId}")
public ResponseEntity<ExportProgress> getExportProgress(@PathVariable String taskId) {
ExportProgress progress = exportTaskService.getProgress(taskId);
return ResponseEntity.ok(progress);
}
/**
* 下载异步导出的文件
*/
@GetMapping("/download/{taskId}")
public void downloadExportFile(@PathVariable String taskId, HttpServletResponse response) {
// 1. 查询任务信息
ExportTask task = exportTaskService.getTaskById(taskId);
if (task == null || !"SUCCESS".equals(task.getStatus())) {
throw new RuntimeException("任务不存在或未完成");
}
// 2. 读取临时文件
File file = new File(task.getFilePath());
if (!file.exists()) {
throw new RuntimeException("导出文件不存在");
}
// 3. 写入响应(复用工具类的响应头设置)
ExcelExporter.setupResponse(response, "全量用户数据_" + taskId);
try (FileInputStream in = new FileInputStream(file);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
} catch (Exception e) {
throw new RuntimeException("文件下载失败", e);
}
}
}
4.4 步骤4:补充异步导出相关类
4.4.1 任务进度实体
@Data
public class ExportTask {
private String taskId; // 任务ID
private String status; // 状态:PROCESSING/SUCCESS/FAILED
private int progress; // 进度(0-100)
private String filePath; // 文件路径(成功后)
private String errorMsg; // 错误信息(失败后)
public ExportTask(String taskId, String status, int progress) {
this.taskId = taskId;
this.status = status;
this.progress = progress;
}
}
4.4.2 任务进度服务
@Service
public class ExportTaskService {
// 实际业务中替换为数据库/Redis存储
private final Map<String, ExportTask> taskMap = new ConcurrentHashMap<>();
public void save(ExportTask task) {
taskMap.put(task.getTaskId(), task);
}
public void updateProgress(String taskId, int progress) {
ExportTask task = taskMap.get(taskId);
if (task != null) {
task.setProgress(progress);
}
}
public void updateStatus(String taskId, String status, int progress, String msg) {
ExportTask task = taskMap.get(taskId);
if (task != null) {
task.setStatus(status);
task.setProgress(progress);
if ("SUCCESS".equals(status)) {
task.setFilePath(msg);
} else if ("FAILED".equals(status)) {
task.setErrorMsg(msg);
}
}
}
public ExportProgress getProgress(String taskId) {
ExportTask task = taskMap.get(taskId);
if (task == null) {
return new ExportProgress(taskId, "NOT_FOUND", 0);
}
return new ExportProgress(taskId, task.getStatus(), task.getProgress());
}
public ExportTask getTaskById(String taskId) {
return taskMap.get(taskId);
}
}
4.4.3 进度返回VO
@Data
public class ExportProgress {
private String taskId; // 任务ID
private String status; // 状态
private int progress; // 进度(0-100)
public ExportProgress(String taskId, String status, int progress) {
this.taskId = taskId;
this.status = status;
this.progress = progress;
}
}
4.4.4 自定义线程池配置(异步导出用)
@Configuration
public class ThreadPoolConfig {
@Bean
public ThreadPoolExecutor asyncTaskExecutor() {
return new ThreadPoolExecutor(
5, // 核心线程数
10, // 最大线程数
60, // 空闲线程存活时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(100), // 任务队列
new ThreadFactory() { // 线程命名
private int count = 0;
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "export-task-" + (++count));
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
}
}
五、关键优化点 & 注意事项
5.1 核心优化点
-
分页查询+分批写入:避免一次性加载全量数据到内存,从根源解决OOM
-
内存及时释放:每页写入后调用
pageData.clear()清空当前页数据 -
资源必释放:ExcelWriter必须调用
finish(),流必须用try-with-resources关闭 -
异步导出:超大数据量(百万级)需异步执行,避免请求超时
5.2 注意事项
-
页大小选择:建议设置为1000-5000条,过大会占用内存,过小会增加IO次数
-
文件名编码:工具类已处理中文/空格编码,无需额外处理
-
响应头设置:固定使用
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet(xlsx格式) -
异步导出文件存储:临时文件建议定期清理,或存储到OSS等对象存储
-
异常处理:所有IO操作必须捕获异常,统一抛出业务异常
六、常见问题排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| OOM内存溢出 | 一次性加载全量数据/页大小过大 | 改用分页导出,减小页大小 |
| Excel文件损坏 | ExcelWriter未调用finish() | 确保finally块中执行excelWriter.finish() |
| 文件名乱码 | 未编码/编码方式错误 | 使用工具类的setupResponse方法统一处理 |
| 接口超时 | 大数据量同步导出 | 改用异步导出+进度查询 |
| 线程池满 | 异步任务提交过多 | 调整线程池参数,增加队列容量 |
总结
-
小数据量:直接使用
ExcelExporter.exportSimple,全量查询 + 一次性写入,简单高效; -
大数据量:使用
ExcelExporter.exportByPage,分页查询 + 分批写入,核心解决OOM问题; -
超大数据量(百万级):采用异步导出方案,先写临时文件,再提供下载接口,避免请求超时和线程阻塞。
核心原则:避免一次性加载全量数据到内存,分批处理、及时释放资源。
更多推荐


所有评论(0)