ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

百万级数据导入导出最佳实践:EasyExcel如何替代POI破局内存溢出

百万级数据导入导出最佳实践:EasyExcel如何替代POI破局内存溢出 干这行做后端时间久了你会发现导入导出这四个字平时看着就是个工具类的小事可真到了百万级数据量的场景它直接能把你从今天功能上线打回今晚通宵定位问题。我在生产环境里处理过几百万行数据的 Excel 文件也见过同事用 POI 一把梭读文件最后把 4G 堆内存直接塞满、服务原地去世的惨状。后来整套方案重构核心引擎换成了阿里开源的 EasyExcel导入导出这潭水才算真正趟平了。这篇文章我挑重点讲为什么百万级是道坎、POI 到底输在哪、EasyExcel 该怎么做依赖引入和版本避坑、基于监听器的分批导入怎么设计、流式导出怎么配合 MyBatis/MyBatis-Plus 稳定落盘还有真实环境里最容易翻车的几个隐藏细节。代码都是可以直接贴进项目里改改就能跑的后面还附了一张我本地压测的性能对比数据表给你做个参考基线。1. 百万级数据背后的技术挑战与方案选型1.1 为什么说百万级是分水岭先搞清楚一个事实Excel 单表最多支持到 1048576 行。所以百万级其实已经贴着了 Excel 的天花板实际项目里几十万行到上百万行是最容易出问题的区间。这个量级下文件大小通常是几百兆单元格数动辄几百万到上千万。任何把数据一次性塞进内存再处理的思路在这个区间都会遇到严重的资源瓶颈。我见过很多系统的导入逻辑是这样写的前端传文件后端用WorkbookFactory.create(inputStream)把整个工作簿读进来然后遍历 Sheet、遍历 Row、遍历 Cell再把数据逐行转成实体类最后分批 insert。这套代码在小文件时跑得非常顺畅可一旦文件超过 30 万行问题就来了先是 GC 频繁然后内存曲线一路上扬最后直接 OOM连异常信息都来不及记录完整。1.2 POI 为什么会内存溢出POI 的 UserModel 体系XSSFWorkbook在设计上是一棵完整的对象树Workbook 持有 Sheet 的引用Sheet 持有 Row 列表Row 持有 Cell 列表每个 Cell 又是一个完整对象里面还保存着样式引用、类型信息、数值、字符串等。哪怕你只是读一个单元格POI 也会先把整个工作簿里的所有单元格全部对象化。换句话说读一个 100 万行、25 列的文件意味着 JVM 里同时存在两千多万个对象每个对象还要算上内部数据结构开销内存 2G 以内几乎必爆。你去看 XSSFWorkbook 的源码它的构造函数里有一条解析整棵 DOM 的逻辑这在低数据量时不算什么在高数据量时就是灾难。POI 后来也出了 SXSSFWorkbook专门用于导出场景靠滑动窗口控制内存中保留的行数。但导出方向用它可以导入方向它依然无能为力因为解析 Excel 文件时SXSSF 并不会帮你降低读取端的内存占用。所以对于既想导入稳定又想导出不爆内存的完整需求POI 并不是一个合格的统一方案。1.3 EasyExcel 为什么能解决这个问题EasyExcel 的核心思路是重写读取路径走 SAX 事件驱动模型。解析的时候不是把整张表读进内存而是基于 XML 的流的逐标签解析每解析到一个单元格就触发一次回调处理完就丢弃所以内存里只保留当前这一行的数据。导出端也一样EasyExcel 的写模型是逐行写出不会先在内存里构建完整 sheet再一次性 flush。数据是一行一行往输出流里写的配合不收集历史行内存占用基本能控制在几十到几百兆的量级。这个思路本身不复杂但实现细节很重要EasyExcel 团队把 SAX 解析、空行处理、格式转换、并发读 Sheet 这些边界都打磨得很扎实业务侧只要背靠它提供的 API 做二次封装就行。2. 项目搭建与 EasyExcel 核心配置2.1 依赖引入与版本选择先用 Maven 把依赖拉进来。dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency3.3.x 这个系列在社区里用得最多稳定性和文档都比较完整。3.4.x 后续版本也可以但有些 4.0 预览版改动比较大生产环境不建议赶新。EasyExcel 内部已经带了对 POI 的封装3.3.4 默认依赖的是 POI 5.2.2 左右的版本。如果你的 SpringBoot 项目里之前已经显式引入了 poi 或 poi-ooxml要注意版本冲突通常做法是统一由 easyexcel 管理 POI 依赖自己项目里不要再单独声明 poi。另外如果你的项目用的是 SpringBoot 2.x直接引入没问题。如果用的是 SpringBoot 3.x因为 Jakarta 命名空间的变化EasyExcel 3.x 确认过兼容性但个别版本还是建议做一轮全链路测试。我自己的项目是在 JDK 8 SpringBoot 2.7 上跑的目前没有踩到坑。2.2 实体映射与注解处理EasyExcel 最方便的一点是可以用注解直接把 Excel 列和 Java 字段绑定。下面是一个典型的导入导出共用实体模型import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.HeadRowHeight; Data ColumnWidth(20) HeadRowHeight(24) public class UserExcelModel { ExcelProperty(value 用户ID, index 0) private String userId; ExcelProperty(value 用户姓名, index 1) private String userName; ExcelProperty(value 手机号, index 2) private String mobile; ExcelProperty(value 邮箱, index 3) private String email; ExcelProperty(value 创建时间, index 4) private Date createTime; }index用来声明列顺序value用来声明表头文案。读取时 EasyExcel 会根据 index 帮我们把单元格映射到字段而不需要手动 getCell(0)。导出时它会自动按实体字段生成表头。这里有一个我在生产环境踩过的坑用户ID、手机号这类看起来像数字的列实体类型一定要用 String不要用 Long 或 Integer。Excel 底层把数字存成 IEEE 754 浮点格式超过一定位数之后精度会丢失21 亿以上的数值都可能对不上更别说 18 位身份证号、19 位订单号。用 String 配合NumberFormat可以规避科学计数法问题ExcelProperty(value 订单号, index 3) NumberFormat(0) private String orderNo;2.3 动态表头与复杂表头处理固定表头用注解就够了但实际业务里经常遇到表头不固定的情况比如用户自定义导出列、多级表头报表。EasyExcel 提供了动态表头方案写入时用ListListString描述表头结构ListListString head new ArrayList(); for (String title : columnNames) { head.add(Collections.singletonList(title)); } WriteSheet writeSheet EasyExcel.writerSheet(数据报表).head(head).build();多级表头则是在 List 里多放几层字符串。比如用户基础信息作为一级表头姓名和手机号作为它的子表头就可以写成ListString headGroup1 Arrays.asList(用户基础信息, 姓名); ListString headGroup2 Arrays.asList(用户基础信息, 手机号); ListString headGroup3 Arrays.asList(用户基础信息, 邮箱);EasyExcel 会按照每个子列表的元素层级自动合并表头单元格。导入端的复杂表头处理思路类似读取时通过invokeHeadMap(MapInteger, String headMap)拿到的是扁平化后的表头映射如果有多级表头可以再根据实际场景做二次解析。这里我的建议是导入场景应尽量固定模板格式多级表头尽量在导出场景使用否则大量自定义解析逻辑会显著增加维护成本。3. 百万级数据导入实践从文件到数据库3.1 监听器模式是导入的核心EasyExcel 导入最核心的概念是AnalysisEventListener。它的原理是解析器每读完一行就回调一次invoke方法整个文件解析完成后再回调一次doAfterAllAnalysed。你不需要自己控制一行一行怎么读只需要在这个回调里定义拿到这一行之后干什么。下面是通用化的监听器写法适合绝大多数业务public class UserImportListener extends AnalysisEventListenerUserExcelModel { private static final int BATCH_SIZE 3000; private final UserService userService; private final ListUserExcelModel cache new ArrayList(); private final ListString errorMsgs new ArrayList(); public UserImportListener(UserService userService) { this.userService userService; } Override public void invoke(UserExcelModel data, AnalysisContext context) { String checkMsg validate(data); if (checkMsg ! null) { errorMsgs.add(第 context.readRowHolder().getRowIndex() 行: checkMsg); return; } cache.add(data); if (cache.size() BATCH_SIZE) { flush(); } } private void flush() { if (cache.isEmpty()) { return; } userService.batchInsert(cache); cache.clear(); } Override public void doAfterAllAnalysed(AnalysisContext context) { flush(); } private String validate(UserExcelModel row) { if (StringUtils.isBlank(row.getMobile())) { return 手机号不能为空; } return null; } }调用方式PostMapping(/import) public void importExcel(MultipartFile file) throws IOException { UserService userService SpringUtils.getBean(UserService.class); EasyExcel.read(file.getInputStream(), UserExcelModel.class, new UserImportListener(userService)).sheet().doRead(); }sheet()默认读第一个 Sheet。如果需要读取多个 Sheet可以用EasyExcel.read().sheet(0).doRead()再加一个.sheet(1).doRead()或者构建ExcelReader手动指定多个 ReadSheet。3.2 分批入库与事务边界控制上面代码里最关键的一步是flush()每攒满 3000 条就调用一次userService.batchInsert(cache)然后立刻clear()。这个设计的目的是防止cache列表无限增长。如果不批量清空100 万行数据会一直累积在内存最终结果和 POI 全量加载没区别。事务边界需要注意。监听器是通过new创建出来的它不在 Spring 容器管理范围内所以你在监听器里使用Autowired注入 Service 是没有意义的要么在构造器里把 Service 传进去要么用SpringUtils.getBean的方式手动获取容器中的 Bean。至于事务我的建议是把batchInsert方法本身加上Transactional让它保证单批数据的原子性。这样如果某批插入过程中出现了主键冲突或字段超长回滚的只是这一批的 3000 条不会导致整个文件 100 万条数据全部回滚。虽然可能有一部分数据会重复读、重复插入的风险但配合业务表设计里的唯一索引这种风险是可控的。如果你要用整个文件一个事务的玩法比如导入文件中存在一条错误数据就全部回滚我建议先做完整文件校验全部通过后再做再次读取和入库而不是在监听器里直接操作事务。我曾经在实践中遇到过一个 80 万行的文件整个事务跑了一个多小时回滚时数据库连接直接超时最后靠运维手工 kill 才恢复过来。生产环境别这么干。3.3 导入校验与错误行收集EasyExcel 本身不负责业务校验它只会告诉你这一行转成 UserExcelModel 成功了或者类型转换失败。业务校验像手机号格式、邮箱格式、枚举值范围必须自己在invoke里写。校验错误的处理策略要提前想清楚。我常用的做法是错误行不加入cache只把错误信息收集到errorMsgs里文件读取结束后把错误清单返回给前端正确数据照常入库。这个方案的优点是体验好用户能一次性看到所有错误行缺点是用户可能只修正错误行但正确数据已经入库了会产生半成功状态。如果要求更严格比如全对才入库有一条错就全不导那就需要用两步方案第一步先只校验并收集错误把所有正确数据先写入一张临时表或者内存缓存第二步在doAfterAllAnalysed里判断errorMsgs是否为空非空则直接返回为空才把缓存数据一次性写入正式表。注意100 万条数据全放内存缓存依然会爆内存所以这种严格模式更适合先校验、后入库的两遍文件读取。另外ExcelDataConvertException是导入时最常见的异常通常是类型转换不匹配比如某一列写了1.5但实体字段声明的是 Integer。遇到这种情况默认 EasyExcel 会中断解析想跳过错误行继续解析需要重写监听器的onException方法Override public void onException(Exception exception, AnalysisContext context) { if (exception instanceof ExcelDataConvertException) { ExcelDataConvertException ex (ExcelDataConvertException) exception; errorMsgs.add(第 ex.getRowIndex() 行第 ex.getColumnIndex() 列数据格式异常); } }注意抛ExcelDataConvertException的那一行数据是没法继续读取的所以errorMsgs必然包含它而业务校验的错误行由于我们已经return掉了也不会入库这一点要在返回给前端的错误提示里写清楚。4. 百万级数据导出实践从数据库到文件4.1 防止 OOM 的分批查询策略导出方向的核心矛盾很多人理解错了。他们以为只要导出用 EasyExcel 就万事大吉EasyExcel 确实解决了Excel 文件对象驻留内存的问题但数据库查询端如果没有控制好同样会 OOM。最典型的错误写法是一次性查出 100 万条用户数据的 List然后再遍历写入 Excel。这一步在 MyBatis 里会导致一个非常隐蔽的问题——MyBatis 默认会把所有结果集一次性加载到内存然后用DefaultResultHandler逐条映射对象。100 万条记录每个对象几十个字段光这个 List 就占了 1G 内存再配合 Excel 写出的开销很容易在写出阶段触发 OOM。正确的姿势有两种。第一种是分页循环每页查 10000 条写一页清一页内存public void exportUsers(HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(UTF-8); String fileName URLEncoder.encode(用户数据, UTF-8); response.setHeader(Content-Disposition, attachment;filename fileName .xlsx); ExcelWriter writer EasyExcel.write(response.getOutputStream(), UserExcelModel.class).build(); WriteSheet writeSheet EasyExcel.writerSheet(用户数据).build(); Long lastId 0L; int pageSize 10000; ListUser pageData; while (true) { pageData userMapper.selectListByLastId(lastId, pageSize); if (pageData.isEmpty()) { break; } ListUserExcelModel models pageData.stream().map(this::convertToExcelModel).collect(Collectors.toList()); writer.write(models, writeSheet); lastId pageData.get(pageData.size() - 1).getId(); pageData.clear(); models.clear(); } writer.finish(); }这里的lastId方案我强烈推荐。如果是用传统的LIMIT offset, pageSizeoffset 越大数据库需要扫描跳过的行越多第 90 万行时性能极差。而WHERE id #{lastId} ORDER BY id LIMIT 10000每次查询都能利用主键索引快速定位性能稳定得多这就是常说的避免深分页。第二种是 MyBatis 的流式查询模式用Cursor作为返回值配合 JDBC 的fetchSizepublic interface UserMapper { Select(select * from t_user) Options(fetchSize 5000) CursorUser scanAll(); }这种模式能真正做到数据库端一条一条流出来内存占用最低但有一个致命限制Cursor 查询必须在一个开放的数据库事务内执行否则流会在拿到第一条结果前被自动关闭。所以调用场景一般要加Transactional或者在业务代码里手动控制SqlSessionTemplate开启 SqlSession。如果你导出和业务逻辑在同一个事务里事务长时间持有连接会消耗数据库连接池并发高时会有风险。我的经验是导出场景优先用lastId分页稳妥、好控制流式查询适合数据量极大且必须降低内存峰值的场景但要提前做好连接管理。4.2 数据转换与写出链路设计convertToExcelModel这个方法大家不要忽略。数据库里查出来的实体通常包含很多不需要导出的字段比如deleted、internalRemark、passwordHash。如果直接复用数据库实体加ExcelProperty注解很容易把不该给用户看的东西漏出去。所以我一贯的做法是单独定义导出模型在转换方法里做字段裁剪和格式处理。写到这不少朋友会问为什么不在 Service 层把数据查好再一次性给 controller我的回答是不要把 100 万数据放在内存里流动。查询端每次只放一批写出一批后立刻 clearExcel 文件在磁盘上持续追加写入业务内存里永远只有 10000 条左右的数据这样整个导出过程的峰值内存才能压得住。写出细节上有两个地方要注意第一writer.finish()必须写在 finally 里。finish()会负责写入 EOF 标记并释放 IO 资源如果忘了调用导出的文件容易出现文件损坏或最后一截数据缺失。建议写成ExcelWriter writer null; try { writer EasyExcel.write(...).build(); // 循环写数据 } finally { if (writer ! null) { writer.finish(); } }第二导出大文件时响应头里的文件名要做 URL 编码否则中文名在部分浏览器里会变成乱码甚至丢失后缀。还有Content-Length不要手动设因为输出流模式下你根本不知道最终文件有多大设错了反而会让浏览器认为文件不完整。4.3 样式与性能的平衡取舍EasyExcel 默认的写出模式是无样式裸写这是性能最好的状态。但我见过很多同事在导出 Excel 时喜欢加一堆样式合并单元格、设置单元格背景色、加边框、设置行高列宽所有样式叠加之后性能下降可能不止一个量级。原因是 Excel 的样式是作为独立对象存储在 workBook 里的每个单元格引用一个样式索引。如果你在循环里对每一行都创建一个新的CellStyle每个样式都要写入文件几万行之后文件体积暴涨写出耗时也会拉长。所以如果确实需要局部样式比如表头加粗、关键列加背景色建议只对表头行设置或者复用同一个样式对象不要对数据行的每个单元格都打样式。模板填充是另一个常用技巧。你先在本地做一个 Excel 模板里面放好样式、表头、公式导出的路径改为EasyExcel.write(outputStream).withTemplate(templateInputStream)然后只填充数据区域。这样既能保住复杂样式又避免了大量 API 拼接样式的性能和代码量开销。我导出复杂报表基本都走模板填充这条路代码能少写一半性能也更可控。5. 性能调优与实战踩坑记录5.1 JVM 参数与核心运行参数配置EasyExcel 读取解析百万行时JVM 堆内存其实不需要给得特别夸张。以我生产环境 4 核 8G 的机器为例给应用分配-Xms2048m -Xmx2048m就足够跑百万级导入导出了关键是不要让堆频繁发生 Full GC。接入项目后可以在启动脚本里这样配置JAVA_OPTS-server -Xms2g -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis200G1GC 配合 EasyExcel 这种大量短生命周期对象的场景表现比 CMS 更平滑。如果你用的是默认的 ParallelGC也可以跑但百万行解析时 Young GC 会比较频繁偶尔卡顿会更明显。此外导入时如果你自己包装了InputStream注意别做多次缓冲包装。EasyExcel.read()接受FileInputStream时本身会自动做缓冲没必要在外面再包一层BufferedInputStream包多了反而增加内存开销。文件上传场景如果接口是全量读取MultipartFile.getInputStream()建议先落盘到临时目录然后直接读文件流避免把整个上传文件内容加载进内存。5.2 批量插入与数据库层面的配合导入落库阶段即使你用batchInsert一批 3000 条如果数据库连接串里的 rewriteBatchedStatements 参数没开批量插入的效果会大打折扣。MySQL 的 JDBC 驱动默认不会把多条 insert 语句合并成多值语句只有显式开启这个参数才会重写 SQLjdbc:mysql://localhost:3306/db?useUnicodetruecharacterEncodingutf8 rewriteBatchedStatementstrueuseMysqlMetadatatrue我实测过开启rewriteBatchedStatements后批量插入 3000 条耗时能缩短一半以上这是性价比极高的一项优化。useMysqlMetadata主要是让驱动提前获取参数元数据也能减少一些计算开销。数据库端要配合的另一点是导入前建议先关闭不必要的二级索引。如果目标表有 5 个二级索引每插入一条数据都要同步更新所有索引100 万条数据这个索引维护成本相当可观。我通常的做法是先把索引删掉批量导入完成后重建索引或者使用ALTER TABLE ... DISABLE KEYS这么做在 MyISAM 表上效果最明显InnoDB 表需要自己评估锁的影响。这一条只适合离线导入任务线上实时导入不要碰索引。5.3 现场实测POI 与 EasyExcel 的性能对比下面这组数据是我在 4 核 8G 的测试机器上用 100 万行、25 列、文件大小约 480MB 的 xlsx 文件做的对比。数据是我自己生成的第一版测试结果不同机器配置、不同 CPU 型号、不同文件内容都会影响结果大家当个参考基线就好。方案读取耗时导出耗时内存峰值结果POI XSSFWorkbook 读取约 170 秒后 OOM-堆 4G 不够用失败POI SXSSFWorkbook 导出-约 75 秒可跑但代码复杂约 1.8G勉强可用EasyExcel 读取约 28 秒-约 320M成功EasyExcel 导出-约 35 秒约 450M成功EasyExcel 导出 lastId 分批查询-约 48 秒约 780M成功从这张表你能明显看出差异点EasyExcel 读取端内存只有几百兆POI 直接把堆打爆导出端如果配合合理的数据查询方式内存峰值也不会超过 1G。最后一行比前面高是因为数据库查询侧额外占用了内存而不是 Excel 写入导致。5.4 常见问题速查与解决手册以下是高频排查合集建议收藏。现象根本原因解决思路导入时日期变成一串数字字段没有加日期格式注解加DateTimeFormat(yyyy-MM-dd HH:mm:ss)长数字被读成 1.2345678E10Excel 科学计数法字段用 String加NumberFormat(0)导出文件名中文乱码Content-Disposition 头没编码URLEncoder.encode(fileName, UTF-8)导出的文件提示损坏写入过程异常或忘了调用finish()finally 中调用writer.finish()导入读取到一半报ExcelDataConvertException某一列类型转换失败重写onException跳过并记录错误行100 万行导入速度极慢批量插入语句未真正批量合并开启rewriteBatchedStatements导出时内存持续上涨一次性查全量数据改用lastId分页或流式 Cursor本地能读线上读NoSuchMethodErrorPOI 版本依赖冲突统一由 easyexcel 管理 POI 版本文件读取时调用readSheet编号从 0 开始意图和 API 的出入记住sheet(0)是第一个 Sheet5.5 并发导入导出的额外提醒最后聊一个容易忽略的点并发。如果同一个服务里同时跑多个导入导出任务CPU 和内存争抢会比较明显。建议在业务入口做任务级并发控制。最简单粗暴的做法是用Semaphore或线程池限流固定并行度比如 2~4 个导入任务、2 个导出任务避免把服务线程池全部打满。另外导入导出任务不要和核心业务接口共用线程池。如果你用的是 SpringBoot 默认的 Tomcat那线程池是所有请求共用的一个百万行导入任务如果阻塞了线程其他接口的响应时间会全线飙升。我项目里的做法是单独建一个ExecutorService固定 4 个线程跑这些大任务主流程接口完全不受影响。用户进度则通过一个简单的 Redis 或数据库状态字段来反馈前端轮询就行不复杂但非常有效。我个人在实际操作中的体会是EasyExcel 解决的是Excel 本身的问题但完整的数据导入导出链路里数据库查询、事务边界、批量插入配置、任务并发控制同样是决定成败的关键。刚开始我只把 POI 换成 EasyExcel数据量上去之后还是会出问题后来把整条链路按分批、小事务、流式、限流这四个原则重构才真正稳定下来。所以如果你也在做百万级导入导出别只盯着框架 API多想想数据是怎么流动的内存是怎么被占用的。把这两件事想明白了这套方案就能扛住绝大多数生产场景。
返回列表