ARTICLE · 1030468
大数据量 Excel 导入的工程化实践:Spring Boot + EasyExcel 流式解析与任务隔离

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("用户名")private String username;@ExcelProperty("手机号")private String phone;@ExcelProperty("邮箱")private String email;@ExcelProperty("入职日期")private String hireDateStr;// 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> {privatefinalint batchSize;privatefinal List<T> cachedList;privatefinal Consumer<List<T>> batchConsumer;publicBatchDataListener(Consumer<List<T>> batchConsumer){this(batchConsumer, 1000); }publicBatchDataListener(Consumer<List<T>> batchConsumer, int batchSize){this.batchConsumer = batchConsumer;this.batchSize = batchSize;this.cachedList = new ArrayList<>(batchSize); }@Overridepublicvoidinvoke(T data, AnalysisContext context){ cachedList.add(data);if (cachedList.size() >= batchSize) { batchConsumer.accept(new ArrayList<>(cachedList)); cachedList.clear(); } }@OverridepublicvoiddoAfterAllAnalysed(AnalysisContext context){if (!cachedList.isEmpty()) { batchConsumer.accept(new ArrayList<>(cachedList)); cachedList.clear(); } }}批大小我觉得 1000 比较合适,不要一次整 5000 甚至 10000。批太大,内存峰值会上去,而且一批 SQL 的数据量也太大,数据库执行时间变长,事务风险反而增加。一批 1000 行,处理完就清空,GC 也能及时回收,内存占用很稳定。
对了,这里有个细节必须提醒:如果你想让消费逻辑能定位到 Excel 原始行号,最好在 invoke 时把行号记录下来,放到一个包装对象里。因为 T 本身不一定有行号字段,等你到消费阶段再想拿行号,已经晚了。
publicclassRowData<T> {privatefinalint rowIndex; // 从数据行算起的行号privatefinal T data;}如果嫌包装麻烦,也可以在监听器里用一个 Map<Object, Integer>,以对象内存地址或者业务主键作为 key 存行号,但包装对象更干净。
校验不要“快速失败”,要允许“半成功”
很多业务系统里的导入代码是这么写的:
if (dto.getUsername() == null) {thrownew IllegalArgumentException("用户名为空");}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 (UserImportDTO dto : batch) { Integer count = 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")public Executor importExecutor(){ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix("import-task-");// 如果队列满了,可以让调用方线程执行,起到自然限流的作用 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize();return executor; }}注意这里的选择。如果业务上不允许排队等待,CallerRunsPolicy 会导致请求线程同步执行导入,那还不如不异步。我更建议做好任务排队,队列长度设一个合理值,满了直接让前端提示“系统繁忙,请稍后再试”。
任务本身需要一张表来记录状态和进度,我习惯叫 import_task:
CREATETABLE`import_task` (`id`bigint PRIMARY KEY AUTO_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` datetime NOTNULL,`finish_time` datetime NULL,UNIQUEKEY`uk_batch_no` (`batch_no`)) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;Controller 接口接收上传文件和前端生成的 clientToken,这个 token 就作为 batch_no,后端先检查是否已经存在,如果存在就不重复创建,直接返回已有任务状态。
@PostMapping("/import/users")public Result<String> importUsers(@RequestParam("file") MultipartFile file, @RequestParam("clientToken") String clientToken) {if (file.isEmpty()) {return Result.fail("文件为空"); }if (importTaskService.existByBatchNo(clientToken)) {return Result.success("重复提交,任务已存在:" + clientToken); } String batchNo = importTaskService.createTask(file, clientToken);return Result.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(Long taskId, String batchNo, File tmpFile){ ImportTask task = importTaskMapper.selectById(taskId);if (task == null || task.getStatus() != 1) {return; } ErrorRecordCollector errorCollector = new ErrorRecordCollector(); AtomicInteger totalCount = new AtomicInteger(); AtomicInteger successCount = new AtomicInteger();try { BatchDataListener<UserImportDTO> listener = new BatchDataListener<>(batch -> { List<UserImportDTO> validList = new ArrayList<>();for (UserImportDTO dto : 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(); String errorFileUrl = null;if (!errorCollector.isEmpty()) { File errorFile = genErrorFile(errorCollector); errorFileUrl = uploadToOss(errorFile); }int success = successCount.get();int fail = totalCount.get() - success; task.setSuccessCount(success); task.setFailCount(fail); task.setErrorFileUrl(errorFileUrl); task.setFinishTime(new Date()); task.setStatus(fail == 0 ? 2 : 4); importTaskMapper.updateById(task); } catch (Exception e) { log.error("导入任务执行失败, batchNo={}", batchNo, e); task.setStatus(3); task.setErrorMessage(e.getMessage()); task.setFinishTime(new Date()); importTaskMapper.updateById(task); } finally {if (tmpFile != null) { tmpFile.delete(); } }}这里有个地方容易踩坑:如果你使用了 BatchDataListener 这个通用类,它只负责把一行的数据回调出来,并不会额外传行号。所以我们需要定义一个 RowData 包装类,或者在 invoke 时把行号记录到 DTO 的一个字段里。我的建议是包装类,不要让 DTO 过多地承担“位置”这种技术信息。
一句话总结我的经验
把大文件的 Excel 导入做好,不是靠某一个框架就完事。EasyExcel 帮你解决了“读文件不爆内存”,但剩下的事情——文件怎么存、任务怎么记、错误怎么反馈、数据怎么防重、异步线程池怎么配——都需要你自己去设计。上线的导入接口不应该是黑盒,它得有进度、有状态、有错误报告、有数据校验,这样用户用着踏实,运维也省心。
真把这些细节打磨好,后面再遇到别的导入需求,你会发现基本可以套用同一套骨架,改改字段和校验规则就能交付。这也是值得认真做一遍的原因。
🎁 福利时间
如果你正在备战大厂面试,我整理了一个 开发者的知识库 涵盖 Java 程序员需要掌握的核心知识。
知识库地址:https://farerboy.com/

架构设计之道在于在不同的场景采用合适的架构设计,架构设计没有完美,只有合适。
在代码的路上,我们一起砥砺前行。用代码改变世界!
感谢观看,如果觉得对您有用,还请动动您那发财的手指头,点赞、转发、在看、收藏
更多精彩合集请关注公众号🔽🔽🔽🔽🔽🔽🔽🔽


欢迎学习或从事编程开发、技术招聘 HR 进群,欢迎大家分享自己公司的内推信息,相互帮助,一起进步!
工作 3 年还在写 CRUD?简历投递杳无音讯?面试屡屡受挫,迟迟拿不到 offer?
在竞争激烈的大环境下,只有不断提升核心竞争力才能立于不败之地。
扫码留言【我要晋级】一对一指导,带你晋级。

广告人士勿入,切勿轻信私聊,防止被骗