基于 EasyExcel + 线程池 解决 POI 导出时的内存溢出与超时问题

发布于:2025-09-05 ⋅ 阅读:(16) ⋅ 点赞:(0)

导出 Excel 看似简单,但在真实生产环境里,尤其是大数据量导出,往往遇到两个头疼问题:

  • 内存溢出(OOM):Apache POI 在处理大量数据时会占用大量内存(尤其 XLSX/样式/图片多时);

  • 请求超时 / 响应卡顿:导出耗时长,HTTP 同步响应容易超时、占用连接,影响用户体验与资源。

本文围绕 EasyExcel + 线程池(生产者-消费者) 的实战方案,讲清为什么要这样做、架构如何设计、关键代码、性能与复杂度分析、常见问题与解决办法,以及若干典型工程案例与最佳实践清单,帮助你把导出做得稳、快、可运维。


一、为什么选择 EasyExcel + 线程池(设计动机)

  1. EasyExcel 优势

    • 基于阿里实现的流式写入,内部用较轻量的方式操作 Excel,内存占用远小于直接使用 POI 的内存模式(XSSF);

    • 写大量行时性能好,适合大批量导出。

  2. 为什么还要加线程池

    • 导出分为两个主要环节:数据读取(从 DB / 调用方获取)文件写入

    • 数据读取可并行化(并发从 DB 分页取数据);写入(同一个 Excel 文件)通常只能由单线程顺序写,否则文件会损坏。

    • 用线程池把数据获取并行化、并用**生产者-消费者(BlockingQueue)**把“数据批次”安全、高效地交给单写入线程,能在不占用太多内存的前提下把 IO/DB 和 CPU 并行利用起来,缩短总耗时并防止 OOM。

  3. 避免超时

    • 对于大导出,建议将导出过程改成 异步任务(后台生成 + 返回下载链接) 或者 流式下载(先生成到临时文件并在生成完成后下发),避免 HTTP 请求超时问题。


二、整体架构与数据流(High-level)

概念流程:

客户端请求导出 → 后端创建导出任务(任务ID)→ 后端异步执行导出流程:

  • 创建临时文件(stream to disk)

  • 新建 BlockingQueue<List>

  • 创建线程池,提交 N 个 Producer(数据抓取) 任务(每个按分页或 keyset 分段读取)把数据批次放入队列

  • 创建 1 个 Consumer(写入) 线程,从队列取出批次并调用 EasyExcel 写入(小批量写入,立即 flush)

  • 所有数据写完后关闭 writer,上传/移动/通知用户下载地址

客户端可以:

  • 轮询任务状态或通过回调/消息通知得到下载链接

  • 或在请求端阻塞等待(只适合小量数据且有很长超时时间的场景,不推荐)

注意:写入同一文件必须由单线程负责;并发写入同文件会导致文件损坏或异常。


三、关键技术细节与代码示例(Spring Boot 风格)

下面给出一个简化的、可直接参考的实现思路。重点展示:线程池 + BlockingQueue + EasyExcel 写入。

说明:示例为演示性代码,实际请根据你项目的异常处理、事务、日志和监控进行补充。

1) DTO / 工具类

// 表示一行数据的POJO(与EasyExcel的注解配合)
public class ExportRow {
    @ExcelProperty("ID")
    private Long id;
    @ExcelProperty("用户名")
    private String username;
    @ExcelProperty("金额")
    private BigDecimal amount;
    // getters/setters
}

2) Controller:提交导出任务(异步生成)

@RestController
@RequestMapping("/export")
public class ExportController {
    private final ExportService exportService;

    public ExportController(ExportService exportService) {
        this.exportService = exportService;
    }

    @PostMapping("/orders")
    public ResponseEntity<String> exportOrders(@RequestBody ExportRequest req) {
        String taskId = exportService.submitExportTask(req);
        // 返回任务id 给前端,前端轮询或后台通知获取下载地址
        return ResponseEntity.accepted().body(taskId);
    }
}

3) ExportService:任务提交与执行管理

@Service
public class ExportService {
    private final ExecutorService producerPool;
    private final ExecutorService singleWriter; // 单线程写(可用单独线程)
    private final DataFetchService dataFetch; // 负责分页查询DB
    private final Path tmpDir = Paths.get("/tmp/exports");

    public ExportService() {
        // 根据机器资源调优线程数与队列大小
        int cpu = Runtime.getRuntime().availableProcessors();
        this.producerPool = new ThreadPoolExecutor(
                Math.max(2, cpu/2),
                Math.max(4, cpu),
                60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(100),
                new ThreadPoolExecutor.CallerRunsPolicy());
        this.singleWriter = Executors.newSingleThreadExecutor();
        // ensure dir exists
        Files.createDirectories(tmpDir);
    }

    public String submitExportTask(ExportRequest req) {
        String taskId = UUID.randomUUID().toString();
        Path tmpFile = tmpDir.resolve(taskId + ".xlsx");

        // 异步提交导出任务
        CompletableFuture.runAsync(() -> {
            try {
                runExportTask(req, tmpFile);
                // 标记任务完成 -> 保存下载地址到 DB 或发通知
            } catch (Exception e) {
                // 记录失败 & 清理
            }
        });

        return taskId;
    }

    private void runExportTask(ExportRequest req, Path targetFile) throws Exception {
        // 使用有界队列避免内存无限增长
        BlockingQueue<List<ExportRow>> queue = new ArrayBlockingQueue<>(200);
        CountDownLatch producersDone = new CountDownLatch(req.getProducerCount()); // producer 数量

        // 启动单写线程:从 queue 消费并写入 EasyExcel
        Future<?> writerFuture = singleWriter.submit(() -> {
            try (OutputStream out = Files.newOutputStream(targetFile, StandardOpenOption.CREATE)) {
                ExcelWriter excelWriter = EasyExcel.write(out, ExportRow.class).build();
                WriteSheet sheet = EasyExcel.writerSheet("sheet1").build();
                boolean finished = false;
                while (!finished) {
                    List<ExportRow> batch = queue.poll(5, TimeUnit.SECONDS);
                    if (batch != null) {
                        excelWriter.write(batch, sheet);
                    } else {
                        // 若队列为空并且所有生产者都完成,则结束
                        if (producersDone.getCount() == 0 && queue.isEmpty()) {
                            finished = true;
                        }
                    }
                }
                excelWriter.finish();
            } catch (Exception ex) {
                throw new RuntimeException(ex);
            }
        });

        // 启动 producers:并行从 DB 分页读取并放入 queue
        int producerCount = req.getProducerCount();
        for (int i = 0; i < producerCount; i++) {
            final int idx = i;
            producerPool.submit(() -> {
                try {
                    // 根据分段策略读取(例如:按 ID 范围 / hash 分片 / keyset 分页)
                    int page = 0;
                    int pageSize = req.getPageSize();
                    while (true) {
                        List<ExportRow> rows = dataFetch.fetchPageShard(req, idx, producerCount, page, pageSize);
                        if (rows == null || rows.isEmpty()) break;
                        // 阻塞式放入队列(防止内存暴涨)
                        queue.put(rows);
                        page++;
                    }
                } catch (Exception e) {
                    // 记录异常(可将异常信息传递给主流程)
                } finally {
                    producersDone.countDown();
                }
            });
        }

        // 等待所有 producers 完成
        producersDone.await();
        // 等待 writer 完成(写完剩余队列)
        writerFuture.get(30, TimeUnit.MINUTES); // 根据数据量调整超时时间
    }
}

4) 数据抓取实现(示例:keyset 分页来避免 OFFSET)

// Example: dataFetch.fetchPageShard(...)
public List<ExportRow> fetchPageShard(ExportRequest req, int shardIndex, int totalShards, int page, int pageSize) {
    // 推荐使用 keyset pagination(例如:WHERE id > lastId ORDER BY id LIMIT pageSize)
    // 这里示意按 shardIndex 跳过:WHERE id % totalShards = shardIndex AND id > lastId
    // 更好的做法是按某个时间或 ID 范围切片,避免 OFFSET 性能问题
    String sql = "SELECT id, username, amount FROM orders WHERE (id % ?) = ? AND id > ? ORDER BY id LIMIT ?";
    // 执行查询并 map 成 ExportRow
}

四、关键点讲解与工程化注意事项

1. 分片读取策略(不要用 OFFSET)

OFFSET 随着偏移量增大会越来越慢,应使用 keyset pagination(基于 last_id) 或按某个列范围(时间/ID)切分、或者按 hash 分片(id % N)分配给不同 producer。这样读速稳定且可并行。

2. 批量大小(batch size)如何选

  • 太小:频繁 IO、上下文切换,效率低;

  • 太大:队列元素变大、占用内存,容易 OOM。
    经验:每个批次 500 ~ 5,000 行(与列数、每行大小有关)。在内存受限环境下优先靠近 500 ~ 1,000。

3. 队列容量与背压

使用有界 BlockingQueue(例如 200),当生产者速度远超写入速度时会被阻塞(queue.put),从而自动产生背压,防止内存飙升。

4. 写文件的位置

  • 直接写到响应流(OutputStream)会把写过程绑定到 HTTP 请求,容易超时与占用连接。不推荐用于大数据导出。

  • 推荐写到 临时文件(磁盘),生成完成后再提供下载(HTTP 直接返回或 S3/OSS 链接)。磁盘 IO 比内存便宜,可用 SSD 提升速度。

5. 避免 Excel 样式/公式/图片的滥用

样式(CellStyle)与大量图片会显著增加内存与生成时间。除非必要,否则尽量只写纯数据。若必须格式化,尽量复用统一样式并限制样式个数。

6. XLSX 行数上限

Excel 单 sheet 行上限约 1,048,576 行(XLSX)。若超出这个数,需要分成多个 sheet 或多个文件并打包成 zip。

7. 错误恢复与断点续传

  • 导出任务可能失败(节点重启、DB 报错等)。实现任务状态持久化(任务表),支持重试或从上次写入位置续做(记录 lastWrittenId)。

  • 如果采用多文件分片写入(每个 producer 生成独立文件),失败恢复/合并更容易,但会增加最终合并/下载复杂度。


五、性能分析(性能表现与瓶颈)

说明:下面是通用经验与瓶颈点,实际吞吐与耗时与硬件/数据宽度/网络/DB/TCP 参数强相关,仅供参考和决策优化方向。

可能的瓶颈

  1. 数据库扫描速度(通常是主瓶颈) — 优化索引、避免 full scan、使用 keyset 分页、多线程并行读取。

  2. 磁盘写入速度(SSD vs HDD) — 写到 SSD 的速度明显更好。

  3. 单线程写入速率 — EasyExcel 写入是 CPU+IO 操作,单线程写入速度有限。通过增加 producer 并行抓取数据并合理调度写入批次(减少写操作次数)能提高吞吐。

  4. 网络上传/下载(如果最终上传至对象存储或客户端下载) — 需考虑网络带宽。

性能调优点

  • 批量发送写入:每次写入 500–2000 行,减少 write() 次数(但单次内存与 CPU 也增加)。

  • 并行读取 + 单线程写入:把 DB/CPU/网络并行化,写入仍然顺序,整体速度提升。

  • 使用分文件并行写(tradeoff):将导出任务拆成 M 个子任务,每个任务写一个文件(并行写不同文件),最后打包成 zip。优点:写的并行度提升;缺点:需要后处理合并/压缩与更多磁盘 IO。

  • 当数据量极大时考虑 CSV:CSV 写入简单、消耗极少内存且生成速度快(缺点:不能直接打开复杂格式/样式)。很多场景 CSV 更合适(尤其数据“表格化”大量记录)。

经验数据(示意)

  • 单机 SSD + EasyExcel、批量 1000 行、单写线程:每秒写几千至几万条记录(取决于列宽、数据类型)。

  • 如果需要每秒 10k+ 写入,建议:并行分文件写 + SSD + 高性能 DB 查询 + 网络优化。


六、复杂度分析(实现 & 运维)

  • 实现复杂度:中等

    • 基础实现(单线程 EasyExcel 写、分页读取)很简单;

    • 加入线程池、生产者-消费者、任务状态管理、断点续传、并发错误处理,复杂度上升至中等。

  • 运维复杂度:中等偏高

    • 需要监控磁盘、队列长度、任务失败率、数据库慢查询;

    • 如果使用对象存储/分布式文件系统,需要关注上传/下载策略与权限。


七、常见问题与解决策略(FAQ)

  1. 为什么还是 OOM?

    • 检查是否把整个数据集一次性放进内存(例如把所有行都放到 List 再写);确保使用批量并有限队列(BlockingQueue 有界)。

    • 避免大量样式/图片、减少行内大对象(如大 JSON 字段)。

  2. 写文件时出现超时或连接被关闭?

    • 不要直接在 HTTP 响应流中做长时间写入;改为后台生成文件、再返回下载链接或异步通知。

    • 若必须流式下载,保证 HTTP 超时阈值大于最长生成时间,或使用断点续传/分块下载。

  3. 如何保证文件不损坏?

    • 保证只有单线程对同一文件进行写操作;若多线程写不同文件再合并,合并逻辑要正确(zip 或 server-side 合并)。

  4. 如何处理超大数据(10M+ 行)?

    • 优先考虑 CSV、或分文件并打包;或分多个 sheet(每 sheet 行数不能超过 1,048,576);或推荐用户只导出查询结果的子集(按日期/条件导出)。

  5. 数据库分页慢或死锁?

    • 使用 keyset 分页避免高 OFFSET;合理设置 isolation level;监控慢查询并加索引。

  6. 如何监控导出任务?

    • 建议将任务状态持久化到任务表(WAITING/PROCESSING/SUCCESS/FAIL),并记录进度(已写行数 / 总行数),便于前端展示与报警。


八、典型工程案例(两种常见模式)

案例 A:实时生成一个大表(单文件)供下载(常见 BI 报表)

  • 场景:用户导出 200 万条订单记录。需要把查询结果导成 XLSX。

  • 方案:

    • 后端提交导出任务,返回 taskId。

    • 后端使用 8 个 producer(并行读取),1 个 writer(单线程写入 Excel 单文件),写到临时文件,完成后提供下载链接(或上传到对象存储并返回预签名链接)。

  • 优点:节省内存,保证文件正确性;并行读取提高速度;避免 HTTP 超时。

  • 缺点:单写线程成瓶颈,如果需求更高需要分文件并行写。

案例 B:分文件并行导出后打包(超大导出)

  • 场景:导出 2000 万条数据,Excel 单文件不可行或用户接受多个文件打包下载。

  • 方案:

    • 将数据按某字段切成 N 段(N = 并发写文件数),每个线程独立生成一个 Excel(或 CSV)文件(各自使用 EasyExcel)。

    • 所有文件完成后服务器端把这些文件压缩成 zip 并提供下载。

  • 优点:写并行度高,总耗时更短;写每个文件时不互相影响。

  • 缺点:更多磁盘空间与后处理时间;合并打包 IO 成本高。


九、最佳实践清单(Checklist)

  • 不把全部数据一次放内存:采用分批(batch)读取与写入。

  • 使用有界 BlockingQueue 做生产者-消费者,避免内存飙升。

  • 数据分页用 keyset pagination 或按 shard 分片(避免 OFFSET)。

  • 写入同一 Excel 文件由单线程负责;需要并行时用分文件策略。

  • 写到临时文件(磁盘)并在完成后提供下载;不要直接阻塞 HTTP 请求。

  • 避免复杂样式、尽量少使用合并单元格和图片。

  • 对导出任务做持久化(状态、进度、失败原因),支持重试/续传。

  • 监控:队列长度、生产者等待时间、写入速度、磁盘空间、任务成功率。

  • 针对超大数据场景考虑 CSV 或直接提供数据库导出/数据仓库查询下载。

  • 在导出 API 层面做限流与鉴权,避免滥用资源(结合你之前的 API Key / 滑动窗口限流方案)。


十、总结与建议

  1. 为什么这套方案稳?

    • EasyExcel 提供低内存写入能力;线程池把耗时的 DB 读取并行化;BlockingQueue 保证内存受控、背压自适应;写入集中化保证文件正确性。

  2. 何时选择分文件策略?

    • 当单文件写入速度成为瓶颈、或数据量超过单文件或单 sheet 限制时,采用分文件并行写 + 后端打包。

  3. 其他可替代/补充方案

    • 使用 CSV(万级/百万级行数最佳);或把数据写入对象存储(S3/OSS)并返回下载链接;或用专门的报表/ETL 工具导出(快照到 parquet/csv)。

示例代码


网站公告

今日签到

点亮在社区的每一天
去签到