当前位置: 首页 > news >正文

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

导出 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; // 负责分页查询DBprivate 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 existsFiles.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 消费并写入 EasyExcelFuture<?> 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 分页读取并放入 queueint 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)。

示例代码


文章转载自:

http://LA1cxWv6.hbpjb.cn
http://gDer3lKB.hbpjb.cn
http://R1SslFiL.hbpjb.cn
http://455KRL5k.hbpjb.cn
http://SR04CfDb.hbpjb.cn
http://JOEQBnJF.hbpjb.cn
http://I42kzVMY.hbpjb.cn
http://qBOprdMo.hbpjb.cn
http://AB2jGjDx.hbpjb.cn
http://mCGJ6mPo.hbpjb.cn
http://OV8LBgtG.hbpjb.cn
http://17qkLGXX.hbpjb.cn
http://4xRmLr9G.hbpjb.cn
http://hcNZMKYI.hbpjb.cn
http://9fGRXDxJ.hbpjb.cn
http://pyVovB5t.hbpjb.cn
http://AMgcHQpd.hbpjb.cn
http://L1IzgTN8.hbpjb.cn
http://I1LsV6ZY.hbpjb.cn
http://5iZbCm4r.hbpjb.cn
http://LoeABfuO.hbpjb.cn
http://fqS1xZuf.hbpjb.cn
http://wgg5y1La.hbpjb.cn
http://IjOrINpw.hbpjb.cn
http://u82x8deo.hbpjb.cn
http://HRqeaSjj.hbpjb.cn
http://9Ni7Xifo.hbpjb.cn
http://YUZdPpVb.hbpjb.cn
http://NLnM3mVi.hbpjb.cn
http://YjadPLlU.hbpjb.cn
http://www.dtcms.com/a/366301.html

相关文章:

  • 【CouponHub项目开发】EasyExcel解析Excel并使用线程池异步执行和延时队列兜底
  • 鸿蒙:从图库选择图片并上传到服务器
  • sqlserver2008导入excel表数据遇到的问题
  • 【MFC中OnInitDialog虚函数详解:哪个是虚函数?两个OnInitDialog的关系】
  • 算法-根据前序+中序遍历打印树的右视图
  • vite与webpack对比
  • 用AI做TikTok影视解说,全流程全自动成片,不懂外语也能做全球矩阵!
  • 开源混合专家大语言模型(DBRX)
  • GitHub 热榜项目 - 日榜(2025-09-04)
  • openEuler2403安装部署Kafka
  • CDN加速的安全隐患与解决办法
  • (E题|AI 辅助智能体测)2025年高教杯全国大学生数学建模国赛解题思路|完整代码论文集合
  • Process Explorer 学习笔记(第三章3.1.2):管理权利与提权机制解析)
  • SQL Server服务管理
  • OpenAI开放ChatGPT Projects功能,免费用户也能用了!
  • 【已更新文章+代码】2025数学建模国赛A题思路代码文章高教社杯全国大学生数学建模-烟幕干扰弹的投放策略
  • Java集合---Collection接口和Map接口
  • 应对反爬:使用Selenium模拟浏览器抓取12306动态旅游产品
  • PDF.AI-与你的PDF文档对话
  • Apache PDFBox 与 spire.pdf for java 使用记录
  • Access开发导出PDF的N种姿势,你get了吗?
  • 那些年我们一起追过的Java技术,现在真的别再追了!
  • 记一次 Nuxt 3 + pnpm Monorepo 中的依赖地狱:`@unhead/vue` 引发的致命错误
  • 前端基础(四十三):文本数据解析为键值对
  • vue3入门- script setup详解上
  • JS(DOM对象)
  • Linux内存管理章节三:绘制Linux的内存地图:内核与用户空间布局详解
  • window使用ffmep工具,加自定义脚本执行视频转码成h264(运营人员使用)
  • webrtc之语音活动上——VAD能量检测原理以及源码详解
  • STM32H750 RTC介绍及应用