SpringBoot读取Excel入库:从POI内存爆炸到EasyExcel流式批量导入
如果你接手过后台管理系统大概率被运营同事塞过这么个需求手里一份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.x2.7.x也一样能用持久层用的MyBatis-PlusExcel部分引入EasyExcel。pom里核心依赖长这样dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency注意EasyExcel自带了对POI的依赖传递不需要再单独引POI否则版本冲突起来很烦。我就踩过这个坑项目里原本有个老版本POIEasyExcel一进来启动时各种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的关系要理解清楚如果只写valueEasyExcel会拿表头文字去匹配运营同事改动表头文字比如把单价改成销售价字段就全空了如果只写index表头文字随便变都不影响但列顺序必须稳定。实际项目中我一般以index为主value作为校验提示这样最稳。3.3 监听器逐行读取但绝不逐行入库EasyExcel读取的核心是AnalysisEventListener。它的invoke方法每解析一行就回调一次doAfterAllAnalysed在整个Sheet读完时回调。最忌讳的写法是在invoke里直接做单条insert——几万行就是几万次网络往返性能惨不忍睹。正确做法是在监听器里攒一批攒够500条就批量入库一次。代码如下public class ProductExcelListener extends AnalysisEventListenerProductExcelRow { private static final int BATCH_COUNT 500; private final ListProductExcelRow 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继承ServiceImplProductMapper, Product直接调this.saveBatch(products)它内部也是分批批量提交。不过我更喜欢自己写XML的foreach批量insert因为能合并成一条SQL、插入速度更快也便于控制SQL大小。Service public class ProductImportService { Autowired private ProductMapper productMapper; public void saveBatch(ListProductExcelRow rows) { ListProduct 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 XMLinsert idbatchInsert INSERT INTO product (sku_code, product_name, category_id, price, stock, create_time) VALUES foreach collectionlist itemitem 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 ResultString 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: 50MB4. 踩坑集锦日期格式、空值、表头变化和数据重复代码能跑通只是第一步。真实环境里报表数据千奇百怪我把这几年踩过、同事踩过的典型坑整理一遍这些都是文档里不会写、但生产里一定会遇到的点。4.1 Excel里的日期本质上是个数字这是新手最容易懵的地方。Excel内部把日期存成序列数比如2024年1月1日对应44927这种数字。如果你在Excel里看到单元格显示2024-01-01就以为用字符串读出来没问题那就错了——有些表格填的是真日期单元格有些则是手打的文本两种数据落到Java里类型完全不一样。处理办法实体字段直接声明成LocalDate或LocalDateTimeEasyExcel拿到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、错误行收集这三板斧足够应付绝大多数场景。至于更进阶的方向还可以做断点续传、清洗规则可配置、用数据库直插方案绕过应用层这些就留到下一篇再聊了。