接手过不少带Excel导入功能的后台项目,每次看到需求文档里写着“支持批量导入数据”,我都会先追问三个问题:总共多少行?文件是什么格式?如果某一行数据有问题,是全部回滚还是跳过继续?这三个问题没有答案,后面写出来的SpringBoot导入接口,十有八九要返工。这篇文章我打算把一套在SpringBoot里从零实现Excel导入并写入数据库的完整方案拆开讲透,包括框架选型、接口设计、批量入库、逐行校验,还有几个特别隐蔽的坑。如果你刚接触这类需求,或者已经被大数据量、脏数据、格式错乱的文件折磨过,这篇文章应该能帮你省下不少修改时间。
Excel导入这个功能,表面上看就是一个文件上传加数据库插入,真正做起来却很容易翻车。我见过太多项目在最开始直接用POI一把梭,结果线上导入两万行就把内存打爆;也见过因为事务写得太粗,几千条坏数据把整张业务表锁死,最后只能半夜跑脚本修复。所以我想把这套经验写下来,或许有的人看完会觉得不过如此,但真正踩过坑的人应该能会心一笑。
1. 从一个“又要导Excel?”的需求说起:这类接口到底难在哪
1.1 真实需求场景是什么样的
通常这类需求的起点都很朴素:业务方手里有一堆历史Excel表格,需要把这些数据导入系统,供后续查询、统计、二次编辑。比如一份用户名单,里面可能有用户名、年龄、邮箱、生日、部门编号等字段;又比如一份商品清单,包含SKU、价格、库存、分类。文件行数可能只有几百行,也可能有几万甚至几十万行。
管理员的操作流程一般是这样的:登录管理后台,进入某个数据管理页面,点击“导入”按钮,选择一个文件,系统解析并落库。如果解析过程中有数据不符合规则,系统需要明确告诉用户第几行第几列是什么问题,而不是笼统地返回一个“导入失败”。等到文件处理完成,后台最好还能展示成功多少条、失败多少条,失败的明细也最好能下载下来给业务方自行核对。
我刚做这类功能的时候,第一反应也是“这不就是个读Excel循环insert吗”。后来发现完全不是这么回事,因为它要同时面对数据量、格式、脏数据、事务边界、性能、错误反馈六个问题,任何一个没处理好,生产环境都会出幺蛾子。
1.2 这类接口为什么比普通增删改查难写
先看最直观的差异:普通CRUD接口一次只操作一条数据,入参类型明确,字段校验可以逐个参数来做。导入接口面对的是一个“未知文本”,Excel本身又允许用户任意填,甚至允许单元格是空值、日期格式五花八门、电话带空格、邮箱写错、数字被存成了文本。换句话说,交给你的不是干净的数据,而是一张“充满人性”的电子表格。
再看性能问题。假设导入十行数据和十万行数据,代码如果是循环一条条insert,十万条会产生十万次数据库网络往返,每次都有创建连接、发送SQL、等待响应、事务提交的开销,接口不慢才怪。如果用POI的XSSFWorkbook直接读200MB的Excel,JVM堆内存稍微小一点就会报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字段; - 监听器:实现
AnalysisEventListener<T>,重写invoke方法和doAfterAllAnalysed方法; - 读取入口:
EasyExcel.read(inputStream, DemoData.class, listener).sheet().doRead()。
在invoke方法里,你拿到的就是一行已经完成类型映射的Java对象,读取模型是流式的,已经读过的行可以被GC回收,所以即使在百万行级别,内存占用也能控制在非常低的水平。
依赖引入也简单,在pom.xml加上:
<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.4</version> </dependency>2.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> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.4</version> <exclusions> <exclusion> <groupId>org.apache.poi</groupId> <artifactId>poi-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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Result<ImportResult> 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 AnalysisEventListener<UserImportDTO> { private static final int BATCH_COUNT = 5000; private final List<UserImportDTO> 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; } // 先做逐行校验,通过才放行,不通过就记录错误 List<String> 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(List<UserImportDTO> 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连接串上要加rewriteBatchedStatements=true:
jdbc:mysql://localhost:3306/your_db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true这个参数的作用是让MySQL驱动把一批INSERT语句在客户端重写成一条多VALUES的语句再发到服务端,而不是一批语句一条一条执行。没加这个参数之前,batchUpdate实际上只是复用一条PreparedStatement循环执行,性能提升有限;加上之后,大批量插入性能可以提升好几倍,这是我实际对比过的。
4. 逐行校验与错误反馈:让用户一眼看懂哪一行出了什么问题
4.1 校验逻辑放在哪:监听器是最合适的位置
很多人在写导入功能时,习惯先用EasyExcel把所有行都读到一个List里,然后在Service层再for循环校验、再插入。这样做不是不行,但有两个问题:一是所有数据先堆在List里,大数据量下内存吃紧;二是你丢了“当前行号”这个上下文,后面要重新遍历才能知道每条数据来自Excel的哪一行。
直接在监听器里做校验,是更聪明的选择。因为EasyExcel回调你的invoke时,本来就把当前行号给你了,你在这一行就能完成校验、转换、错误记录、放行或拦截。校验通过的行才进入批量list;校验失败的行不进list,不会污染数据库。这种“边读边判”的模式,也符合流式解析的设计初衷。
4.2 一个轻量的导入结果收集器
错误信息需要有一个统一结构。我习惯定义一个ImportResult:
public class ImportResult { private int successCount; private int failCount; private List<RowError> 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参数
我在前面已经提到了rewriteBatchedStatements=true,这里再展开说一下。MySQL JDBC驱动在没有开启这个参数的情况下,PreparedStatement.executeBatch()其实是循环一条一条发送SQL到服务器端的。驱动只是把多次执行攒成了“批量模式”,但网络包的构造还是逐条发送,这样做的收益主要是减少客户端到数据库的往返次数,但效果有限。
开启rewriteBatchedStatements=true后,驱动会把同一张表上的多条INSERT语句重写成一条多VALUES语句,再一次性发送给MySQL服务端。比如5000条数据就可能变成一条包含5000组VALUES的INSERT,SQL执行效率完全不是一个级别。数据量越大,这个参数的收益越明显,几十万行导入时,性能差距甚至可以达到四到五倍。
需要注意:这个参数是针对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里某个单元格写的是公式,比如=A1+B1,解析时EasyExcel默认拿到的可能是公式字符串,而不是公式计算的结果。用户明明看到列里显示的是100,读出来却变成“=A1+B1”,插入数据库时自然就报错。
解决方案是根据业务场景决定:如果希望拿到公式计算后的结果,需要确保Excel文件在保存时已经计算了公式;或者在读取时不要直接映射到数值字段,而是先用String接收,再自己判断是否是数字开头。如果业务上根本不允许出现公式,就在校验逻辑里判断字符串是否以=开头,是的话直接报错“该单元格不允许使用公式”。这里要提醒一句:如果Excel里放的不是公式而是纯数字,EasyExcel读出来没有任何问题,但如果你用getNumericCellValue之类的API去处理不同单元格类型,就要格外小心。
6.4 扩展名是xlsx,其实是个CSV
这个坑真的很隐蔽。有时候业务方会从某个系统导出数据,导出的文件叫“users.xlsx”,但它实际上是一个CSV文件,把扩展名改成了.xlsx。你用EasyExcel去读,可能直接报“无法解析文件头”,或者读出来的内容全是乱码、挤在一行。
解决办法是不要完全相信扩展名。读取文件前,先判断文件的魔数(Magic Number)。比如:
- xlsx(Office Open XML)文件头是
PK(50 4B),本质是一个zip包; - xls老格式文件头是
D0 CF 11 E0; - CSV文件通常开头是普通文本,没有固定魔数,但可以通过内容判断。
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导入功能看起来不起眼,但它直接面对业务方的日常作业,做得好坏很影响信任感。我自己的体会是:这类功能最值得投入的不是一开始的“能读出来”,而是错误提示、边界情况、大数据量这三个维度。每次处理完一批奇怪的文件,我都会把用户常见问题补充到校验逻辑里,久而久之,失败率会越来越低,线上工单也会越来越少。
如果你正在做一个全新项目,我建议先定好文件大小上限和行数上限,再决定同步还是异步;先做好行级错误收集,再优化批量插入;先跑通小数据量,再用大数据量做压测。这样从第一版开始,你的导入功能就能站在一个比较稳的基础上,后面不管面对什么格式的文件,都能少走弯路。