☰
Spring Boot读取Excel批量入库:方案选型、避坑实战与性能优化
2026/10/14 4:32:37 网站建设 项目流程

“Spring Boot 读取 Excel 并存入数据库”,这句话放在任何技术社群里,都能引来一大片后端开发者点头。做管理后台的人对这个需求太熟了:运营丢过来一张 Excel,里面有用户名单、优惠券发放名单、商品批量更新清单,你要做的就是把这个文件解析出来、校验几轮,然后塞进数据库里,再给前端一个“导入成功”或者“某些行有问题”的反馈。这事看起来门槛不高,真做起来,从选型、字段映射到批量写入,藏着不少一碰就翻车的细节。这篇文章把我这些年做 Excel 导入项目沉淀下来的方案和踩过的坑都捋一遍,适合刚接触 Spring Boot 导入需求的开发者,也适合已经写过一版、但总被“内存溢出”“手机号变成科学计数法”困扰的同学参考。

1. 方案选型:Excel 导入这件“小事”其实不简单

先把格局打开:你要的不是“把 Excel 读出来”,而是“稳定地把 Excel 里的业务数据落到数据库”。所以第一步不是写代码,而是选解析方案。方案没选对,后面要么性能撑不住,要么遇到复杂的样式需求时开发效率低到怀疑人生。

1.1 两个主流的解析路线

Java 界读 Excel,主流就两条路。一条是直接用 POI,这是比较传统的方案,它把 Excel 文件当成一个包含多个 Sheet 的对象树,Workbook、Sheet、Row、Cell 一层层遍历;另一条是基于 POI 封装的 EasyExcel,它走的是 SAX 模式,逐行解析单元格,边读边用,不把整个文件一次性塞进内存。

我刚接触导入需求时也纠结过到底用哪个,最后用一张表把两者理清楚了:

对比维度POI(传统 UserModel 模式)EasyExcel
读取模型一次把整个工作簿加载进内存逐行解析,流式读取
10 万行数据容易内存溢出内存占用很小
开发体验要处理 Workbook、Sheet、Row、Cell 一堆 API注解 + 监听器,声明式映射
上手成本中高低
复杂格式支持强,能操作样式、合并单元格弱,主要面向数据读写
维护活跃度稳定更新更新也很快

这个表格不是说 POI 不行,而是说在“数据导入数据库”这个场景下,EasyExcel 的模型更匹配你的目标。如果你做的不是数据导入,而是 Excel 报表工具,可能又要换一个结论。

1.2 为什么多数场景我会首选 EasyExcel

原因有三。

第一,内存模型完全不一样。POI 传统模式读一个 10 万行的文件,相当于把整个 Excel 在内存里重建了一遍,堆内存在 512M 参数下大概率会 Full GC 甚至 OOM。EasyExcel 是流式读取,读一行就回调一次你的业务代码,内存里永远只留当前这一批数据。之前给某运营系统做用户名单导入,一次 10 万行的文件,换用 EasyExcel 之后内存曲线几乎是一条直线。

第二,开发效率高。你不要去记 CellType、DateUtil、CellStyle 这些原生 API 的七七八八,直接在实体类上加注解,Excel 表头和数据字段的对应关系就声明好了,代码量能少一半不止。

第三,写导出模板也很方便。很多导入功能都要求配套提供一个模板下载入口,EasyExcel 几行代码就能生成带表头的空模板,不需要额外封装工具类。

1.3 什么时候依然要回头用 POI

EasyExcel 也不是万能的。如果你要动态合并单元格、要给导入模板加下拉校验、要读取带复杂公式的 Excel,EasyExcel 的表达力就不够了,这种时候老老实实回去用 POI。

还有一种情况:你们公司已经基于 POI 封装了一套自己的导入组件,那也没有必要为了换而换。选型没有绝对的银弹,核心是匹配场景。我的判断标准很简单:普通数据导入、表格结构固定、数据量大 → EasyExcel;需要在导入过程里做复杂样式操作、读取公式链、或者要和既有 POI 体系对接 → POI。

2. 工程搭建:依赖、实体映射和读取框架

选完型,接下来就是搭工程。这里的细节决定了你后面代码的稳定性和可扩展性,尤其是实体类字段映射和监听器的写法,不少人在这两步踩了坑。

2.1 依赖引入与版本选择

用 EasyExcel 的话,一个依赖就够了:

<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency>

版本建议直接用 3.3.x 及以上,Java 8 起的项目都能用,Spring Boot 2.x 和 3.x 都兼容。如果你项目里已经引了低版本,建议升上来,因为低版本的 API 在 Converter 和日期处理上有不少差异,网上的教程和实际版本经常对不上。

提示:EasyExcel 内部依赖了 POI,所以不用再额外引 POI,强行引两个版本反而容易冲突。

2.2 实体类字段映射的三个关键点

实体类就是 Excel 每一行数据的载体。我一般直接建一个 DTO,不直接复用数据库实体类,因为导入的数据和表结构往往不完全一致,DTO 上要加的校验和临时字段也比较灵活。

public class UserImportDTO { @ExcelProperty(value = "姓名", index = 0) private String name; @ExcelProperty(value = "年龄", index = 1) private Integer age; @ExcelProperty(value = "手机号", index = 2) private String phone; @ExcelProperty(value = "入职日期", index = 3) private LocalDateTime hireDate; @ExcelIgnore private String errorMsg; }

写实体类时记住三个关键点。

第一,@ExcelProperty可以按表头名匹配,也可以按 index 指定列号。表头名匹配的可读性好,但一旦运营改了表头文字就废了;index 匹配更稳定,哪怕表头名字变了,只要列顺序不变就没事。我这边发下去的模板,表头固定,所以一直用 index。

第二,数字、日期字段的类型要谨慎。手机号虽然看起来是数字,但绝对不能用 Integer 或 Long 接收,因为“13812345678”这个长度用 Long 不会出错,可一旦 Excel 里存成科学计数法,到代码里就会变成“1.3812345678E10”,根本没法用。正确做法是声明成 String,靠代码或模板规范去兜底。

第三,不需要导入的字段用@ExcelIgnore注解忽略。比如你导入用户时要回传“错误原因”给前端,这个字段就不要参与 Excel 映射。

2.3 监听器的正确打开方式:千万别写成 Spring 单例

EasyExcel 的读取模型是“逐行读取 + 监听器回调”,你需要写一个监听器类来接收每一行数据。核心代码如下:

public class UserDataListener extends AnalysisEventListener<UserImportDTO> { private static final int BATCH_SIZE = 500; private final UserService userService; private final List<UserImportDTO> list = new ArrayList<>(); private final List<String> errorList = new ArrayList<>(); private int rowNum = 1; public UserDataListener(UserService userService) { this.userService = userService; } @Override public void invoke(UserImportDTO data, AnalysisContext context) { rowNum++; if (StringUtils.isBlank(data.getPhone()) || !data.getPhone().matches("^1\\d{10}$")) { errorList.add("第" + rowNum + "行手机号不合法"); } list.add(data); if (list.size() >= BATCH_SIZE) { userService.saveBatch(list); list.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!list.isEmpty()) { userService.saveBatch(list); list.clear(); } } public List<String> getErrorList() { return errorList; } }

这里藏着一个很多新手都会踩的坑:监听器不能直接注册成 Spring 的@Component单例。因为每次读取 Excel 时,EasyExcel 都需要一个全新的监听器实例来保存这一文件的中间数据;如果做成单例,多个用户同时上传文件,A 用户的中间数据会被 B 用户的数据串掉,出现“读到一半数据乱了”这种诡异问题。

正确做法是在调用读取接口时手动 new 一个监听器,需要用到哪些 Spring 管理的 Service,直接在构造方法传进去。这也是这个监听器类本身不带任何 Spring 注解的原因。

BATCH_SIZE 为什么是 500?这是根据经验折中的结果。一次批量插入 500 条,单条 SQL 大小约 40KB,在 MySQL 默认包大小限制下毫无压力,事务耗时也不长。如果一行字段特别多,可以降到 200;如果数据量很大但字段较少,提到 800 问题也不大。

监听器的整个流程是:EasyExcel 内部用 SAX 模式一行一行读 Excel → 每读一行调一次invoke→ 攒够 500 条就调一次批量插入 → 文件解析完,把不足 500 条的零头再插一次。这就是很多教程说的“边读边写”。

3. 完整链路:文件上传到批量入库的实现

监听器搭好了,接下来就是完整链路:前端传来 MultipartFile,后端读取 InputStream,解析出 DTO,再批量写库。这一节的细节决定了整个导入功能的性能和稳定性。

3.1 Controller 接口:从 MultipartFile 到 EasyExcel.read

Controller 层只需要做一件事:接收文件,触发解析,返回结果。

@PostMapping("/import") public Map<String, Object> importExcel(@RequestParam("file") MultipartFile file) throws IOException { UserDataListener listener = new UserDataListener(userService); EasyExcel.read(file.getInputStream(), UserImportDTO.class, listener) .sheet() .headRowNumber(1) .doRead(); if (!listener.getErrorList().isEmpty()) { return Map.of("code", 500, "msg", "部分数据校验失败", "errors", listener.getErrorList()); } return Map.of("code", 200, "msg", "导入成功"); }

有几个细节值得单独说。

第一,EasyExcel.read的第一个参数是 InputStream,这里直接把MultipartFile的流传进去就行,不需要先把文件落到磁盘。但如果文件特别大,或者你需要在导入失败后保留原始文件做排查,也可以先存到服务器的临时目录,再把文件路径传给 EasyExcel。

第二,.sheet()默认读取第一个 Sheet。如果 Excel 里有多个 Sheet,业务上要逐 Sheet 导入,可以写循环读取;如果只导入指定名字的 Sheet,可以用.sheet("用户名单")指定。

第三,.headRowNumber(1)表示从第 2 行开始解析数据,也就是跳过第 1 行的表头。如果模板上面还有一行大标题,表头实际在第 2 行,就要填.headRowNumber(2)。

第四,doRead()是同步方法,会阻塞到整个文件解析完。这个方法耗时跟文件大小和数据校验逻辑有关,如果文件很大,对接口响应时间有要求,建议做异步化处理,这我在后面第 4 章再展开。

3.2 Service 层:批量插入的细节与参数选择

Service 层也别老老实实循环insert,单条插入 1 万行数据可能要几十秒,批量插入可以压缩到秒级。我这里有两种常用方案。

如果项目用了 MyBatis-Plus,直接继承IService后用saveBatch即可:

@Service public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService { @Override public void saveBatch(List<UserImportDTO> list) { List<UserEntity> entities = list.stream().map(dto -> { UserEntity entity = new UserEntity(); BeanUtils.copyProperties(dto, entity); return entity; }).collect(Collectors.toList()); super.saveBatch(entities, 500); } }

如果项目是手写 MyBatis XML,批量插入 SQL 可以这样写,注意foreach的写法:

<insert id="insertBatch"> INSERT INTO t_user (name, age, phone, hire_date) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.phone}, #{item.hireDate}) </foreach> </insert>

批量插入的批次大小和数据库配置有直接关系。MySQL 的max_allowed_packet默认值决定了单条 SQL 不能超过一定大小,如果一次插入太多行、每行字段又多,很容易触发“Packet too large”的异常。500 条是比较稳妥的值,如果你插入的行有几十个字段,建议减到 200。

注意:批量插入虽然快,但也别直接在 XML 里 foreach 上万条数据,MySQL 的 SQL 解析和网络传输都扛不住。分批插入是更稳的思路。

3.3 事务边界:先想清楚是“整体成功”还是“边读边写”

这是我做导入功能时纠结最久的地方,也是方案设计里最容易被忽略的一环。Excel 导入中间如果第 3000 行数据有问题,前面 2500 行已经插进库了,这算成功还是失败?

一般有三种策略。

第一种,整体一个大事务。在 Controller 方法上加@Transactional,解析和写入全部包在同一个事务里,任何一行失败,全部回滚。优点是数据强一致,缺点是事务时间太长,数据库连接被绑住很久,大批量数据时行锁和 undo 日志膨胀会拖垮数据库。

第二种,每批独立小事务。也就是监听器里每攒够 500 条就调一次saveBatch,每次提交独立事务。优点是性能好、资源释放快,缺点是中途出错时前面批次已经入库了,需要业务上做补偿,比如记录导入日志、提示用户“已完成 3500 行,第 3501 行失败”。

第三种,先校验后导入两阶段。先把整个文件解析并校验完,所有数据都检查没问题,再统一批量写入。这种做法最安全,但内存占用高,比较适合 5 万行以内的中小文件;数据量一大,还是回到第二种。

我的推荐是:默认选第二种,配合完善的错误报告机制。如果业务上要求严格一致、数据量又不大,选第三种,而不是选第一种。因为大文件下整体事务的风险远大于那点回滚便利。

有一个概念要澄清:在 Controller 上加@Transactional虽然技术上能管住监听器里的批量插入,但前提是解析期间不抛异常、不中断,而且整个事务持续占用数据库连接。我之前在一个 20 万行的导入需求里试过,解析加写入跑了将近一分钟,数据库连接池都被拖出告警。千万别觉得“加个事务就万事大吉”。

4. 实战避坑实录:这些坑我基本都踩过

读完前面代码,导入功能已经能跑通了。但真实世界的 Excel 非常不讲武德:单元格格式五花八门,有的合并单元格,有的留了一堆空行,有的日期看起来是人样、读出来是数字。这一章我把高频翻车现场都列出来,能躲一个是一个。

4.1 手机号科学计数法是最高频的翻车现场

这个坑几乎是每个做导入的人都会撞一次。现象是:Excel 里你明明看到的是“13812345678”,读出来变成“1.3812345678E10”,写到数据库里是一串完全没法用的数字。

产生原因是 Excel 单元格格式设置成了“数字”,超过 11 位的数字在显示时自动变成科学计数法,数据底层也存成了 double。如果你用 POI 的老写法拿getNumericCellValue()再toString(),一定会踩中。

用 EasyExcel 的话,规避办法很粗暴:实体类字段声明成String,不要用 Long、Double 或 BigDecimal 接收。EasyExcel 对 String 类型读取数字单元格时会自动转成普通字符串形式,不会保留科学计数法。如果是从 Excel 模板源头解决,就让运营把手机号列的单元格格式设置成“文本”,双保险。

4.2 日期格式:Excel 里看到的是 yyyy-MM-dd,Java 读出来却是数字

第二个高频坑是日期。Excel 里的日期本质上是一个数字序列号,比如 2024 年 1 月 1 日,底层可能是 45000 之类的数。EasyExcel 对 Date 和 LocalDateTime 字段会自动转换,大多数情况下不会出问题。

但真实业务里两种场景容易翻车:一种是模板里的日期列是文本格式,但运营在不同单元格里填了“2024/01/01”“2024-01-01”“2024年1月1日”等各种格式;另一种是日期带了时分秒,入库后发现全部带上了 00:00:00。

我的习惯是:实体类日期字段用LocalDateTime接收,模板里明确要求填写yyyy-MM-dd格式,代码里再统一做一次格式校验和转换。如果模板不统一,没法靠代码解决,只能先把模板规范好再放开导入权限。

4.3 空行、表头偏移和合并单元格的处理

这三个问题属于“数据格式不规范”的典型代表。

空行问题。用户的 Excel 里在数据区中间留了一堆空白行,监听器invoke可能会收到 null 或者全空对象。你要么在invoke里判空跳过,要么在读取时设置自动忽略空行。代码里判空是底线操作,不能省。

表头偏移问题。有些模板最上面一行是大标题,下面才是列名,前端拿到的文件也多套了一层标题。如果headRowNumber配错,第一行数据会被当成表头跳过,或者表头会被当成数据处理。用 index 映射后,这类问题会好排查很多。

合并单元格问题。EasyExcel 对合并单元格的默认处理是,除了左上角的第一个单元格,其他被合并的单元格读出来可能是 null 或空值。如果业务上依赖某一列合并单元格来去重,最好在模板规范里禁止合并,或者用代码去校验“某个关键字段为空就报错提示”,而不是默默跳过。

4.4 常见问题排查速查表

把上面这些经验整理成一个速查表,遇到问题直接对号入座:

问题现象可能原因处理建议
手机号变成科学计数法单元格是数字格式,代码用数字类型接收字段声明为 String,模板列设为文本格式
日期读出来是数字Excel 日期以序列号存储,类型声明不对用 Date / LocalDateTime 接收,规范模板
内存溢出用了 POI 全量加载或把全量数据收集到 List换 EasyExcel 流式读取,监听器分批写
第一行表头被当成数据写入headRowNumber配置不对检查跳过行数,标题 + 表头时配 2
部分字段全部显示 null合并单元格被跳过模板禁用合并,代码里做空值校验
批量插入报 Packet too large一次插入行数太多、SQL 超过包大小调小批次到 200-300
多个用户同时导入数据串监听器被做成了 Spring 单例每次读取 new 一个监听器实例

提示:排查导入问题时,最有效的做法不是反复看代码,而是先拿到原始 Excel 文件,手工检查它的单元格格式。很多所谓“代码 bug”,本质是“文件数据不规范”造成的。做导入功能的人,一定要有“先怀疑数据,再怀疑代码”的直觉。

最后再分享一个实际扩展开的方向:如果导入的文件很大,比如几十万行,同步接口会卡住前端请求。我后来改成异步任务方案——上传文件后立即返回一个任务 ID,后端线程池异步执行导入,前端轮询任务状态。监听器里每处理完一批数据就更新一下进度,用户能在页面上看到“已导入 35000 / 120000 行”。这个体验比同步等一分钟强非常多,但复杂度也高一个台阶,适合在核心高频场景里做。

回头再看“读取 Excel 并存入数据库”,其实算不上高深技术,真正费神的全是数据格式、边界和性能这些暗坑。我接到这类需求时,习惯先问三个问题:数据量多大?业务允许部分成功还是必须全量一致?模板是你们发出去的还是用户自己随便造的?这三个问题定了,方案自然就出来了。希望你下次接到导入需求时,不用再踩我这些坑。

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

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

立即咨询