处理大型excel文件的技术选型
处理大型excel文件的技术选型
前言
最近在做财务系统的时候,遇到了一个头疼的问题:客户经常要导入几十万行的Excel账单文件,用传统的POI直接读取,内存直接爆了。这不,就踩坑踩了一圈,把几个主流的解决方案都试了个遍,今天就来聊聊大型Excel文件到底该怎么处理。
问题背景
我们的场景很简单:财务人员需要导入各个平台(美团、抖音、京东等)导出的Excel账单,少则几千行,多则几十万行。一开始用POI的普通读取方式,结果10万行的文件直接OOM了。
这时候你就会发现,Excel大文件处理是个坑,不是所有的库都能hold住。
方案一:POI 5.0 的 SAX 模型
优点
- 内存占用低:真正的流式读取,理论上可以处理无限大的文件
- 官方支持:Apache POI 是官方库,不用担心兼容性问题
- 性能优秀:底层基于事件驱动,速度快
缺点(重点来了)
- 代码复杂度爆表:你需要实现一堆回调接口,什么 SharedStringsTable、XSSFReader、ContentHandler,看着就头大
- 维护成本高:半年后你自己都不想碰这段代码,更别说接手的同事了
- 学习曲线陡峭:新人基本看不懂,还得花时间培训
实战感受
我试着用SAX模型写了一版,光是处理表头和数据解析就写了200多行代码,还得处理各种边界情况。代码看起来像这样:
class MyXSSFSheetXMLHandler implements XSSFSheetXMLHandler.SheetContentsHandler {@Overridepublic void startRow(int rowNum) {// 处理行开始}@Overridepublic void cell(String cellReference, String formattedValue, XSSFComment comment) {// 处理每个单元格// 还得自己解析列索引...}@Overridepublic 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
实战感受
我一开始很兴奋地引入了这个库,代码写起来确实舒服:
try (InputStream is = new FileInputStream(file);Workbook workbook = StreamingReader.builder().rowCacheSize(100).bufferSize(4096).open(is)) {for (Sheet sheet : workbook) {for (Row r : sheet) {// 直接遍历,多简单!}}
}
但是!运行的时候直接崩溃:
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,结果真香!代码量直接减少了一半,而且可读性提升了好几个档次。
先加个依赖:
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.3.4</version>
</dependency>
然后写个监听器:
public class BillDataListener implements ReadListener<Map<Integer, String>> {private static final int BATCH_SIZE = 1000;private List<BillEntity> dataList = new ArrayList<>();private BillMapper mapper;@Overridepublic void invoke(Map<Integer, String> data, AnalysisContext context) {// 处理每一行数据BillEntity bill = convertToBill(data);dataList.add(bill);// 达到批量大小就入库if (dataList.size() >= BATCH_SIZE) {saveToDB();dataList.clear();}}@Overridepublic void doAfterAllAnalysed(AnalysisContext context) {// 处理剩余数据saveToDB();}private void saveToDB() {if (!dataList.isEmpty()) {mapper.insertBatch(dataList);}}
}
使用起来也超级简单:
EasyExcel.read(filePath, new BillDataListener(mapper)).headRowNumber(1)  // 表头行数.sheet().doRead();
就这么简单!EasyExcel会自动流式读取,内存占用一直很稳定,我测试过50万行的文件,内存占用不到200M。
实战技巧
- 
批量处理:别一行一行地插入数据库,攒够1000条再批量插入,性能提升10倍 
- 
headRowNumber(0):如果你要自己控制表头解析,就设置为0,让EasyExcel不跳过任何行 
- 
类型转换:监听器收到的是 Map<Integer, Object>,不是String!需要自己转换:
private String convertToString(Object value) {if (value == null) return "";if (value instanceof String) return (String) value;if (value instanceof Double) {// 去掉.0Double 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这种斜杠格式,要多支持几种格式:
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解析场景,强烈建议封装一个抽象基类,避免重复代码:
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;@Overridepublic 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++;}@Overridepublic 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();
}
这样子类只需要关注业务逻辑,调用起来超级简单:
public class BillExcelParser extends AbstractBaseExcelParser {@Overrideprotected 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对象。千万别这么干!
错误示例:
// ❌ 错误:直接存储Row对象
List<Row> batchRows = new ArrayList<>();EasyExcel.read(file, new AnalysisEventListener<Map<Integer, Object>>() {@Overridepublic void invoke(Map<Integer, Object> data, AnalysisContext context) {Row row = context.readRowHolder().getRow();  // 获取POI原始RowbatchRows.add(row);  // 💣 内存泄漏!if (batchRows.size() >= 1000) {processRows(batchRows);batchRows.clear();  // 清空了,但Row还持有Workbook引用!}}
}).doRead();
为什么会内存泄漏?因为Row对象内部持有对Sheet的引用,Sheet持有Workbook的引用,Workbook持有整个文件的数据结构。即使你clear()了List,这些Row对象还在你的批处理队列里,导致垃圾回收器无法释放Workbook占用的内存。
正确做法:立即转换为纯数据结构
// ✅ 正确:立即转换为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对象引用
}
使用这个方法后:
List<Map<Integer, String>> batchRowData = new ArrayList<>();  // 存纯数据EasyExcel.read(file, new AnalysisEventListener<Map<Integer, Object>>() {@Overridepublic 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月,基于实际项目经验总结
