☰
SpringBoot读取Excel入库:从POI内存爆炸到EasyExcel流式批量导入
2026/10/14 4:32:04 网站建设 项目流程

如果你接手过后台管理系统,大概率被运营同事塞过这么个需求:手里一份Excel,帮我把数据导进去。SpringBoot读取Excel并存入数据库,听起来是个普通得不能再普通的功能,真做起来却能把人折腾到怀疑人生。我最早也图省事,用POI把工作簿整个读进内存,再逐条INSERT,直到某次导入三万多行商品数据把JVM老年代直接撑爆,才老老实实研究起流式解析和批量入库。这篇就围绕这条链路把完整方案拆开讲:选型思路、核心代码、批量写库、各种踩坑点,以及从"能导入"到"好用的导入"的优化路径。适合第一次做导入功能的同学,也适合还在POI逐行塞数据的同行参考。

1. 先别急着写代码:导入需求背后的四个关键问题

Excel导入看起来就是把文件读出来、塞进数据库,但实际动手前我会先想清楚几个问题,因为它们直接决定架构怎么搭。

1.1 这份Excel有多大

文件大小是第一个变量。几百行的配置文件,用什么方式读都无所谓;但如果是几万行甚至几十万行的业务数据,POI那种把整个Workbook加载进内存的做法就会出问题。EasyExcel这类流式解析方案是按行读取,内存里始终只保留一小批数据,这才是处理大数据量的正确形态。我见过不少项目因为前期没考虑这一点,上线后被一张大表直接打挂。

1.2 表头和数据规范到什么程度

运营手里的Excel永远不会像你想象中那么规范。可能有合并单元格、有标题行、有空行,表头顺序偶尔还会变。这时候就需要明确:我们是按照表头名字匹配字段,还是按照列索引强制对应?这两者在代码里是两个思路,后面我会专门说清楚。

1.3 数据入库要不要保证原子性

导入几千条数据,中间某一行格式错了,是整批回滚,还是跳过错误行继续导入?这个问题必须在动手前和业务方对齐。整批回滚逻辑简单,但大文件会让事务时间拉得很长;逐行或分批提交又可能出现"导入了一半"的脏数据。没有标准答案,只有取舍。我通常的做法是:数据量在几万以内且来源可控时用整批事务,超过这个量级会做分段处理并配合补偿策略。

1.4 导入结果怎么反馈给使用者

后台导入功能面向的多是非技术用户,他们最关心两件事:导入了多少条?哪些行有问题?所以导入接口的返回值不能只甩一个"导入成功",最好能把处理总数、失败行数、每行失败原因都带出来。这一点看似不起眼,实际使用体验差距非常大,后面讲异常处理时专门展开。

这四个问题想清楚,代码怎么写其实已经八九不离十了。

2. 选型对比:POI、EasyExcel、Hutool,我最终选了谁

Java生态里读Excel的方案来来回回就那么几个,我把实际用过的三种放在一起对比,方便你按场景挑。

2.1 三种方案的核心差异

方案底层模型内存表现适合场景上手成本
Apache POI把整个Workbook对象加载进内存大文件容易OOM需要精细操作单元格样式、合并单元格、写复杂报表中高
EasyExcelSAX流式解析,一行一行回调几万到几十万行都能稳定跑大数据量导入导出、注解映射、模板化读写低
Hutool ExcelReader基于POI封装,简化API仍是整表加载快速读取小文件、不想写样板代码低

POI不是不好,它功能最全,Excel能玩出的花样它基本都支持;问题是它的读取模型决定了内存占用随文件大小线性增长。之前有次读一个带格式的6万行报表,分配了1G堆还是报OOM,排查半天才发现是POI在解析样式和字体对象时开销远比想象中大。

Hutool的Excel工具用起来确实爽,几行代码就能读完一个Sheet,但它的底层还是POI那一套,文件一大同样扛不住。做小工具、一次性脚本非常合适,放在对外服务的导入接口里我是不太放心。

EasyExcel能成为我这边默认选项,核心就两点:一是流式解析,内存占用稳定;二是在读和写两个方向都做了注解映射,字段和列的关系写在实体上,代码直观、好维护。

2.2 EasyExcel的细节要留意

EasyExcel并不是把POI所有能力都包住了,它主要面向"表格式读写",如果你要读的是嵌套复杂样式、带图片的报表,它的表达力就不够。另外不同版本的API差异不小,尤其3.x之后的监听器接口签名和一些参数名都变过,网上搜到的老代码直接粘过来经常编译不过。我建议锁定一个稳定版本,比如3.3.x系列,项目里统一用它,不要混着升级。

2.3 什么时候还是要回头用POI

我不是说有了EasyExcel就完全不碰POI了。当你需要动态生成复杂格式的导出文件、需要操作单元格样式、需要把某个Sheet复制合并,POI仍然是最顺手的。包括一些老项目已经在用POI且数据量可控,也没必要为了换而换。选型的原则从来不是"谁最火",而是"当前数据的量级和格式复杂度对应哪种模型最稳"。

3. 代码落地:实体设计、监听器读取、批量写库的完整链路

下面进入正题。我用一个最常见的"商品档案导入"来做例子,含义是:Excel里有商品编码、名称、分类、单价、库存这几列,导入后写入product表。整个链路是Controller收文件 → Service触发EasyExcel读取 → 监听器每满一批调用批量插入 → 返回导入结果。

3.1 引入依赖

我用的SpringBoot版本是3.x(2.7.x也一样能用),持久层用的MyBatis-Plus,Excel部分引入EasyExcel。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.7</version> </dependency>

注意EasyExcel自带了对POI的依赖传递,不需要再单独引POI,否则版本冲突起来很烦。我就踩过这个坑:项目里原本有个老版本POI,EasyExcel一进来,启动时各种NoSuchMethodError,查了半天才定位到是依赖冲突。

3.2 实体类:一张Excel对应一个数据类

这里要区分两个类:一个是和Excel列对应的ProductExcelRow,一个是和数据库表对应的Product实体。让它们各司其职,比直接拿实体类去加@ExcelProperty注解要灵活——Excel里字段顺序、命名和数据库表经常不完全一致,一旦耦合,改一边就得动另一边。

ProductExcelRow定义如下:

public class ProductExcelRow { @ExcelProperty(value = "商品编码", index = 0) private String skuCode; @ExcelProperty(value = "商品名称", index = 1) private String productName; @ExcelProperty(value = "分类ID", index = 2) private Long categoryId; @ExcelProperty(value = "单价", index = 3) private BigDecimal price; @ExcelProperty(value = "库存", index = 4) private Integer stock; // getter/setter 略 }

我把value和index同时写上,是因为这里表头是固定的。index和value的关系要理解清楚:如果只写value,EasyExcel会拿表头文字去匹配,运营同事改动表头文字(比如把"单价"改成"销售价")字段就全空了;如果只写index,表头文字随便变都不影响,但列顺序必须稳定。实际项目中我一般以index为主,value作为校验提示,这样最稳。

3.3 监听器:逐行读取,但绝不逐行入库

EasyExcel读取的核心是AnalysisEventListener。它的invoke方法每解析一行就回调一次,doAfterAllAnalysed在整个Sheet读完时回调。最忌讳的写法是在invoke里直接做单条insert——几万行就是几万次网络往返,性能惨不忍睹。

正确做法是在监听器里攒一批,攒够500条就批量入库一次。代码如下:

public class ProductExcelListener extends AnalysisEventListener<ProductExcelRow> { private static final int BATCH_COUNT = 500; private final List<ProductExcelRow> cache = new ArrayList<>(BATCH_COUNT); private final ProductImportService importService; private int totalCount = 0; public ProductExcelListener(ProductImportService importService) { this.importService = importService; } @Override public void invoke(ProductExcelRow row, AnalysisContext context) { cache.add(row); totalCount++; if (cache.size() >= BATCH_COUNT) { importService.saveBatch(cache); cache.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!cache.isEmpty()) { importService.saveBatch(cache); cache.clear(); } } public int getTotalCount() { return totalCount; } }

这里有个细节:为什么不在doAfterAllAnalysed里一次性全量入库?因为那样等于把整个文件的数据都堆在内存里,流式解析的意义就没有了。攒一批处理一批,内存占用始终是O(BATCH_COUNT),稳定可控。

3.4 Service层:批量入库的关键写法

Service里的saveBatch方法最核心。如果用的是MyBatis-Plus,可以让Service继承ServiceImpl<ProductMapper, Product>,直接调this.saveBatch(products),它内部也是分批批量提交。不过我更喜欢自己写XML的foreach批量insert,因为能合并成一条SQL、插入速度更快,也便于控制SQL大小。

@Service public class ProductImportService { @Autowired private ProductMapper productMapper; public void saveBatch(List<ProductExcelRow> rows) { List<Product> products = rows.stream() .map(this::convertToEntity) .collect(Collectors.toList()); productMapper.batchInsert(products); } public String doImport(MultipartFile file) { try { ProductExcelListener listener = new ProductExcelListener(this); EasyExcel.read(file.getInputStream(), ProductExcelRow.class, listener) .sheet(0) .headRowNumber(1) .doRead(); return "导入完成,共处理 " + listener.getTotalCount() + " 行"; } catch (Exception e) { throw new RuntimeException("Excel解析失败", e); } } private Product convertToEntity(ProductExcelRow row) { Product product = new Product(); product.setSkuCode(row.getSkuCode()); product.setProductName(row.getProductName()); product.setCategoryId(row.getCategoryId()); product.setPrice(row.getPrice()); product.setStock(row.getStock()); return product; } }

对应的Mapper XML:

<insert id="batchInsert"> INSERT INTO product (sku_code, product_name, category_id, price, stock, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.skuCode}, #{item.productName}, #{item.categoryId}, #{item.price}, #{item.stock}, NOW()) </foreach> </insert>

批量INSERT相比单条插入,性能差距有两个数量级都不夸张。原因很简单:每次数据库交互的固定开销(网络、SQL解析、事务日志)被几百条数据摊薄了。我实测过导入10万行,逐条插入大概要两三分钟,改成批量之后基本在几秒到十几秒之间。

3.5 Controller接口:接收文件并触发导入

最后是入口,注意文件校验和异常处理别偷懒:

@RestController @RequestMapping("/api/product") public class ProductImportController { @Autowired private ProductImportService importService; @PostMapping("/import") public Result<String> importExcel(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件为空"); } String originalFilename = file.getOriginalFilename(); if (originalFilename == null || (!originalFilename.endsWith(".xlsx") && !originalFilename.endsWith(".xls"))) { return Result.error("仅支持.xlsx或.xls文件"); } try { String summary = importService.doImport(file); return Result.ok(summary); } catch (Exception e) { return Result.error("导入失败:" + e.getMessage()); } } }

这里有个很容易忽略的点:不要拿到file.getInputStream()就以为万事大吉了,要确认文件确实能解析成Excel,不是改了后缀名的假文件;另外上传大小限制要在Spring的配置里调大一点,默认1MB很多时候不够用。application.yml里可以这样:

spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB

4. 踩坑集锦:日期格式、空值、表头变化和数据重复

代码能跑通只是第一步。真实环境里,报表数据千奇百怪,我把这几年踩过、同事踩过的典型坑整理一遍,这些都是文档里不会写、但生产里一定会遇到的点。

4.1 Excel里的日期本质上是个数字

这是新手最容易懵的地方。Excel内部把日期存成序列数,比如2024年1月1日对应44927这种数字。如果你在Excel里看到单元格显示"2024-01-01",就以为用字符串读出来没问题,那就错了——有些表格填的是真日期单元格,有些则是手打的文本,两种数据落到Java里类型完全不一样。

处理办法:实体字段直接声明成LocalDate或LocalDateTime,EasyExcel拿到Excel的日期单元格会自动做转换。但要注意,如果单元格本身是"文本型"的日期,字段声明成LocalDate反而会报转换异常。最稳妥的做法是字段先声明成String,在监听器里用统一的方法去解析,自己控制格式容错:

private LocalDate parseDate(String raw) { if (raw == null || raw.trim().isEmpty()) { return null; } // 兼容多种常见的日期格式,这里只列举一种 return LocalDate.parse(raw.trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd")); }

我的经验是:导入场景里对日期和数字的清洗逻辑越早做越好,千万不要信任Excel单元格的"显示格式"。

4.2 表头顺序和字段对不上

很多导入功能开发时拿到的是一份"理想模板",上线后业务方拿来的是另一份"实际模板"。列顺序换了、中间插了一列备注,index模式还好,value模式直接大面积丢字段。我的建议是:

  • 如果是内部固定模板,用index + value双保险,导入前先校验表头文字,不一致就立即报错,别让数据带着null往下走。
  • 如果表头确实不固定,那就得在代码里做列名到字段的映射,读取一行后手动取值再按表头匹配。

核心原则就一句:宁可导入前报"表头不对",也不要导入后让用户面对一屏null。

4.3 空行和"看起来是空"的单元格

Excel文件里经常有整行空行、或单元格里有空格字符的情况。EasyExcel默认会忽略完全空白的行,但"有一个空格的空行"不会被认为是空的,读出来就是一个全null但被invoke到的对象。所以监听器里要加一层过滤:

@Override public void invoke(ProductExcelRow row, AnalysisContext context) { if (isBlankRow(row)) { return; } cache.add(row); totalCount++; // 攒批逻辑 } private boolean isBlankRow(ProductExcelRow row) { return (row.getSkuCode() == null || row.getSkuCode().trim().isEmpty()) && (row.getProductName() == null || row.getProductName().trim().isEmpty()) && row.getPrice() == null; }

过滤空行的逻辑必须放在invoke最开始,否则一堆全null的数据混进批次里,入库时非空约束一声惨叫,你还得去文件里找是第几行。

4.4 重复数据与唯一约束:靠数据库兜底

导入场景里重复数据几乎必然出现。运营把同一份文件导了两遍,或者文件里本身编码就重复。处理这种问题,我的做法是两层配合:

第一层,代码里做前置去重。在监听器攒批的过程中,用一个Set记录业务唯一键(比如skuCode),重复的塞进失败列表,最后随结果一起返回。

第二层,数据库层面建立唯一索引兜底。这是最可靠的防线,因为并发场景下代码判断可能产生竞态。一旦插入触发DuplicateKeyException,捕获后把当前这一批单独拆出来定位,或者直接按"已存在跳过"处理。

两层结合的好处是:正常重复能在第一层就给出友好提示,极端并发情况由第二层保证数据不脏。

4.5 给每一行错误带上行号

任何导入功能,错误提示不带行号都是耍流氓。用户面对上千行数据,你说"第某条数据价格格式错误",他没法定位;你说"第128行单价格式错误",他一下就能改。

实现起来不复杂。在监听器里通过context.readRowHolder().getRowIndex()获取实际行号,校验失败时把行号和原因拼起来塞进错误列表。全部读取完成后,把这些错误汇总返回。这样导入结果就变成了:成功N条、失败M条、失败明细(行号+原因)一清二楚。这个小改动对使用体验的提升非常明显,我强烈建议每个导入功能都做。

5. 从能用到好用:性能调优、异步化与进度反馈

如果只是导入能用,技术上已经及格了。但要真正上线到生产环境,还有几个点需要打磨。

5.1 批量大小为什么是500到1000

批量插入不是越大越好。一次INSERT太多行,拼接出来的SQL文本会超过数据库max_allowed_packet的限制,直接报PacketTooBigException。我习惯把批大小控制在500到1000之间,既能把网络往返次数压下来,又不会触碰SQL体积上限。以一行数据平均200字节算,500行也就100KB左右,非常安全。

这个参数我一般做成常量放在监听器里,方便按文件字段多少动态调;如果单行特别长,就适当调小批大小。

5.2 事务边界:整批一致 vs 分批提交

这个前面提过,这里展开说。如果调用doImport的Service方法上加了@Transactional,那么整个读取加入库过程是一个大事务,任何一个批次失败,前面所有批次全部回滚,保证数据原子性。好处是逻辑简单干净,代价是几十万行数据会让事务持续很久,数据库连接长时间被占用,binlog和undo膨胀也都比较快。

如果去掉大事务、每个批次各自提交,性能更好,但前面批次成功、后面批次失败时,会留下半截数据。生产环境怎么选?我的习惯是:

  • 10万行以内、来源相对可控 → 整个导入一个事务,出错全回滚,重来一次成本也不高。
  • 更大文件或来源不可控 → 分批提交 + 幂等设计 + 失败行单独记录,方便修复后继续导入而不是全量重来。

事务这块没有银弹,但一定要在需求阶段就定清楚,否则上线后数据对不上,排查成本极高。

5.3 导入异步化:别让HTTP请求干等

同步导入在文件大时会让前端请求长时间挂起,体验很差。更合理的结构是:上传接口把文件保存下来,返回一个任务ID,导入过程放到线程池里异步执行,前端轮询任务状态。

异步化之后要注意线程池隔离。导入是IO密集型任务,不要把系统的业务线程池和它混用,单独配一个ThreadPoolExecutor,核心线程数按文件大小和数据量预估。还要考虑同一个用户重复提交:文件上传后先检查任务状态,已有进行中的导入任务就直接拒绝,避免两批数据同时写同一张表。

进度反馈这块,可以在监听器里用AtomicInteger统计已处理行数,再除以文件总行数得到进度百分比,存入缓存供前端轮询。文件总行数在读取前可以先遍历Sheet统计一次,或者边读边更新,成本都不高。

5.4 前置校验与模板下载:把问题挡在入口

比导入报错更优的方案,是让用户根本拿不到错误的文件。我的项目里通常会配套提供两样东西:

一是标准模板下载接口。运营同学先下载模板,再按模板填数,能从源头上减少表头错乱。模板文件用EasyExcel的write动态生成,表头里带上必填项标记。

二是上传后的前置校验。包括文件后缀、文件是否为空、Sheet是否存在、列数和模板是否一致。这些校验放在Controller或Service入口,比一路解析到一半才发现问题强太多。有一次生产事故就是用户上传了一个从网页复制来的损坏文件,文件后缀没错但内容全是乱码,前期如果不做文件类型校验,后面解析异常会非常难排查。

6. 收尾前再分享几条实操习惯和扩展方向

把这些都做完,SpringBoot读取Excel并写入数据库的导入功能才算是真正闭环。我自己这几年做导入导出的体会是:Excel导入这种功能,难点从来不在"读文件"本身,而在数据清洗、异常定位、性能边界和用户体验这些容易被忽视的地方。尤其是行号反馈和模板规范这两件事,投入产出比极高,谁做谁知道。

再补充几个个人习惯。第一,导入代码里一定要打日志,监听器里每处理一批就记一条,包含文件来源、批号、行数、耗时,线上出问题能快速定位到是哪一批。第二,尽量让导入相关的实体类和监听器保持无状态设计,方便写单元测试;我会在测试里放一个只有几十行的小xlsx先跑通链路,再用生产级别的文件压一遍,确认内存曲线平稳。第三,如果业务方将来会把这套逻辑扩展到CSV或其他数据源,建议在Service层把"解析"和"落库"拆成两个独立接口,后面接其他格式只需要换解析器,落库逻辑完全不用动。

如果你接下来要接类似需求,我的建议是先从文件量级和格式规范这两个问题问起,再决定选型和事务策略;代码照着上面的链路搭,监听器攒批、批量INSERT、错误行收集这三板斧足够应付绝大多数场景。至于更进阶的方向,还可以做断点续传、清洗规则可配置、用数据库直插方案绕过应用层,这些就留到下一篇再聊了。

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

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

立即咨询