简介这份PDF文档面向已具备SpringBoot与AntDesignVue基础的开发者聚焦Excel导入功能的前后端协同实现适合需要快速搭建文件上传入口、校验文件类型并处理上传状态的业务场景。文档共1个PDF文件压缩包约198KB内容围绕Ant Design Vue的upload组件展开涵盖accept限制xlsx/xls、customRequest自定义上传、change事件监听以及上传中/成功/失败状态切换等关键点并给出前端按钮图标禁用反馈与后端MultipartFile接口、Service导入处理的示例。已有2440人学习可帮助读者理解导入流程中的参数传递、FormData封装和重复代码封装思路适合作为课程设计或管理后台功能开发的参考。1. 一次列错位让我重写了整条导入链路去年帮某团队看一个学生信息后台SpringBoot AntDesignVue 的 Excel 导入功能上线两周运营反馈导入提示成功但列表里姓名列显示的是年龄爱好列显示的是家庭地址。翻代码发现解析时用getPhysicalNumberOfCells()遍历列遇到空单元格直接跳过后面的列下标整体前移——一张表里只要有一个空格子整行数据就错位。这种问题不会报错只会静默写脏数据比直接抛异常难查十倍。这篇记录把 AntDesignVue 的 a-upload 四个关键属性、customRequest 的 FormData 传参、change 状态机、SpringBoot 侧 MultipartFile 接收与 POI 解析、单元格类型判空、日期兜底、批量入库与事务回滚按我实际落地的顺序拆一遍。适合已经能把文件传上去、但卡在解析稳定性和事务边界上的开发者也适合第一次写导入、想直接抄一份能跑代码的人。2. 前端 a-upload 的四个关键属性accept、customRequest、change 与禁用态2.1 为什么默认上传行为在业务导入里基本不能用AntDesignVue 的 a-upload 组件默认行为很简单给一个action属性指向后端地址组件自己发 multipart 请求自己维护文件列表和进度。放在 Demo 里三行代码就能跑但放进真实项目几乎必炸原因有三条。第一默认请求绕过了项目统一的 axios 封装token 塞不进请求头网关那一层直接 401。第二返回值的结构不受控组件只认 HTTP 状态码业务层返回{code:500,msg:第 3 行姓名重复}而 HTTP 状态是 200 的情况组件会把它当成功处理页面提示导入成功实际一条没进去。第三loading 态和业务失败态对不上默认的进度条走完就消失后台还在慢慢处理几万行用户以为卡死了又点一次。所以业务导入场景里customRequest不是可选项是必选项。它把发请求这一步的控制权交回来你可以用项目里封装好的请求方法、可以自己决定什么时候算成功、可以在 finally 里统一收尾。代价是要自己维护状态后面几节讲清楚。2.2 accept 的 MIME 写法与后缀兜底先看模板部分。这段代码是整个功能的入口属性不多但每一个都有讲究!-- 只允许选择 Excel 文件两个 MIME 分别对应 .xlsx 与 .xls -- a-upload acceptapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel :customRequestcustomRequest :showUploadListfalse :disableduploadDisabled changecustomChange a-button typeprimary :iconuploadIcon :disableduploadDisabled 导入 /a-button /a-uploadaccept的两个 MIME 类型分别对应.xlsxOffice Open XML和.xls老的二进制格式中间用英文逗号加空格分隔。要说明的是accept 只是浏览器文件选择框的过滤提示用户在弹窗里把筛选改成所有文件照样能选中一个.txt然后点确定。所以它只能算体验优化真正拦住非法文件的是后端那层校验后面 3.1 节会写。:showUploadListfalse是我加的原写法里没提。默认组件会在按钮下面渲染一行文件名和进度条业务导入通常不需要这个视觉噪音——导入结果用 message 提示就够了文件列表留在页面上反而让用户以为可以反复提交同一个文件。:disabled绑定的是响应式变量uploadDisabled配合:iconuploadIcon一起控制按钮的形态。这两个变量在 data 里初始化export default { data() { return { uploadIcon: to-top, // 空闲态向上箭头暗示导入 uploadDisabled: false // 空闲态按钮可点 } }, methods: { // 方法定义见 2.3、2.4 } }to-top是 AntDesignVue 图标库里的名字换成import-outlined之类的语义名字更贴切但要注意图标库的注册方式——如果是按需引入换名字会直接渲染成空白。这一点在换图标时容易翻车。2.3 customRequest 里 FormData 的参数对齐customRequest是整个前端逻辑的核心它接收组件透传的一个对象里面装着文件、进度回调、成功回调、失败回调。下面是能直接用的版本/** * 自定义上传接管 a-upload 的默认 XHR 行为 * param {Object} data 组件透传的上传信息对象 */ customRequest(data) { const file data.file // 组件包装后的文件对象 const formData new FormData() formData.append(file, file) // key 必须与后端 RequestParam(file) 完全一致 data.onProgress({ percent: 30 }) // 手动推进进度否则进度条不刷新 batchImportGetRecord(formData) // 项目里已封装好的 axios 请求方法 .then(res { if (res res.code 200) { data.onSuccess(res, file) // 关键通知组件这次上传成功了 this.$message.success(导入成功) } else { data.onError(new Error(res.msg)) // 通知组件失败change 会收到 error 状态 this.$message.error(res.msg || 导入失败) } }) .catch(err { data.onError(err) this.$message.error(网络异常请稍后重试) }) .finally(() { this.switchIconAndStatus(false) // 无论成败都必须解锁按钮 }) }逐条说参数。data.file是组件包装后的对象直接 append 进 FormData 时浏览器会自动取出原始 File 并设置正确的 MIME 和文件名不需要再手动new File()。formData.append(file, file)里的file这个字符串是前后端之间唯一的约定后端写成RequestParam(file)就必须叫 file叫excelFile、uploadFile都会让后端拿到 null。我见过有人在两个项目之间复制代码前端改了后端没改排查了半天。data.onProgress()不传参会报错或者不生效必须给一个对象用{ percent }控制。严格来说这里传死值 30 也只是个视觉安慰真正的进度靠后台返回但没有这一步的话进度条会一直显示 0%。data.onSuccess()和data.onError()是最容易被漏掉的两个回调。不调 onSuccess组件内部的file.status永远停在uploadingchange就不会触发 done 分支按钮的 loading 图标永远转下去。原文代码里只有data.onProgress()没有 onSuccess/onError直接抄的话状态机会卡死在第一步。2.4 change 状态机与按钮防连点change在文件上传中、完成、失败时都会触发收到的对象里有file.status取值是uploading、done、error、removed四种。用它来驱动按钮形态/** * 文件状态变化回调 * param {Object} data 包含 file、fileList */ customChange(data) { const status data.file.status if (status uploading) { this.switchIconAndStatus(true) } else if (status done || status error) { this.switchIconAndStatus(false) } // removed 状态不处理用户主动取消时按钮本来就是可点状态 } /** * 切换导入按钮的图标与禁用状态 * param {Boolean} flag true处理中(转圈禁用) false空闲(箭头可点) */ switchIconAndStatus(flag) { this.uploadIcon flag ? loading : to-top this.uploadDisabled flag }把图标切成loading有两个效果AntDesignVue 的 loading 图标自带旋转动画同时按钮进入禁用视觉。禁用态是关键——后台解析一张五千行的 Excel 可能要七八秒这期间用户看页面没反应第一反应就是再点一次。血泪经验不加禁用最夸张的一次同一批数据被导进去九遍运营清库清了半小时。switchIconAndStatus这个封装看起来只是省了几行 if实际价值在于把状态切换收敛到一个函数里。后面如果要加埋点、要加 traceId、要联动画布上的统计数字只改这一处就够不用满页面找赋值的地方。3. 后端接收与解析MultipartFile 到 XSSFWorkbook 的完整链路3.1 Controller 层的参数名对齐与请求方式前端拿 FormData 发过来后端第一站是 Controller。这段代码很短但两个细节决定成败PostMapping(/import) public ResultBoolean importExcelData(RequestParam(file) MultipartFile file) { // 快速失败前端 accept 只是提示后端必须二次校验 if (file null || file.isEmpty()) { return Result.fail(上传文件为空); } String name file.getOriginalFilename(); if (name null || !(name.toLowerCase().endsWith(.xlsx) || name.toLowerCase().endsWith(.xls))) { return Result.fail(仅支持 .xlsx 或 .xls 格式); } // 大小兜底application.yml 里的 multipart 限制是服务层配置 if (file.getSize() 20 * 1024 * 1024L) { return Result.fail(单个文件不能超过 20MB); } return Result.ok(studentService.importExcelData(file)); }PostMapping而不是RequestMapping是因为文件上传必须是 POST用 RequestMapping 不指定 method 会让 GET 请求也能打进来然后getInputStream()抛异常日志里一堆看不懂的堆栈。RequestParam(file)里的file就是那个约定。这里还有一层容易忽略的Spring Boot 的 multipart 默认单个文件上限是 1MB、单次请求上限 10MB。不配置的话用户传一个 2MB 的 xlsx 就直接 500报MaxUploadSizeExceededException前端只会看到导入失败四个字。常见做法是在 application.yml 里放开spring: servlet: multipart: max-file-size: 20MB # 单个文件上限 max-request-size: 25MB # 整个请求上限要比 file-size 大 enabled: truefile.getOriginalFilename()在部分浏览器环境下会带上路径做后缀判断前建议先substring(lastIndexOf(/) 1)截一下或者干脆只判后缀不判路径。这里为了简洁没加知道有这回事就行。3.2 Service 层逐行解析与列映射进入 Service核心是把文件流读成 Workbook再一行行映射成实体。下面是改过的版本重点是两个不要不要用getPhysicalNumberOfCells()遍历列不要用getStringCellValue()无脑取值。Override public boolean importExcelData(MultipartFile file) { ListStudent list new ArrayList(); // try-with-resources 确保 workbook 和流在异常时也能关掉 try (InputStream in file.getInputStream(); XSSFWorkbook workbook new XSSFWorkbook(in)) { XSSFSheet sheet workbook.getSheetAt(0); // 只解析第一张工作表 DataFormatter formatter new DataFormatter(); // 按显示值取值规避类型差异 FormulaEvaluator evaluator workbook.getCreationHelper().createFormulaEvaluator(); // 支持公式单元格 // getLastRowNum 返回最后一行的下标0 基中间空行也计入 for (int i 1; i sheet.getLastRowNum(); i) { XSSFRow row sheet.getRow(i); if (row null) continue; // 整行是空的 Student student new Student(); // 固定列下标不用 getPhysicalNumberOfCells 遍历 student.setName(getCellString(row.getCell(0), formatter, evaluator)); student.setAge(getCellInt(row.getCell(1), formatter, evaluator)); student.setHobby(getCellString(row.getCell(2), formatter, evaluator)); student.setAddress(getCellString(row.getCell(3), formatter, evaluator)); student.setBirthday(getCellDate(row.getCell(4), formatter)); if (StringUtils.isBlank(student.getName())) continue; // 姓名空视为无效行 list.add(student); } if (list.isEmpty()) return false; return studentService.saveBatch(list); } catch (Exception e) { log.error(Excel 导入失败, e); throw new RuntimeException(导入失败 e.getMessage()); // 必须往外抛 } }workbook.getSheetAt(0)的下标从 0 开始取第一张表。如果用户把数据放在第二张表这里就解析不到所以产品侧最好在模板里明确写请把数据放在第一个 Sheet。循环上界用getLastRowNum()而不是getPhysicalNumberOfRows()这是原文没展开的一个区别。getPhysicalNumberOfRows()返回的是物理存在的行数中间有整行空的时候它会把这些空行跳过不计数getLastRowNum()返回的是最后一行的下标空行占位也计入。拿前者当i的上界一旦中间有空行后面的真实数据就全被截断了表现是导入了 100 行只进去 60 行。这个坑不会报错只会在对账时被发现。DataFormatter配合FormulaEvaluator是取值最省事的组合。它按单元格上显示的格式返回字符串——数字 18 不会变成18.0金额1,200.00会原样带逗号日期按单元格设置显示成2024-01-05而不是45296。代价是取出来的都是字符串要自己做类型转换这个在 3.3 讲。3.3 单元格类型判断getStringCellValue 不能乱用cell.getStringCellValue()是新手最容易踩的一个雷。它只在单元格类型是 STRING 时有效遇到数字、日期、布尔、公式直接抛IllegalStateException: Cannot get a STRING value from a NUMERIC cell。年龄列填的是数字姓名列填的是文本一段代码取所有列必然崩。用一个取值方法收口把类型差异挡在这里/** 按单元格显示值取字符串null 安全 */ private String getCellString(XSSFCell cell, DataFormatter formatter, FormulaEvaluator evaluator) { if (cell null) return ; return formatter.formatCellValue(cell, evaluator).trim(); } /** 取整数兼容 Excel 里年龄被存成 18.0 的情况 */ private Integer getCellInt(XSSFCell cell, DataFormatter formatter, FormulaEvaluator evaluator) { String s getCellString(cell, formatter, evaluator); if (StringUtils.isBlank(s)) return null; try { return (int) Double.parseDouble(s.replace(,, )); // 去掉千分位 } catch (NumberFormatException e) { throw new RuntimeException(年龄列格式错误 s); } }formatCellValue有个空值细节单元格是 BLANK 类型时返回空字符串但如果单元格根本不存在row.getCell(0)返回 null调用会 NPE。所以外面先判cell null返回空串让上层统一走空值跳过的逻辑比在每一列都写一遍判空清爽。getCellInt里先Double.parseDouble再强转是必须的。Excel 里所有数字底层都是 double一个填了 18 的年龄单元格formatCellValue在某些格式下会返回18在另一些格式下返回18.0。直接Integer.parseInt会抛NumberFormatException。replace(,, )是防用户把金额或人数写成1,200这种带千分位的样式。公式单元格是另一个隐藏雷点。如果模板里有A2B2这类拼接列formatCellValue不带 evaluator 会返回公式字符串本身而不是计算结果。带上evaluator参数就解决了代价是每次都要创建注意createFormulaEvaluator()是有开销的放在循环外。3.4 日期列的三种格式与解析兜底日期是导入功能里翻车率最高的一列。Excel 里的日期其实存在两种完全不同的形态一种是真日期单元格类型 NUMERIC底层存的是从 1900-01-01 起的天数序列号显示层再按格式渲染另一种是用户手动敲进去的文本比如2024年1月5日单元格类型就是 STRING。两种形态必须分开处理/** 解析日期列兼容真日期与三种常见文本格式 */ private Date getCellDate(XSSFCell cell, DataFormatter formatter) { if (cell null) return null; // 情况一Excel 识别为真日期直接取序列号转 Date if (cell.getCellType() CellType.NUMERIC DateUtil.isCellDateFormatted(cell)) { return cell.getDateCellValue(); } // 情况二用户敲成文本按常见格式逐个试 String s formatter.formatCellValue(cell).trim(); if (StringUtils.isBlank(s)) return null; String[] patterns {yyyy-MM-dd, yyyy/M/d, yyyy年M月d日, yyyy-MM-dd HH:mm:ss}; for (String p : patterns) { try { return new SimpleDateFormat(p).parse(s); } catch (ParseException ignored) { // 当前格式不匹配继续试下一个 } } throw new RuntimeException(日期格式无法识别 s); }DateUtil.isCellDateFormatted(cell)是判断真日期的关键不能只看 NUMERIC。因为数字 45296 既可以是日期也可以是一个金额只有单元格格式被设成日期类时才算日期。格式数组里前三项覆盖了绝大多数用户输入。原文只写了yyyy年M月d日HH:mm:ss一种用户只要少打一个时分秒SimpleDateFormat就抛ParseException整行数据被丢弃。血泪经验日期格式一定要列一组往下试别指望用户按模板填。SimpleDateFormat是非线程安全的这里在方法内new每次调用都是新实例没有共享问题。但千万不要图省事把它提成类的成员变量多线程导入的时候会出现日期错乱这种玄学问题查起来极其痛苦。JDK 8 以上更推荐DateTimeFormatter它是不可变类天生线程安全。4. 批量入库与事务saveBatch 为什么会写进半张表4.1 批量插入的收益与 MyBatis-Plus 的边界解析出来的ListStudent怎么入库直接影响导入耗时。一条条insert的问题是网络往返每插一条都要跟数据库握一次手五千行就是五千次往返本地测试感受不到线上跨机房能拖到十几秒。批量插入把 N 条合成一次 SQL 提交收益是数量级的。用 MyBatis-Plus 的saveBatch是最省事的写法它是IService接口自带的不需要自己写 XML// 默认每批 1000 条超过会自动分多次提交 boolean ok studentService.saveBatch(list); // 也可以显式指定批次大小行数少的时候调小能降低单条 SQL 长度 boolean ok2 studentService.saveBatch(list, 500);saveBatch的默认批次大小来自GlobalConfig的DEFAULT_BATCH_SIZE是 1000。这里有个边界必须清楚它是分批的不是把一万行拼成一条 SQL。如果外层没有事务第 1 批 1000 条提交成功、第 3 批因为唯一键冲突失败前两批已经落库了——用户看到导入失败库里却多了两千条脏数据这就是写进半张表。4.2 Transactional 与 setRollbackOnly 的配合解决分批提交的半成功问题靠的是外层加事务让整批插入要么全成、要么全回滚Service public class StudentServiceImpl extends ServiceImplStudentMapper, Student implements StudentService { Override Transactional(rollbackFor Exception.class) // 必须写 rollbackFor public boolean importExcelData(MultipartFile file) { // ... 3.2 的解析逻辑 if (CollectionUtils.isEmpty(list)) { return false; } boolean ok saveBatch(list); if (!ok) { // 手动标记回滚配合 Transactional 生效 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } return ok; } }rollbackFor Exception.class这行不能省。Spring 的Transactional默认只对RuntimeException和Error回滚对受检异常IOException、ParseException不回滚。原文代码里在 catch 块里 catch 了Exception然后调setRollbackOnly()但方法上没写注解——这种情况下 setRollbackOnly 会抛NoTransactionException或者干脆静默不生效取决于调用链上有没有别的Transactional兜底。还有一个更隐蔽的坑Transactional靠 Spring AOP 代理生效同类内部自调用不走代理。如果 Controller 直接调的是本类里的另一个方法事务注解形同虚设。这个问题的表现形式是注解明明加了数据还是写进去了排查时先看调用链有没有跨类。setRollbackOnly()正确的用法是我已经拿到了一个false的返回值但我还想让整个事务回滚——比如saveBatch返回 false 而不是抛异常的情况。如果解析阶段已经抛了RuntimeException异常会一路往上冒事务自动回滚不需要再手动标记。4.3 数据校验前置与失败行定位导入功能上线后用户最烦的不是失败是失败之后不知道错在哪。导入失败四个字对用户毫无帮助他手上可能有一张三千行的表你让他一行行找不现实。做法是在解析阶段就把校验做掉收集错误行号和原因一次性返回ListString errors new ArrayList(); for (int i 1; i sheet.getLastRowNum(); i) { XSSFRow row sheet.getRow(i); if (row null) continue; Student student new Student(); student.setName(getCellString(row.getCell(0), formatter, evaluator)); // ... 其余列 int excelRowNo i 1; // 下标从 0 起Excel 行号从 1 起 if (StringUtils.isBlank(student.getName())) { errors.add(第 excelRowNo 行姓名不能为空); } if (student.getAge() null || student.getAge() 6 || student.getAge() 100) { errors.add(第 excelRowNo 行年龄需在 6 到 100 之间); } // 校验通过才入列失败的跳过 if (errors.isEmpty() || !errors.get(errors.size() - 1).contains(第 excelRowNo 行)) { list.add(student); } } // 只回传前 5 条避免一次性甩几千行错误给前端 if (!errors.isEmpty()) { String msg String.join(, errors.subList(0, Math.min(5, errors.size()))); throw new RuntimeException(共 errors.size() 行数据有问题 msg); }行号换算要注意sheet.getRow(i)里的i是 0 基下标Excel 界面显示的行号是 1 基而且第 0 行是表头。所以第 1 条数据对应下标 1显示给用户时是第 2 行i 1正好对上。这个换算错了用户按提示去找会找到隔壁那行体验极差。校验前置还有一个好处脏数据根本进不了数据库。如果等 saveBatch 抛唯一键冲突再回头定位那时候事务已经开了一半日志里只有一条 SQL 异常看不出是哪一行。5. 避坑排查导入功能上线后最常被问的五个问题5.1 现象后端拿到的 file 是 null接口返回 400现象很直接前端点了导入Network 面板里请求发出去了但后端日志打出来Required request part file is not present或者参数绑定直接 400。原因通常有两个。一是 FormData 的 key 和后端RequestParam里的名字不一致前端写formData.append(excel, file)后端写RequestParam(file)两边对不上。二是 axios 封装里全局设了Content-Type: application/json传给 FormData 时没覆盖掉浏览器就不会自动生成带 boundary 的 multipart 头Spring 解析不出来。解决方式是前后端统一用file这个 key并且在请求方法里不要手动设置Content-Type——传 FormData 时让浏览器自己加手写multipart/form-data反而会丢掉 boundary 参数。如果项目里 axios 拦截器统一设了 json 头给这个接口单独加一个覆盖配置。5.2 现象解析到某一列抛 IllegalStateException提示 Cannot get a STRING value日志里是java.lang.IllegalStateException: Cannot get a STRING value from a NUMERIC cell堆栈指向getStringCellValue。原因是代码对所有列统一调了cell.getStringCellValue()而 Excel 里数字列年龄、学号、手机号的单元格类型是 NUMERIC文本列才是 STRING。Excel 不关心你的实体字段类型它只关心这个格子被填成了什么。解决是用DataFormatter.formatCellValue(cell, evaluator)统一取值它按显示格式返回字符串不区分底层类型。如果某几列需要保留数值语义先getCellType()判断分支再分别调getNumericCellValue()和getStringCellValue()。不要在两处混用容易在同一个方法里既取字符串又取数字最后自己都搞不清走的哪条路。5.3 现象日期列导入后变成 45296 这样的数字数据库里 birthday 字段存的是45296页面上显示成 1900 年某天用户一脸问号。原因是单元格确实是日期格式但取值时走了getStringCellValue()或者formatCellValue()在特定格式下返回了序列号。Excel 的日期本质是从 1900-01-01 起过了多少天45296 对应 2024 年初。解决是先判DateUtil.isCellDateFormatted(cell)为真时用cell.getDateCellValue()直接拿 Date这是唯一能保证拿到正确日期的方式。判完之后再把formatter.formatCellValue()作为兜底处理文本日期两条路分开走。另外注意时区服务器时区和用户时区不一致时跨零点的时间会差一天存库前统一转成东八区。5.4 现象页面提示导入失败但数据库里多了一批数据这个最要命用户会以为没成功又导一次数据翻倍。原因有三层可能。第一层saveBatch默认 1000 条一批外层没加Transactional前面几批已经提交后面某批失败这就是半成功。第二层方法上加了Transactional但没写rollbackFor Exception.classcatch 里吞掉的IOException是受检异常默认不回滚。第三层加了注解但方法是同类内部自调用AOP 代理没生效注解等于没写。解决按顺序排查先看方法有没有被别的类调用跨类才有代理再看注解有没有写 rollbackFor最后看 catch 块是不是把异常吞了只 return false。三样都对还不行就在setRollbackOnly()那行打个断点确认真的走到了。5.5 现象超过三千行就超时或者报 OutOfMemoryError小文件一切正常用户传了一张两万行的表接口转了一分钟然后 502日志里是堆内存溢出。原因是XSSFWorkbook是 DOM 解析模型它把整个 xlsx 解压后全部加载到内存里构建对象树。xlsx 本身是压缩包解压膨胀比大概 10 到 20 倍一个 5MB 的文件在内存里能占几百 MB。并发两三个这样的请求堆就撑爆了。解决分两条路。轻量方案是限制单次导入行数超过五千行直接提示请拆分文件成本最低。彻底方案是换成流式解析用 EasyExcel 或者 POI 的 SAX 事件模型逐行读逐行处理内存里只保留一个批次。这个改动不只是换个工具类整个解析和入库的节奏都要跟着变下一章展开。6. 进阶十万行 Excel 怎么不 OOM——从 DOM 换到逐行流式解析前面那套XSSFWorkbook的写法处理五千行以内的表毫无压力代码也好读。但只要业务长到需要导入几万行内存这道坎就绕不过去。我一般的判断标准是单次导入稳定超过一万行或者并发导入超过三个人就该换流式解析。换法的核心思路是读一行、处理一行、丢一行内存里任何时候只保留一个批次的数据。用 EasyExcel 的监听器模式是最省事的落地方式它底层就是 SAX// 逐行回调内存里只留一个批次 EasyExcel.read(file.getInputStream(), Student.class, new AnalysisEventListenerStudent() { private static final int BATCH_SIZE 500; private final ListStudent batch new ArrayList(BATCH_SIZE); Override public void invoke(Student data, AnalysisContext context) { // 这里能拿到当前行号做错误定位很方便 Integer rowNo context.readRowHolder().getRowIndex() 1; if (StringUtils.isBlank(data.getName())) { throw new RuntimeException(第 rowNo 行姓名不能为空); } batch.add(data); if (batch.size() BATCH_SIZE) { studentService.saveBatch(batch); // 攒够一批就落库 batch.clear(); // 立刻释放别攒着 } } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!batch.isEmpty()) { studentService.saveBatch(batch); // 收尾处理最后不足一批的尾巴 } } }).sheet(0).headRowNumber(1).doRead();headRowNumber(1)告诉它第一行是表头从第二行开始回调invoke。AnalysisContext里能拿到当前行下标做错误提示时比自己在外层数i准。batch.clear()必须调不调的话ArrayList内部的数组引用一直挂着对象回收不掉流式解析白做。这个方案有个代价要说清楚它不再是一个大事务。每 500 条saveBatch各自提交第 3000 行报错时前面 2500 条已经在库里了。要接受部分成功这个语义或者在业务上加一张导入批次表把 batchId 写进每条数据失败时按 batchId 删掉重来。前者适合允许重试的场景后者适合对账严格的场景。选哪个不看技术看业务能不能容忍脏数据。还有一个折中方案值得提把行数阈值做成配置项小文件走XSSFWorkbook全量解析 单事务大文件走 EasyExcel 流式 分批提交。用一个file.getSize()或者先读前几行估行数来判断走哪条路代码复杂度上去了但两头的好处都占着。验证流式解析有没有真的省内存别只看不报错了。做法是在解析前后打一次Runtime.getRuntime().totalMemory() - freeMemory()传一张五万行的表对比两个实现的峰值差。我第一次做这个对比时DOM 版本峰值吃到 700 多 MB流式版本稳定在 80 MB 上下差了一个数量级。从那以后凡是导入功能我都会先问一句最大文件多大超过一万行就直接上监听器不再走全量解析这条回头路。希望这篇能帮你少走一次那个列错位查三天的弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?