Fesod Excel 内存优化完整指南:五道关卡,带你驯服百万行数据
凌晨两点半,运维小李盯着监控大屏,心跳漏了一拍——报表系统又红了。日志里躺着一行熟悉的报错:OutOfMemoryError: Java heap space。原因也熟悉:运营导出的 Excel 从 20 万行涨到了 80 万行,程序一次性把整张表塞进内存,直接撑爆了 JVM 堆。这不是小李第一次遇到这种问题,传统 Java Excel 处理库在处理大文件时几乎都会栽在同一个坑里:要么内存溢出,要么慢到让人怀疑人生。
如果你也经历过"一导大数据就宕机"的至暗时刻,那么今天要介绍的 Apache Fesod(孵化中)就是为你准备的解药。它是一款专攻大文件场景的高性能 Java Excel 处理库,核心卖点只有一句话:处理电子表格时,再也不用担心大文件引发 OOM。下面这篇文章,我会把 Fesod Excel 内存优化的完整路径拆成五道关卡,从"它凭什么不炸内存"讲到"百万行数据读写实战",你照着闯关即可,全程不需要任何高深背景。
图为 Apache Fesod 在 Apache 孵化器中的状态展示,社区与文档体系完整
第一关:先搞懂 Fesod 凭什么不炸内存
流式读取:像拧水龙头,而不是把整条河装进桶里
老牌方案为什么容易 OOM?因为它们读取 Excel 时,喜欢把整个文件解析成一张巨大的"对象网"塞进内存,文件多大,内存就要多大。Fesod 换了个思路:流式读取。
它内部用 SAX 解析器逐行扫描 Excel 的 XML 内容,读到一行就回调一行,处理完就丢弃。这就像拧开水龙头接水——你需要多少,就放多少,而不是先把整条河抽进你家水桶。所以 Fesod 的内存占用和文件总大小无关,只和"当前这一行"有关。这就是 Fesod Excel 内存优化的底层根基。
共享字符串缓存:把大头开销从内存挪到磁盘
Excel 2007 之后的格式里有个"共享字符串表"(Shared Strings),所有单元格文本都指向这张表。如果把它整体加载进内存,占用的空间能达到文件本身的 3 到 10 倍——这才是大文件 OOM 的真正元凶。
Fesod 的处理策略很聪明:先把文件落盘,再反序列化读取。默认情况下,小于 5MB 的共享字符串放在内存,大于 5MB 的自动改存临时文件。这样读一个超大文件,常驻内存通常只有 30MB 左右,剩下的临时内存会被 GC 快速回收。代价是效率下降约 30%-50%,但换来的是"文件再大也不慌"的底气,这笔账非常划算。
第二关:Fesod 入门教程,三分钟跑通第一个读写
环境配置:Maven 和 Gradle 二选一
Fesod 要求 Java 8 及以上版本,目前最新版为 2.0.2-incubating,支持 JDK8 到 JDK25。依赖也很轻:底层复用 Apache POI 做文件解析,用 Apache Commons CSV 支持 CSV,用 Ehcache 做缓存。配置方式如下:
Maven 用户,在 pom.xml 里加:
<dependency>
<groupId>org.apache.fesod</groupId>
<artifactId>fesod-sheet</artifactId>
<version>2.0.2-incubating</version>
</dependency>
Gradle 用户,在 build.gradle 里加:
dependencies {
implementation 'org.apache.fesod:fesod-sheet:2.0.2-incubating'
}
小提醒:如果你的项目里已经引入了 POI 相关组件,记得手动排除冲突的 jar 包,避免版本打架。
定义实体类:一个注解都不用也能跑
Fesod 的用法和 easyexcel 一脉相承,先定义一个 POJO 对应表格结构:
public class DemoData {
@ExcelProperty("字符串列")
private String string;
@ExcelProperty("日期列")
private Date date;
@ExcelProperty("数字列")
private Double doubleData;
@ExcelIgnore
private String ignore; // 标注后这一列会被跳过
}
@ExcelProperty 负责把字段和表头对应起来,@ExcelIgnore 表示"这个字段别写入 Excel"。
写文件:一行链式调用搞定
FesodSheet.write("demo.xlsx", DemoData.class)
.sheet("Sheet1")
.doWrite(() -> data());
FesodSheet.write() 返回一个写入构建器,.sheet() 指定工作表名称,.doWrite() 接收一个数据供给函数。第一行会自动生成表头,数据从第二行开始排。就这么简单,你已经完成第一次 Fesod 写入了。
读文件:监听器模式逐行处理
FesodSheet.read("demo.xlsx", DemoData.class, new ReadListener<DemoData>() {
@Override
public void invoke(DemoData data, AnalysisContext context) {
// 每读到一行,这里就被调用一次
System.out.println(data);
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
System.out.println("全部读取完成");
}
}).sheet().doRead();
这里有个关键概念先记住:ReadListener(读取监听器)。它就像流水线上的质检员,Fesod 每解析出一行数据就递给你处理一次。注意,监听器不能交给 Spring 管理,每次读取都要 new 一个新的实例。
第三关:百万行数据读取实战,如何让内存稳如泰山
用 PageReadListener 分批消化,而不是一口吞
新手最容易犯的错:用 doReadSync() 把全部数据一次性读进 List。这个方法适合小数据量,遇到百万行直接内存爆炸。正确的姿势是分批处理——Fesod 提供了现成的 PageReadListener,每攒够一批就回调一次,你可以在这时落库或写文件:
FesodSheet.read("million.xlsx", DemoData.class,
new PageReadListener<>(dataList -> {
// 每 100 条数据回调一次,批量插入数据库
saveBatch(dataList);
})).sheet().doRead();
这样内存里永远只存活"当前这一批",百万行数据读取的内存开销几乎恒定。想调整批大小,可以给 PageReadListener 传入自定义的批容量参数。
进阶调优:用缓存选择器精确控制内存上限
如果你要处理的是"动不动几百 MB"的极端文件,Fesod 还给了你一把精确的尺子:ReadCacheSelector(读取缓存选择器)。它允许你手动分配"共享字符串存内存 vs 存文件"的比例。
FesodSheet.read("huge.xlsx", DemoData.class, listener)
.readCacheSelector(new SimpleReadCacheSelector(5, 20))
.sheet().doRead();
参数含义:第一个数字表示超过多少 MB 的共享字符串转存临时文件(默认 5),第二个数字表示文件存储时,内存里最多保留多少 MB 的临时共享字符串缓存(默认 20)。比如你愿意为这次读取付出 100MB 常驻内存,就设成 new SimpleReadCacheSelector(20, 90)。
判断参数是否合理的技巧:打开 debug 日志,观察最后输出的 Already put :4000000 和 Cache misses count:4001,两者相除(400 万除以 4000)约等于 1000,说明缓存命中率健康;如果比值小于 500,就要调大缓存了。
什么时候该用同步读取?
doReadSync() 并非一无是处。当数据量只有几万行、文件几十 MB、内存充足且无高并发时,直接同步读成 List 反而更快——省去了复杂的缓存策略开销。一句话结论:小文件图省事用同步,大文件求稳用流式。
第四关:百万行数据写入实战,批量写加压缩双管齐下
为什么一次性 doWrite 会翻车
写入和读取是镜像问题:把 100 万行一次性交给 doWrite(),数据会全部驻留内存等待写出,照样 OOM。正确做法是用 ExcelWriter 对象分批次写入,每写一批,内存就释放一批。
核心套路:try-with-resources + 分批 write
try (ExcelWriter excelWriter = FesodSheet.write("large.xlsx", DemoData.class).build()) {
WriteSheet writeSheet = FesodSheet.writerSheet("数据导出").build();
for (int i = 0; i < 1000; i++) {
excelWriter.write(generateBatch(i), writeSheet);
}
}
FesodSheet.write() 构建出 ExcelWriter,writerSheet() 生成工作表描述,循环里每次 write() 只处理一批数据(比如 100 条)。用 try-with-resources 保证最后 close() 被调用,文件才会真正落盘。
磁盘紧张?给临时文件开压缩
Fesod 底层用的是 POI 的流式 API(SXSSFWorkbook),写入过程中会生成大量临时 XML 文件,非常占磁盘。在磁盘空间紧张的环境下,可以通过注册一个 WorkbookWriteHandler 开启临时文件压缩:
FesodSheet.write("large.xlsx", DemoData.class)
.registerWriteHandler(new WorkbookWriteHandler() {
@Override
public void afterWorkbookCreate(WorkbookWriteHandlerContext context) {
Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
if (workbook instanceof SXSSFWorkbook) {
((SXSSFWorkbook) workbook).setCompressTempFiles(true);
}
}
})
.build();
这段代码的含义:在 Workbook 创建完成后回调,如果内部工作簿是 SXSSFWorkbook,就开启临时文件压缩。代价是多消耗一点 CPU,但能显著降低磁盘占用——内存、磁盘、CPU 三者的取舍,你可以自己定。
写入性能三件套
- 用
ExcelWriter批量写,不要用doWrite()一次灌入; - 按行宽和可用内存调整批大小(示例中一批 100 条);
- 定期用
FileUtils.getPoiFilesPath()检查临时目录体积,防止磁盘被写满。
第五关:避坑清单,用 Fesod 前必须知道的五件事
闯到这里,你已经具备处理百万行数据的完整能力。最后送上这份避坑清单,帮你少走弯路:
- 监听器每次都要 new:ReadListener 不能作为 Spring Bean 复用,同一个实例重复读取会串数据。
- 同步读取是"小数据专用通道":
doReadSync()只适合数据量可控的场景,别拿它读大文件。 - POI 版本冲突要手动排除:项目里已有 POI 依赖时,记得排除冗余 jar,否则可能出现诡异异常。
- 临时文件要常清理:大文件读取和写入都会产生临时文件,留意
FileUtils.getPoiFilesPath()对应目录的增长。 - 压缩开关是"磁盘换 CPU":
setCompressTempFiles(true)能省磁盘,但会牺牲少量性能,按环境权衡。
动手吧:跑起来才是最好的学习
理论到此为止,现在轮到你了。把项目克隆到本地,跑一遍示例代码,你会感受到什么叫"内存稳稳的":
git clone https://gitcode.com/gh_mirrors/fast/fesod
cd fesod
mvn clean install
仓库里的 fesod-examples/fesod-sheet-examples 模块下有从快速入门到高级用法的全套示例;想要深入原理,可以读 website/docs/sheet/advanced/large-file.md 和 website/docs/sheet/help/large-data.md;想了解完整 API,直接翻 fesod-sheet/src/main/java/org/apache/fesod/sheet/FesodSheet.java,入口方法一目了然。
核心优势速览:
- 🚀 流式处理:内存占用恒定,文件多大都不怕,彻底告别 OOM
- ⚡ 缓存策略可调:共享字符串"内存/磁盘"比例自由配置,内存上限自己说了算
- 📦 批量读写:写入分批落盘、读取分批回调,百万行数据轻松拿捏
- 🔧 API 极简:链式调用 + 注解映射,三分钟上手,新手无门槛
- 🛡️ Apache 孵化项目:社区规范、文档完善,可放心用于生产
下一张百万行的报表,就让 Fesod 替你扛下来吧。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



