一、工具介绍

本文档提供的工具类基于阿里 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(分页写入核心)

处理分页写入的核心逻辑,核心能力:

  1. 分批次查询数据,避免一次性加载全量数据到内存

  2. 写入后立即清空当前页数据,释放内存

  3. 自动计算总页数,循环写入

  4. 确保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 核心优化点

  1. 分页查询+分批写入:避免一次性加载全量数据到内存,从根源解决OOM

  2. 内存及时释放:每页写入后调用 pageData.clear() 清空当前页数据

  3. 资源必释放:ExcelWriter必须调用 finish(),流必须用try-with-resources关闭

  4. 异步导出:超大数据量(百万级)需异步执行,避免请求超时

5.2 注意事项

  1. 页大小选择:建议设置为1000-5000条,过大会占用内存,过小会增加IO次数

  2. 文件名编码:工具类已处理中文/空格编码,无需额外处理

  3. 响应头设置:固定使用 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet(xlsx格式)

  4. 异步导出文件存储:临时文件建议定期清理,或存储到OSS等对象存储

  5. 异常处理:所有IO操作必须捕获异常,统一抛出业务异常

六、常见问题排查

问题 原因 解决方案
OOM内存溢出 一次性加载全量数据/页大小过大 改用分页导出,减小页大小
Excel文件损坏 ExcelWriter未调用finish() 确保finally块中执行excelWriter.finish()
文件名乱码 未编码/编码方式错误 使用工具类的setupResponse方法统一处理
接口超时 大数据量同步导出 改用异步导出+进度查询
线程池满 异步任务提交过多 调整线程池参数,增加队列容量

总结

  1. 小数据量:直接使用 ExcelExporter.exportSimple,全量查询 + 一次性写入,简单高效;

  2. 大数据量:使用 ExcelExporter.exportByPage,分页查询 + 分批写入,核心解决OOM问题;

  3. 超大数据量(百万级):采用异步导出方案,先写临时文件,再提供下载接口,避免请求超时和线程阻塞。

核心原则:避免一次性加载全量数据到内存,分批处理、及时释放资源

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐