去年年中我接手了一个商城后台的重构,分类管理页面里的“导入分类”功能,光是写在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 Map<String, List<String>> 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.Entry<String, List<String>> 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 同级唯一性:分类表最常见的脏数据来源
分类表脏数据最多的一类就是“同名分类”。同一个父分类下有两个“手机壳”,用户在界面上根本分不清哪个是哪个。所以导入时必须做同级唯一校验。
实现上,我在内存里维护一个Set<String>,key的格式是parentPath + "/" + name。每校验一行,先查这个Set,如果已存在则报错,否则就加入Set。这里要特别说明一个细节:用parentPath而不是parentId做key。因为导入时数据还没入库,parentId根本不存在,而parentPath是解析时从用户填写的“父分类路径”列直接读到的,天然是稳定的。
比如这样:
Set<String> 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来判断:
Set<String> 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找父级,如果某个节点又走回了自己,就说明存在循环依赖:
Map<String, String> parentMap = new HashMap<>(); for (CategoryImportRow row : rows) { parentMap.put(row.getName(), row.getParentName()); } for (String node : parentMap.keySet()) { Set<String> 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万次查询,性能完全不可接受。
我采用的是“路径索引法”:内存中维护一个Map<String, Long>,key是分类路径,value是数据库里已经存在的分类ID。每插入一行,就把它自己的路径也写进这个Map,子分类处理时直接查这个Map就能拿到父分类ID,全程零SQL查询。
Map<String, 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行做一次批量INSERT:
List<CategoryDO> 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 BOM(EF 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虽然是流式读取,但如果你把所有行都塞进一个List,5万行数据每行10个字段,List里也积攒了大量对象,内存占用少说几百MB。我把“收集所有行”改成“每500行回调一次业务处理器”,用doAfterAllAnalysed做最后的收尾处理。
private static final int BATCH_SIZE = 500; private List<CategoryImportRow> buffer = new ArrayList<>(BATCH_SIZE); @Override public void invoke(Map<Integer, 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, List<CategoryImportRow> errors) { EasyExcel.write(outputStream) .head(buildErrorHead()) .sheet("失败明细") .doWrite(errors.stream() .map(row -> buildErrorRow(row)) .collect(Collectors.toList())); }这个文件我建议存放到OSS或本地磁盘,并给一个短期有效的下载地址,比如24小时。因为失败明细文件通常包含业务敏感数据,不能长期暴露下载链接。同时,我还会在任务完成后发一条通知,内容只有三件事:导入结果、失败数量、失败明细下载链接。不要真的去统计“导入耗时xxx秒”这种指标放给业务方看,他们只关心结果和怎么修正。
做这个模块前后花了我大约三周时间,中间改了三个大版本。最大的体会是:导入功能看似简单,实际上“文件解析、校验、落库”每一层都有独立的复杂性,而分类导入比普通实体导入又多了一个“树重建”的维度。把树干(通用解析和落库)与树枝(分类业务规则)分开,才算是一个能长期维护的导入模块。如果你也在做类似功能,建议一开始就把表头映射、错误收集、批量写入这三个基础设施搭好,后面的业务接入会顺畅很多。最后再提醒一个小技巧:所有导入模块的入口都统一打一个日志点,记录taskId+文件名+总行数+成功行数+失败行数,线上出问题的时候,你会发现这个日志比任何debugger都管用。