首页 / 资讯中心 / 文章详情

SpringBoot+EasyExcel实现Excel导入导出与数据库存储实战

SpringBoot+EasyExcel实现Excel导入导出与数据库存储实战 ★ FEATURED ARTICLE
简介Java解析Excel并写入数据库、再从数据库导出为Excel的SpringBoot示例代码面向需要实现Excel数据导入导出的后端开发者和Java学习者。项目基于SpringBoot整合解析与导出工具覆盖读取xlsx文件入库、查询数据库生成Excel两个完整流程符合企业日常数据交换与报表导出场景。资源共22个文件压缩包仅40KB其中10个Java文件覆盖Excel解析、数据写入、查询导出等核心逻辑2个xlsx文件提供导入与导出的测试样例SQL脚本用于初始化表结构yml与properties负责环境配置另有Maven包装器、README说明及Postman接口调试集合代码、配置、测试数据分工清晰目前已有7582人学习下载。参照README中的操作步骤结合Postman集合、样例数据与SQL脚本可快速跑通导入导出接口掌握字段映射、数据持久化、Excel文件生成等关键技巧通过接口调试可直观看到请求参数与返回结果方便对照代码理解流程。无论是首次接触Excel导入导出需求还是需要在既有系统中快速集成这套示例都能提供清晰参考。1. 先用一个反直觉的结论开场解析 Excel 这件事难点从来不在读写拿到「java解析Excel文件并把数据存入数据库和导出数据为excel文件SpringBoot代码示例」这个需求时大多数人脑子里浮现的是 POI 的 Workbook、Sheet、Row、Cell 那一串 API然后一头扎进去写循环。我做过不少这类导入导出功能想先说一个判断读写 Excel 本身只占工作量两成剩下八成在类型转换、数据校验、批量入库、内存控制和排错上。尤其当你处理的文件从几千行变成几十万行或者用户随手甩来一个 .xls 而不是说好的 .xlsx你才会明白这活儿的真正难点在哪。这篇文章不打算把 POI 文档翻译一遍。我直接给你一套能用起来的方案基于 SpringBoot EasyExcel MyBatis Plus包含导入入库和导出下载两条完整链路每一步都落到代码。配置、参数、踩坑点全部摊开。新手照着能跑通熟手看边界和异常场景。2. 技术选型与项目骨架为什么是 EasyExcel 而不是直接用原生 POI2.1 先认清 POI 家族的三个类HSSF、XSSF、SXSSF 到底差在哪Apache POI 是 Java 生态里操作 Excel 的底层事实标准SpringBoot 项目里用 POI 做导入导出非常常见。但对新手来说POI 最大的坑是 API 层次多、内存消耗大。POI 家族里有三个核心类它们的区别直接决定了你会不会在线上翻车类支持格式内存行为适用场景HSSFWorkbook.xlsExcel 97-2003整体读入内存行数上限 65536老系统遗留文件XSSFWorkbook.xlsxExcel 2007整体读入内存DOM 模型小文件几十 MB 以内SXSSFWorkbook.xlsx滑动窗口行数据写满就刷盘百万行级写入HSSFWorkbook 操作的是 .xls 二进制格式XSSFWorkbook 操作的是 .xlsx 的 XML 结构会把整个 Sheet 的 XML 节点全部加载到内存文件一大就会报 OutOfMemory。SXSSF 是 XSSF 的流式版本它只保留窗口内的行对象窗口外的数据刷到磁盘临时文件写大数据量时是首选。但我的建议很直接业务项目里别再直接封装 POI 了。EasyExcel 是阿里开源的底层还是 POI但把读写模型重构成了事件驱动和流式处理。你用 EasyExcel 读文件它从 Excel 里一行行拉数据而不是一次性把整个文件塞进内存写文件时也自带缓冲机制。你省掉的是处理 POI 那套繁琐的 Cell 类型判断和日期格式化代码。2.2 搭建 SpringBoot 工程依赖、配置与数据库表设计先建一个最普通的 SpringBoot Web 工程。我在实际项目里常用 Spring Boot 2.7.x 系列搭配 MyBatis Plus 3.5.x 和 EasyExcel 3.x。这里有一个非常值得说的热知识springboot 版本太高反而容易出问题比如 Spring Boot 3.x 基于 Jakarta EE很多老版本的 POI/EasyExcel 工具类还依赖 javax 命名空间直接升级会 NoClassDefFoundError。如果团队没有迁移到 JDK 17 的计划老老实实用 2.7.x 最稳。pom.xml 里加这几组依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency注意mysql-connector-java 8.0.33 之后官方把 artifactId 改成了 com.mysql:mysql-connector-j新版本用新坐标。在 application.yml 里配置数据源和 MyBatis Plusspring: datasource: url: jdbc:mysql://localhost:3306/excel_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 50MB max-request-size: 50MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: automap-underscore-to-camel-case: true打开下划线转驼峰这样数据库字段user_name能直接映射到实体类的userName不用写一堆 ResultMap。max-file-size: 50MB是给导入接口用的不调大容易被 Spring 的默认 1MB 限制拦下来。接下来建一张业务表这里我以最常见的「用户名单导入导出」为例CREATE TABLE user_info ( id bigint NOT NULL AUTO_INCREMENT, user_name varchar(64) NOT NULL, phone varchar(20) DEFAULT NULL, email varchar(128) DEFAULT NULL, age int DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对应实体类和 Mapper 接口。用 MyBatis Plus 的好处是增删改查的通用方法已经内置你不需要写 XMLData TableName(user_info) public class UserInfo { TableId(type IdType.AUTO) private Long id; private String userName; private String phone; private String email; private Integer age; private LocalDateTime createTime; }public interface UserInfoMapper extends BaseMapperUserInfo { }这里要说一个很多人会忽略的点Excel 里的列和数据库字段、实体类字段是三个不同的命名空间。Excel 列头可能叫「姓名」「手机号」数据库字段叫user_name实体类叫userName。中间这层映射关系谁来管答案是 EasyExcel 的ExcelProperty注解这是我们下一步的核心。3. 先做导出从数据库取数生成 Excel 文件3.1 最小可用的导出接口用 EasyExcel 写出第一批数据在项目里插入数据saveBatch是 MyBatis Plus 自带的方法底层是拆成多条 insert 语句分批提交。有一个常见的坑很多人用了saveBatch还是会内存溢出原因是没有意识到 EasyExcel 的doWrite在传入大集合时不会自动分页一次性构建所有 Sheet 的写上下文。所以导出层的第一个设计原则是分批查、分批写不要等把所有数据都装进 List 再写文件。提示服务端导出思路我会用两段代码讲清楚先看最小实现再看分页优化。先定义导出文件的表头模型类Data public class UserExportVO { ExcelProperty(姓名) private String userName; ExcelProperty(手机号) private String phone; ExcelProperty(邮箱) private String email; ExcelProperty(年龄) private Integer age; }ExcelProperty(姓名)的值就是最终呈现在 Excel 里的列头。字段顺序决定列顺序所以你把属性按展示顺序排好即可。然后是 Service 层public interface ExportService { void exportUserList(HttpServletResponse response) throws IOException; }实现类Service RequiredArgsConstructor public class ExportServiceImpl implements ExportService { private final UserInfoMapper userInfoMapper; Override public void exportUserList(HttpServletResponse response) throws IOException { // 设置响应头让浏览器识别为下载 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(用户名单, UTF-8).replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); // 分批查询每 5000 条写一次 long pageSize 5000L; long pageNum 1L; ListUserExportVO voList new ArrayList(); // 先用 MyBatis Plus 的分页查询拿到总记录数 PageUserInfo page userInfoMapper.selectPage(new Page(pageNum, pageSize), null); EasyExcel.write(response.getOutputStream(), UserExportVO.class) .sheet(用户名单) .doWrite(new ArrayList()); // 先写入空表头避免表头重复 long total page.getTotal(); long pages (total pageSize - 1) / pageSize; for (int i 1; i pages; i) { PageUserInfo p userInfoMapper.selectPage(new Page(i, pageSize), null); ListUserExportVO list p.getRecords().stream() .map(u - { UserExportVO vo new UserExportVO(); vo.setUserName(u.getUserName()); vo.setPhone(u.getPhone()); vo.setEmail(u.getEmail()); vo.setAge(u.getAge()); return vo; }) .collect(Collectors.toList()); if (!list.isEmpty()) { EasyExcel.write(response.getOutputStream(), UserExportVO.class) .sheet(用户名单) .doWrite(list); } } } }这段代码有几种写法争议很大。我明确说明上面的写法不是最优的第一次调用doWrite时已经写入了表头第二次再调用会重复写入表头。EasyExcel 的doWrite每次调用都会创建一个完整的 Sheet 写入流程连续两次调用同一个输出流且不换 Sheet 名结果就是文件里出现两份表头。这是 EasyExcel 最容易踩的坑之一。// 正确的单次流式导出写法 EasyExcel.write(response.getOutputStream(), UserExportVO.class) .inMemory(false) .sheet(用户名单) .doWrite(() - { // 分页拉取每次返回 5000 条 PageUserInfo page userInfoMapper.selectPage(new Page(current, size), null); return page.getRecords().stream().map(...).collect(Collectors.toList()); });不过doWrite接收List而不是Supplier所以上面的写法在实际代码里需要配合WriteSheet手动分批。我一般用ExcelWriter来做这件事它支持多次写同一 Sheet且表头只写一次。看下面的写法public void exportUserList(HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(用户名单, UTF-8).replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); ExcelWriter excelWriter null; try { excelWriter EasyExcel.write(response.getOutputStream(), UserExportVO.class).build(); WriteSheet writeSheet EasyExcel.writerSheet(用户名单).build(); long pageSize 5000L; long pageNum 1L; while (true) { PageUserInfo page userInfoMapper.selectPage(new Page(pageNum, pageSize), null); ListUserExportVO voList page.getRecords().stream().map(this::convert).collect(Collectors.toList()); if (voList.isEmpty()) { break; } excelWriter.write(voList, writeSheet); pageNum; if (pageNum page.getPages()) { break; } } } finally { if (excelWriter ! null) { excelWriter.finish(); } } }ExcelWriter.write(list, writeSheet)只会写数据行表头由第一次写入时创建。finish()负责刷出所有缓冲数据到输出流并释放资源不调用会导致文件损坏——这是记在脑子里的一条铁律。3.2 用模板导出动态表头下的 3 个必调参数上面说的动态查询导出适合简单场景。但很多业务里用户要求「给我一个填好的模板按照模板格式导出来」比如固定格式的采购单、审批单、带合并单元格的报表。这个时候ExcelProperty注解方式不好使了因为模板的列头是用合并单元格画出来的。提示模板导出解决的是复杂表头不只是外观问题。EasyExcel 提供了模板填充模式先放一个 .xlsx 模板到 resources 目录代码里用EasyExcel.write().withTemplate()打开它再fill数据。模板里的占位符用{变量名}表示。这个模式下表头、合并单元格、列宽样式全部由模板文件决定代码只填数据。三个必调参数withTemplate(File)指定模板文件必须放在EasyExcel.write()之后、sheet()之前。模板文件路径要精确否则直接 FileNotFoundException。fill(List, FillConfig)FillConfig.builder().forceNewRow(Boolean.TRUE).build()是列表填充的关键。forceNewRow(true)强制每一条数据占一行不加这个参数多行数据会堆在同一行里。.sheet().doFill(list)模板模式下没有doWrite取而代之的是doFill。它接受 List 或单个对象对象里的字段名要和模板占位符匹配。3.3 导出文件名的响应头细节导出接口里文件名编码是很多人会忽略的一步。直接用中文文件名要处理浏览器编码URLEncoder.encode(用户名单, UTF-8)得到的是百分号编码再拼到Content-disposition里时要用filename*utf-8这个格式。如果你用老式的filename名单.xlsx部分浏览器会乱码。这里还有一层Content-type要精确设置。.xlsx的 MIME 类型是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet你写application/octet-stream虽然也能下载但浏览器不会把它当作 Excel 文件打开而会提示「是否用记事本打开」。4. 再做导入解析 Excel 并写入数据库4.1 监听器模式EasyExcel 的 ReadListener 与逐行拉取导入的核心机制是监听器。EasyExcel 读取 Excel 时不会一次性把所有行加载到内存而是每解析出一条数据就回调监听器一次。你必须在回调里处理这条数据而不是等所有数据读完再处理否则内存优势就没了。先定义导入用的 DTO它同时承担「Excel 列头映射」和「参数校验」两个职责Data public class UserImportDTO { ExcelProperty(姓名) NotBlank(message 姓名不能为空) private String userName; ExcelProperty(手机号) Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; ExcelProperty(邮箱) Email(message 邮箱格式不正确) private String email; ExcelProperty(年龄) Min(value 0, message 年龄不能小于0) Max(value 150, message 年龄不能大于150) private Integer age; }注意这里ExcelProperty的值必须和 Excel 里的列头文字完全一致姓名写错成名字就匹配不上了。然后定义监听器。这是导入功能里最关键的一个类Slf4j public class UserImportListener implements ReadListenerUserImportDTO { private final UserInfoMapper userInfoMapper; private final ListUserImportDTO cache new ArrayList(); private final int batchSize 500; private int successCount 0; private int failCount 0; private final ListString errorMessages new ArrayList(); public UserImportListener(UserInfoMapper userInfoMapper) { this.userInfoMapper userInfoMapper; } Override public void invoke(UserImportDTO data, AnalysisContext context) { // 每一行数据解析完成后回调 if (data null) { return; } cache.add(data); // 攒够一批就批量入库避免单条 insert 的性能损耗 if (cache.size() batchSize) { saveBatch(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { // 所有行都读完把剩余不足一批的数据入库 saveBatch(); } private void saveBatch() { if (cache.isEmpty()) { return; } try { ListUserInfo userList cache.stream().map(dto - { UserInfo entity new UserInfo(); entity.setUserName(dto.getUserName()); entity.setPhone(dto.getPhone()); entity.setEmail(dto.getEmail()); entity.setAge(dto.getAge()); entity.setCreateTime(LocalDateTime.now()); return entity; }).collect(Collectors.toList()); // saveBatch 内部自动分批这与监听器的 batchSize 是两层概念 userInfoMapper.insertBatchSomeColumn? // 真实项目里直接用 userInfoService.saveBatch(userList) successCount cache.size(); } catch (Exception e) { log.error(批量插入失败, e); failCount cache.size(); errorMessages.add(批量插入失败: e.getMessage()); } finally { cache.clear(); } } public int getSuccessCount() { return successCount; } public int getFailCount() { return failCount; } public ListString getErrorMessages() { return errorMessages; } }上面代码里我故意留了一行insertBatchSomeColumn的注释因为新手很容易在这里纠结——MyBatis Plus 的IService层面有saveBatch直接注入UserInfoService就行不非得用 Mapper 层。监听器invoke方法在每行数据解析完成后被调用。它的一个隐蔽细节是表头那一行不会被回调EasyExcel 自己判断了表头和数据的分界。所以你在监听器里不需要写「跳过第一行」的逻辑。cache列表的作用是攒批。如果你的文件有 10 万行每解析一行就insert一次数据库会被拖垮。攒到 500 条批量提交一次10 万行只需要 200 次 insert 调用性能差距是数量级的。4.2 校验、事务与批量入库的关系不少人在导入功能里用 Spring 的Transactional注解整个读取流程这是一个典型的错误用法。原因是工作表里的数据是逐行解析、逐行回调的如果你在事务里执行doRead()且某一行数据有问题整个事务回滚前面几万行已经插进去的数据全部回滚效率极低且监听器里抛异常会导致文件解析中断。正确的做法是把事务控制放在「一批数据的插入」这个粒度上。我通常的落地方案是先解析不直接入库把所有行解析成ListUserImportDTO然后统一校验校验通过后用IService.saveBatch分批写入包在一个事务里。既能保证校验结果先返回给用户又避免了大事务。Transactional(rollbackFor Exception.class) public BatchImportResult importUser(MultipartFile file) { // 1. 解析文件为 List ListUserImportDTO dtoList new ArrayList(); try (InputStream inputStream file.getInputStream()) { ExcelReader reader EasyExcel.read(inputStream, UserImportDTO.class, new UserImportListener(userInfoMapper)).build(); ReadSheet readSheet EasyExcel.readSheet(0).build(); reader.read(readSheet); } catch (IOException e) { throw new RuntimeException(文件解析失败, e); } // 2. 这里拿不到监听器里 cache 的存量数据了 }上面这个写法有问题监听器把数据saveBatch之后cache就清空了等你read()结束再想拿全部数据已经空了。所以监听器的职责要拆成两种一种是解析完直接装配另一种是解析完先收集到外部列表再由 Service 统一处理。业务上通常选第二种因为校验时你希望返回所有错误行而不是插一半失败一半。修正后的监听器做一个「只收集不入库」的版本public class UserCollectListener implements ReadListenerUserImportDTO { private final ListUserImportDTO list new ArrayList(); Override public void invoke(UserImportDTO data, AnalysisContext context) { list.add(data); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 所有行解析完list 已包含全部数据 } public ListUserImportDTO getList() { return list; } }这样校验逻辑集中在 Service 里做返回给前端的信息也更友好哪一行哪个字段错了都能看得清清楚楚。4.3 导入接口的完整调用链与返回结果Controller 里接收上传文件调用 ServiceRestController RequestMapping(/api/user) RequiredArgsConstructor public class UserImportController { private final UserImportService userImportService; PostMapping(/import) public ResultBatchImportResult importExcel(RequestParam(file) MultipartFile file) { if (file null || file.isEmpty()) { return Result.error(请选择文件); } return Result.ok(userImportService.importUser(file)); } }Service 里做三件事读取文件、校验数据、批量入库最后返回成功数、失败数和错误明细。Service RequiredArgsConstructor public class UserImportServiceImpl implements UserImportService { private final UserInfoService userInfoService; Override public BatchImportResult importUser(MultipartFile file) { UserCollectListener listener new UserCollectListener(); try (InputStream in file.getInputStream()) { EasyExcel.read(in, UserImportDTO.class, listener) .sheet() .doRead(); } catch (IOException e) { throw new RuntimeException(文件解析失败请检查文件格式, e); } ListUserImportDTO dtoList listener.getList(); if (dtoList.isEmpty()) { return BatchImportResult.of(0, 0, 文件没有数据); } // 逐条校验收集所有错误避免用户改一次错一个 ListString errors new ArrayList(); ListUserInfo validList new ArrayList(); for (int i 0; i dtoList.size(); i) { UserImportDTO dto dtoList.get(i); SetConstraintViolationUserImportDTO violations validator.validate(dto); if (!violations.isEmpty()) { String message violations.stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining(; )); errors.add(第 (i 1) 行: message); } else { UserInfo entity new UserInfo(); entity.setUserName(dto.getUserName()); entity.setPhone(dto.getPhone()); entity.setEmail(dto.getEmail()); entity.setAge(dto.getAge()); entity.setCreateTime(LocalDateTime.now()); validList.add(entity); } } // 有效数据批量入库注意这里别用 Transactional 包整个方法 if (!validList.isEmpty()) { userInfoService.saveBatch(validList); } return BatchImportResult.of(validList.size(), errors.size(), errors); } }userInfoService.saveBatch(validList)底层会按batchSize自动分批执行 insert不需要你自己循环。BatchImportResult是一个简单 POJO里面放了successCount、failCount、errors三个字段前端拿到后能展示「导入成功 499 条失败 1 条失败原因见列表」。每批入库失败时的处理我会在避坑章里展开讲。5. 导入导出避坑指南这些问题我基本每次都遇到5.1 数字精度丢失手机号、身份证变成科学计数法现象用户上传的 Excel 里手机号显示正常导入数据库后变成1.381234E10或者末尾几位变成 0。原因Excel 底层把超过 11 位的数字存成了浮点数POI 读出来时会转换成 doubledouble 无法精确表示超过 2^53 的整数导致精度丢失。这是 Excel 文件本身的问题不是 Java 代码的 bug。解决导入时在 DTO 的ExcelProperty上不做处理先把那列读成 String再由用户保证原文件里这一列是文本格式。更可靠的方法是在ExcelProperty注解里指定converter写一个StringNumberConverter把读到的 Double 转成字符串。代码层面最实用的防御是ExcelProperty(value 手机号, converter StringNumberConverter.class) private String phone;导出时同理从数据库取出String类型的手机号写到 Excel 时 EasyExcel 默认会把它当作文本写入不要再Integer.parseInt。一旦转成数字导出文件里手机号又会变回科学计数法。5.2 日期格式解析失败同一个 Excel有人传 2024/1/5有人传 2024-01-05 00:00:00现象解析文件时偶发IllegalArgumentException: Can not parse date同一个模板不同人填有的表格能导有的报错。原因EasyExcel 按ExcelProperty上的类型推断列类型。DTO 字段是LocalDateTime时它会尝试用内置格式解析单元格用户单元格里写的是2024/1/5或者带中文「2024年1月5日」格式对不上就抛异常。解决导入字段统一用String接收入库前自己在代码里做一次多格式兼容解析。我有一次想把这事儿简化试着给字段加DateTimeFormat结果发现 EasyExcel 根本不认 Spring 这套注解。最省心的策略是写一个日期解析工具方法public static LocalDateTime parseDate(String raw) { if (raw null || raw.trim().isEmpty()) { return null; } String v raw.trim(); // 尽量用标准化格式 String[] patterns {yyyy-MM-dd HH:mm:ss, yyyy/MM/dd HH:mm:ss, yyyy-MM-dd, yyyy/MM/dd}; for (String pattern : patterns) { try { return LocalDateTime.parse(v, DateTimeFormatter.ofPattern(pattern)); } catch (Exception ignored) { } } throw new IllegalArgumentException(无法解析日期: raw); }5.3 空行和空表尾解析结果里多出一条 null现象Excel 文件最后几行是空的或者中间有整行空白导入后发现库里多了几条全字段为null的数据。原因EasyExcel 在解析时会把真实存在的空行也回调给监听器尤其是用户手动给表格加了背景色或边框的行这些行会被识别为有效行。invoke里拿到UserImportDTO字段全为 null。解决在监听器invoke开始处加一行判断if (data null || data.getUserName() null) { return; }如果你要更严格可以判断所有字符串字段是否为空private boolean isBlankRow(UserImportDTO dto) { return dto.getUserName() null dto.getPhone() null dto.getEmail() null dto.getAge() null; }5.4 .xls 和 .xlsx 混用一次内存溢出的排查现象同一个接口测试时传 .xlsx 正常传 .xls 报OutOfMemoryError: Java heap space。原因大多数踩过这个坑的人都以为是文件太大其实问题出在 POI 对 .xls 和 .xlsx 的处理方式不同。EasyExcel 读 .xls 时用的是HSSF解析器它会把整个文件一次性加载进内存无法做到流式读 .xlsx 时用的是XSSFReader是流式解析。一个 20MB 的 .xls 解析时可能占用 500MB 堆内存而同样的 .xlsx 只用 200MB。Spring Boot 默认堆内存不大线上很容易炸。解决在接口入口处做文件类型判断限制只接受.xlsxString fileName file.getOriginalFilename(); if (fileName null || !fileName.endsWith(.xlsx)) { throw new RuntimeException(仅支持 .xlsx 格式文件.xls 请先另存为 .xlsx); }别相信用户嘴上说的「我传的就是 xlsx」很多时候文件后缀改了文件头还是 .xls 格式。更严谨的做法是读文件头字节判断真实类型。如果业务确实无法避免 .xls就单独调高 JVM 堆内存并把导入放在异步线程里执行。5.5 批量插入成功一半到底要不要回滚现象一万行数据入库第 5000 行出现唯一键冲突saveBatch抛出DuplicateKeyException前面 4999 条已经插进去了。用户重新上传整个文件又重复插入。原因saveBatch内部通常是分批执行每一批有自己的事务边界批次之间没有全局事务。如果你用Transactional把 Service 方法包住可以整体回滚但大事务又带来锁竞争和性能问题。我见过很多项目在这里反复摇摆。解决从业务上设计「全量校验 全量替换」策略。导入前先查库里是否已有这批数据的唯一标识手机号或身份证有则跳过没有才插入或者干脆支持「清空重导」——导入前把所有历史数据物理删除或逻辑删除再插入新数据。这样既避免了大事务又不会产生重复数据。配合唯一索引兜底比靠代码捕捉异常要稳得多。6. 再进一步大数据量导出的分页拉取和异步任务最后这一层讲一个我日常一定会用到的进阶手法。很多人一开始做导出用最简单的list()查出全量再写文件数据量到 20 万行时接口超时、内存报警。正确的姿势是前面提过的ExcelWriter配合selectPage分页拉取但它还有一个后续问题如果导出的数据量达到几十万行接口同步返回会让用户一直干等前端容易超时。我常用的方案是异步导出。接口提交一个「导出任务」参数后台线程池执行查询和写文件完成后把文件路径存到任务表前端轮询任务状态拿到结果后再下载。核心代码Service public class AsyncExportService { private final UserInfoMapper userInfoMapper; private final ThreadPoolTaskExecutor asyncExecutor; public Long submitExportTask(Long deptId) { // 创建导出任务状态为 DOING Long taskId taskMapper.insert(new ExportTask(deptId)); asyncExecutor.execute(() - doExport(taskId, deptId)); return taskId; } SneakyThrows private void doExport(Long taskId, Long deptId) { String filePath /tmp/export_ taskId .xlsx; ExcelWriter writer null; try (OutputStream out new FileOutputStream(filePath)) { writer EasyExcel.write(out, UserExportVO.class).build(); WriteSheet sheet EasyExcel.writerSheet(用户).build(); PageUserInfo page userInfoMapper.selectPage(new Page(1, 5000), null); long total page.getTotal(); for (int i 1; i (total 4999) / 5000; i) { ListUserExportVO records userInfoMapper.selectPage(new Page(i, 5000), null) .getRecords().stream().map(this::convert).collect(Collectors.toList()); writer.write(records, sheet); } } finally { if (writer ! null) { writer.finish(); } } // 更新任务状态为 DONE记录文件路径 taskMapper.finishTask(taskId, filePath); } }除了异步还有一层是「下载文件的清理」。导出文件放在 /tmp 或云存储上不能只生成不清理否则磁盘会被塞满。我一般顺手在任务表里记录创建时间单独一个定时任务每天凌晨删掉三天前的文件。要特别注意finally里writer.finish()的调用时机。文件写入过程中如果出现异常finish()没执行能打开文件但内容不完整Excel 会提示「文件损坏是否尝试修复」。所以不管正常流程还是异常分支finish()必须在finally里这是血泪经验。最后一句话留给做过大量导入导出功能的人这个功能做得好不好不是看你会不会写 POI 的 API而是看你有没有在设计之初就把类型转换、文件大小、批量粒度、事务边界和异常反馈这些「配角」想清楚。每次被线上导入问题找上门基本都是栽在这五个配角上。希望这篇笔记能帮你少踩几个坑。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站