1. 批量导入这件事,难的不是读Excel,而是把失败的那部分说清楚
做过后台管理系统的人大概都有过这种经历:运营同事丢过来一个几百上千行的Excel,说“帮忙导一下”,你写了个接口,用EasyExcel几行代码读进来,批量插入数据库,本地跑得挺顺。结果上线第一天,运营又来了——“有几十条没导进去,是哪几十条?为什么没进去?”这时候你才发现,真正麻烦的根本不是“读Excel”,而是怎么把成功了多少、失败了多少、每一条为什么失败讲清楚,并且把失败的那些原样吐回去让人家改。
这个需求听起来朴素,但它是所有数据导入功能的“最后一公里”。一个只返回“导入成功”或“导入失败”的接口,在生产环境里基本等于没做完。运营需要的是:我这次导了500条,成功480条,失败20条;失败的20条里,有5条是手机号格式不对,有8条是必填字段为空,有7条是编码在系统里已经存在。而且这20条要能直接下载下来,最好还带上失败原因列,我改完再导一次。
这篇内容就围绕这个场景展开,讲清楚基于EasyExcel怎么把“读取—校验—入库—统计—失败回写导出”这一整条链路做扎实。关键词是EasyExcel读取、成功失败数量统计、失败原因回写、失败数据导出到系统路径。适合正在做后台导入功能的后端开发,也适合想把这套逻辑抽象成通用组件的同学。我会把每一步为什么这么做、参数怎么定、坑在哪里都摊开讲,代码给到能直接改改就用的程度。
先说结论性的判断:这个功能的核心设计思想是**“逐行独立处理 + 结果分类收集 + 失败行原样保留”**。不要试图用一次批量插入去赌全部成功,也不要读完整个Excel再统一校验,那样你根本定位不到具体是哪一行出的问题。逐行处理虽然看起来“笨”,但它是唯一能精确归因的做法。
2. 为什么监听器里不能直接写数据库,以及正确的分层姿势
2.1 EasyExcel的读取模型决定了你必须“边读边攒”
EasyExcel读取的核心是ReadListener,它按行回调invoke方法。很多人图省事,直接在invoke里调Service做插入,插一条算一条。小数据量没问题,但一旦数据量上来,或者插入逻辑里带了事务,就会出大问题:事务边界混乱、连接池被长时间占用、某一行抛异常导致整个读取中断。
正确的姿势是:监听器只负责“读”和“攒”,业务处理放到读取完成之后统一做。具体来说,监听器里把每一行解析成实体对象,塞进一个List,等doAfterAllAnalysed回调触发时,说明整个文件读完了,再把这个List交给业务层去逐条校验和入库。
这样分层的好处很直接:读取和业务解耦,读取阶段不会因为业务异常而中断;业务阶段可以自由控制事务粒度,比如每条独立事务,或者分批提交。
public class ImportDataListener<T> extends AnalysisEventListener<T> { // 攒数据的容器,注意用线程安全的或者每次new一个监听器 private final List<T> dataList = new ArrayList<>(); // 记录读取过程中的解析异常(比如单元格类型不对) private final List<String> parseErrors = new ArrayList<>(); @Override public void invoke(T data, AnalysisContext context) { // 这里只做最轻量的事:收集 dataList.add(data); } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 读取完成,什么都不做,交给外部处理 } @Override public void onException(Exception exception, AnalysisContext context) { // 单行解析异常不要直接抛,记录下来继续读 parseErrors.add("第" + context.readRowHolder().getRowIndex() + "行解析异常"); } public List<T> getDataList() { return dataList; } public List<String> getParseErrors() { return parseErrors; } }这里有个关键点:onException方法一定要重写。默认情况下,EasyExcel遇到解析异常会直接抛出,整个读取就断了。重写之后把它记下来继续读,才能保证“一行坏数据不影响其他行”。这是做批量导入的基本素养。
2.2 监听器不要做成Spring单例Bean
这是踩过坑才知道的事。如果你把ReadListener交给Spring管理成单例,那dataList这个成员变量就是共享的。第一次导入攒了500条,第二次导入又往同一个List里加,数据就串了。更隐蔽的是并发场景,两个用户同时导入,数据直接混在一起。
所以监听器要么每次new一个,要么用@Scope("prototype")声明成多例。我个人的习惯是每次读取时直接new,简单直接,不依赖Spring的作用域机制,少一层心智负担。
2.3 读取时的实体映射要留好“原始行号”
失败数据导出时,运营最想知道的是“我Excel里第几行出的问题”。EasyExcel的实体里默认没有行号,但你可以通过AnalysisContext拿到rowIndex。做法是在实体里加一个rowNum字段,在invoke里手动赋值:
@Override public void invoke(T data, AnalysisContext context) { // rowIndex是从0开始的,+1变成Excel里看到的行号,再+1跳过表头 int rowIndex = context.readRowHolder().getRowIndex(); if (data instanceof RowNumAware) { ((RowNumAware) data).setRowNum(rowIndex + 1); } dataList.add(data); }定义一个RowNumAware接口,让所有导入实体实现它,这样行号信息就能一路带到失败导出环节。这个小小的设计,能让运营的体验提升一大截——他们不用自己数行号。
3. 逐行校验与入库:成功失败怎么分桶,事务怎么切
3.1 校验和入库的顺序:先校验全部,还是边校验边入库
两种做法各有取舍。先全部校验再统一入库的好处是,如果校验阶段发现大量错误,可以快速失败,不用碰数据库;坏处是校验通过的合法数据要再遍历一次。边校验边入库的好处是逻辑线性,一条走完流程;坏处是事务控制要小心。
我的建议是:校验和入库放在同一个循环里,但每条数据用独立的事务。原因是导入场景下,一条数据的失败不应该影响其他数据。如果用一个大事务包住整个循环,任何一条失败都会导致全部回滚,那“成功480条失败20条”就变成了“全部失败”,完全违背需求。
实现上,把单条处理逻辑抽成一个方法,加上@Transactional(propagation = Propagation.REQUIRES_NEW),或者用编程式事务手动控制。注意REQUIRES_NEW会挂起外层事务,如果外层本来就没事务,效果就是每条独立提交。
public ImportResult importData(List<ImportRow> rows) { ImportResult result = new ImportResult(); List<FailRow> failRows = new ArrayList<>(); int successCount = 0; for (ImportRow row : rows) { try { // 校验 String error = validate(row); if (error != null) { failRows.add(buildFailRow(row, error)); continue; } // 入库,独立事务 self.saveOne(row); successCount++; } catch (Exception e) { // 数据库层面的异常,比如唯一索引冲突 failRows.add(buildFailRow(row, "入库失败:" + e.getMessage())); } } result.setSuccessCount(successCount); result.setFailCount(failRows.size()); result.setFailRows(failRows); return result; }注意这里用了self.saveOne(row),self是注入的自己(通过AopContext.currentProxy()或者注入自身),目的是让@Transactional生效。如果直接this.saveOne(),Spring的代理不生效,事务注解就是摆设。这个坑非常经典,很多人栽在这里还以为事务配错了。
3.2 校验规则要可配置、可累积
校验最容易写成一坨if-else。更好的做法是把校验规则抽成一个个独立的校验器,每个校验器返回一个错误信息,多个错误可以累积。比如一行数据既手机号格式错、又编码重复,运营希望一次看到两个问题,而不是改完一个再发现另一个。
public interface RowValidator { String validate(ImportRow row); } // 组合校验 List<RowValidator> validators = Arrays.asList( new RequiredFieldValidator(), new PhoneFormatValidator(), new DuplicateCodeValidator() ); List<String> errors = new ArrayList<>(); for (RowValidator v : validators) { String err = v.validate(row); if (err != null) errors.add(err); } if (!errors.isEmpty()) { failRows.add(buildFailRow(row, String.join(";", errors))); continue; }这样设计还有一个好处:校验规则可以按业务类型动态组装。比如导入用户和导入商品,用的校验器集合不同,但框架是同一套。
3.3 数据库唯一约束冲突要单独处理
有一种失败是校验阶段发现不了的:并发导入时,两条数据都通过了“编码不重复”的校验,但入库时唯一索引冲突。这种异常必须捕获,并且给出人话提示,而不是把DuplicateKeyException的堆栈直接甩给运营。
catch (DuplicateKeyException e) { failRows.add(buildFailRow(row, "编码已存在,请检查是否重复导入")); }把常见的数据库异常映射成业务语言,是导入功能专业度的体现。运营看不懂SQLIntegrityConstraintViolationException,但看得懂“编码已存在”。
4. 失败数据回写导出:怎么把原始行和失败原因拼回一个Excel
4.1 失败导出的本质是“原数据 + 一列原因”
运营拿到失败文件后,期望的是:打开一看,还是他原来那些列,只是多了一列“失败原因”,他改完直接再导。所以失败导出的结构应该是原始列 + 失败原因列。
实现上有两种思路。一种是失败行实体里保留所有原始字段,导出时用EasyExcel写这个实体;另一种是动态列,因为不同导入模板列不一样。如果导入模板是固定的,用固定实体最省事;如果要做通用组件,就得用动态表头。
固定实体的写法:
public class FailRow { // 原始字段,和导入实体一致 private String name; private String phone; private String code; // 额外加的行号和原因 private Integer rowNum; private String failReason; }导出时用EasyExcel.write(outputStream, FailRow.class).sheet("失败数据").doWrite(failRows),几行搞定。
4.2 导出到“系统路径”要考虑的几个现实问题
需求里说“导出到系统路径中”,这个“系统路径”通常指服务器上的某个目录,比如/data/import/fail/。这里有几个必须想清楚的点:
第一,文件名要唯一且可追溯。用时间戳加业务标识,比如fail_20240520153012_用户导入.xlsx。如果多个用户同时导入,文件名冲突会互相覆盖。
第二,目录要提前创建。File.mkdirs()别忘了,否则写文件时直接报“路径不存在”。
第三,要考虑清理策略。失败文件堆在服务器上不会自己消失,时间长了磁盘就满了。常见做法是保留7天或30天,用定时任务清理,或者按日期分目录,删旧目录。
第四,返回给前端的应该是下载地址而不是文件流。既然已经落到系统路径了,接口返回一个可访问的URL(比如通过静态资源映射或者文件下载接口),前端点一下就能下载,比直接把流塞进响应体更灵活,也方便运营事后重新下载。
String dir = "/data/import/fail/"; File dirFile = new File(dir); if (!dirFile.exists()) dirFile.mkdirs(); String fileName = "fail_" + System.currentTimeMillis() + ".xlsx"; String fullPath = dir + fileName; try (OutputStream os = new FileOutputStream(fullPath)) { EasyExcel.write(os, FailRow.class).sheet("失败数据").doWrite(failRows); } // 返回可下载的相对路径或URL result.setFailFilePath("/download/fail/" + fileName);4.3 失败原因列要放在最后一列还是第一列
这是个细节但影响体验。放在最后一列,运营需要横向滚动才能看到原因,尤其是列多的时候;放在第一列,运营一眼就能看到问题,但会打乱他原来的列顺序,改完再导时可能对不上。
我的经验是:放在最后一列,但把行号放在第一列。行号是运营定位问题的关键,放最前面方便他对照原文件;失败原因放最后,不干扰原始数据的阅读。如果失败原因很长,可以在导出时设置列宽自适应,或者干脆把原因列宽设大一点。
5. 把结果封装成一个能直接返回给前端的对象
5.1 返回结构设计:数量、原因、文件路径一个都不能少
前端拿到导入结果后要展示什么?通常是三块:成功多少条、失败多少条、失败文件下载链接。如果失败条数不多,还可以直接把失败明细列表返回,前端弹窗展示,不用下载。
public class ImportResult { private int totalCount; // 总条数 private int successCount; // 成功条数 private int failCount; // 失败条数 private String failFilePath; // 失败文件路径 private List<FailRow> failRows; // 失败明细(可选,条数少时返回) }这里有个取舍:失败明细要不要全量返回?如果失败500条,全塞进JSON响应体会很大。我的做法是失败条数小于50条时返回明细,否则只返回文件路径。前端根据这个规则决定是弹窗展示还是提示下载。
5.2 空文件和全成功全失败的边界处理
边界情况最容易被忽略,但线上偏偏就出这些:
- 空文件:只有表头没有数据行。这时候
dataList是空的,应该直接返回“文件中没有数据”,而不是走一遍流程返回0成功0失败。 - 全成功:
failCount为0,不生成失败文件,failFilePath为null,前端不显示下载按钮。 - 全失败:
successCount为0,失败文件包含所有行,提示运营全部需要修改。 - 文件格式错误:比如上传的是
.txt改了后缀,EasyExcel读取时会抛异常,要捕获并返回友好提示。
if (dataList.isEmpty()) { throw new BizException("导入文件中没有数据,请检查"); }5.3 大文件的内存控制:别让一个导入把服务搞挂
如果运营传了一个10万行的Excel,全攒在List里,内存直接飙升。EasyExcel本身是流式读取的,但你把所有行都攒起来就失去了流式的意义。
对于大文件,有两个策略。一是分批处理:监听器里每攒够500条就交给业务层处理并清空List,处理完再继续攒。二是限制单次导入条数:超过阈值直接拒绝,提示分批导入。实际项目中,后者更常见也更稳妥,因为导入本身就不该是高频大数据量的操作。
private static final int BATCH_SIZE = 500; private List<T> batch = new ArrayList<>(BATCH_SIZE); @Override public void invoke(T data, AnalysisContext context) { batch.add(data); if (batch.size() >= BATCH_SIZE) { processBatch(batch); batch.clear(); } }注意分批处理后,成功失败计数要累加,失败文件要追加写入(EasyExcel支持多次写入同一个sheet,但要注意用ExcelWriter手动控制)。如果嫌追加写入麻烦,也可以每批生成一个临时文件最后合并,但那样更复杂。我的建议是:如果业务允许,直接限制单次导入上限(比如5000条),超过就提示分批,这是性价比最高的方案。
6. 几个只有踩过才知道的坑
6.1 日期和数字格式的解析陷阱
Excel里的日期单元格,EasyExcel默认可能读成数字或者字符串,取决于单元格格式。如果实体字段是Date类型,而单元格是文本格式的“2024-01-01”,会解析失败。解决办法是在实体字段上加@DateTimeFormat,或者统一用String接收再手动转换。数字也是同理,手机号这种长数字如果单元格是数值格式,可能变成科学计数法,用String接收最稳。
6.2 表头不匹配时EasyExcel的静默行为
如果Excel的表头和实体@ExcelProperty配置对不上,EasyExcel默认可能读不到数据但不报错,你会得到一个空List,然后困惑“为什么没数据”。建议开启表头校验,或者在读取前先读一行表头做比对。EasyExcel的headRowNumber参数要设对,默认是1,如果文件有多行表头要调整。
6.3 失败文件里的原始数据要“原样”,不要二次加工
导出失败数据时,千万不要把原始值做格式化。比如手机号本来是13800138000,你导出时格式化成138-0013-8000,运营改完再导又对不上了。失败文件的原则是:除了多一列失败原因,其他列和原文件一模一样。这样才能保证“改完直接再导”的闭环。
6.4 并发导入同一批数据时的重复校验
两个运营同时导入同一份文件,校验阶段都通过了,入库时一个成功一个唯一键冲突。这不是bug,是并发场景的正常表现。处理方式就是前面说的捕获DuplicateKeyException并给出友好提示。如果想更严格,可以在导入前加分布式锁按业务键加锁,但那样会牺牲并发,一般没必要。
7. 关于这套方案我自己的使用体会
这套“逐行处理 + 结果分桶 + 失败回写”的模式,我在好几个项目里都用过,从用户导入、商品导入到订单导入,结构基本不用大改,换的只是校验器和实体。它最大的价值不是技术多高深,而是把“导入”这件事从“黑盒”变成了“白盒”——运营知道哪条错了、为什么错、怎么改,开发也不用被反复追问“到底哪条没进去”。
如果让我给正在做这个功能的同学一句建议,那就是:先把失败路径想清楚,再写成功路径。大多数人写导入都是先想着怎么把数据插进去,最后才补失败处理,结果补得手忙脚乱。反过来,先把失败数据怎么收集、怎么导出、怎么返回想明白,成功路径反而是水到渠成的事。
另外,失败文件建议保留一段时间(比如7天),别导入完就删。运营经常是当天没改,过两天才回来找文件。留个定时清理任务,既不会撑爆磁盘,又能覆盖大多数回看需求。这个细节不起眼,但能省掉很多“文件还能再发我一份吗”的沟通成本。