Excel 导入这功能,做业务开发的应该都不陌生。刚接需求的时候总觉得没什么:上个文件、解析、再插入数据库,完事。可一旦用户开始传几十万行数据,原来的“简单功能”就会变成事故导火索。我不是在吓唬人,是真的被线上 OOM 搞到半夜起来过。
先说几个实际会遇到的问题,大家都有画面:
- 用 Apache POI 的普通 API 读
.xlsx,整个文件会被构建成一棵对象树。50 万行 × 20 列,不算夸张,堆内存直接飙到 1G 以上。小应用常用-Xmx512m,一条导入请求就能把应用拖死,其他接口也跟着遭殃。 - 同步导入时前端一直等着,几十万行数据从解析到入库怎么也得几分钟。网关超时、负载均衡超时、浏览器超时,一个都避免不了。
- 很多导入功能只判“非空”,甚至什么都不判,脏数据直接写主表。等第二天下游报表出问题,才发现前一天导入的数据里有非法手机号、重复用户名、格式乱七八糟的日期。
- 导入失败只返回个“导入失败”,用户根本不知道哪里错。线上只能翻日志,而且日志里往往也没有行号和原始内容,完全没办法排查。
所以 Excel 导入这事,真不是一个“读文件 + 写库”两步走的问题。你得考虑流式读取、数据校验、异步任务、进度反馈、幂等防重、错误行定位…… 这篇文章就是把这些东西串起来,讲一套能扛住几十万行数据的落地方案。
选型:为什么我更推荐 EasyExcel
Apache 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里,每来一行就校验一次、插入一次。这样写当然能用,但是数据库访问次数太多,而且也没有发挥“批处理”的优势。更好的做法是定义一个通用监听器,内部先攒一批,够数了交给一个Consumer<List<T>>去消费,之后清空列表继续读。
publicclassBatchDataListener<T>extendsAnalysisEventListener<T>{privatefinalintbatchSize;privatefinalList<T>cachedList;privatefinalConsumer<List<T>>batchConsumer;publicBatchDataListener(Consumer<List<T>>batchConsumer){this(batchConsumer,1000);}publicBatchDataListener(Consumer<List<T>>batchConsumer,intbatchSize){this.batchConsumer=batchConsumer;this.batchSize=batchSize;this.cachedList=newArrayList<>(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本身不一定有行号字段,等你到消费阶段再想拿行号,已经晚了。
publicclassRowData<T>{privatefinalintrowIndex;// 从数据行算起的行号privatefinalTdata;}如果嫌包装麻烦,也可以在监听器里用一个Map<Object, Integer>,以对象内存地址或者业务主键作为 key 存行号,但包装对象更干净。
校验不要“快速失败”,要允许“半成功”
很多业务系统里的导入代码是这么写的:
if(dto.getUsername()==null){thrownewIllegalArgumentException("用户名为空");}userMapper.insert(dto);这是典型的“第一行有错就整体挂掉”。如果文件里有 2 万行,第 1 行和第 200 行都有问题,用户得反复修改、反复上传 200 次,这谁能受得了?
正确思路是:异常数据收集起来,正常数据该导入继续导入。最后给用户一个错误文件,里面标明每一行的行号、错误列、错误原因。用户看着错误文件改,一次就能改完。
字段级的基础校验可以用 Bean Validation 的注解,比如@NotBlank、@Email、@Size之类。EasyExcel 读出来的 DTO 可以直接丢给Validator校验,不通过就收集错误,不往下走。
Set<ConstraintViolation<T>>violations=validator.validate(obj);但要小心一个性能陷阱:不能对每一行数据单独查一次数据库来判断手机号是否存在、用户名是否重复。几十万行数据,逐行查库,数据库肯定要被拖死。常规做法是先收集这一批里所有需要校验的手机号或用户名,发一次批量查询,把已有的记录捞到一个 Map 里,然后逐行查 Map。
List<String>phones=batch.stream().map(UserImportDTO::getPhone).filter(Objects::nonNull).distinct().collect(Collectors.toList());Map<String,Integer>existsMap=userMapper.countByPhoneList(phones);for(UserImportDTOdto:batch){Integercount=existsMap.get(dto.getPhone());if(count!=null&&count>0){errorCollector.collect(dto.getRowIndex(),"手机号已存在");continue;}// 其他校验通过,加入合法列表}错误文件没必要做得太复杂,但至少要包含这些信息:Excel 原始行号、出错的列名、错误原因、原始那一行的完整数据。这样用户才不需要对着原文件一个个数。如果有能力,直接把错误内容生成一个新的.xlsx,让用户下载后自行对照,体验会好很多。
异步化:同步接口不该承担这种长耗时任务
一开始就应该明确:读取 Excel 并入库,绝对不能放在 Controller 的请求线程里。前端只是上传完文件,后台立刻返回一个任务编号,导入过程在另外的线程池里去执行。
Spring Boot 里开个@Async,自己定义一个线程池。线程池大小不能拍脑袋乱设。每个导入任务会消耗较多内存和数据库连接,并发太大容易把服务拖垮,通常 2~4 个线程就不少了。
@EnableAsync@ConfigurationpublicclassImportExecutorConfig{@Bean("importExecutor")publicExecutorimportExecutor(){ThreadPoolTaskExecutorexecutor=newThreadPoolTaskExecutor();executor.setCorePoolSize(2);executor.setMaxPoolSize(4);executor.setQueueCapacity(100);executor.setThreadNamePrefix("import-task-");// 如果队列满了,可以让调用方线程执行,起到自然限流的作用executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy());executor.initialize();returnexecutor;}}注意这里的选择。如果业务上不允许排队等待,CallerRunsPolicy会导致请求线程同步执行导入,那还不如不异步。我更建议做好任务排队,队列长度设一个合理值,满了直接让前端提示“系统繁忙,请稍后再试”。
任务本身需要一张表来记录状态和进度,我习惯叫import_task:
CREATETABLE`import_task`(`id`bigintPRIMARYKEYAUTO_INCREMENT,`batch_no`varchar(32)NOTNULLCOMMENT'业务批次号',`file_name`varchar(255)NOTNULL,`total_count`intNULLCOMMENT'总行数(不含表头)',`success_count`intNULL,`fail_count`intNULL,`status`tinyintNOTNULLCOMMENT'1处理中 2成功 3失败 4部分失败',`error_file_url`varchar(500)NULLCOMMENT'错误文件路径',`error_message`varchar(1000)NULL,`create_time`datetimeNOTNULL,`finish_time`datetimeNULL,UNIQUEKEY`uk_batch_no`(`batch_no`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;Controller 接口接收上传文件和前端生成的clientToken,这个 token 就作为batch_no,后端先检查是否已经存在,如果存在就不重复创建,直接返回已有任务状态。
@PostMapping("/import/users")publicResult<String>importUsers(@RequestParam("file")MultipartFilefile,@RequestParam("clientToken")StringclientToken){if(file.isEmpty()){returnResult.fail("文件为空");}if(importTaskService.existByBatchNo(clientToken)){returnResult.success("重复提交,任务已存在:"+clientToken);}StringbatchNo=importTaskService.createTask(file,clientToken);returnResult.success(batchNo);}后端createTask里把文件保存成临时文件,然后插入 task 记录,再异步执行真正的导入逻辑。注意这里必须用临时文件,因为MultipartFile拿到的是输入流,如果是同一个任务因为重试或补偿需要二次读取,流可能已经不在,所以先把文件落在磁盘某个临时目录。
异步 Service 里需要注意任务状态流转。终态只能是“成功/失败/部分失败”,状态不能从终态再改回“处理中”。我之前就看到有同事把状态更新写反了,任务结果都算完了,最后一下又把状态设成 1,导致所有导入任务永远都显示“处理中”。
幂等和防重:避免用户点一次就产生两遍数据
前面说的clientToken防重复提交只是一方面。用户网络抖动时可能点了几次按钮,前端会生成不同的 token,后端会创建不同任务,同一个文件就可能被重复导入。
解决思路有几层:
第一,前端保证一次上传只发一次请求,用按钮 loading+disabled 防止连点,同时每次进入页面的 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){ImportTasktask=importTaskMapper.selectById(taskId);if(task==null||task.getStatus()!=1){return;}ErrorRecordCollectorerrorCollector=newErrorRecordCollector();AtomicIntegertotalCount=newAtomicInteger();AtomicIntegersuccessCount=newAtomicInteger();try{BatchDataListener<UserImportDTO>listener=newBatchDataListener<>(batch->{List<UserImportDTO>validList=newArrayList<>();for(UserImportDTOdto:batch){// 这里需要拿到 RowData 的行号,示例里简化了Map<String,String>fieldErrors=validateBean(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();StringerrorFileUrl=null;if(!errorCollector.isEmpty()){FileerrorFile=genErrorFile(errorCollector);errorFileUrl=uploadToOss(errorFile);}intsuccess=successCount.get();intfail=totalCount.get()-success;task.setSuccessCount(success);task.setFailCount(fail);task.setErrorFileUrl(errorFileUrl);task.setFinishTime(newDate());task.setStatus(fail==0?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/