处理大型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 小时前
【苍穹外卖 Day12| Excel 入门】
excel
用户298698530141 天前
Python 数据处理:XML 与 Excel 互转的实用指南
后端·python·excel
xiaopai9452 天前
采购系统和Excel表格区别:钢结构项目物资管理的真实较量
excel·建米软件·采购系统
协享科技2 天前
2026 年 AI 表格工具怎么选:ChatGPT、原生 Copilot/Gemini 与公式助手
人工智能·chatgpt·excel·copilot
用户298698530142 天前
前端实战:在 React 中使用 JavaScript 一键导出 Excel 图表与形状为图片
javascript·react.js·excel
Q一件事2 天前
arcpy实现分区统计到一张excel中
excel
AI刀刀2 天前
deepseek 内容粘贴后符号丢失怎么办?AI 导出鸭实测解决排版乱码问题
android·人工智能·excel·ai导出鸭
圣光SG3 天前
Excel 函数大全
算法·excel
小小的木头人4 天前
Python 批量解析 Excel 经纬度,调用高德地图 API 获取中文地址
开发语言·python·excel
观远数据4 天前
从离线开发到实时同步:DataFlow如何支撑企业级数据治理闭环
java·windows·microsoft·excel