ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Excel批量导入模块化设计:从分类导入到通用数据校验与落库实践

Excel批量导入模块化设计:从分类导入到通用数据校验与落库实践 去年年中我接手了一个商城后台的重构分类管理页面里的“导入分类”功能光是写在controller和service里的逻辑就有六百多行而且这还不是唯一一份——商品模块、品牌模块各自复制了一份改改字段名就直接用。最让我头疼的不是代码丑而是每次产品提“导入模板里加一列”这种需求我得同时改三处解析、三处校验、三处落库漏掉一处线上就进脏数据。后来我把“导入分类”这件事单独拆成了一个功能模块再把商品、品牌的导入入口统一收敛到这一套代码上。这段时间我抽出空来把整个模块的设计思路、关键代码和踩过的坑完整整理一遍。如果你在开发CRM、商城、CMS这类后台系统恰好也在做分类导入、Excel或CSV批量导入相关功能这篇文章里的代码结构和避坑经验可以直接拿去参考。1. 为什么要把“导入分类”单独拎出来做模块1.1 我在旧项目里看到的三份重复代码旧项目里“导入分类”的实现方式是这样的每个业务模块各写各的分类导入逻辑放在CategoryController里商品导入逻辑放在ProductController里品牌导入逻辑放在BrandController里。表面上只有“分类”和“商品”的差异实际上解析文件、读取表头、逐行校验、拼装实体、批量插入这五步的代码结构几乎一模一样。真正不同的只有三处实体字段名、数据库表名、几个业务字段的校验规则。也就是说一份通用逻辑被复制了三遍然后各自改了几行。这类代码最大的问题不是重复而是演化速度不一致。今天产品只改了分类模板的校验规则我只改了分类那套代码下个月商品那边要加同样的规则但当时写商品导入的同事已经离职了没人知道那份代码里有哪些隐式假设。等到线上出现“导入成功但分类层级错乱”的问题时排查成本被成倍放大——因为你永远不知道你改的这一套和线上还在跑的那一套到底有多少差异。1.2 导入这件事的本质解析、校验、落库三件事如果把“导入”放到足够高的抽象层面去看任何实体模块的导入都只有三件事解析把文件格式xlsx、csv转成统一的行数据模型这一步和业务完全无关。校验对行数据做合法性检查业务相关但可配置、可插拔。落库把合法数据写入数据库这一步的重点是事务边界和幂等策略。分类模块的特殊性在于第二件事和第三件事比普通表单导入更复杂。普通导入校验的是字段格式分类导入还要校验父子关系、同级唯一性、循环引用普通导入直接INSERT就行分类导入需要先把平铺数据重建成树结构再去决定parent_id怎么填。所以分类导入模块不能只写一层“读文件插库”它的核心价值在于把这套“层级数据处理逻辑”沉淀下来。1.3 独立模块的边界哪些东西应该留在业务里拆模块不是把代码全部抽到公共类就完事关键是划清边界。我在设计时定了三条原则文件解析、公共校验、批量写入框架属于模块内核业务方不能动。分类特有的校验规则比如同级唯一、父节点存在性通过实现一个CategoryImportValidator接口注入。落库之前的字段映射规则、落库之后的个性化处理比如刷新缓存、同步搜索引擎留在业务侧。这样拆完之后商品模块如果也想用导入功能只需要实现自己的Validator接口再配置一套表头映射和实体映射关系完全不需要碰模块内部的任何代码。2. 模块落地的第一层文件解析与统一行模型2.1 为什么我弃用了原生POI改用EasyExcel我最早用的是Apache POI的WorkbookFactory因为项目里已经有POI依赖图省事。但跑了两个版本之后发现两个问题第一POI读取大Excel时内存占用很夸张一个5万行的xlsx能把堆内存吃到600MB以上导一次就把老年代打满第二POI对合并单元格、日期格式的处理非常“原始”拿到手的基本是CellType加一堆if-else判断代码写起来又臭又长。后来换成了EasyExcel核心原因是它的流式读取模型。EasyExcel在ReadListener的invoke方法里一行一行回调不会把整个文件一次性载入内存。同样是5万行的文件EasyExcel跑完大概只占几十MB堆内存。而且它对表头、日期、字符串格式的处理已经封装好了配合AnalysisEventListener可以拿到每一行的原生Map数据很适合做通用导入模块。2.2 表头映射让用户改列名也不怕导入模块最容易被业务方吐槽的点就是“模板列名改一个字代码就崩”。比如Excel表头是“分类名称”用户另存模板时不小心改成了“分类名”导入程序按headers[0]去取值就会出错。我的做法是建立一套“业务字段名”和“Excel表头名”的映射配置而且支持一个字段对应多个别名public class HeaderMapping { // 业务字段名 - Excel列名列表 private MapString, ListString mapping new HashMap(); public void add(String fieldName, String... headerNames) { mapping.put(fieldName, Arrays.asList(headerNames)); } public String matchField(String headerName) { String trimmed headerName.trim(); for (Map.EntryString, ListString entry : mapping.entrySet()) { for (String alias : entry.getValue()) { if (alias.equalsIgnoreCase(trimmed) || alias.replace( , ).equals(trimmed.replace( , ))) { return entry.getKey(); } } } return null; // 未匹配到的列 } }这里我做了两个小优化忽略大小写以及忽略表头里的空格。别小看这两条用户从PDF里复制表头粘贴到Excel经常带出莫名其妙的空格和全角字符。更好的做法是在匹配前统一做一次字符归一化把全角空格、制表符全部剔除再比较。2.3 一个容易翻车的细节表头多行和合并单元格第二个版本上线后业务方反馈说“模板里加了一行说明文字导入就乱了”。打开模板一看第一行是“注带星号为必填项”的说明第二行才是真正的表头。EasyExcel默认headRowNumber(1)把第一行当表头遇到这种情况说明文字会被当成列名整行数据全部偏移。解决方法是允许配置表头起始行EasyExcel.read(inputStream) .sheet(0) .headRowNumber(skipRows) // 可配置为1或2 .doRead();然后在监听器里把第headRowNumber行作为真正的表头输出之前的行全部跳过。至于合并单元格我在实践里发现分类导入模板基本不需要合并单元格但如果有只保留左上角的值、其余置空。千万别试图去还原合并后的完整结构那会让解析逻辑复杂十位不说还容易受Excel版本差异影响。3. 模块落地的第二层分类数据的校验规则3.1 必填和格式校验最基础但最容易被忽略我见过很多“导入失败但不报原因”的系统原因就是校验逻辑散落在各处没有统一的错误收集机制。我在模块里做了一个ErrorCollector对象每一行校验出的错误都放进这个收集器等所有行校验完后一次性返回给前端告诉用户“第3行分类名称不能为空第7行排序号必须是数字”。这里最关键的教训是不要一行报错就中断整个导入任务。Excel导入不是表单提交用户往往一次就要修正几十条数据你只告诉他第一行错了他会很崩溃。基础的必填校验其实就两行代码if (StringUtils.isBlank(row.getName())) { collector.add(row.getRowNum(), 分类名称不能为空); }但格式校验要根据业务来写对分类模块来说排序号上限、名称长度限制、是否允许空格字符这些规则我统一放进了CategoryImportValidator接口public interface CategoryImportValidator { void validate(CategoryImportRow row, ErrorCollector collector); }这样做的好处是模块内核不做任何业务假设分类的规则就是分类的规则商品模块如果将来接入完全可以换一套自己的规则实现。3.2 同级唯一性分类表最常见的脏数据来源分类表脏数据最多的一类就是“同名分类”。同一个父分类下有两个“手机壳”用户在界面上根本分不清哪个是哪个。所以导入时必须做同级唯一校验。实现上我在内存里维护一个SetStringkey的格式是parentPath / name。每校验一行先查这个Set如果已存在则报错否则就加入Set。这里要特别说明一个细节用parentPath而不是parentId做key。因为导入时数据还没入库parentId根本不存在而parentPath是解析时从用户填写的“父分类路径”列直接读到的天然是稳定的。比如这样SetString uniqueSet new HashSet(); for (CategoryImportRow row : rows) { String key row.getParentPath() / row.getName(); if (!uniqueSet.add(key)) { collector.add(row.getRowNum(), 同级下已存在同名分类: row.getName()); } }这个校验必须在重建树之前执行因为树重建时遇到重复名称会导致路径错乱报出来的错误会让用户完全看不懂。3.3 父节点校验与循环依赖检测的实现思路分类导入比其他实体多一重麻烦用户填的“父分类路径”可能根本不存在或者路径中间环节缺失。比如用户填/数码/手机/iPhone壳但导入数据里没有“数码”或“手机”这两个节点。这时候不能直接报“父分类不存在”因为如果数据是乱序的——先出现叶子再出现父级——会被误判。我的处理是分两遍走第一遍先把所有行按照parentPath的深度排序路径越短越靠前第二遍再逐一校验父分类是否存在。排序后父分类一定比子分类先处理校验时直接用“已确认存在路径”的Set来判断SetString existsPath new HashSet(); existsPath.add(/); // 根分类 for (CategoryImportRow row : sortedRows) { if (!existsPath.contains(row.getParentPath())) { collector.add(row.getRowNum(), 父分类路径不存在: row.getParentPath()); continue; } existsPath.add(row.getParentPath() / row.getName()); }循环依赖的情况则出现在“用户用父分类名称而不是父分类路径”的模板里。我会专门跑一个DFS从每个分类节点出发沿着parentName找父级如果某个节点又走回了自己就说明存在循环依赖MapString, String parentMap new HashMap(); for (CategoryImportRow row : rows) { parentMap.put(row.getName(), row.getParentName()); } for (String node : parentMap.keySet()) { SetString visited new HashSet(); String current node; while (current ! null !current.isEmpty()) { if (!visited.add(current)) { throw new ImportException(分类存在循环依赖: String.join( - , visited) - current); } current parentMap.get(current); } }这段逻辑还有一个隐藏收益它能顺带检测出“一个分类同时有两个父级”的冲突数据因为parentMap中一个name只能映射一个parentName。4. 模块落地的第三层从平铺数据到分类树4.1 用“路径栈”而不是“递归查库”来组树分类数据做完校验之后就进入最核心的一步——把Excel里一行行的平铺数据转成树结构再落库。最直观的做法是每次INSERT之后根据parentId递归查询但这样每插入一行都要查一次库5万行数据会有5万次查询性能完全不可接受。我采用的是“路径索引法”内存中维护一个MapString, Longkey是分类路径value是数据库里已经存在的分类ID。每插入一行就把它自己的路径也写进这个Map子分类处理时直接查这个Map就能拿到父分类ID全程零SQL查询。MapString, Long pathIdMap new HashMap(); pathIdMap.put(/, 0L); // 虚拟根节点 for (CategoryImportRow row : sortedRows) { Long parentId pathIdMap.get(row.getParentPath()); if (parentId null) { // 实际上前面校验层已经拦掉了这里属于二次防御 collector.add(row.getRowNum(), 父分类不存在: row.getParentPath()); continue; } CategoryDO entity new CategoryDO(); entity.setName(row.getName()); entity.setParentId(parentId); entity.setSort(row.getSort()); categoryMapper.insert(entity); String selfPath row.getParentPath() / row.getName(); pathIdMap.put(selfPath, entity.getId()); }这里有一个必须强调的点全部插入没有事务保护是灾难。如果3万行数据插到第29998行时挂了前面29798行已经写进去了且没有任何标记能告诉你这次导入是不完整的。所以要么用Transactional包住整个导入方法要么用分批事务。分类导入场景我推荐整个导入方法一个事务因为纯INSERT的耗时通常还能接受真正频繁出现的是校验错误而不是中途异常所以一个事务也不会带来太长的锁持有时间。4.2 批量插入与parent_id回填的正确姿势上面的代码是逐行insert如果单批数据量超过几千行性能会明显下降。我的优化方案是攒够500行做一次批量INSERTListCategoryDO batch new ArrayList(500); for (CategoryImportRow row : sortedRows) { // ... 组装 entity ... batch.add(entity); if (batch.size() 500) { categoryMapper.insertBatch(batch); // 回填ID如果批量插入支持生成主键需要借助MyBatis的useGeneratedKeys pathIdMap.put(selfPath, entity.getId()); batch.clear(); } } if (!batch.isEmpty()) { categoryMapper.insertBatch(batch); }这里有个常见的坑MyBatis的useGeneratedKeys在批量插入时只有ibatis的BatchExecutor能正确回填ID如果你用的是普通executor回填的ID可能是null或者同一组ID。我踩过一次之后干脆换了一种更稳妥的做法——在插入前用雪花ID或数据库序列生成好主键然后批量INSERT时直接指定ID这样就不需要任何回填机制entity.setId(idGenerator.nextId());然后insertBatch的SQL只负责插入不关心主键生成策略insert之后pathIdMap直接使用entity.getId()绝对可靠。这种做法对分库分表也更友好因为主键是应用层生成的不依赖数据库的auto increment。4.3 重复导入的幂等策略先查重再决定insert还是update业务上经常遇到“同一个分类模板被导入了两次”的场景。如果每次导入都是无条件INSERT库里会出现一整组重复分类用户还得手工去删。我在模块里加了一个可选的幂等开关核心思路是给分类表加一列import_key比如分类模板文件名第N批次或者更简单点用“父路径分类名称”作为业务唯一键。开启幂等后处理逻辑变成CategoryDO exist categoryMapper.findByParentPathAndName(parentPath, name); if (exist ! null) { exist.setSort(row.getSort()); exist.setEnabled(row.getEnabled()); categoryMapper.updateById(exist); } else { categoryMapper.insert(entity); }这个方案有一个前提分类表必须建立(parent_id, name)的唯一索引否则并发场景下还是会插出重数数据。如果业务上允许同级同名但用其他字段区分就不要开这个开关直接走INSERT。5. 实战中遇到的坑这五个问题让我改了三次代码5.1 UTF-8 BOM让第一列列名带了不可见字符第一个版本上线后运营人员反馈“为什么我导出的模板再导入就提示第一列不存在”。排查了半天发现模板文件是从WPS导出的CSV文件头带着UTF-8 BOMEF BB BF三个字节。EasyExcel读取CSV时第一个单元格的值变成了\uFEFF分类名称表头映射匹配不上整列数据全部丢失。后来我在解析入口统一做了一次BOM检测如果是CSV文件先读文件头三个字节发现BOM就跳过public InputStream stripBOM(InputStream in) throws IOException { PushbackInputStream pushback new PushbackInputStream(in, 3); byte[] head new byte[3]; int len pushback.read(head); if (len 3 (head[0] 0xFF) 0xEF (head[1] 0xFF) 0xBB (head[2] 0xFF) 0xBF) { return pushback; } pushback.unread(head, 0, len); return pushback; }说实话这个问题本身不难难的是定位——因为\uFEFF在界面上完全看不出来数据库里也查不到只有把单元格的十六进制打出来才看得到。再后来我在表头匹配环节也做了兜底对每个表头字符串调用一次replace(\uFEFF, )双保险。5.2 Excel单元格的日期和时间戳溢出分类导入模板里有个“生效时间”列用户有时候填的是中文日期“2025/05/08”有时候填的是Excel原生日期类型显示为“5月8日”。EasyExcel用Map模式读取时Excel原生日期会被解析成java.util.Date对象而中文日期会被解析成字符串。这导致校验逻辑里同一列要写两套判断而且日期为空的单元格在某些Excel版本里会被解析成0或1899-12-30这样的“幽灵日期”。我的解决策略很直接在表头映射配置里强制指定列类型如果你配置了某个字段是DATE类型解析时统一用LocalDateTimeStringConverter转换成标准字符串“yyyy-MM-dd HH:mm:ss”如果原始值是Date类型就直接格式化如果是字符串就先用正则提取年月日。这样校验层永远只用处理同一个格式。这里还衍生出一个教训别在导入模块里让业务方随便用Excel原生日期类型。模板说明里明确要求“时间列一律填文本格式如2025-05-08”。虽然Excel会弹“此单元格是数字格式”的恼人提示但文本格式在导入时最不容易出错。5.3 EasyExcel监听器里开事务的大坑第三次踩坑是事务不生效。我的代码结构是这样的ImportService.importData()方法标了Transactional里面调用了EasyExcel.read().registerReadListener(new MyListener()).doRead()MyListener的invoke方法里直接执行了categoryMapper.insert(...)。看起来一切正常但后来发现一条数据都没插入成功而且数据库没有任何报错。查了很久才明白问题出在Spring AOP的代理机制上。Transactional注解是加在importData方法上的整个头部流式读取的过程被包在事务代理方法里按理说单线程执行时事务是能生效的——但EasyExcel的监听器在底层处理数据时使用的是异步线程池尤其是开启useDefaultListener或遇到大文件时会触发异步读取事务绑定的是上层调用线程的ThreadLocal异步线程根本拿不到这个事务上下文。结果就是每一条insert都走了独立自动提交而外层事务由于没有实际参与任何SQL操作提交时什么都没发生。我的修正方案是不在invoke方法里做任何SQL操作。监听器只负责把解析出来的行数据收集到内存List里等doRead全部跑完后在importData()方法里对所有行统一做校验和入库。这样SQL操作全部发生在Transactional方法所在线程中事务才能真正生效。分类数据量一般不会超过十万行全部收集到内存是可接受的。5.4 大文件的读取性能与内存控制性能问题的突破口其实是“分页读取”。EasyExcel虽然是流式读取但如果你把所有行都塞进一个List5万行数据每行10个字段List里也积攒了大量对象内存占用少说几百MB。我把“收集所有行”改成“每500行回调一次业务处理器”用doAfterAllAnalysed做最后的收尾处理。private static final int BATCH_SIZE 500; private ListCategoryImportRow buffer new ArrayList(BATCH_SIZE); Override public void invoke(MapInteger, String rowData, AnalysisContext context) { CategoryImportRow row converter.convert(rowData); if (row ! null) { buffer.add(row); if (buffer.size() BATCH_SIZE) { processBatch(buffer); buffer.clear(); } } } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!buffer.isEmpty()) { processBatch(buffer); } }processBatch内部再走“校验落库”这样一个大文件就会被拆成很多个小批次每批占用的内存在处理完就释放。用这个方法10万行xlsx的导入时间控制在30秒以内且堆内存稳定在200MB左右比最初POI的600MB好太多。5.5 用户导入了空文件和超大表头这两个问题虽然小但处理方式值得提一下。空文件只有表头没有数据行不能静默返回成功要明确提示“未检测到任何数据行”。超大表头的问题则是在表头映射解析完成后发现用户完全放弃了模板自己拼了一堆完全对不上的列名导致所有列都匹配不到业务字段。我的兜底策略是表头匹配率低于60%时直接拒绝导入并返回“模板列名与标准模板不匹配请重新下载模板”。这个阈值可以做成配置但我实测60%比较合理——因为用户通常会保留绝大多数标准列只改个别列名全改成不同名字的情况基本就是用了错误的模板。6. 后续扩展从同步导入到异步任务6.1 什么时候必须换异步方案模块做完同步版本后我用压测工具跑了各种体积的模板发现一个明显的分界点5000行以内同步导入响应时间基本在3秒内体验可以接受超过2万行即使所有优化都用上同步导入也需要20秒以上。此时前端HTTP连接很容易超时用户不知道任务是不是挂了就会重复点击反而造成重复导入。所以我在模块里引入了“导入任务”的异步模型。核心改动是增加一张import_task任务表包含task_id、status、total_count、success_count、error_count、error_file_path、create_time。用户上传文件后接口立即返回一个taskId后端定时线程池或MQ异步处理文件前端通过轮询或WebSocket获取任务进度。public ImportTaskVO submitImport(MultipartFile file) { ImportTask task new ImportTask(); task.setStatus(ImportStatus.PROCESSING); importTaskMapper.insert(task); asyncExecutor.execute(() - processImportTask(task, file)); return ImportTaskVO.builder().taskId(task.getId()).status(ImportStatus.PROCESSING).build(); }这里有个经验异步任务里的processImportTask方法不要再用Transactional包住整个流程因为异步线程事务时间过长会影响数据库连接池。改成每批500行单独开启事务事务粒度小失败时只需要标记当前批次不需要整体回滚。这个思路跟前面监听器里的问题本质上一致——事务的粒度决定了系统的稳定性和并发表现。6.2 进度反馈与失败明细的文件化输出异步导入之后的反馈设计我推荐把“错误明细”做成一个可下载的Excel文件。用户导入失败后前端界面显示“共5000条成功4800条失败200条点击下载失败明细”。失败明细的格式尽量复刻原模板还要在每行后面追加一个“失败原因”列这样用户可以直接在原模板基础上修改后重新导入。public void generateErrorFile(ImportTask task, ListCategoryImportRow errors) { EasyExcel.write(outputStream) .head(buildErrorHead()) .sheet(失败明细) .doWrite(errors.stream() .map(row - buildErrorRow(row)) .collect(Collectors.toList())); }这个文件我建议存放到OSS或本地磁盘并给一个短期有效的下载地址比如24小时。因为失败明细文件通常包含业务敏感数据不能长期暴露下载链接。同时我还会在任务完成后发一条通知内容只有三件事导入结果、失败数量、失败明细下载链接。不要真的去统计“导入耗时xxx秒”这种指标放给业务方看他们只关心结果和怎么修正。做这个模块前后花了我大约三周时间中间改了三个大版本。最大的体会是导入功能看似简单实际上“文件解析、校验、落库”每一层都有独立的复杂性而分类导入比普通实体导入又多了一个“树重建”的维度。把树干通用解析和落库与树枝分类业务规则分开才算是一个能长期维护的导入模块。如果你也在做类似功能建议一开始就把表头映射、错误收集、批量写入这三个基础设施搭好后面的业务接入会顺畅很多。最后再提醒一个小技巧所有导入模块的入口都统一打一个日志点记录taskId文件名总行数成功行数失败行数线上出问题的时候你会发现这个日志比任何debugger都管用。
返回列表