处理大型excel文件的技术选型
前言
最近在做财务系统的时候,遇到了一个头疼的问题:客户经常要导入几十万行的Excel账单文件,用传统的POI直接读取,内存直接爆了。这不,就踩坑踩了一圈,把几个主流的解决方案都试了个遍,今天就来聊聊大型Excel文件到底该怎么处理。
问题背景
我们的场景很简单:财务人员需要导入各个平台(美团、抖音、京东等)导出的Excel账单,少则几千行,多则几十万行。一开始用POI的普通读取方式,结果10万行的文件直接OOM了。
这时候你就会发现,Excel大文件处理是个坑,不是所有的库都能hold住。
方案一:POI 5.0 的 SAX 模型
优点
- 内存占用低:真正的流式读取,理论上可以处理无限大的文件
- 官方支持:Apache POI 是官方库,不用担心兼容性问题
- 性能优秀:底层基于事件驱动,速度快
缺点(重点来了)
- 代码复杂度爆表 :你需要实现一堆回调接口,什么
SharedStringsTable、XSSFReader、ContentHandler,看着就头大 - 维护成本高:半年后你自己都不想碰这段代码,更别说接手的同事了
- 学习曲线陡峭:新人基本看不懂,还得花时间培训
实战感受
我试着用SAX模型写了一版,光是处理表头和数据解析就写了200多行代码,还得处理各种边界情况。代码看起来像这样:
java
class MyXSSFSheetXMLHandler implements XSSFSheetXMLHandler.SheetContentsHandler {
@Override
public void startRow(int rowNum) {
// 处理行开始
}
@Override
public void cell(String cellReference, String formattedValue, XSSFComment comment) {
// 处理每个单元格
// 还得自己解析列索引...
}
@Override
public void endRow(int rowNum) {
// 处理行结束
// 在这里组装数据...
}
}
说实话,写完这段代码我就后悔了。虽然能用,但维护起来真的太痛苦了。不推荐,除非你有特殊需求必须用POI 5.0的SAX。
方案二:excel-streaming-reader
优点
- 代码简洁:API设计得很友好,跟普通的POI读取差不多
- 上手快:基本不需要学习成本,看一眼文档就会用
- 与POI结合好:如果你用的是POI 4.x,这个库简直完美
缺点(致命伤)
- 不兼容POI 5.x:这是最大的问题!POI 5.0改了很多底层API,excel-streaming-reader的最新版本(2.1.0)还停留在POI 4.x
- 会报错 :如果你的项目用的是POI 5.2.3(像我们一样),直接就报
NoSuchMethodError
实战感受
我一开始很兴奋地引入了这个库,代码写起来确实舒服:
java
try (InputStream is = new FileInputStream(file);
Workbook workbook = StreamingReader.builder()
.rowCacheSize(100)
.bufferSize(4096)
.open(is)) {
for (Sheet sheet : workbook) {
for (Row r : sheet) {
// 直接遍历,多简单!
}
}
}
但是!运行的时候直接崩溃:
vbnet
NoSuchMethodError: 'org.apache.poi.xssf.model.SharedStringsTable
org.apache.poi.xssf.eventusermodel.XSSFReader.getSharedStringsTable()'
查了一圈发现,POI 5.0把这个方法签名改了,excel-streaming-reader还没适配。无奈只能放弃。
结论:如果你的项目还在用POI 4.x,强烈推荐这个库;如果已经升级到POI 5.x,就别折腾了。
方案三:阿里 EasyExcel(强烈推荐)
优点
- 完美兼容POI 5.x:不用担心版本冲突问题
- 性能优秀:阿里内部大规模使用,经过生产验证
- 内存占用极低:100万行数据也能稳稳处理
- API友好:监听器模式,代码清晰易懂
- 功能丰富:支持读、写、导出、样式设置等
缺点
- 基本没有,硬要说的话就是多了一个依赖
实战感受
最后我选择了EasyExcel,结果真香!代码量直接减少了一半,而且可读性提升了好几个档次。
先加个依赖:
xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.4</version>
</dependency>
然后写个监听器:
java
public class BillDataListener implements ReadListener<Map<Integer, String>> {
private static final int BATCH_SIZE = 1000;
private List<BillEntity> dataList = new ArrayList<>();
private BillMapper mapper;
@Override
public void invoke(Map<Integer, String> data, AnalysisContext context) {
// 处理每一行数据
BillEntity bill = convertToBill(data);
dataList.add(bill);
// 达到批量大小就入库
if (dataList.size() >= BATCH_SIZE) {
saveToDB();
dataList.clear();
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 处理剩余数据
saveToDB();
}
private void saveToDB() {
if (!dataList.isEmpty()) {
mapper.insertBatch(dataList);
}
}
}
使用起来也超级简单:
java
EasyExcel.read(filePath, new BillDataListener(mapper))
.headRowNumber(1) // 表头行数
.sheet()
.doRead();
就这么简单!EasyExcel会自动流式读取,内存占用一直很稳定,我测试过50万行的文件,内存占用不到200M。
实战技巧
-
批量处理:别一行一行地插入数据库,攒够1000条再批量插入,性能提升10倍
-
headRowNumber(0):如果你要自己控制表头解析,就设置为0,让EasyExcel不跳过任何行
-
类型转换 :监听器收到的是
Map<Integer, Object>,不是String!需要自己转换:
java
private String convertToString(Object value) {
if (value == null) return "";
if (value instanceof String) return (String) value;
if (value instanceof Double) {
// 去掉.0
Double d = (Double) value;
if (d == d.longValue()) {
return String.valueOf(d.longValue());
}
return String.valueOf(d);
}
if (value instanceof Date) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value);
}
return value.toString();
}
- 日期格式兼容 :Excel中的日期可能是
2025/10/3这种斜杠格式,要多支持几种格式:
java
private Date parseDate(String dateStr) {
String[] patterns = {
"yyyy-MM-dd HH:mm:ss",
"yyyy/M/d HH:mm:ss",
"yyyy/MM/dd HH:mm:ss",
"yyyy-MM-dd",
"yyyy/M/d",
"yyyy/MM/dd"
};
for (String pattern : patterns) {
try {
return new SimpleDateFormat(pattern).parse(dateStr);
} catch (Exception e) {
// 继续尝试下一个格式
}
}
return null;
}
- 封装成抽象基类:如果你的项目有多个Excel解析场景,强烈建议封装一个抽象基类,避免重复代码:
java
public abstract class AbstractBaseExcelParser {
/**
* 流式处理Excel文件(带批量处理)
* @param filePath 文件路径
* @param batchSize 批次大小,建议1000
* @param batchProcessor 批处理回调函数
*/
protected void processExcelFileStreaming(Path filePath, int batchSize,
BiConsumer<Map<Integer, String>, List<Map<Integer, String>>> batchProcessor) throws Exception {
final Map<Integer, String> headerRowMap = new HashMap<>();
final List<Map<Integer, String>> batchRowData = new ArrayList<>(batchSize);
final int headerRowIndex = getTargetHeaderRowIndex();
EasyExcel.read(filePath.toFile(), new AnalysisEventListener<Map<Integer, Object>>() {
private int currentRow = 0;
private boolean headerParsed = false;
@Override
public void invoke(Map<Integer, Object> data, AnalysisContext context) {
// 解析表头
if (currentRow == headerRowIndex) {
data.forEach((key, value) -> {
if (value != null) {
headerRowMap.put(key, convertToString(value).trim());
}
});
headerParsed = true;
currentRow++;
return;
}
// 跳过表头前的行
if (!headerParsed || currentRow <= headerRowIndex) {
currentRow++;
return;
}
// 转换数据类型并过滤空行
Map<Integer, String> rowData = convertToStringMap(data);
if (!isEmptyRow(rowData)) {
batchRowData.add(rowData);
}
// 达到批次大小时触发回调
if (batchRowData.size() >= batchSize) {
batchProcessor.accept(headerRowMap, new ArrayList<>(batchRowData));
batchRowData.clear();
}
currentRow++;
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 处理剩余数据
if (!batchRowData.isEmpty()) {
batchProcessor.accept(headerRowMap, new ArrayList<>(batchRowData));
}
}
}).sheet(0).headRowNumber(0).doRead();
}
// 类型转换辅助方法
private Map<Integer, String> convertToStringMap(Map<Integer, Object> objectMap) {
Map<Integer, String> stringMap = new HashMap<>();
objectMap.forEach((key, value) -> {
if (value != null) {
stringMap.put(key, convertToString(value));
}
});
return stringMap;
}
// 子类需要实现的方法
protected abstract int getTargetHeaderRowIndex();
}
这样子类只需要关注业务逻辑,调用起来超级简单:
java
public class BillExcelParser extends AbstractBaseExcelParser {
@Override
protected int getTargetHeaderRowIndex() {
return 0; // 第一行是表头
}
public void parseAndSave(Path filePath) throws Exception {
processExcelFileStreaming(filePath, 1000, (headerMap, rowDataList) -> {
// 批量转换为实体
List<BillEntity> bills = rowDataList.stream()
.map(this::convertToBill)
.collect(Collectors.toList());
// 批量入库
billMapper.insertBatch(bills);
});
}
private BillEntity convertToBill(Map<Integer, String> rowData) {
// 根据headerMap找到对应的列,转换为实体
// ...
}
}
-
处理不同Excel格式的坑:
- 有些Excel文件前几行是说明文字,表头在第3行,这时候
getTargetHeaderRowIndex()返回2就行 - Excel中的数字会被读取为
Double类型,身份证号、订单号这种要特别处理,不然会变成科学计数法 - 空单元格EasyExcel会跳过,所以你的
Map<Integer, String>可能没有某些key - 表头有合并单元格时要小心,EasyExcel会把合并单元格的值放在第一个格子里
- 有些Excel文件前几行是说明文字,表头在第3行,这时候
-
内存优化的关键设计(重点!):
很多人用流式读取时会犯一个致命错误:直接存储POI的Row对象或Cell对象。千万别这么干!
错误示例:
java
// ❌ 错误:直接存储Row对象
List<Row> batchRows = new ArrayList<>();
EasyExcel.read(file, new AnalysisEventListener<Map<Integer, Object>>() {
@Override
public void invoke(Map<Integer, Object> data, AnalysisContext context) {
Row row = context.readRowHolder().getRow(); // 获取POI原始Row
batchRows.add(row); // 💣 内存泄漏!
if (batchRows.size() >= 1000) {
processRows(batchRows);
batchRows.clear(); // 清空了,但Row还持有Workbook引用!
}
}
}).doRead();
为什么会内存泄漏?因为Row对象内部持有对Sheet的引用,Sheet持有Workbook的引用,Workbook持有整个文件的数据结构。即使你clear()了List,这些Row对象还在你的批处理队列里,导致垃圾回收器无法释放Workbook占用的内存。
正确做法:立即转换为纯数据结构
java
// ✅ 正确:立即转换为Map<Integer, String>
private Map<Integer, String> convertToStringMap(Map<Integer, Object> objectMap) {
Map<Integer, String> stringMap = new HashMap<>(); // 纯数据结构
objectMap.forEach((key, value) -> {
if (value != null) {
stringMap.put(key, convertToString(value)); // 立即转String
}
});
return stringMap; // 返回的Map不持有任何POI对象引用
}
使用这个方法后:
java
List<Map<Integer, String>> batchRowData = new ArrayList<>(); // 存纯数据
EasyExcel.read(file, new AnalysisEventListener<Map<Integer, Object>>() {
@Override
public void invoke(Map<Integer, Object> data, AnalysisContext context) {
// 立即转换,不持有POI对象
Map<Integer, String> rowData = convertToStringMap(data);
batchRowData.add(rowData); // ✅ 安全!
if (batchRowData.size() >= 1000) {
processRows(batchRowData);
batchRowData.clear(); // 真正释放了内存
}
}
}).doRead();
效果对比:
- 错误方式:88MB文件,内存占用飙到2GB+,最后OOM
- 正确方式:88MB文件,内存稳定在20-50MB
这就是为什么我说convertToStringMap()是个巧妙的设计------它切断了与POI对象的引用链,让垃圾回收器能够及时回收已处理的数据。
- 内存监控 :开发的时候建议加上JVM参数
-Xmx512m -XX:+PrintGCDetails,看看内存到底占用多少,心里有个数
最终建议
根据我的实战经验,给出以下建议:
-
新项目直接用EasyExcel
- 不用纠结,闭着眼睛选就行
- 性能好、代码简洁、维护方便
-
老项目用POI 4.x的,可以试试excel-streaming-reader
- 改动成本低,代码侵入性小
- 但要评估一下后续是否会升级POI版本
-
POI 5.x的项目,要么用EasyExcel,要么别折腾了
- excel-streaming-reader不兼容
- SAX模型代码复杂度太高,不值得
-
实在想用POI 5.x的SAX,请三思
- 除非你有特殊需求(比如必须用POI的某些高级特性)
- 做好写复杂代码和长期维护的准备
总结
处理大型Excel文件,技术选型真的很重要。不要盲目追求性能,代码的可维护性同样重要。
我的最终选择:EasyExcel
理由很简单:
- 性能够用
- 代码简洁
- 维护方便
- 社区活跃
- 坑少
如果你也在纠结用哪个库,希望这篇文章能帮到你。少踩坑,早下班!
写于2025年10月,基于实际项目经验总结