处理大型excel文件的技术选型

处理大型excel文件的技术选型

前言

最近在做财务系统的时候,遇到了一个头疼的问题:客户经常要导入几十万行的Excel账单文件,用传统的POI直接读取,内存直接爆了。这不,就踩坑踩了一圈,把几个主流的解决方案都试了个遍,今天就来聊聊大型Excel文件到底该怎么处理。

问题背景

我们的场景很简单:财务人员需要导入各个平台(美团、抖音、京东等)导出的Excel账单,少则几千行,多则几十万行。一开始用POI的普通读取方式,结果10万行的文件直接OOM了。

这时候你就会发现,Excel大文件处理是个坑,不是所有的库都能hold住。

方案一:POI 5.0 的 SAX 模型

优点

  • 内存占用低:真正的流式读取,理论上可以处理无限大的文件
  • 官方支持:Apache POI 是官方库,不用担心兼容性问题
  • 性能优秀:底层基于事件驱动,速度快

缺点(重点来了)

  • 代码复杂度爆表 :你需要实现一堆回调接口,什么 SharedStringsTableXSSFReaderContentHandler,看着就头大
  • 维护成本高:半年后你自己都不想碰这段代码,更别说接手的同事了
  • 学习曲线陡峭:新人基本看不懂,还得花时间培训

实战感受

我试着用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。

实战技巧

  1. 批量处理:别一行一行地插入数据库,攒够1000条再批量插入,性能提升10倍

  2. headRowNumber(0):如果你要自己控制表头解析,就设置为0,让EasyExcel不跳过任何行

  3. 类型转换 :监听器收到的是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();
}
  1. 日期格式兼容 :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;
}
  1. 封装成抽象基类:如果你的项目有多个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找到对应的列,转换为实体
        // ...
    }
}
  1. 处理不同Excel格式的坑

    • 有些Excel文件前几行是说明文字,表头在第3行,这时候getTargetHeaderRowIndex()返回2就行
    • Excel中的数字会被读取为Double类型,身份证号、订单号这种要特别处理,不然会变成科学计数法
    • 空单元格EasyExcel会跳过,所以你的Map<Integer, String>可能没有某些key
    • 表头有合并单元格时要小心,EasyExcel会把合并单元格的值放在第一个格子里
  2. 内存优化的关键设计(重点!):

很多人用流式读取时会犯一个致命错误:直接存储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对象的引用链,让垃圾回收器能够及时回收已处理的数据。

  1. 内存监控 :开发的时候建议加上JVM参数 -Xmx512m -XX:+PrintGCDetails,看看内存到底占用多少,心里有个数

最终建议

根据我的实战经验,给出以下建议:

  1. 新项目直接用EasyExcel

    • 不用纠结,闭着眼睛选就行
    • 性能好、代码简洁、维护方便
  2. 老项目用POI 4.x的,可以试试excel-streaming-reader

    • 改动成本低,代码侵入性小
    • 但要评估一下后续是否会升级POI版本
  3. POI 5.x的项目,要么用EasyExcel,要么别折腾了

    • excel-streaming-reader不兼容
    • SAX模型代码复杂度太高,不值得
  4. 实在想用POI 5.x的SAX,请三思

    • 除非你有特殊需求(比如必须用POI的某些高级特性)
    • 做好写复杂代码和长期维护的准备

总结

处理大型Excel文件,技术选型真的很重要。不要盲目追求性能,代码的可维护性同样重要。

我的最终选择:EasyExcel

理由很简单:

  • 性能够用
  • 代码简洁
  • 维护方便
  • 社区活跃
  • 坑少

如果你也在纠结用哪个库,希望这篇文章能帮到你。少踩坑,早下班!


写于2025年10月,基于实际项目经验总结

相关推荐
技术钱18 小时前
vue3前端解析excel文件
前端·vue.js·excel
VBAMatrix21 小时前
数据重构!按一级科目拆分序时账,批量生成明细账
excel·财务·审计·会计师事务所·tb工具箱·明细账
缺点内向1 天前
Java 使用 Spire.XLS 库合并 Excel 文件实践
java·开发语言·excel
焚 城1 天前
EXCEL(带图)转html【uni版】
前端·html·excel
**蓝桉**1 天前
EXCEL 函数
excel
工业甲酰苯胺1 天前
Excel高性能异步导出完整方案!
excel
洋就在江州2 天前
jeecgboot 使用apache poi excel导入带图片
java·apache·excel
腾蛇月猫2 天前
Excel转VCF文件一键导入通讯录联系人信息
javascript·excel·vcf
best_scenery2 天前
excel中加载数据分析工具的步骤
大数据·数据分析·excel