☰
说好不内存溢出的 EasyExcel,为什么一上线还是把服务干 OOM 了?
2026/9/29 20:05:04 网站建设 项目流程

说好不内存溢出的 EasyExcel,为什么一上线还是把服务干 OOM 了?

很多人觉得,既然用了号称防止 OOM 的 EasyExcel,大数据量导出就稳了。结果写完接口一压测,或者上了生产环境,还是眼睁睁看着堆内存报警,服务直接挂掉。

大家肯定纳闷:这 EasyExcel 难道是虚假宣传?

其实跟 EasyExcel 没关系,这锅得业务代码来背。今天咱们盘一盘,用 EasyExcel 导出一两百万条数据时,到底哪里吃掉了内存。

内存到底去哪了?

以前大家用原生 Apache POI 导出,POI 会把所有数据、样式、单元格对象全缓存在内存里,等到最后再一把写进文件。一百万行数据,POI 可能要吃掉几个 G 的内存。
EasyExcel 确实解决了这个问题,它的核心做法是一行行处理,边解析边刷盘。所以在它这块,内存是绝对控得住的。

问题出在传给 EasyExcel 的数据源上。

很多人的导出接口是这么写的:

// 1. 一次性从数据库查出 100 万条数据List<UserDTO>allData=userMapper.selectAllUsers();// 2. 一次性扔给 EasyExcel 去写EasyExcel.write("user.xlsx",UserDTO.class).sheet("模板").doWrite(allData);

看出来了吧?EasyExcel 是没占多少内存,但 MyBatis 查出来的这 100 万个UserDTO对象,全在堆内存里堆着。
如果你一个对象占 1KB,100 万个对象就是将近 1GB 内存。这时候再来两个运营同时点一下导出,老年代当场爆满,直接触发 Full GC,接着就是 OOM 退出。

怎么做到真正的低内存消耗?

想在低内存下导完大数据,整个数据流都必须是“流式”的。不能只靠写文件流式,查数据库也得一段一段来。

这里有两种常用的做法。

第一种:分页查,分批写

这是最直接的做法。一次只查 5000 条,丢给 EasyExcel 写,然后再查下个 5000 条。内存里永远只有 5000 个对象。

try(ExcelWriterexcelWriter=EasyExcel.write("user.xlsx",UserDTO.class).build()){WriteSheetwriteSheet=EasyExcel.writerSheet("用户数据").build();intpageSize=5000;for(inti=1;i<=totalPage;i++){// 每次只查一页的数据List<UserDTO>data=userMapper.selectPage(i,pageSize);excelWriter.write(data,writeSheet);}}

好处是代码好写,也不怎么挑数据库。
代价就是分页查询的通病:越往深处翻页越慢。比如 MySQL 里的LIMIT 1000000, 5000,数据库要扫描一百多万行然后丢弃,性能很差。如果要优化深分页,还得配合主键游标来查(比如根据上次查询的最大 ID 来检索)。

第二种:MyBatis 游标查询(Cursor)

MyBatis 支持返回Cursor<T>。这玩意相当于数据库的游标,你调用iterator.next()才会去数据库取数据。

// Mapper 接口@Select("SELECT * FROM user")Cursor<UserDTO>streamQuery();// 业务代码try(Cursor<UserDTO>cursor=userMapper.streamQuery();ExcelWriterexcelWriter=EasyExcel.write("user.xlsx",UserDTO.class).build()){WriteSheetwriteSheet=EasyExcel.writerSheet("用户数据").build();List<UserDTO>buffer=newArrayList<>();for(UserDTOuser:cursor){buffer.add(user);// 攒够 5000 条写一次if(buffer.size()>=5000){excelWriter.write(buffer,writeSheet);buffer.clear();}}// 写最后剩下的一点数据if(!buffer.isEmpty()){excelWriter.write(buffer,writeSheet);}}

这个方案把数据库到底层的交互也变成了流式,完全解决了深分页慢的问题。

但要注意两个坑:

  1. 必须在事务内执行:MyBatis 游标必须保持数据库连接不关闭,通常得在方法上加@Transactional保证整个循环期间连接存活。
  2. 长时间占着连接:如果你写文件很慢,数据库连接就会一直被你占着。并发稍微大一点,数据库连接池很容易被耗尽,影响其他正常业务。

怎么做选择?

绝大多数后台管理系统,直接用分页查+分批写就行,如果太慢就做主键 ID 偏移量分页。这招不用一直占着数据库连接,最稳妥。

要是导出任务实在太重,或者面向 C 端用户,那就别做成同步等待的接口。让用户点完按钮后去干别的,后台开个异步任务慢慢跑,生成完了给用户发个系统通知或者邮件,提供 OSS 下载链接。

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

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

立即咨询