SpringBoot实现Excel导入:从EasyExcel到批量入库的完整方案 接手过不少带Excel导入功能的后台项目每次看到需求文档里写着“支持批量导入数据”我都会先追问三个问题总共多少行文件是什么格式如果某一行数据有问题是全部回滚还是跳过继续这三个问题没有答案后面写出来的SpringBoot导入接口十有八九要返工。这篇文章我打算把一套在SpringBoot里从零实现Excel导入并写入数据库的完整方案拆开讲透包括框架选型、接口设计、批量入库、逐行校验还有几个特别隐蔽的坑。如果你刚接触这类需求或者已经被大数据量、脏数据、格式错乱的文件折磨过这篇文章应该能帮你省下不少修改时间。Excel导入这个功能表面上看就是一个文件上传加数据库插入真正做起来却很容易翻车。我见过太多项目在最开始直接用POI一把梭结果线上导入两万行就把内存打爆也见过因为事务写得太粗几千条坏数据把整张业务表锁死最后只能半夜跑脚本修复。所以我想把这套经验写下来或许有的人看完会觉得不过如此但真正踩过坑的人应该能会心一笑。1. 从一个“又要导Excel?”的需求说起这类接口到底难在哪1.1 真实需求场景是什么样的通常这类需求的起点都很朴素业务方手里有一堆历史Excel表格需要把这些数据导入系统供后续查询、统计、二次编辑。比如一份用户名单里面可能有用户名、年龄、邮箱、生日、部门编号等字段又比如一份商品清单包含SKU、价格、库存、分类。文件行数可能只有几百行也可能有几万甚至几十万行。管理员的操作流程一般是这样的登录管理后台进入某个数据管理页面点击“导入”按钮选择一个文件系统解析并落库。如果解析过程中有数据不符合规则系统需要明确告诉用户第几行第几列是什么问题而不是笼统地返回一个“导入失败”。等到文件处理完成后台最好还能展示成功多少条、失败多少条失败的明细也最好能下载下来给业务方自行核对。我刚做这类功能的时候第一反应也是“这不就是个读Excel循环insert吗”。后来发现完全不是这么回事因为它要同时面对数据量、格式、脏数据、事务边界、性能、错误反馈六个问题任何一个没处理好生产环境都会出幺蛾子。1.2 这类接口为什么比普通增删改查难写先看最直观的差异普通CRUD接口一次只操作一条数据入参类型明确字段校验可以逐个参数来做。导入接口面对的是一个“未知文本”Excel本身又允许用户任意填甚至允许单元格是空值、日期格式五花八门、电话带空格、邮箱写错、数字被存成了文本。换句话说交给你的不是干净的数据而是一张“充满人性”的电子表格。再看性能问题。假设导入十行数据和十万行数据代码如果是循环一条条insert十万条会产生十万次数据库网络往返每次都有创建连接、发送SQL、等待响应、事务提交的开销接口不慢才怪。如果用POI的XSSFWorkbook直接读200MB的ExcelJVM堆内存稍微小一点就会报OOM。还有一个容易忽略的点事务边界。很多人习惯在Service方法上加一个大Transactional把整个文件的所有行都包在一个事务里。看起来“要么全部成功要么全部回滚”很安全但问题是如果有几千行校验不合法handler会一直攒到最后一刻才告诉你前面几千行都白处理了数据库连接也被你占了很久。更麻烦的是大量数据在单个事务里插入对数据库的锁竞争、binlog写入、Undo log增长都有压力导100万行数据时甚至可能把数据库拖慢。所以Excel导入真正考验的不是你会不会用POI而是你懂不懂文件流解析、批处理、事务拆分和错误设计。这也是这篇文章的核心基调我接下来会按我实际落地时的顺序从选型开始一步步展开。2. 选型定生死为什么我放弃POI改用EasyExcel做导入2.1 POI的硬伤整本读入内存的模型Apache POI是Java里操作Excel的老牌库功能是真的很全读写Word、Excel、PPT都能干。但如果你直接用它的标准方式解析一个.xlsx文件问题会非常明显。典型的错误写法是Workbook workbook WorkbookFactory.create(inputStream); Sheet sheet workbook.getSheetAt(0); for (Row row : sheet) { // 逐行处理 }这一行WorkbookFactory.create(inputStream)干的事情是把整个Excel文件的结构全部加载进内存。对于.xlsx这种基于XML的格式POI要解析所有sheet、所有行、所有单元格构建成一张包含大量Java对象的内存模型。看起来你只是在“逐行遍历”但其实整张表格已经被完整地搬进了堆内存。我印象很深的是一次内部测试业务方丢过来一个20万行、三十多列、带不少样式的Excel我用上面的写法去解析启动参数堆内存给了1GB结果接口直接就卡死了。后来看日志GC一直在Full GC一个导入接口把整台服务的内存都吃掉了最后只能重启服务。POI也提供了SAX模式的事件驱动API比如XSSFReader配合ContentHandler但是没用过的人第一次看那个API多半会懵它要求自己处理XML事件、管理sheet和cell的临时资源门槛是真的高。对于绝大多数业务团队来说这不是一个能快速落地的方案。2.2 EasyExcel的流式读模型SAX风格的回调EasyExcel是阿里开源的工具底层虽然还是基于POI但它把POI的SAX解析封装得非常友好内部呈现给开发者的就是“一行一行回调”。我用一个比较形象的类比POI标准模式相当于你为了找一句话把一个图书馆里所有的书全部搬到桌子上再慢慢翻EasyExcel是让图书馆管理员一页一页递给你你只看当前这一页看完它自己就把这一页收走了。内存压力差了不止一个数量级。EasyExcel用到三个主要概念实体类用ExcelProperty注解把Excel列映射到Java字段监听器实现AnalysisEventListenerT重写invoke方法和doAfterAllAnalysed方法读取入口EasyExcel.read(inputStream, DemoData.class, listener).sheet().doRead()。在invoke方法里你拿到的就是一行已经完成类型映射的Java对象读取模型是流式的已经读过的行可以被GC回收所以即使在百万行级别内存占用也能控制在非常低的水平。依赖引入也简单在pom.xml加上dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency2.3 依赖版本冲突是第一个坑说到版本我不得不提一下EasyExcel和POI的版本冲突问题。EasyExcel 3.x版本内部会依赖POI 5.x如果你项目里还同时引了老版本的poi-ooxml比如3.15或4.1.2启动时很容易出现找不到方法或者类加载失败的情况最常见的报错是NoSuchMethodError。我建议的做法是项目里如果有其他地方也用POI尽量通过dependencyManagement统一版本或者直接排除掉EasyExcel传递进来的POI只保留一个主版本。但如果你的项目没有其他POI场景就千万别手动再加poi和poi-ooxml完全没必要必要的依赖EasyExcel都会带进来。在Maven里排除多余的POI依赖可以这样写dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version exclusions exclusion groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId /exclusion /exclusions /dependency如果不想单独维护POI版本就直接保留EasyExcel自身带的POI不要额外再引用。这个原则我到现在还在用凡是用EasyExcel的项目POI相关依赖就尽量不再动。选型这件事做完之后后面的事情就顺很多。EasyExcel的另一个优势是内置了CSV、xls、xlsx的读取能力对不同伪装文件也有一定容错不过这个问题我会放到最后一节专门讲。3. 接口落地全流程从Controller到批量入库的完整代码拆解3.1 先定表结构和实体类任何导入功能都应该先想清楚“数据最终落到哪里”再写代码。我以导入用户名单为例假设最终落库到user_info表表结构大概是CREATE TABLE user_info ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, age int DEFAULT NULL COMMENT 年龄, email varchar(100) DEFAULT NULL COMMENT 邮箱, birthday date DEFAULT NULL COMMENT 生日, created_at datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户信息表;然后定义Excel对应的DTO。这里的字段最好和数据库实体分开因为Excel列的表头文案和数据库字段名不一定一样而且DTO里可以多放一个“行号”字段方便后面报错。public class UserImportDTO { ExcelProperty(value 用户名, index 0) private String username; ExcelProperty(value 年龄, index 1) private String age; ExcelProperty(value 邮箱, index 2) private String email; ExcelProperty(value 生日, index 3) private String birthday; // 记录Excel中真实行号方便错误提示 private Integer rowNumber; }我推荐用index来明确列位置而不是只靠表头名匹配。很多业务Excel的表头字体、缩进、空格都不规范只靠value匹配偶尔会失效。用index虽然要求列顺序固定但一旦模板确定下来这种方式最稳。年龄和生日先用String接收。为什么要这么干因为Excel单元格类型太灵活数字、文本、日期都有可能如果强转成Integer或者LocalDate监听到异常数据类型时会在框架内部直接抛异常我没法在那一行记录友好的错误提示。先在DTO层拿String自己在invoke里做更细致的转换和校验控制感更强。3.2 上传接口的Controller层Controller层的代码很直接接收MultipartFile校验文件是否为空、大小是否超限然后交给Service处理PostMapping(/import/user) public ResultImportResult importUser(RequestParam(file) MultipartFile file) { if (file null || file.isEmpty()) { return Result.error(请选择要导入的Excel文件); } if (file.getSize() 20 * 1024 * 1024) { return Result.error(文件大小不能超过20MB); } ImportResult result userImportService.importExcel(file); return Result.success(result); }这里20MB是一个保守值。如果业务上确实有大文件需求建议配合异步导入方案而不是无限调大接口限制不然一个请求长时间占着Tomcat线程容易拖垮其他接口。文件类型校验不能只看扩展名具体判断方法在最后一节会讲。Controller这层还要考虑一个点生产环境如果用Nginx做了上传大小限制记得同步改client_max_body_size否则前端传到一半被Nginx拦截会给你返回一个看不懂的413错误。3.3 核心DataListener逐行读取与批量落库监听器是这个功能的核心。EasyExcel框架解析到一行数据时会调用invoke方法。我们的策略是先把解析出来的行加到List里攒到一定数量比如5000条时执行一次批量insert然后清空这个List。这样既能利用批处理优势又不会让List里堆积太多对象。public class UserDataListener extends AnalysisEventListenerUserImportDTO { private static final int BATCH_COUNT 5000; private final ListUserImportDTO dataList new ArrayList(); private final UserImportService userImportService; // 用一个结果对象收集成功数、失败数、错误明细 private final ImportResult importResult; public UserDataListener(UserImportService userImportService, ImportResult importResult) { this.userImportService userImportService; this.importResult importResult; } Override public void invoke(UserImportDTO dto, AnalysisContext context) { Integer rowIndex context.readRowHolder().getRowIndex(); dto.setRowNumber(rowIndex 1); if (isEmptyRow(dto)) { return; } // 先做逐行校验通过才放行不通过就记录错误 ListString errors UserImportValidator.validate(dto); if (!errors.isEmpty()) { importResult.addError(dto.getRowNumber(), String.join(; , errors)); return; } dataList.add(dto); if (dataList.size() BATCH_COUNT) { userImportService.batchInsert(dataList); importResult.addSuccess(dataList.size()); dataList.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!dataList.isEmpty()) { userImportService.batchInsert(dataList); importResult.addSuccess(dataList.size()); dataList.clear(); } } private boolean isEmptyRow(UserImportDTO dto) { return StringUtils.isBlank(dto.getUsername()) StringUtils.isBlank(dto.getAge()) StringUtils.isBlank(dto.getEmail()) StringUtils.isBlank(dto.getBirthday()); } }为什么要5000条一批这个数字不是拍脑袋定的。单批3000到5000条在MySQL上做批插入既不会让事务时间过长也不会因为单批SQL太长导致网络包过大。如果你的服务器和数据库内网延迟很低可以适当提高到1万条。我实测下来5000是个比较稳的起步值。context.readRowHolder().getRowIndex()拿到的行号是从0开始的所以转换成Excel中的真实行号要加1刚好能对应Excel界面左侧看到的行号。用户看到“第3行解析失败”就不会看成“第2行”。3.4 为什么批量插入这么重要如果不做批量插入而是一行一行调用Mapper的insert每条数据都会有一次独立的SQL发送和数据库响应。假设是10万行数据就是10万次网络往返哪怕每次只花1毫秒光网络延迟就要100秒再加上数据库执行时间根本没法用。批量插入常见的实现方式有两种一种是MyBatis-Plus的saveBatch另一种是直接注入JdbcTemplate调用batchUpdate。如果你的mapper是纯MyBatis XML也可以在Mapper接口方法里直接传入List用foreach标签拼出一条多VALUES的insert。用JdbcTemplate的话Service可以这样写public void batchInsert(ListUserImportDTO list) { jdbcTemplate.batchUpdate( INSERT INTO user_info(username, age, email, birthday) VALUES (?, ?, ?, ?), list, 5000, (ps, dto) - { ps.setString(1, dto.getUsername()); ps.setInt(2, Integer.parseInt(dto.getAge())); ps.setString(3, dto.getEmail()); ps.setDate(4, DateUtil.parseToSqlDate(dto.getBirthday())); } ); }这里batchUpdate的重载方法允许传入一个batchSize它会自动按5000个一组去分批执行非常契合我们的监听器逻辑。有一个参数必须记住MySQL JDBC连接串上要加rewriteBatchedStatementstruejdbc:mysql://localhost:3306/your_db?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrue这个参数的作用是让MySQL驱动把一批INSERT语句在客户端重写成一条多VALUES的语句再发到服务端而不是一批语句一条一条执行。没加这个参数之前batchUpdate实际上只是复用一条PreparedStatement循环执行性能提升有限加上之后大批量插入性能可以提升好几倍这是我实际对比过的。4. 逐行校验与错误反馈让用户一眼看懂哪一行出了什么问题4.1 校验逻辑放在哪监听器是最合适的位置很多人在写导入功能时习惯先用EasyExcel把所有行都读到一个List里然后在Service层再for循环校验、再插入。这样做不是不行但有两个问题一是所有数据先堆在List里大数据量下内存吃紧二是你丢了“当前行号”这个上下文后面要重新遍历才能知道每条数据来自Excel的哪一行。直接在监听器里做校验是更聪明的选择。因为EasyExcel回调你的invoke时本来就把当前行号给你了你在这一行就能完成校验、转换、错误记录、放行或拦截。校验通过的行才进入批量list校验失败的行不进list不会污染数据库。这种“边读边判”的模式也符合流式解析的设计初衷。4.2 一个轻量的导入结果收集器错误信息需要有一个统一结构。我习惯定义一个ImportResultpublic class ImportResult { private int successCount; private int failCount; private ListRowError errorRows new ArrayList(); public void addSuccess(int count) { this.successCount count; } public void addError(int rowNum, String message) { this.failCount; this.errorRows.add(new RowError(rowNum, message)); } // getter/setter public static class RowError { private int rowNum; private String message; // 构造方法、getter/setter } }这个类不需要花哨但要注意successCount应该统计的是真正落库的行数而不是读取的总行数。因为读取过程中可能会跳过空行也可能会遇到校验失败的行。如果统计时把两者的概念混在一起前端展示的“成功1000条失败3条”跟实际对不上用户就会来投诉。4.3 错误消息怎么写才不招骂错误消息是这类功能体验的关键直接决定你的工单量。我见过很多项目返回的提示是“第5行导入失败”用户看到这句话只会一头雾水还是要找开发问原因。好的错误提示要包含三个要素行号、字段名、具体原因。例如第12行邮箱格式不正确年龄必须为数字 第25行用户名不能为空生日格式应为yyyy-MM-dd同一个单元格里如果有多列校验失败可以把它们用分号拼在一起一次告诉用户不用让他修完一列又上传一次再被提示另一列有问题。这里有个很容易漏的细节凡是校验失败的行不要停止整个导入流程。业务上通常采取“能导入的就导入错误行记录下来”的策略否则几百行错误数据会导致所有正常数据全部被拒业务方的抱怨会直接翻倍。如果你真的遇到“错一行就必须全部回滚”的需求一般不是技术上做不到而是业务逻辑还没想清楚。大多数场景下部分成功加明细提示才是业务方真正想要的体验。5. 大数据量导入的实战调优批量提交、事务边界与异步改造5.1 一个事务处理N行数据你迟早会后悔我刚做导入功能的时候也犯过一个错误在Service的importExcel方法上加了Transactional然后把整个文件的所有行都在同一个事务里提交。试想一下如果这个文件有5万行其中有1000行有问题你的代码把这些行全部跳过只把没问题的4.9万行插入数据库一旦最后一步抛了一个异常整个事务全部回滚刚才4.9万行全部白处理用户的等待时间也白花了。事务的边界要分成两层来看第一层每次batchInsert自身可以是一个独立事务或者你有意识地把每批操作放在一个小事务中。这样如果某一批因为数据库异常导致失败前面已经提交的批次不会回滚接口也能明确告诉用户“处理到多少行时出了问题”。第二层如果需要支持“部分成功”那么监听器里的单批插入就不用加大的事务如果需要“全成功或全失败”则要给整个读取和插入过程包一个事务但此时千万不要用默认的REQUIRED传播级别硬撑几十万行不然数据库连接长时间不释放线上会出连锁问题。我在生产里更倾向的做法是小文件几千行可以接受全量事务大文件几万行以上默认部分成功批次独立提交最后返回成功数与错误明细。业务方一般也愿意接受这种方案。5.2 被低估的rewriteBatchedStatements参数我在前面已经提到了rewriteBatchedStatementstrue这里再展开说一下。MySQL JDBC驱动在没有开启这个参数的情况下PreparedStatement.executeBatch()其实是循环一条一条发送SQL到服务器端的。驱动只是把多次执行攒成了“批量模式”但网络包的构造还是逐条发送这样做的收益主要是减少客户端到数据库的往返次数但效果有限。开启rewriteBatchedStatementstrue后驱动会把同一张表上的多条INSERT语句重写成一条多VALUES语句再一次性发送给MySQL服务端。比如5000条数据就可能变成一条包含5000组VALUES的INSERTSQL执行效率完全不是一个级别。数据量越大这个参数的收益越明显几十万行导入时性能差距甚至可以达到四到五倍。需要注意这个参数是针对MySQL驱动行为不是MySQL服务端配置。加了参数后你不用改代码所以强烈建议在项目初期就把它配置上。对于批量UPDATE这个参数同样有效。5.3 超过几十万行时异步任务与进度反馈一个动辄几十万行的Excel文件即使批量插入优化过了整个接口的耗时也可能超过几十秒。如果同步处理前端HTTP请求早就超时了用户体验极差Tomcat线程也被长时间占用。我的建议是引入异步导入模式流程是接口收到文件后先把文件保存到本地临时目录生成一个taskId把解析和落库任务丢到线程池中异步执行接口立即返回taskId前端根据taskId轮询导入进度后台线程处理过程中定期更新任务表里的“处理行数、成功数、失败数、状态”处理完成后状态变为成功或失败前端拿到状态后展示明细或提供失败文件下载。任务表可以很轻量字段一般是task_id、file_name、total_count、success_count、fail_count、status、create_time、finish_time。线程池建议单独定义一个不要用JDK默认的公共线程池避免和其他业务共享资源Bean(importTaskExecutor) public Executor importTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix(import-task-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }线程数不用开太多。导入任务一般是IO密集型两到四个核心线程足够核心线程太多反而会争抢数据库连接和带宽。CallerRunsPolicy的作用是当任务队列满时让提交任务的线程自己执行相当于一个背压机制比直接丢弃任务或抛异常要好。5.4 更进一步的方案临时表 INSERT INTO SELECT如果数据量真的到了百万级以上Java侧逐行batchInsert的性能已经不那么理想可以考虑“先导临时表再汇总”的路线。思路是先把Excel解析出来的数据批量写入一张临时表或使用LOAD DATA LOCAL INFILE直接加载等全部数据都进入临时表后再用一条INSERT INTO ... SELECT语句做数据清洗和落库。这样做的好处是大量数据转换的逻辑可以下推到MySQL里利用SQL的CASE WHEN、STR_TO_DATE等函数处理字段比Java逐行转换还要快而且事务边界更容易控制。当然这个方案对数据库权限、文件导入路径、批量加载工具都有额外要求不是所有项目都适合。如果你只是处理五六万行数据前面讲的批处理方案已经绰绰有余没必要一开始就上临时表。6. 那些让我调试到深夜的坑日期格式、空行、公式与文件伪装6.1 日期单元格到底是文本还是Excel序列号日期是Excel导入里最典型的一个坑。用户在Excel里看到的日期是“1990-01-01”但底层存储可能是一个序列号比如数字32874也可能是文本字符串还可能是带时间的“1990/1/1 8:30”。EasyExcel默认读日期类型可以转成Date但如果单元格格式比较特殊或者Excel里存的是文本直接映射到Date类型就会报转换异常。所以我在DTO里把生日字段设计成String就是想在监听器里自己统一转换。转换逻辑类似if (StringUtils.isBlank(dto.getBirthday())) { return; } DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd); try { LocalDate.parse(dto.getBirthday(), formatter); } catch (DateTimeParseException e) { importResult.addError(dto.getRowNumber(), 生日格式应为yyyy-MM-dd); }如果你的Excel里生日列已经确认为日期格式也可以用LocalDateTime去接收然后在字段上加一个DateTimeFormat(yyyy-MM-dd HH:mm:ss)。但为了兼容文本和数字两种形态用String接收再统一处理是更稳的思路。另一个容易被忽略的点是时区Excel日期序列号转java.util.Date时默认可能按UTC转换东八区用户会看到日期差8小时。如果你的实体用LocalDate或LocalDateTime而不是Date这个坑会少很多。6.2 “空行”为什么还会被读进来Excel里有一种很怪的现象看起来是空行但其实某些单元格有格式残留比如设置了边框、背景色或只是敲了一个空格。EasyExcel处理这些行时不会自动识别为“空行并跳过”它仍然会回调invoke只是DTO里的字段都是null或者空白字符串。如果不做任何处理这些“空行”就会进入校验逻辑被记录成“用户名不能为空”造成一些莫名其妙的失败记录。正确做法是在监听器里主动判断整行是否为空。判断逻辑就是把DTO的所有字段拼起来看是否isBlank。我在invoke方法里已经写了isEmptyRow它的核心作用就是拦截这种行。6.3 公式读出来是公式字符串还是结果值如果Excel里某个单元格写的是公式比如A1B1解析时EasyExcel默认拿到的可能是公式字符串而不是公式计算的结果。用户明明看到列里显示的是100读出来却变成“A1B1”插入数据库时自然就报错。解决方案是根据业务场景决定如果希望拿到公式计算后的结果需要确保Excel文件在保存时已经计算了公式或者在读取时不要直接映射到数值字段而是先用String接收再自己判断是否是数字开头。如果业务上根本不允许出现公式就在校验逻辑里判断字符串是否以开头是的话直接报错“该单元格不允许使用公式”。这里要提醒一句如果Excel里放的不是公式而是纯数字EasyExcel读出来没有任何问题但如果你用getNumericCellValue之类的API去处理不同单元格类型就要格外小心。6.4 扩展名是xlsx其实是个CSV这个坑真的很隐蔽。有时候业务方会从某个系统导出数据导出的文件叫“users.xlsx”但它实际上是一个CSV文件把扩展名改成了.xlsx。你用EasyExcel去读可能直接报“无法解析文件头”或者读出来的内容全是乱码、挤在一行。解决办法是不要完全相信扩展名。读取文件前先判断文件的魔数Magic Number。比如xlsxOffice Open XML文件头是PK50 4B本质是一个zip包xls老格式文件头是D0 CF 11 E0CSV文件通常开头是普通文本没有固定魔数但可以通过内容判断。Java里可以读文件的前几个字节来判断。简单写法是byte[] headerBytes new byte[4]; try (InputStream is new BufferedInputStream(new FileInputStream(file))) { is.read(headerBytes); } String headerHex bytesToHex(headerBytes); if (504B.equals(headerHex)) { // xlsx或docx等zip格式 } else if (D0CF11E0.equals(headerHex)) { // xls老格式 } else { // 大概率是CSV或纯文本 }我用这个方式拦住了不少“伪xlsx”文件。如果判断出来是CSV要么用EasyExcel的.csv()方式读取要么直接用CSV解析库处理。不要指望Excel解析器能对CSV文件完美兼容。6.5 最后关于导入功能的一点个人体会Excel导入功能看起来不起眼但它直接面对业务方的日常作业做得好坏很影响信任感。我自己的体会是这类功能最值得投入的不是一开始的“能读出来”而是错误提示、边界情况、大数据量这三个维度。每次处理完一批奇怪的文件我都会把用户常见问题补充到校验逻辑里久而久之失败率会越来越低线上工单也会越来越少。如果你正在做一个全新项目我建议先定好文件大小上限和行数上限再决定同步还是异步先做好行级错误收集再优化批量插入先跑通小数据量再用大数据量做压测。这样从第一版开始你的导入功能就能站在一个比较稳的基础上后面不管面对什么格式的文件都能少走弯路。