
1. 项目概述为什么百万级Excel处理是个“硬骨头”做后台管理系统的朋友对Excel导入导出这个功能肯定不陌生。需求听起来很简单用户上传一个Excel我们把数据读出来存到数据库或者从数据库查出一批数据生成Excel文件给用户下载。早期数据量小的时候用一些现成的工具比如Apache POI几行代码就能搞定跑起来也感觉不到什么延迟。但是当数据量从几百、几千飙升到几十万、上百万的时候问题就全暴露出来了。我经历过最典型的一次事故是运营同学导出一个约80万条记录的报表服务直接内存溢出OOM整个应用崩溃。还有一次是导入一个50MB的Excel文件接口超时前端一直转圈圈最后超时失败。这些问题本质上都是因为传统的Excel处理方式是全量内存操作。想想看Apache POI的XSSFWorkbook在读取一个.xlsx文件时会把整个文件结构包括所有单元格、样式、公式全部加载到JVM堆内存里。一个几十MB的Excel在内存中的占用可能轻松突破几百MB。对于百万行数据内存占用更是以GB计这对任何一个Java应用来说都是不可承受之重。导出也是同理在内存中构建一个包含百万行的工作簿对象还没等写入磁盘内存就先撑爆了。所以这个标题里的“性能爆表”核心挑战就在于如何突破内存限制实现海量数据的高效、稳定处理。这不仅仅是会用某个API而是需要对IO流、内存管理、并发编程有更深的理解设计出一套资源可控的解决方案。接下来我就结合在SpringBoot项目中的实战拆解如何一步步实现这个目标。2. 核心思路与架构选型告别全量内存模型面对百万数据首要原则是流式处理分而治之。绝对不能把整个文件或整个数据集一次性放进内存。2.1 导入方案选型SAX模式 vs 用户模式Apache POI提供了两种主要方式读取Excel用户模式UserModel 也就是我们最熟悉的HSSFWorkbook.xls和XSSFWorkbook.xlsx。它提供了完整的、面向对象的API可以方便地读写单元格值、样式、公式等。但正如前面所说它需要将整个文档模型加载到内存。SAX模式EventModel 这是一种基于事件的解析模式。它不像用户模式那样构建完整的文档树而是像解析XML一样顺序读取文件流在遇到文档开始、行开始、单元格开始等事件时触发回调方法。处理完一行数据后相关的内存就可以被释放。结论非常明确对于海量数据导入必须选择SAX模式。在POI中对应XSSF的SAX解析器是XSSFReader配合SheetContentsHandler。但直接使用POI的SAX API比较底层和繁琐。因此我们通常会选择基于此模式封装的、更易用的开源工具。主流工具对比工具核心原理优点缺点百万数据导入适用性EasyExcel (Alibaba)基于POI的SAX模式深度封装内存模型优化1. API简洁学习成本低。2. 内存占用极低约几MB。3. 社区活跃文档丰富。4. 自带监听器模型逻辑分离清晰。对复杂样式、公式的支持较弱但海量数据场景通常不需要。强烈推荐Apache POI (SAX)原生SAX事件驱动1. 最底层灵活性最高。2. 无第三方依赖。1. API复杂需要自己处理大量解析细节。2. 样板代码多。可用但不推荐开发效率低。JExcelApi仅支持较旧的.xls格式内存占用相对较小1. 不支持.xlsx格式。2. 项目已停止维护。不适用实操心得 在绝大多数业务场景下尤其是互联网应用EasyExcel是平衡了性能、易用性和功能的最佳选择。它的“监听器”设计模式让你可以专注于一行数据的业务逻辑转换而不用操心文件解析、内存回收的底层细节。2.2 导出方案选型分页查询与流式写入导出比导入更复杂因为它涉及“读数据库”和“写Excel”两个耗资源操作。数据库读取必须分页绝对禁止SELECT * FROM huge_table。要使用分页查询每次从数据库取出固定条数例如5000条进行处理。Spring Data JPA的PageableMyBatis-Plus的Page或者原生的LIMIT offset, size都是必备技能。// 示例MyBatis-Plus 分页查询 PageYourEntity page new Page(currentPage, pageSize); IPageYourEntity pageResult yourMapper.selectPage(page, queryWrapper); ListYourEntity records pageResult.getRecords();Excel写入必须流式POI提供了SXSSFWorkbook它是XSSFWorkbook的流式变体。它的原理是在内存中只保持一定数量的行比如100行当行数超过这个“滑动窗口”时最早的行会被刷新到磁盘临时文件。这样就能用很小的内存开销生成巨大的Excel文件。// 创建一个滑动窗口为100行的SXSSFWorkbook SXSSFWorkbook workbook new SXSSFWorkbook(100); SXSSFSheet sheet workbook.createSheet(百万数据); // ... 写入表头 for (YourEntity item : dataList) { Row row sheet.createRow(rowNum); // ... 写入单元格 // 当rowNum超过100时会自动将前面的行写入临时文件 }整体导出流程架构用户触发导出请求。后端接口立即响应告知请求已接收防止HTTP超时。导出任务本身放入线程池异步执行。异步任务中开启一个SXSSFWorkbook。使用分页查询循环从数据库读取数据。将每一页数据写入SXSSFWorkbook。全部数据写入完成后将最终的Excel文件写入HttpServletResponse的输出流或者上传到OSS/MinIO等对象存储生成一个临时下载链接返回给前端。清理SXSSFWorkbook产生的临时磁盘文件。3. 百万数据导入实战基于EasyExcel的稳健实现下面我们以SpringBoot EasyExcel为例实现一个百万级数据的导入。3.1 环境准备与依赖首先在pom.xml中引入EasyExcel。dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version !-- 请使用最新稳定版 -- /dependency3.2 定义数据模型与监听器假设我们要导入一个用户信息Excel包含姓名、邮箱、部门。1. 定义数据模型DTOData // 使用Lombok public class UserImportDTO { ExcelProperty(姓名) // 对应Excel表头 private String name; ExcelProperty(邮箱) private String email; ExcelProperty(部门) private String department; }2. 编写核心监听器这是EasyExcel的精华所在。监听器负责处理每一行解析出的数据。// 注意监听器不能被Spring管理每次读取都要new一个新的实例。 // 如果需要注入Spring管理的Service可以通过构造器传入。 public class UserImportListener extends AnalysisEventListenerUserImportDTO { /** * 每隔1000条处理一次然后清理列表方便内存回收 */ private static final int BATCH_COUNT 1000; private ListUserImportDTO cachedDataList new ArrayList(BATCH_COUNT); // 假设我们需要调用Service将数据入库 private UserService userService; // 通过构造器注入Service public UserImportListener(UserService userService) { this.userService userService; } /** * 每解析一行数据都会调用此方法 */ Override public void invoke(UserImportDTO data, AnalysisContext context) { // 1. 这里可以进行数据校验 if (StringUtils.isBlank(data.getName())) { // 可以记录错误行统一返回给前端 throw new ExcelDataConvertException(...); } // 2. 加入缓存列表 cachedDataList.add(data); // 3. 达到BATCH_COUNT了就批量处理一次然后清空列表 if (cachedDataList.size() BATCH_COUNT) { saveData(); cachedDataList.clear(); } } /** * 所有数据解析完成后会调用此方法 */ Override public void doAfterAllAnalysed(AnalysisContext context) { // 确保最后一批不足BATCH_COUNT的数据也被处理 if (!cachedDataList.isEmpty()) { saveData(); } // 可以在这里记录导入完成日志等 log.info(Excel导入解析完成); } /** * 批量保存数据到数据库 */ private void saveData() { // 这里使用Service进行批量插入。MyBatis-Plus的saveBatch是很好的选择。 userService.saveBatch(cachedDataList); log.info(成功批量导入 {} 条数据, cachedDataList.size()); } /** * 监听器也支持读取异常处理 */ Override public void onException(Exception exception, AnalysisContext context) { log.error(解析失败继续读取下一行, exception); // 如果是业务异常如数据校验失败可以记录到错误列表 // 如果是严重异常可以选择抛出停止整个导入 } }关键点解析BATCH_COUNT是性能调优的关键参数。值太小如100会导致数据库事务和IO过于频繁值太大如10000则内存中缓存的List会变大失去了分批的意义。经过实测在常规业务中1000到3000是一个比较理想的区间需要在内存占用和数据库压力间取得平衡。监听器本身不能是Spring Bean因为它的生命周期和一次文件读取绑定。但我们可以通过构造器将需要的Bean传进去。invoke方法里的校验是第一道防线尽早发现格式错误的数据避免无效数据进入缓存列表。3.3 编写控制器与业务逻辑在Controller中接收MultipartFile类型的文件参数。RestController RequestMapping(/api/user) public class UserImportController { Autowired private UserService userService; PostMapping(/import) public R importUsers(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return R.error(请选择文件); } try { // 1. 获取输入流 InputStream inputStream file.getInputStream(); // 2. 创建监听器实例传入需要的Service UserImportListener listener new UserImportListener(userService); // 3. 开始读取 // 第一个参数是输入流第二个参数是数据模型类第三个参数是监听器 EasyExcel.read(inputStream, UserImportDTO.class, listener) .sheet() // 读取第一个sheet也可以指定sheet名或索引 .headRowNumber(1) // 指定表头行数默认1 .doRead(); // 开始同步读取会阻塞直到完成 return R.ok(导入成功); } catch (IOException e) { log.error(文件读取失败, e); return R.error(文件读取失败); } catch (Exception e) { log.error(导入过程发生异常, e); return R.error(导入失败: e.getMessage()); } } }注意事项这是一个同步接口文件解析和数据库插入会阻塞当前请求线程。对于超大型文件如几百MBHTTP请求可能会超时。生产环境强烈建议将导入做成异步任务接口只负责接收文件并上传到临时存储如OSS然后提交一个异步任务通过Spring的Async、消息队列或分布式任务调度去执行上面的EasyExcel.read逻辑最后通过站内信、WebSocket等方式通知用户结果。一定要做好异常处理和事务管理。上面的例子中saveBatch通常自带事务但如果一批数据中部分失败可能需要更精细的补偿或回滚机制。可以考虑在监听器中收集所有失败行最终统一返回给用户。4. 百万数据导出实战SXSSFWorkbook与异步导出导出接口的设计比导入更需要考虑用户体验和系统稳定性。4.1 同步导出与内存风险我们先看一个错误的同步导出示例以理解风险所在// 【错误示范】同步导出数据量大必OOM GetMapping(/export/bad) public void exportBad(HttpServletResponse response) { // 1. 一次性查询所有数据致命操作 ListUser allUsers userService.list(); // 假设有100万条 // 2. 在内存中创建XSSFWorkbook又一个致命操作 XSSFWorkbook workbook new XSSFWorkbook(); // ... 写入100万行数据 // 3. 写入输出流 workbook.write(response.getOutputStream()); }这段代码在数据量稍大时会在userService.list()或new XSSFWorkbook()阶段直接导致OOM。4.2 正确的异步流式导出实现步骤一设计异步导出任务我们使用Spring的Async来实现简单的异步导出。首先确保应用已开启异步支持在主类加EnableAsync。Service Slf4j public class UserExportService { Autowired private UserMapper userMapper; // 假设使用MyBatis-Plus /** * 异步导出任务 * param queryParams 查询条件 * param outputStream 输出流可以关联到一个临时文件或HttpServletResponse */ Async(exportTaskExecutor) // 指定一个专用的线程池 public CompletableFutureFile asyncExportUsers(UserQueryDTO queryParams, OutputStream outputStream) { String fileName user_export_ System.currentTimeMillis() .xlsx; File tempFile null; try { tempFile File.createTempFile(export_, .xlsx); try (FileOutputStream fos new FileOutputStream(tempFile); SXSSFWorkbook workbook new SXSSFWorkbook(100)) { // 滑动窗口100行 SXSSFSheet sheet workbook.createSheet(用户数据); // 创建表头 Row headerRow sheet.createRow(0); headerRow.createCell(0).setCellValue(ID); headerRow.createCell(1).setCellValue(姓名); headerRow.createCell(2).setCellValue(邮箱); // ... 更多表头 int rowNum 1; int pageSize 5000; // 每页查询5000条 long currentPage 1; boolean hasNext true; // 分页查询循环 while (hasNext) { PageUser page new Page(currentPage, pageSize); IPageUser pageResult userMapper.selectPage(page, buildQueryWrapper(queryParams)); ListUser records pageResult.getRecords(); for (User user : records) { Row row sheet.createRow(rowNum); row.createCell(0).setCellValue(user.getId()); row.createCell(1).setCellValue(user.getName()); row.createCell(2).setCellValue(user.getEmail()); // ... 写入其他字段 } // 判断是否还有下一页 hasNext pageResult.getPages() currentPage; currentPage; // 可选每处理完N页记录一下日志方便追踪进度 if (currentPage % 20 0) { log.info(导出任务进度已处理 {} 页约 {} 条数据, currentPage, (currentPage-1)*pageSize); } } // 写入临时文件 workbook.write(fos); workbook.dispose(); // 释放临时文件 } // 将临时文件复制到目标输出流如果是HTTP导出 // Files.copy(tempFile.toPath(), outputStream); return CompletableFuture.completedFuture(tempFile); } catch (Exception e) { log.error(导出任务失败, e); // 清理临时文件 if (tempFile ! null tempFile.exists()) { tempFile.delete(); } return CompletableFuture.failedFuture(e); } } }步骤二配置专用线程池为了避免导出任务拖垮整个应用的其他HTTP线程必须配置独立的线程池。Configuration EnableAsync public class AsyncConfig { Bean(exportTaskExecutor) public Executor exportTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数根据服务器CPU核心数调整不宜过多 executor.setCorePoolSize(2); // 最大线程数控制并发导出任务数防止资源耗尽 executor.setMaxPoolSize(5); // 队列容量积压任务数 executor.setQueueCapacity(50); // 线程名前缀 executor.setThreadNamePrefix(export-async-); // 拒绝策略CallerRunsPolicy 由调用者线程执行保证任务不丢失 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }步骤三控制器设计触发异步任务导出接口通常有两种模式同步下载适合小数据量 接口阻塞直到文件生成完毕然后输出流。对于大数据量不适用。异步生成返回下载链接推荐 接口立即返回一个任务ID或“任务已提交”的响应。前端轮询任务状态完成后获取一个有时效性的文件下载地址如OSS链接。RestController RequestMapping(/api/user) public class UserExportController { Autowired private UserExportService userExportService; Autowired private TaskManager taskManager; // 一个自定义的任务状态管理器 PostMapping(/export/async) public R asyncExport(RequestBody UserQueryDTO queryParams) { // 1. 生成一个唯一的任务ID String taskId UUID.randomUUID().toString(); // 2. 初始化任务状态为“处理中” taskManager.createTask(taskId, 用户数据导出); // 3. 提交异步任务 CompletableFuture.supplyAsync(() - { try { // 这里可以生成一个指向OSS或服务器临时目录的文件路径 Path exportPath Paths.get(/tmp/export, taskId .xlsx); try (OutputStream os Files.newOutputStream(exportPath)) { // 调用异步导出服务写入指定输出流 // 注意这里需要调整asyncExportUsers方法使其能接收查询参数和输出流 userExportService.asyncExportUsers(queryParams, os).get(); } // 4. 任务完成更新状态和文件URL String fileUrl /api/user/export/download/ taskId; // 或OSS的URL taskManager.updateTaskSuccess(taskId, fileUrl); } catch (Exception e) { taskManager.updateTaskFailed(taskId, e.getMessage()); } return null; }, userExportService.getExecutor()); // 使用导出专用线程池 // 5. 立即返回任务ID给前端 return R.ok().put(taskId, taskId); } GetMapping(/export/status/{taskId}) public R getExportStatus(PathVariable String taskId) { TaskInfo taskInfo taskManager.getTaskInfo(taskId); return R.ok().put(status, taskInfo.getStatus()) .put(progress, taskInfo.getProgress()) .put(fileUrl, taskInfo.getFileUrl()) .put(message, taskInfo.getMessage()); } GetMapping(/export/download/{taskId}) public void downloadExportFile(PathVariable String taskId, HttpServletResponse response) { // 根据taskId找到文件路径设置响应头将文件流写入response // ... 实现文件下载逻辑 } }核心要点分页查询是基石while循环配合分页查询确保每次只加载一部分数据到JVM。SXSSFWorkbook是核心设置合理的windowSize如100控制内存中的行数。异步化是体验保障避免HTTP请求长时间阻塞。通过任务ID轮询状态用户体验更好。资源清理SXSSFWorkbook.dispose()方法很重要它会清理磁盘上的临时文件。务必在try-with-resources或finally块中调用。5. 高级优化与生产级考量实现基本功能后要应对生产环境的海量数据还需要以下优化。5.1 导入性能与稳定性优化多线程批量插入 在监听器的saveData方法中如果单次批量插入1000条仍然较慢可以考虑将cachedDataList拆分成更小的批次用并行流或CompletableFuture并发插入。但要注意数据库连接池压力和事务边界。private void saveData() { // 将1000条数据分成4个250条的子列表并发插入 MapInteger, ListUserImportDTO groups cachedDataList.stream() .collect(Collectors.groupingBy(data - data.hashCode() % 4)); ListCompletableFutureVoid futures groups.values().stream() .map(list - CompletableFuture.runAsync(() - userService.saveBatch(list), dbInsertExecutor)) .collect(Collectors.toList()); // 等待所有插入完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); }警告 并发插入会打乱数据顺序如果业务对顺序有要求如需要保持Excel中的行序则不能使用。同时高并发插入可能导致数据库死锁需要评估。数据库批次优化 调整MyBatis-Plus等ORM框架的批量提交参数。例如在JDBC URL中添加rewriteBatchedStatementstrueMySQL可以大幅提升批量插入性能。内存与GC优化 监听器中的cachedDataList要及时清空。确保BATCH_COUNT大小合理避免产生大量短期存活的对象引发Young GC频繁。5.2 导出性能与稳定性优化查询优化只查询需要的字段SELECT id, name, email而不是SELECT *。数据传输量和内存占用会少很多。覆盖索引确保分页查询的WHERE条件和ORDER BY字段有合适的索引避免慢查询。百万级数据的分页深分页LIMIT 1000000, 100是性能杀手可以考虑基于有序唯一ID如自增主键进行分页WHERE id ? ORDER BY id LIMIT ?。规避N1查询如果导出数据涉及关联实体如用户所属部门名称务必使用JOIN一次性查询出来或者在内存中做映射绝对不能在循环里逐条查询数据库。样式与内存慎用单元格样式SXSSFWorkbook中每个单元格的样式对象都是独立存储在内存的。如果为100万行*10列都创建不同的样式内存会暴涨。最佳实践是先创建有限的几种样式如标题样式、数据样式然后复用这些样式对象。// 在循环外创建样式 CellStyle headerStyle workbook.createCellStyle(); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); CellStyle dataStyle workbook.createCellStyle(); dataStyle.setDataFormat(workbook.createDataFormat().getFormat()); // 文本格式 // 在循环内复用样式 for (User user : records) { Row row sheet.createRow(rowNum); Cell cell0 row.createCell(0); cell0.setCellStyle(dataStyle); cell0.setCellValue(user.getId()); // ... }超大文件与超时处理对于可能超过几分钟的导出任务**必须使用异步任务进度查询结果文件存储如OSS**的模式。HTTP连接不可能保持那么久。在异步任务中可以定期更新任务进度如已处理页数/总页数让前端有进度条显示。生成的Excel文件可能非常大几百MB直接通过应用服务器下载会占用大量带宽和IO。最佳实践是上传到对象存储OSS/S3/MinIO返回一个预签名的临时URL给前端下载实现下载流量与业务服务器的分离。5.3 常见问题与排查技巧实录问题1导入时EasyExcel报错“java.lang.NoSuchMethodError”或“java.lang.ClassNotFoundException”。排查 这是典型的依赖冲突。POI有多个模块poi, poi-ooxml, poi-ooxml-schemas等且EasyExcel对POI版本有要求。解决 使用mvn dependency:tree命令检查POI依赖。确保排除掉所有传递引入的老版本POI统一使用EasyExcel推荐的或兼容的版本。在pom.xml中显式声明POI依赖并排除冲突。dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version exclusions exclusion groupIdorg.apache.poi/groupId artifactId*/artifactId /exclusion /exclusions /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency问题2导出文件在Windows用Excel打开报“文件已损坏”或“格式错误”。排查流未正确关闭 确保SXSSFWorkbook和OutputStream在try-with-resources中或finally块中被正确关闭。流未关闭会导致文件内容不完整。响应头设置错误HTTP导出时 必须设置正确的Content-Type和Content-Disposition。response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(导出数据.xlsx, UTF-8).replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName);写入过程中发生异常 部分数据已写入但异常导致中断文件不完整。要加强异常处理在发生不可恢复错误时可以考虑删除已生成的临时文件。问题3导入/导出过程中CPU或内存占用异常高。排查导入 检查BATCH_COUNT是否设置过大。检查invoke方法中的业务逻辑是否过于复杂如频繁的RPC调用、复杂计算。使用jstack和jmap工具分析线程和堆内存。导出 检查分页查询的pageSize是否过大。检查是否在循环内创建了大量单元格样式。检查SXSSFWorkbook的windowSize是否过小导致频繁刷盘或过大内存占用高。使用JVisualVM或Arthas进行实时监控观察GC情况和内存各代的使用曲线。问题4并发导出时服务器负载激增甚至拖垮其他服务。解决限流 在导出接口入口处增加限流如使用Sentinel、Resilience4j限制单位时间内触发的导出任务数。线程池隔离 正如我们之前做的使用独立的、有界队列的线程池处理导出任务。并设置合理的拒绝策略如CallerRunsPolicy当队列满时由调用者线程执行起到“慢降级”的效果保护线程池本身。资源限制 在异步任务中可以对单个任务的资源使用进行限制例如通过CompletableFuture设置超时时间防止某个异常任务永远不结束。问题5数据准确性问题比如导入后数据错位、丢失。排查DTO字段与Excel列映射 检查ExcelProperty的value或index是否与Excel表头严格对应。建议使用index属性按列索引绑定更稳定。数据类型转换 EasyExcel会尝试自动转换但日期、数字格式可能因本地化设置出错。可以使用DateTimeFormat、NumberFormat注解或在监听器的invoke方法中进行手动转换和校验。批量插入的原子性 如果一批数据如1000条中有一条插入失败如唯一键冲突默认整个批次会回滚吗这取决于你用的ORM和事务配置。需要在业务层考虑部分失败的处理逻辑例如记录失败行继续插入其他成功行最后汇总报告给用户。处理百万级数据的Excel导入导出就像驾驶一辆重载卡车平稳和安全比速度更重要。核心思想始终是流式处理和分而治之将大数据块拆解成小批次在内存、数据库IO、CPU时间之间取得平衡。从工具选型EasyExcel SXSSF到架构设计异步任务分页查询再到生产细节线程池隔离、样式复用、异常处理每一步都需要仔细考量。上面分享的方案和踩过的坑都是我们在多个高负载项目中验证过的希望能帮你绕过我们曾经掉进去的那些“深坑”真正实现“性能爆表”的稳定服务。