☰
SpringBoot整合EasyExcel:高效Excel导入导出与避坑实战
2026/10/8 5:18:14 网站建设 项目流程

简介:面向Java后端开发者的Spring Boot示例,围绕电子表格数据批量写入数据库、以及从数据库查询数据后生成电子表格两个高频业务场景,配套接口测试集合、建表脚本和验证用电子表格,适合有Java基础、想快速落地的读者。资源包共22个文件,压缩后约40KB,以10个Java源码文件为主,辅以Maven配置、环境配置、SQL脚本、接口测试集合和电子表格样例,目录划分清晰,操作步骤在说明文档中有详述。项目采用分层结构,数据源配置、解析逻辑、导入导出接口彼此分开,便于按需替换或复用。按文档指引配置数据库并启动后,借助接口调用即可完成导入导出演示,可与测试表格核对结果,降低上手和排错成本。目前已有7582人学习下载,作为轻量级参考工程,调整字段和接口即可集成到真实业务。

1. 这套组合拳到底解决什么问题:从 Excel 到数据库再回去的完整闭环

做 Java 后端的人迟早会撞上这么个需求:业务方甩来一张几十万行的 Excel,让你把数据洗干净存进数据库;过几天又说要把某张表导回 Excel 发给客户。单次转换不难,难的是整个过程里那些说不清道不明的坑——编码乱掉、时间格式变样、导入一半报错导致数据半截入库、导出大数据量时内存直接溢出。这个标题讲的正是用 SpringBoot 把这两件事串成一条完整链路的标准做法,核心用 Apache POI 或阿里 EasyExcel 做文件解析,配合 MyBatis 或 Spring Data JPA 做持久化,再通过 HttpServletResponse 把数据以 Excel 流的形式吐给前端。适合正在做后台管理系统、数据迁移工具或报表模块的 Java 工程师,也适合准备这类面试题的人拿来当实战素材。

我见过太多项目在这个环节上翻车,不是没选对库,就是没处理好异常和事务边界。这篇把理论立住、把代码写透、把常见的坑挑明,照着复现一次,基本能覆盖日常 80% 以上的导入导出场景。

2. 选型先选对:POI 还是 EasyExcel,以及 SpringBoot 整合的最小依赖

2.1 两者本质区别:一个吃内存,一个吃流

Apache POI 是 Java 操作 Excel 的老牌库,支持 xls 和 xlsx,功能最全,能精确到单元格样式、合并区域、公式计算,但它有个臭名昭著的毛病:解析大文件时会把整个工作簿读进内存。一个 50MB 的 xlsx,POI 解析时内存轻松飙到 500MB 以上,服务器稍微紧张点就直接 OOM。而阿里开源的 EasyExcel 底层虽然也依赖 POI 的 SAX 模式,但它是流式解析,一行一读,内存占用能控制在几十 MB 区间。如果说 POI 是大而全的重型工具,EasyExcel 就是为大数据量导入导出场景量身定做的轻骑兵。

如果你的数据量长期在几万行以内,POI 完全够用;但凡是可能上十万行、几十万行的场景,我一般直接上 EasyExcel,省得上线后被人追着说系统崩了。还有一点,EasyExcel 不需要你关心 xlsx 和 xls 的差异,它统一按 xlsx 处理,对于新项目来说这反而省心。

2.2 最小依赖配置:Maven 里加什么、版本怎么锁

以一个标准的 SpringBoot 2.x 项目为例,必要的依赖有 spring-boot-starter-web、mybatis-plus-boot-starter(或 spring-boot-starter-data-jpa)、mysql-connector-java,再加一个 easyexcel 和 lombok。这里有一个非常容易被忽略的依赖冲突:EasyExcel 3.x 依赖的 poi 版本是 3.17,而 poi-ooxml 的某些功能在 4.x 以上才有,如果你在 pom 里单独引入更高版本的 poi,轻则安全扫描报漏洞,重则方法找不到直接 NoSuchMethodError。我习惯的做法是让 easyexcel 自己管理 poi 版本,不在 pom 中显式声明 poi 相关坐标,除非项目里已有其他模块强制引入了 poi。

下面是一份能直接跑的最小 pom 依赖段:

<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.4</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

需要说明的是,MyBatis-Plus 这里选 3.5.5 是因为它内置了批量插入的优化,直接调用 IService 的 saveBatch 方法就能走 JDBC 批处理,省去手写 SQL 拼接的麻烦。如果你的项目用的是 Spring Data JPA,思路完全一样,只是把 Mapper 换成 Repository,核心流程不受影响。

2.3 和数据库访问层的配合:批量插入为什么要用 saveBatch 而不是 for 循环

新手最容易写的代码是解析一行 insert 一行,遇到十几万行的 Excel 时,这种行为会产生十几万次数据库连接往返,速度慢得让人怀疑人生,而且任何一个字段违规都会导致事务回滚全部数据,极其痛苦。常见做法是先把数据读到一个 List 里,攒够一个批次再一次性写入。MyBatis-Plus 的 saveBatch 默认按 1000 条一批执行,内部用 rewriteBatchedStatements 开启真正的 JDBC 批处理,插入 10 万条数据从原来的一分多钟缩短到十秒以内,效果非常明显。

这里有个注意点:saveBatch 是逐条预编译再批量执行的,如果你在实体类里配置了逻辑删除字段或者自动填充字段,插件会额外处理,但性能损耗仍在可接受范围。真正可能让你惊讶的是,第一次调用 saveBatch 时因为需要生成批量 SQL,耗时偏长,这是正常现象,不要让监控告警把运维吓得大半夜爬起来找你。

3. 解析 Excel 并写入数据库:表结构设计、监听器模式与事务边界

3.1 实体类与表结构:注解映射是第一步

不管你用什么解析库,第一步永远是定义实体类和数据表。实体类里的字段要和 Excel 列一一对应,用 EasyExcel 的 @ExcelProperty 注解指定列名或列索引,用 MyBatis-Plus 的 @TableName 映射到表。这里有个很实际的问题:Excel 里列头是中文,Java 实体字段是驼峰命名,数据库列是下划线命名,三层映射要在一开始就理清楚。

一种常见做法是用中文列名做 @ExcelProperty 的 value,数据库列名用 @TableField 显式指定,避免 SpringBoot 的驼峰转下划线规则在某些自定义 SQL 里失效。示例实体类如下:

@Data @TableName("user_import") public class UserImport { @TableId(type = IdType.AUTO) private Long id; @ExcelProperty("姓名") private String name; @ExcelProperty("年龄") private Integer age; @ExcelProperty("入职日期") private LocalDate hireDate; @ExcelProperty("部门") private String department; @TableField(exist = false) private String errorMsg; }

注意 @TableField(exist = false) 这个字段:它不参与数据库的增删改查,纯粹用来存放校验失败时的错误描述,方便在导入结束后把问题行单独生成一个错误报告 Excel 反馈给业务方。这个字段在导错报告时非常好用,等会儿会展开讲。

提示:Excel 里的日期如果格式五花八门,例如 2024/1/1、2024-01-01、44000 这种数字,统一在解析阶段转成 LocalDate,不要存字符串,否则后面做 SQL 查询日期范围时会非常难受。

3.2 监听器写业务逻辑:AnalysisEventListener 的正确打开方式

EasyExcel 的流式解析依赖监听器回调。每解析一行数据,就会调用 invoke 方法,读取完整个文件再触发 doAfterAllAnalysed。业务代码最自然的写法是在 invoke 里做数据校验、转换、累加到一个 list,每攒够 1000 条调用一次批量插入,然后清空 list。这样既能控制内存,又能保证写入效率。

下面是一个完整的监听器实现,把校验、转换、批量插入都收拢在一起:

public class UserImportListener extends AnalysisEventListener<UserImport> { private static final int BATCH_COUNT = 1000; private List<UserImport> batchList = new ArrayList<>(); private List<UserImport> errorList = new ArrayList<>(); private final UserImportService userImportService; public UserImportListener(UserImportService userImportService) { this.userImportService = userImportService; } @Override public void invoke(UserImport data, AnalysisContext context) { // 基础校验:年龄必须大于0小于150,姓名不能为空 if (data.getName() == null || data.getName().trim().isEmpty()) { data.setErrorMsg("姓名为空"); errorList.add(data); return; } if (data.getAge() != null && (data.getAge() <= 0 || data.getAge() >= 150)) { data.setErrorMsg("年龄不合法"); errorList.add(data); return; } batchList.add(data); if (batchList.size() >= BATCH_COUNT) { saveBatch(); } } private void saveBatch() { if (!batchList.isEmpty()) { userImportService.saveBatch(batchList); batchList.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { saveBatch(); } public List<UserImport> getErrorList() { return errorList; } }

这段代码的逻辑链条很清晰:invoke 每被调用一次就收到一行数据,先做基础合法性校验,不通过的行直接进 errorList,不阻塞其余行的导入。通过校验的行攒到 1000 条就批量落库。doAfterAllAnalysed 保证文件结束前最后不足 1000 条的数据也能被写入。

这里需要讲一下为什么校验失败的行不抛异常而是收集到 errorList。生产环境中最普遍的需求是"好坏行隔离":不能因为 2 行脏数据让整个导入任务失败,那会让业务方骂街。收集错误行后,可以在接口返回里告诉他们"导入成功 9998 条、失败 2 条,失败原因见附件"。这才是能被实际项目接受的交互方式。

3.3 Controller 层:文件接收与异步化的平衡

Controller 里接收 MultipartFile 后,直接调用 EasyExcel.read 传入输入流和监听器即可。但这里有一个非常关键的设计决策:如果 Excel 文件很大,同步处理会让 HTTP 请求长时间挂起,网关超时、前端超时都是麻烦事。常见做法是把"解析入库"这个动作丢给线程池异步执行,接口立刻返回"任务已提交",前端轮询任务状态。

下面给出同步写法的完整 Controller,它适合中小数据量,清晰易懂:

@RestController @RequestMapping("/import") public class UserImportController { private final UserImportService userImportService; public UserImportController(UserImportService userImportService) { this.userImportService = userImportService; } @PostMapping("/excel") public Map<String, Object> importExcel(@RequestParam("file") MultipartFile file) throws IOException { UserImportListener listener = new UserImportListener(userImportService); EasyExcel.read(file.getInputStream(), UserImport.class, listener) .sheet() .headRowNumber(1) .doRead(); Map<String, Object> result = new HashMap<>(); result.put("successCount", listener.getErrorList().isEmpty() ? "全部成功" : "有失败"); result.put("errorList", listener.getErrorList()); return result; } }

代码里的 headRowNumber(1) 指定了标题行在第 1 行,从第 2 行开始正式读取数据。如果你的 Excel 前两行是合并单元格的大标题,这里就要改成 2。sh 这是一个非常常见的翻车点——不同模板的标题行位置不同,换模板时忘改这个参数,轻则数据错位,重则把表头当数据存进库,那是真灾难。

假如你的项目从一开始就要面对超大文件,把导入任务改异步的执行流程是:Controller 收到文件后存入临时目录(或云存储),把文件路径和任务 ID 丢进线程池,返回前端一个任务 ID;线程池里的任务调用同一个监听器完成解析入库;前端用任务 ID 轮询进度。这个方案能让导入接口永远秒回,避免所有 HTTP 层超时问题。代价是多一张任务状态表需要维护,但对生产级系统来说是值得的。

3.4 事务边界:回滚哪些、不回滚哪些、为什么

导入场景的事务格外容易出岔子。有的项目为了"保险",把整个文件解析都包在 @Transactional 里,结果一个脏数据导致 10 万行全部回滚。这不是保险,是定时炸弹。合理的边界是:只有"批量插入"这一步需要事务,解析和校验放在事务外。

MyBatis-Plus 的 saveBatch 默认自带事务,每条数据的插入都在同一个事务里执行,批次内任一条失败则本批全部回滚,但这个回滚不会影响之前已经成功写入的批次。这恰好符合"好坏行隔离"的预期:批次之间的数据完全独立,坏行所属的批次最多影响自身那一批,其他批次正常落库。

如果你希望整个文件要么全成要么全败,可以把所有数据先解析到内存中的 list 里,然后在 service 方法上加 @Transactional 统一次性插入。但这么做只适合 1 万行以内的小文件,超过这个量内存和事务时长都会成为新的问题。这两条路线没有绝对的对错,取决于业务到底是"容忍部分成功"还是"必须原子完成",提前和业务方确认远比事后补救省心。

4. 把数据库数据导出成 Excel:模板、流式写出与自定义样式

4.1 导出接口的标准姿势:HttpServletResponse 与 MIME 类型

导出数据和导入不同,它是数据的单向输出,核心诉求是文件不生僻、打开不乱码、大表不 OOM。SpringBoot 里导出 Excel 的标准姿势是:查询数据库得到数据列表,用 EasyExcel.write 拿到 OutputStream 写入响应流,同时设置响应头让浏览器识别为附件下载。

这一段代码是导出功能的最小骨架。要点是响应头的 Content-Disposition 必须用 URLEncoder 编码文件名,否则浏览器下载时中文文件名会变成一堆百分号。编码格式建议用 UTF-8,并且设置 Content-Type 为 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,对应 xlsx 的 MIME 类型。

@GetMapping("/export") public void exportUser(HttpServletResponse response) throws IOException { String fileName = URLEncoder.encode("用户数据_" + System.currentTimeMillis(), "UTF-8"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx"); List<UserImport> userList = userImportService.list(); EasyExcel.write(response.getOutputStream(), UserImport.class) .sheet("用户数据") .doWrite(userList); }

这里有一个坑要特别提醒:数据量大时不要一次性把所有数据查出来放内存再写,而是用 MyBatis-Plus 的分页查询或者游标查询逐批取数、逐批写入。EasyExcel 的 doWrite 可以接收一个 List,也可以配合 WriteHandler 实现分批写。实际开发里更常见的是自己控制查询批次,每次查 1 万条就 write 一次,用多次查询代替一次巨大查询。这能同时降低数据库查询压力和 JVM 内存压力。

4.2 让 OpenCSV 风格的列映射滚蛋:注解构建导出模板

EasyExcel 的导出和导入共用一套实体类注解,这省去了大量写列映射的功夫。导出时 @ExcelProperty 的 value 直接变成 Excel 表头,字段顺序决定列顺序。这里有个巧妙的用法:如果导入和导出对同一批字段的表头要求不一样(Excel 里常发生),可以用两个不同的 DTO 类来分别承接导入和导出,互不干扰。过度复用同一个实体类做导入导出,遇到业务方说"导入模板要叫工号,导出的表头要叫员工编号"时就得改代码。

如果你需要在导出时指定列宽、行高、冻结首行、背景色,可以自定义一个 CellWriteHandler,再在 EasyExcel.write 之后调用 registerWriteHandler 注册它。下面示例展示的是把表头行背景色设为浅灰色、字体加粗、所有列宽自适应内容:

public class CustomCellWriteHandler implements CellWriteHandler { @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead) { CellStyle cellStyle = writeSheetHolder.getSheet().getWorkbook().createCellStyle(); cellStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); cell.setCellStyle(cellStyle); } } }

这段代码的触发时机是每个单元格写完内容之后,isHead 参数为 true 表示当前是表头行。把它注册进 EasyExcel 的 writer 后,表头会自动带斑马纹底色,开起来更专业。但注意,这个 handler 是写所有 sheet 共用的,如果项目里多 sheet 导出且不同 sheet 要不同样式,需要根据 WriteSheetHolder 的 sheet 名做分支判断。自定义样式这一小步最容易出彩也最容易出错,尤其是单元格边框和背景色的设置需要手动 new 一个 CellStyle,避免直接修改 workbook 默认样式对象,否则会影响其他 sheet 的格式。

4.3 大数据量导出:分批查询加分批写的完整代码

当数据量跨越百万行时,一次性查询全部数据再写入既不可行也没必要。常见做法是每次从数据库取出 2 万行,调用 EasyExcel 的 write 多次,最后一次调用 finish 方法关闭流。下面这套代码是我多次用于生产环境的分批导出模板:

@GetMapping("/export/big") public void exportBigUser(HttpServletResponse response) throws IOException { String fileName = URLEncoder.encode("百万用户数据", "UTF-8"); response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx"); ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), UserImport.class).build(); WriteSheet writeSheet = EasyExcel.writerSheet("用户数据").build(); long currentId = 0L; int pageSize = 20000; while (true) { LambdaQueryWrapper<UserImport> wrapper = new LambdaQueryWrapper<>(); wrapper.gt(UserImport::getId, currentId).orderByAsc(UserImport::getId).last("LIMIT " + pageSize); List<UserImport> pageData = userImportService.list(wrapper); if (pageData.isEmpty()) { break; } excelWriter.write(pageData, writeSheet); currentId = pageData.get(pageData.size() - 1).getId(); } excelWriter.finish(); }

这段代码看起来简单,背后有两条关键经验。第一,用 ID 游标代替传统 OFFSET 分页,深分页时 OFFSET 会让数据库做大量无用扫描,而 gt currentId 完全走索引,百万级数据越查越快,这和很多面试题里聊的分页优化完全一致。第二,excelWriter.write 会缓存到内存再写,如果每批条数太小(比如 1000 条),会造成频繁刷盘写入,速度降低;如果调太大又可能内存抖动。我实测下来 2 万条一批是性能和内存的平衡点,你可以根据自己的服务器配置微调。

还有个容易踩的坑是,最后一批数据不满 2 万时会自然跳出循环,但如果最后一页正好空数据,循环也不会多跑一次。真正需要注意的反而是 finish 方法体会被调用——这是一个非常常见的问题。如果忘记调用 finish,生成的 Excel 文件会损坏,打开时会提升缺少结尾标记。即便写得很好的导出代码,在这种细节上丢掉文件完整性也是极其丢人的事情。

5. 导入导出避坑指南:五条最容易翻车的血的教训

5.1 日期的时区与格式陷阱:2007 版 Excel 天数序列值

现象:Excel 里明明写的是 2024-06-15,导入数据库后变成了 1989-03-22 或者 00:00:00 消失只剩日期。原因有两层:一是 EasyExcel 在将 EXCEL 日期转为 Java 对象时,依赖 POI 对 Excel 日期特殊编码的解析,旧版 poi 不识别某些欧洲区域格式,会把它当纯数字处理;二是数据库驱动连接串里没设置 serverTimezone,导致 LocalDate 被 JVM 默认时区转成了另一个日期。解决方法是把 Excel 日期列统一转成字符串再用 DateTimeFormatter 解析。更保险的连接串写法是 jdbc:mysql://localhost:3306/db?serverTimezone=Asia/Shanghai。如果源文件来自不同时区的业务方,你还需要在代码里指定 ZoneId,避免夏令时造成的偏移坑。

5.2 数字精度丢失:Excel 里的大整数变了样

现象:业务方在 Excel 里填的订单号是 6227001234567890123 这种 19 位数字,导入数据库后变成 6227001234567890000,后几位全部归零。原因很符合 Java 处理浮点时的精度丢失机制:POI 读数字默认按 Double 处理,而 double 只能精确表示大约 2 的 53 次方以内的整数。解决方法是把 Excel 这一列在模板里设置成文本格式,Java 实体字段对应写成 String,解析时优先读取字符串而非数字。如果你没法控制上游模板,就在监听器的 invoke 里先拿 CellData 判断类型再做转换,而不是直接 getNumber 然后强转 long。

5.3 大数据量写库时的连接耗尽

现象:导入 50 万行的文件,跑到一半报错 Cannot get a connection, pool timeout。原因是每攒够 1000 条就 saveBatch 一次,如果数据库连接池最大连接数是 20,而批量插入速度太慢,连接被占满就抛超时。另一个隐性原因是解析线程和请求线程各自占用连接,但没有血缘关系。解决方法是调大连接池的 maximum-pool-size,同时把批量插入的批次加大到 3000 或 5000 再落库,降低数据库连接的使用频率。更稳妥的做法是让解析入库线程池的信号量控制并发,连接池按并发量配置。如果你用的是 HikariCP,调 maximumPoolSize 的代价很小,别怕改。

5.4 内存溢出:xlsx 的行数远超预期

现象:一个小伙伴自信地用 POI 的 XSSFWorkbook 去解析一个 40MB 的 xlsx,结果堆内存设了 512MB,直接 OOM。原因就是之前聊过的 POI 会把整个 Sheet 读进内存构建 DOM 树。解决方法是先检查文件的扩展名和实际内容,用 EasyExcel 的流式解析代替 XSSFWorkbook。如果项目里确实必须用 POI,请用 XSSFWorkbook 只读模式的 SAX 解析,或者升级到 XSSFReader,这种 API 专门为大数据量设计。这里有个识别技巧:同一份 Excel,用 EasyExcel 和 POI 各跑一次,观察内存曲线,EasyExcel 的峰值通常只有 POI 的五分之一到十分之一,这个差距在中低配服务器上就是"卡死"和"流畅"的分界线。

5.5 导出文件打开就报"文件已损坏"

现象:导出成功后点击文件,Excel 提示文件格式或扩展名无效。原因最常见的是 response 流被提前关闭,或者写了部分内容但没调用 finish 方法。另一种情况是导出查询出的数据里含有 Java 无法持久化的字段,比如代理懒加载对象的反序列化异常被写入了文件。解决方法是确认导出逻辑中的所有分支都会在 finally 块里调用 excelWriter.finish(),同时检查 DTO 字段类型必须全部是 Excel 支持的简单类型,遇到一对一关联对象时先 map 成扁平字段再写。这个坑比较隐蔽的地方在于它不一定每次都失败,文件小、数据少时可能侥幸逃过,数据一旦多了必然会出问题。

6. 进阶玩法:多 Sheet 拆分、失败报告生成与任务异步化

做到这个程度,导入导出已经能解决大部分业务的日常需求,但生产环境总有更苛刻的场景。我经历过的一个典型需求是:业务方提供 5 个不同 sheet 的 Excel,每个 sheet 对应不同的表结构,需要一次性全部入库,且某个 sheet 失败不能影响其他 sheet。EasyExcel 的 doReadAll 可以读多个 sheet,每个监听器各管一个 sheet,但这里有个不可忽视的细节:doReadAll 是串行解析,sheet 数量多时总耗时是累加的,而且不同 sheet 的实体类不同,需要单独设置监听器。另一种途方是用 excelReader 同时注册多个 readSheet,在同一个文件句柄上轮流解析,内存占用更稳定。实现方式是这样:

ExcelReader excelReader = EasyExcel.read(file.getInputStream()).build(); ReadSheet sheet1 = EasyExcel.readSheet(0).head(UserImport.class).registerReadListener(userListener).build(); ReadSheet sheet2 = EasyExcel.readSheet(1).head(DepartmentImport.class).registerReadListener(deptListener).build(); excelReader.read(sheet1, sheet2); excelReader.finish();

代码本身很短,但这个写法解决了一个很重要的问题:多 sheet 文件导出时,解析器不再是"一次只认一张表"的结构。对于配置了 headRowNumber 的模板来说,每个 sheet 单独设置监听器的做法也避免了表头解析互相污染的问题。

多 sheet 场景常常伴随另一种需求:导入结束后给业务方一份"错误报告",把校验失败的行原样输出,然后追加一列错误原因。实现方法是用之前实体类里的 @TableField(exist = false) 的 errorMsg 字段,把错误行收集成一个 List,再调用 EasyExcel.write 生成一个只含错误行的 Excel 文件。下面这段代码可以放到导入结束后调用:

private void writeErrorReport(List<UserImport> errorList, HttpServletResponse response) throws IOException { String fileName = URLEncoder.encode("导入错误报告", "UTF-8"); response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), UserImport.class) .sheet("错误数据") .doWrite(errorList); }

注意这里直接复用 UserImport 作为导出表头,Excel 里会看到最后一列是 errorMsg,业务方一下子就懂哪行出了什么问题。如果你想让错误报告更友好,可以单独定义一个 ErrorReportDTO,只暴露 Excel 原有列加错误原因列,不带数据库 ID 等内部字段。这是一个典型的"需求简单、细节多"的活,做得好能显著减少和业务方的来回沟通时间。

异步化是另一个值得投入的方向。现在的导入接口如果是同步的,在面对几十万行数据时长时间的 HTTP 连接断线风险会变得非常高,而且用户只能干等页面转圈。我的习惯是做一个导入任务表:提交时就是插入一个任务记录,返回任务 ID。后台线程池立即消费这个待处理任务,解析完 Excel、写入数据库、更新任务状态为完成。前端用定时器轮询任务状态,拿到完成再把错误报告下载链接展示给用户。这个改造的技术细节不算复杂,但能直接改变使用体验,也让系统在处理超大文件时不再受制于网关超时。如果项目已经有消息队列,可以把解析任务丢进 MQ 做削峰,但没 MQ 的时候用线程池加任务表也足够稳。

最后聊一个我自己的习惯:每次做完导入导出功能,我会留下一个特定构造的测试 Excel,里面有乱码列名、空行、日期格式混排、数字超精度这四类脏数据,专门用来回归测试。上线前跑一遍,能拦住大量低级的解析事故。导入导出这段链路看着简单,实际藏了太多边界情况,测试用例里的那点时间永远比上了生产再回滚的代价小得多。希望这些经验对你手头的 SpringBoot 导入导出模块有点实际帮助。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询