文件处理
文件处理:导出 10 万行 Excel 为什么 OOM 了
产品经理说"加个导出功能",你觉得很简单,用 POI 三下五除二就写好了。结果一到线上,用户导出 10 万行数据直接 OOM——服务挂了,工单来了,老板的脸也绿了。
文件处理看似简单,实则暗藏性能陷阱。这篇文章从 POI 的 OOM 原理讲到 EasyExcel 的设计精髓,再到大文件处理的通用方案,帮你彻底搞定文件处理这件事。
一、POI:为什么它会导致内存溢出
1.1 Excel 并没你看到的那么小
你看到的 .xlsx 文件可能只有 2MB,但它其实是一个压缩包。把后缀改成 .zip 解压后,你会发现里面有好几个 XML 文件和文件夹:
_rels/ # 基础配置
docProps/ # 文档属性
xl/ # 核心数据
├── worksheets/sheet1.xml # 工作表数据
├── sharedStrings.xml # 共享字符串池
├── styles.xml # 样式定义
└── ...一个 2MB 的 xlsx,解压后可能有 20MB 甚至更多。POI 处理的是解压后的数据,而不是你看到的压缩文件大小。
1.2 OOM 的根本原因
看一段最常见的 POI 读取代码:
Workbook workbook = new XSSFWorkbook(fileInputStream);这行代码背后调用了 OPCPackage.open(InputStream),看看它的注释:
Note - uses quite a bit more memory than open(String), which doesn't need to hold the whole zip file in memory.
翻译成人话:这个方法会把整个压缩文件加载到内存中。
所以 XSSFWorkbook(和 HSSFWorkbook)的问题是一样的——它们把整个 Excel 一次性塞进内存。文件小的时候没问题,文件大了就 OOM。
1.3 三种 Workbook 格式
| 格式 | 支持版本 | 内存模式 | 适用场景 |
|---|---|---|---|
| HSSFWorkbook | .xls(97-2003) | 全量内存 | 小文件读写 |
| XSSFWorkbook | .xlsx(2007+) | 全量内存 | 小文件读写 |
| SXSSFWorkbook | .xlsx(2007+) | 流式写入 | 大文件写入 |
注意:SXSSFWorkbook 只能用于写入,不能用于读取。
二、大文件写入:SXSSFWorkbook 的秘密
同样写入 10000 行 x 100 列的 Excel:
- XSSFWorkbook:堆内存占用 1200+ MB
- SXSSFWorkbook:堆内存占用 148 MB
差距接近 10 倍。为什么?
2.1 临时文件机制
SXSSFWorkbook 内部有一个 SheetDataWriter,它的工作原理是:
- 数据写入时,不是全部留在内存,而是把超出窗口的行写入磁盘上的临时文件
- 内存中只保留一个滑动窗口大小的数据(默认 100 行)
- 临时文件是 XML 格式,最终会被打包进 xlsx
// 可以自定义滑动窗口大小
SXSSFWorkbook workbook = new SXSSFWorkbook(200); // 内存中保留 200 行生活类比:XSSFWorkbook 就像把整本书抄在黑板上再让人看;SXSSFWorkbook 则是一页一页翻,看完一页就擦掉,黑板永远只放得下几页内容。
2.2 实测对比
用 arthas 观察堆内存,写入同一份文件(10000 行 x 100 列,每个单元格填 UUID):
| Workbook 类型 | 堆内存占用 | 是否 OOM(-Xmx150m) |
|---|---|---|
| XSSFWorkbook | 1200+ MB | OOM |
| SXSSFWorkbook | 148 MB | 正常 |
结论很明显:大文件写入必须用 SXSSFWorkbook。
三、大文件读取:EasyExcel 的设计精髓
SXSSFWorkbook 解决了写入问题,但读取呢?XSSFWorkbook 读取还是全量加载,大文件照样 OOM。
这时候就需要 EasyExcel 了。
3.1 SAX 解析:边读边处理
EasyExcel 的核心是SAX(Simple API for XML)解析——一种基于事件驱动的 XML 流式解析模型。
传统 DOM 解析 vs SAX 解析:
| 维度 | DOM 解析(POI 默认) | SAX 解析(EasyExcel) |
|---|---|---|
| 工作方式 | 把整个 XML 加载到内存,构建树结构 | 逐行读取,触发事件回调 |
| 内存占用 | 与文件大小成正比 | 只占当前行的内存 |
| 随机访问 | 支持 | 不支持(只能顺序读) |
SAX 解析的工作流程:
在 EasyExcel 的 XlsxSaxAnalyser 中,核心流程就是:按需加载 sheet → SAX 流式解析 → 通过 XlsxRowHandler 逐行回调处理。内存中始终只有当前行的数据。
3.2 磁盘缓存策略
xlsx 文件中有一个 sharedStrings.xml,用于存储所有单元格的字符串(重复字符串只存一份以减小文件体积)。如果这个共享字符串表很大,全部加载到内存也会造成压力。
EasyExcel 的做法是动态选择缓存介质:
- 小文件:用内存缓存(MapCache)
- 大文件超过阈值:自动切换到 Ehcache 磁盘缓存
这样即使共享字符串表有几百兆,也不会把内存撑爆。
3.3 实测对比
读取同一个 27.3 MB 的 xlsx 文件:
| 方式 | 堆内存占用 |
|---|---|
| XSSFWorkbook | 1000+ MB |
| EasyExcel | < 100 MB |
EasyExcel 的使用也很简单:
EasyExcel.read(filename, new ReadListener<Object>() {
@Override
public void invoke(Object data, AnalysisContext context) {
// 逐行处理数据
processRow(data);
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 全部读取完成后的收尾工作
}
}).sheet().doRead();四、大文件处理通用方案
不管是 Excel、CSV 还是其他格式的大文件,处理思路都是相通的:
4.1 分批处理
不要一次性把所有数据加载到内存。EasyExcel 天然支持分批读取,你可以在 Listener 中每积累 1000 条就做一次批量处理:
private List<Object> batch = new ArrayList<>(1000);
@Override
public void invoke(Object data, AnalysisContext context) {
batch.add(data);
if (batch.size() >= 1000) {
saveToDB(batch);
batch.clear();
}
}4.2 流式处理
能用流就不要用集合。Java 8 的 Stream API、BufferedReader 的 lines() 方法,都是流式处理的好帮手。核心思想是:数据像水流一样经过你的处理逻辑,处理完就丢弃,永远不在内存中积压。
4.3 文件上传下载的注意事项
- 上传:对文件大小做限制(比如最大 50MB),超大文件考虑分片上传
- 下载:大文件不要先生成到内存再响应,而是用流式写入直接写到 HttpServletResponse 的 OutputStream
- 异步导出:如果导出时间很长,不要让用户同步等待。改成异步任务,导出完成后通知用户下载
五、面试高频题
题目一:为什么 POI 会导致内存溢出?
两个原因:1)xlsx 是压缩格式,解压后实际大小远超文件体积;2)XSSFWorkbook 把整个文件一次性加载到内存。解决方案是写入用 SXSSFWorkbook(流式写入临时文件),读取用 EasyExcel(SAX 事件驱动逐行解析)。
题目二:EasyExcel 为什么内存占用小?
核心是 SAX 解析 + 磁盘缓存。SAX 解析基于事件驱动逐行读取,内存中只保留当前行数据;共享字符串表超过阈值后自动切换到 Ehcache 磁盘缓存,避免大表撑爆内存。
题目三:SXSSFWorkbook 为什么占用内存更小?
SXSSFWorkbook 内部的 SheetDataWriter 会把超出滑动窗口的行数据写入磁盘临时文件(XML 格式),内存中只保留窗口大小的数据(默认 100 行)。最终打包时再把临时文件合并进 xlsx。
题目四:大文件处理的通用思路是什么?
三个关键词:分批、流式、异步。分批处理避免一次性加载全部数据;流式处理让数据"流过"而不积压;异步导出避免长时间阻塞用户请求。具体到 Excel,写入用 SXSSFWorkbook,读取用 EasyExcel。
小结
| 知识点 | 一句话记忆 |
|---|---|
| POI OOM 原因 | xlsx 是压缩包,XSSFWorkbook 全量加载 |
| SXSSFWorkbook | 滑动窗口 + 临时文件,只用于写入 |
| EasyExcel | SAX 逐行解析 + 磁盘缓存,读取内存占用极低 |
| 分批处理 | 每 N 条处理一次,处理完清空 |
| 流式处理 | 数据像水流,流过即丢弃 |
| 异步导出 | 大文件不要同步等,改成后台任务 |
文件处理的核心原则只有一个:永远不要把不需要同时存在的数据放在内存里。
补充:POI 各种 Workbook 的完整对比
HSSFWorkbook 详解
HSSFWorkbook 用于处理 .xls 格式(Excel 97-2003)。这种格式有一个硬性限制:单个 sheet 最多 65536 行、256 列。所以如果你的数据量超过 6 万行,用 HSSFWorkbook 连格式都不支持。
HSSFWorkbook 同样是全量内存模式,但因为 .xls 格式本身就有行数限制,实际使用中 OOM 的情况比 XSSFWorkbook 少(因为数据量天然有上限)。
XSSFWorkbook 深入
XSSFWorkbook 处理 .xlsx 格式,理论上单个 sheet 支持 1048576 行、16384 列——这个容量已经非常大了。但问题是它把整个文件一次性加载到内存。
通过前面的分析我们知道,XSSFWorkbook 读取文件时调用 OPCPackage.open(InputStream),会把整个 zip 解压到内存。而 OPCPackage.open(String path) 则使用文件路径方式打开,可以利用操作系统的内存映射,内存占用会小一些:
// 较差:整个文件加载到内存
Workbook workbook = new XSSFWorkbook(new FileInputStream("example.xlsx"));
// 较好:使用文件路径,可以利用 NIO 的内存映射
OPCPackage pkg = OPCPackage.open(new File("example.xlsx"));
Workbook workbook = new XSSFWorkbook(pkg);但即使用文件路径方式,当你遍历所有行和单元格时,整个文档对象模型还是会被构建在内存中。所以对于大文件,本质上还是需要流式处理。
SXSSFWorkbook 的滑动窗口机制
SXSSFWorkbook 的核心参数是 rowAccessWindowSize,即滑动窗口大小:
// 默认窗口大小 100 行
SXSSFWorkbook workbook = new SXSSFWorkbook();
// 自定义窗口大小为 200 行
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
// 无限窗口(不推荐,等于回到 XSSFWorkbook)
SXSSFWorkbook workbook = new SXSSFWorkbook(-1);当创建新行导致内存中的行数超过窗口大小时,最早创建的行会被自动刷入磁盘临时文件,释放内存。被刷出去的行不能再通过 getRow() 访问——这是流式写入的代价。
所以 SXSSFWorkbook 不适合需要"回头修改已写入行"的场景。如果需要先写数据再回头设置样式,要换一种写法:在写入每一行时就把样式设置好。
补充:EasyExcel 的进阶用法
分批读取 + 入库
实际业务中最常见的场景是"读取 Excel → 写入数据库"。推荐的做法是在 Listener 中积累一批数据后批量入库:
public class UserDataListener implements ReadListener<UserData> {
private static final int BATCH_SIZE = 1000;
private List<UserData> batch = new ArrayList<>(BATCH_SIZE);
private final UserService userService;
public UserDataListener(UserService userService) {
this.userService = userService;
}
@Override
public void invoke(UserData data, AnalysisContext context) {
batch.add(data);
if (batch.size() >= BATCH_SIZE) {
saveData();
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 最后一批可能不足 BATCH_SIZE,也要处理
if (!batch.isEmpty()) {
saveData();
}
}
private void saveData() {
userService.batchInsert(batch);
batch.clear();
}
}注意事项:
- Listener 不能被 Spring 管理(不能加 @Component),因为每次读取都需要新建一个 Listener 实例
- 需要用到 Spring Bean 时,通过构造函数注入
- BATCH_SIZE 建议 1000-5000,太小 DB 压力大(频繁提交事务),太大内存占用高
多 Sheet 读取
// 读取所有 Sheet
EasyExcel.read(filename, UserData.class, new UserDataListener())
.doReadAll();
// 读取指定 Sheet(按序号)
EasyExcel.read(filename, UserData.class, new UserDataListener())
.sheet(0) // 第一个 sheet
.doRead();
// 不同 Sheet 用不同的数据模型
try (ExcelReader reader = EasyExcel.read(filename).build()) {
// 第一个 sheet 读用户数据
ReadSheet sheet1 = EasyExcel.readSheet(0)
.head(UserData.class)
.registerReadListener(new UserDataListener())
.build();
// 第二个 sheet 读订单数据
ReadSheet sheet2 = EasyExcel.readSheet(1)
.head(OrderData.class)
.registerReadListener(new OrderDataListener())
.build();
reader.read(sheet1, sheet2);
}EasyExcel 写入
EasyExcel 的写入也同样简洁:
// 定义数据模型
@Data
public class UserData {
@ExcelProperty("用户ID")
private Long userId;
@ExcelProperty("姓名")
private String name;
@ExcelProperty("注册时间")
private Date registerTime;
}
// 写入
List<UserData> dataList = userService.queryAll();
EasyExcel.write("output.xlsx", UserData.class)
.sheet("用户列表")
.doWrite(dataList);如果数据量很大不能一次性查出来,可以用分页查询 + 追加写入:
try (ExcelWriter writer = EasyExcel.write("output.xlsx", UserData.class).build()) {
WriteSheet sheet = EasyExcel.writerSheet("用户列表").build();
int page = 1;
List<UserData> batch;
do {
batch = userService.queryByPage(page, 5000);
writer.write(batch, sheet);
page++;
} while (batch.size() == 5000);
}补充:文件上传下载的实战方案
大文件上传:分片上传
前端把文件切成固定大小的分片(比如每片 5MB),逐个上传到后端,后端收到所有分片后再合并。
关键点:
- 前端计算文件 MD5,后端通过 MD5 判断文件是否已上传过(秒传)
- 支持断点续传:记录已上传的分片,下次从断点继续
- 后端合并时按分片编号顺序拼接
- 合并后校验整体 MD5,确保文件完整
大文件下载:流式响应
绝对不要先在内存中生成完整文件再返回。正确做法是用流式写入直接输出到 HttpServletResponse:
@GetMapping("/export")
public void exportExcel(HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=users.xlsx");
// 直接写到响应流,不在内存中生成完整文件
EasyExcel.write(response.getOutputStream(), UserData.class)
.sheet("用户列表")
.doWrite(userService.queryAll());
}异步导出方案
如果导出数据量非常大(比如几十万行),同步等待会导致请求超时。推荐异步导出:
- 用户点击"导出",后端创建一个异步任务,立即返回任务 ID
- 后台线程池执行导出逻辑,生成文件后上传到对象存储(OSS/S3)
- 导出完成后更新任务状态,生成下载链接
- 用户可以轮询任务状态,或通过 WebSocket/消息推送获得通知
- 用户通过下载链接下载文件(可以设置链接有效期,比如 24 小时)
CSV vs Excel
不是所有场景都需要 Excel。如果导出的数据不需要格式、公式、多 Sheet 等功能,CSV 格式是更好的选择:
| 维度 | CSV | Excel (.xlsx) |
|---|---|---|
| 生成速度 | 极快(纯文本拼接) | 慢(需要构建 XML + 压缩) |
| 文件大小 | 小 | 大(XML 冗余 + 压缩开销) |
| 内存占用 | 极低 | 较高 |
| 格式支持 | 无 | 丰富(颜色、字体、公式) |
| 打开方式 | 任何文本编辑器 | 需要 Excel/WPS |
如果只是导出数据做分析,CSV 足矣。如果要给业务方看,需要表头样式、合并单元格,那就用 Excel。
补充:文件处理中的编码问题
乱码的根源
文件处理中最常见的问题之一就是乱码。根源是编码不一致:写文件时用的编码和读文件时用的编码不同。
比如一个 CSV 文件用 GBK 编码生成,但读取时用了 UTF-8——中文就变成乱码了。
解决方案:
- 统一使用 UTF-8:新项目全部使用 UTF-8 编码,从源头杜绝问题
- 加 BOM 头:Excel 打开 UTF-8 的 CSV 文件时可能乱码(因为 Excel 默认用系统编码),加一个 BOM 头(
)可以解决 - 自动检测编码:用 Mozilla 的
juniversalchardet库自动检测文件编码
// 导出 CSV 时加 BOM 头,让 Excel 正确识别 UTF-8
response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});数字精度问题
Excel 中的数字有精度限制——超过 15 位的数字会被自动截断。比如手机号 13800138000 在 Excel 中可能显示为 13800138000.0,或者身份证号 110105199001011234 会被截断为 110105199001011000。
解决办法:在 EasyExcel 中将这类字段的类型声明为 String 而不是 Long:
@ExcelProperty("手机号")
private String phone; // 用 String,不要用 Long
@ExcelProperty("身份证号")
private String idCard; // 用 String,不要用 Long日期格式问题
不同用户的 Excel 中日期格式可能不统一——有的是 "2024-01-15",有的是 "2024/01/15",有的是 "Jan 15, 2024"。
EasyExcel 可以通过自定义 Converter 来统一处理:
public class DateConverter implements Converter<Date> {
private static final String[] PATTERNS = {
"yyyy-MM-dd", "yyyy/MM/dd", "yyyy-MM-dd HH:mm:ss",
"MM/dd/yyyy", "dd-MMM-yyyy"
};
@Override
public Date convertToJavaData(ReadCellData<?> cellData,
ExcelContentProperty property, GlobalConfiguration config) {
String value = cellData.getStringValue();
for (String pattern : PATTERNS) {
try {
return new SimpleDateFormat(pattern).parse(value);
} catch (ParseException e) {
// 尝试下一种格式
}
}
throw new RuntimeException("无法解析日期: " + value);
}
}补充:生产环境文件处理的安全考量
文件类型校验
不要只通过文件扩展名判断文件类型——攻击者可以把恶意文件改成 .xlsx 后缀上传。要通过文件头(magic bytes)来判断真实类型:
// xlsx 文件的 magic bytes(实际上是 ZIP 格式的头部)
byte[] header = new byte[4];
inputStream.read(header);
if (header[0] == 0x50 && header[1] == 0x4B) {
// 是 ZIP 格式(xlsx 是 ZIP 压缩的 XML)
}文件大小限制
在 Spring Boot 中配置文件上传大小限制:
spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB超过限制的请求会返回 413 错误,避免超大文件打满服务器磁盘或内存。
临时文件清理
SXSSFWorkbook 和 EasyExcel 在处理过程中都会产生临时文件。如果程序异常退出,临时文件可能不会被清理,长期积累会占满磁盘。
建议定期清理临时文件目录,或在代码中使用 try-with-resources 确保资源释放:
try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
// ... 写入逻辑
workbook.write(outputStream);
} // 自动关闭并清理临时文件防止公式注入(CSV Injection)
如果用户输入的内容以 =、+、-、@ 开头,Excel 会把它当作公式执行。攻击者可以通过这种方式注入恶意公式。
防御方式:在输出时给这类内容前面加一个单引号 ' 或 Tab 字符 \t,让 Excel 把它当作纯文本处理:
private String sanitize(String value) {
if (value != null && value.length() > 0) {
char first = value.charAt(0);
if (first == '=' || first == '+' || first == '-' || first == '@') {
return "'" + value;
}
}
return value;
}