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 ImportDataListenerT extends AnalysisEventListenerT { // 攒数据的容器注意用线程安全的或者每次new一个监听器 private final ListT dataList new ArrayList(); // 记录读取过程中的解析异常比如单元格类型不对 private final ListString 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 ListT getDataList() { return dataList; } public ListString 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(ListImportRow rows) { ImportResult result new ImportResult(); ListFailRow 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); } // 组合校验 ListRowValidator validators Arrays.asList( new RequiredFieldValidator(), new PhoneFormatValidator(), new DuplicateCodeValidator() ); ListString 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. 失败数据回写导出怎么把原始行和失败原因拼回一个Excel4.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 ListFailRow 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 ListT 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天别导入完就删。运营经常是当天没改过两天才回来找文件。留个定时清理任务既不会撑爆磁盘又能覆盖大多数回看需求。这个细节不起眼但能省掉很多“文件还能再发我一份吗”的沟通成本。