
Excel 导入这功能做业务开发的应该都不陌生。刚接需求的时候总觉得没什么上个文件、解析、再插入数据库完事。可一旦用户开始传几十万行数据原来的“简单功能”就会变成事故导火索。我不是在吓唬人是真的被线上 OOM 搞到半夜起来过。先说几个实际会遇到的问题大家都有画面用 Apache POI 的普通 API 读.xlsx整个文件会被构建成一棵对象树。50 万行 × 20 列不算夸张堆内存直接飙到 1G 以上。小应用常用-Xmx512m一条导入请求就能把应用拖死其他接口也跟着遭殃。同步导入时前端一直等着几十万行数据从解析到入库怎么也得几分钟。网关超时、负载均衡超时、浏览器超时一个都避免不了。很多导入功能只判“非空”甚至什么都不判脏数据直接写主表。等第二天下游报表出问题才发现前一天导入的数据里有非法手机号、重复用户名、格式乱七八糟的日期。导入失败只返回个“导入失败”用户根本不知道哪里错。线上只能翻日志而且日志里往往也没有行号和原始内容完全没办法排查。所以 Excel 导入这事真不是一个“读文件 写库”两步走的问题。你得考虑流式读取、数据校验、异步任务、进度反馈、幂等防重、错误行定位…… 这篇文章就是把这些东西串起来讲一套能扛住几十万行数据的落地方案。选型为什么我更推荐 EasyExcelApache POI 是 Java 世界里最经典的 Excel 操作库但它在处理大文件时有个致命伤WorkbookFactory.create()或new XSSFWorkbook()会一次性把整个工作簿加载到内存。你写的for (Row row : sheet)确实简单但内存代价太高了。POI 其实也提供了事件模型比如XSSFReader配合 SAX 解析可以避免全量加载。但是那个 API 是真的底层你得自己处理 sheet 关系、XML 事件、行和单元格的状态写起来很像在造轮子。大部分项目不会为了一个导入功能去维护这么一套东西。EasyExcel 就是在 POI 基础上重新实现了.xlsx解析核心思路是逐行读取行数据解析完就丢交给 GC。很多没用过的人以为它就是“封装了一下 POI”其实它连解析器都是自己的只是底层复用了 POI 的部分数据类型。配合ReadListener你可以每读一行处理一行或者攒一批处理一批内存占用和文件总行数基本没关系只和你的批大小有关。从开发效率上比POI 的普通 API 虽然写起来简单但大文件下不敢用POI 的事件 API 内存可控但开发成本高、坑也多。EasyExcel 介于两者之间用注解声明列映射用监听器做流式消费是大多数业务系统比较省心的选择。数据模型和模板设计要注意的细节用 EasyExcel 时通常直接写一个 DTO列上放ExcelProperty注解publicclassUserImportDTO{ExcelProperty(用户名)privateStringusername;ExcelProperty(手机号)privateStringphone;ExcelProperty(邮箱)privateStringemail;ExcelProperty(入职日期)privateStringhireDateStr;// getter/setter}这里我有一个明确的建议日期、金额、长整数这类字段先用String接收不要直接映射成LocalDateTime或者Long。原因很简单Excel 单元格里的格式太随意了。用户可能在日期列里写“2023/1/1”也可能写“2023-01-01”还有人直接写一串数字序列号比如 44431。如果让 EasyExcel 直接转类型碰到不认识的格式就会抛异常整个导入直接中断。先拿字符串接收后面在业务校验里自己解析反而好控制。模板设计上如果条件允许固定列就固定不要搞太多动态列。虽然 EasyExcel 支持动态表头可以通过继承AnalysisEventListener重写invokeHeadMap来拿每一列的表头然后按列索引处理。但这种方式可读性差线上问题也不好排查。我一般建议模板加个“填写说明”区域示例行占一行然后从第三行开始写入真实数据。读取时用headRowNumber(2)把前两行跳过。EasyExcel.read(inputStream).head(UserImportDTO.class).headRowNumber(2).sheet().doRead();流式解析ReadListener 才是核心你不需要直接操作 EasyExcel 的读取流程只要实现一个ReadListener。每次解析完一行它会回调invoke(T data, AnalysisContext context)整个文件都读完会回调doAfterAllAnalysed(AnalysisContext context)。我经常看到有人把业务逻辑直接写在invoke里每来一行就校验一次、插入一次。这样写当然能用但是数据库访问次数太多而且也没有发挥“批处理”的优势。更好的做法是定义一个通用监听器内部先攒一批够数了交给一个ConsumerListT去消费之后清空列表继续读。publicclassBatchDataListenerTextendsAnalysisEventListenerT{privatefinalintbatchSize;privatefinalListTcachedList;privatefinalConsumerListTbatchConsumer;publicBatchDataListener(ConsumerListTbatchConsumer){this(batchConsumer,1000);}publicBatchDataListener(ConsumerListTbatchConsumer,intbatchSize){this.batchConsumerbatchConsumer;this.batchSizebatchSize;this.cachedListnewArrayList(batchSize);}Overridepublicvoidinvoke(Tdata,AnalysisContextcontext){cachedList.add(data);if(cachedList.size()batchSize){batchConsumer.accept(newArrayList(cachedList));cachedList.clear();}}OverridepublicvoiddoAfterAllAnalysed(AnalysisContextcontext){if(!cachedList.isEmpty()){batchConsumer.accept(newArrayList(cachedList));cachedList.clear();}}}批大小我觉得 1000 比较合适不要一次整 5000 甚至 10000。批太大内存峰值会上去而且一批 SQL 的数据量也太大数据库执行时间变长事务风险反而增加。一批 1000 行处理完就清空GC 也能及时回收内存占用很稳定。对了这里有个细节必须提醒如果你想让消费逻辑能定位到 Excel 原始行号最好在invoke时把行号记录下来放到一个包装对象里。因为T本身不一定有行号字段等你到消费阶段再想拿行号已经晚了。publicclassRowDataT{privatefinalintrowIndex;// 从数据行算起的行号privatefinalTdata;}如果嫌包装麻烦也可以在监听器里用一个MapObject, Integer以对象内存地址或者业务主键作为 key 存行号但包装对象更干净。校验不要“快速失败”要允许“半成功”很多业务系统里的导入代码是这么写的if(dto.getUsername()null){thrownewIllegalArgumentException(用户名为空);}userMapper.insert(dto);这是典型的“第一行有错就整体挂掉”。如果文件里有 2 万行第 1 行和第 200 行都有问题用户得反复修改、反复上传 200 次这谁能受得了正确思路是异常数据收集起来正常数据该导入继续导入。最后给用户一个错误文件里面标明每一行的行号、错误列、错误原因。用户看着错误文件改一次就能改完。字段级的基础校验可以用 Bean Validation 的注解比如NotBlank、Email、Size之类。EasyExcel 读出来的 DTO 可以直接丢给Validator校验不通过就收集错误不往下走。SetConstraintViolationTviolationsvalidator.validate(obj);但要小心一个性能陷阱不能对每一行数据单独查一次数据库来判断手机号是否存在、用户名是否重复。几十万行数据逐行查库数据库肯定要被拖死。常规做法是先收集这一批里所有需要校验的手机号或用户名发一次批量查询把已有的记录捞到一个 Map 里然后逐行查 Map。ListStringphonesbatch.stream().map(UserImportDTO::getPhone).filter(Objects::nonNull).distinct().collect(Collectors.toList());MapString,IntegerexistsMapuserMapper.countByPhoneList(phones);for(UserImportDTOdto:batch){IntegercountexistsMap.get(dto.getPhone());if(count!nullcount0){errorCollector.collect(dto.getRowIndex(),手机号已存在);continue;}// 其他校验通过加入合法列表}错误文件没必要做得太复杂但至少要包含这些信息Excel 原始行号、出错的列名、错误原因、原始那一行的完整数据。这样用户才不需要对着原文件一个个数。如果有能力直接把错误内容生成一个新的.xlsx让用户下载后自行对照体验会好很多。异步化同步接口不该承担这种长耗时任务一开始就应该明确读取 Excel 并入库绝对不能放在 Controller 的请求线程里。前端只是上传完文件后台立刻返回一个任务编号导入过程在另外的线程池里去执行。Spring Boot 里开个Async自己定义一个线程池。线程池大小不能拍脑袋乱设。每个导入任务会消耗较多内存和数据库连接并发太大容易把服务拖垮通常 2~4 个线程就不少了。EnableAsyncConfigurationpublicclassImportExecutorConfig{Bean(importExecutor)publicExecutorimportExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(2);executor.setMaxPoolSize(4);executor.setQueueCapacity(100);executor.setThreadNamePrefix(import-task-);// 如果队列满了可以让调用方线程执行起到自然限流的作用executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy());executor.initialize();returnexecutor;}}注意这里的选择。如果业务上不允许排队等待CallerRunsPolicy会导致请求线程同步执行导入那还不如不异步。我更建议做好任务排队队列长度设一个合理值满了直接让前端提示“系统繁忙请稍后再试”。任务本身需要一张表来记录状态和进度我习惯叫import_taskCREATETABLEimport_task(idbigintPRIMARYKEYAUTO_INCREMENT,batch_novarchar(32)NOTNULLCOMMENT业务批次号,file_namevarchar(255)NOTNULL,total_countintNULLCOMMENT总行数(不含表头),success_countintNULL,fail_countintNULL,statustinyintNOTNULLCOMMENT1处理中 2成功 3失败 4部分失败,error_file_urlvarchar(500)NULLCOMMENT错误文件路径,error_messagevarchar(1000)NULL,create_timedatetimeNOTNULL,finish_timedatetimeNULL,UNIQUEKEYuk_batch_no(batch_no))ENGINEInnoDBDEFAULTCHARSETutf8mb4;Controller 接口接收上传文件和前端生成的clientToken这个 token 就作为batch_no后端先检查是否已经存在如果存在就不重复创建直接返回已有任务状态。PostMapping(/import/users)publicResultStringimportUsers(RequestParam(file)MultipartFilefile,RequestParam(clientToken)StringclientToken){if(file.isEmpty()){returnResult.fail(文件为空);}if(importTaskService.existByBatchNo(clientToken)){returnResult.success(重复提交任务已存在clientToken);}StringbatchNoimportTaskService.createTask(file,clientToken);returnResult.success(batchNo);}后端createTask里把文件保存成临时文件然后插入 task 记录再异步执行真正的导入逻辑。注意这里必须用临时文件因为MultipartFile拿到的是输入流如果是同一个任务因为重试或补偿需要二次读取流可能已经不在所以先把文件落在磁盘某个临时目录。异步 Service 里需要注意任务状态流转。终态只能是“成功/失败/部分失败”状态不能从终态再改回“处理中”。我之前就看到有同事把状态更新写反了任务结果都算完了最后一下又把状态设成 1导致所有导入任务永远都显示“处理中”。幂等和防重避免用户点一次就产生两遍数据前面说的clientToken防重复提交只是一方面。用户网络抖动时可能点了几次按钮前端会生成不同的 token后端会创建不同任务同一个文件就可能被重复导入。解决思路有几层第一前端保证一次上传只发一次请求用按钮 loadingdisabled 防止连点同时每次进入页面的 upload 操作生成一个 token这个 token 在本次会话内是唯一的。第二后端在import_task表上给batch_no加唯一索引如果没有特殊逻辑重复请求直接报错或者直接返回已有任务。这个属于兜底。第三业务数据本身的防重。比如用户表手机号有唯一索引那批量插入时如果出现重复数据库会抛DuplicateKeyException。这时候不能把整个任务打成失败而是要捕获异常把重复的那几行定位出来标记为错误行。不过更实用的是插入之前先批量查询把已经存在的手机号都排除掉。但并发场景下“先查后插”确实有窗口期两个导入同时进来都查了发现不存在又同时插入其中一个会被数据库唯一索引拦下来。所以数据库唯一索引是必须的代码查询只是减少冲突不能完全依赖它。生产环境里容易被忽略的细节1. 最大行数限制流式解析解决了内存不代表你能随便允许多大的文件。解析 入库是有时间开销的一个 200 万行的 Excel后台任务可能要跑一两个小时这对任务系统的稳定性、数据库连接占用都是巨大负担。产品上设定一个单次导入行数上限比较安全比如 50 万行。判断行数不要等到全量读完了再判断那样浪费资源。可以在监听器里计数发现超过上限抛一个ExcelAnalysisStopException来中断解析。EasyExcel 识别到这个异常会停止读取。2. 错误文件的生成错误文件的生成同样要流式化不要在内存里把错误记录堆完再写。处理一批错误就写一批到ExcelWriter写完关流。EasyExcel 的ExcelWriter和WriteSheet配合起来能保证一张大错误文件的内存占用也可控。3. 批量插入的选型用 MyBatis 的时候不要写个for循环里面逐条insert。要么用 MyBatis 的批量 SQL要么用SqlSessionTemplate的 batch 模式。批量大小还是那句话500~1000 行一次比较合适字段比较多的表可以适当调小。批量 SQL 本身有参数数量限制我记得 MySQL 的max_allowed_packet也会影响一条 SQL 里能塞多少数据。4. 日志链路导入任务通常跑在独立线程池里普通日志里看不到一次导入任务的完整过程。可以用日志框架提供的 Mapped Diagnostic Context把batchNo放进 MDC然后配置 logback 的 pattern 输出%X{batchNo}。这样排查问题的时候直接grep batchNo一条链路从头到尾都能串起来比从一堆业务日志里慢慢翻舒服多了。try{MDC.put(batchNo,batchNo);// 业务逻辑}finally{MDC.remove(batchNo);}5. 临时文件清理用临时文件接收上传的 Excel解析完成后一定记得删除临时文件否则长期跑下来磁盘会被占满。如果文件已经传到 OSS 或者其他云存储那也要在异步任务开始时从 OSS 拉成临时文件任务结束再删。一个可参考的异步处理核心逻辑可以写一个ImportAsyncService在Async方法里做几件事读取临时文件、逐批消费、汇总错误、生成错误文件、更新任务状态。核心代码大致是Async(importExecutor)publicvoiddoImport(LongtaskId,StringbatchNo,FiletmpFile){ImportTasktaskimportTaskMapper.selectById(taskId);if(tasknull||task.getStatus()!1){return;}ErrorRecordCollectorerrorCollectornewErrorRecordCollector();AtomicIntegertotalCountnewAtomicInteger();AtomicIntegersuccessCountnewAtomicInteger();try{BatchDataListenerUserImportDTOlistenernewBatchDataListener(batch-{ListUserImportDTOvalidListnewArrayList();for(UserImportDTOdto:batch){// 这里需要拿到 RowData 的行号示例里简化了MapString,StringfieldErrorsvalidateBean(dto);if(!fieldErrors.isEmpty()){fieldErrors.forEach((field,msg)-errorCollector.collect(dto.getRowNum(),field,msg));continue;}validList.add(dto);}if(!validList.isEmpty()){successCount.addAndGet(userService.batchInsert(validList));}totalCount.addAndGet(batch.size());});EasyExcel.read(tmpFile).head(UserImportDTO.class).headRowNumber(1).registerReadListener(listener).sheet().doRead();StringerrorFileUrlnull;if(!errorCollector.isEmpty()){FileerrorFilegenErrorFile(errorCollector);errorFileUrluploadToOss(errorFile);}intsuccesssuccessCount.get();intfailtotalCount.get()-success;task.setSuccessCount(success);task.setFailCount(fail);task.setErrorFileUrl(errorFileUrl);task.setFinishTime(newDate());task.setStatus(fail0?2:4);importTaskMapper.updateById(task);}catch(Exceptione){log.error(导入任务执行失败, batchNo{},batchNo,e);task.setStatus(3);task.setErrorMessage(e.getMessage());task.setFinishTime(newDate());importTaskMapper.updateById(task);}finally{if(tmpFile!null){tmpFile.delete();}}}这里有个地方容易踩坑如果你使用了BatchDataListener这个通用类它只负责把一行的数据回调出来并不会额外传行号。所以我们需要定义一个RowData包装类或者在invoke时把行号记录到 DTO 的一个字段里。我的建议是包装类不要让 DTO 过多地承担“位置”这种技术信息。一句话总结我的经验把大文件的 Excel 导入做好不是靠某一个框架就完事。EasyExcel 帮你解决了“读文件不爆内存”但剩下的事情——文件怎么存、任务怎么记、错误怎么反馈、数据怎么防重、异步线程池怎么配——都需要你自己去设计。上线的导入接口不应该是黑盒它得有进度、有状态、有错误报告、有数据校验这样用户用着踏实运维也省心。真把这些细节打磨好后面再遇到别的导入需求你会发现基本可以套用同一套骨架改改字段和校验规则就能交付。这也是值得认真做一遍的原因。 福利时间如果你正在备战面试或者想要学习其他知识给大家推荐一个宝藏知识库作者整理了一些列 Java 程序员需要掌握的核心知识有需要的自取不谢。知识库地址https://farerboy.com/