一、 当Excel遇上大数据:一个常见的烦恼

大家好,相信很多做后端开发的朋友都遇到过这样的场景:业务方提了个需求,要导出一个包含几十万甚至上百万行数据的报表。你兴冲冲地用最熟悉的Apache POI写好了导出功能,在本地用几百条数据测试时一切正常。可一旦上了生产环境,面对真实的海量数据,服务器内存瞬间飙升,甚至直接“OOM”(内存溢出)崩溃,页面卡死,用户体验极差。

这个问题的根源在于处理Excel的方式。传统的Apache POI在处理大文件时,需要将整个文档,包括所有的行、列、单元格样式,全部加载到内存中形成一个对象模型。数据量一大,这个模型就会变得异常臃肿,吃掉大量内存。今天,我们就来聊聊两个Java处理Excel的利器:老牌的Apache POI和后起之秀EasyExcel,重点对比它们处理大规模文件时的性能表现,并分享如何进行内存优化。

简单来说,你可以把Apache POI的读取方式想象成把一整本厚厚的书全部背下来再去理解,而EasyExcel的方式则是边翻书边读,读一页,理解一页,然后继续下一页,这样大脑(内存)的负担就小多了。

二、 技术选型初探:Apache POI vs. EasyExcel

在深入代码之前,我们先快速了解一下这两位“选手”的基本特点。

Apache POI 是Apache软件基金会的开源项目,可以说是Java处理Office文档的“老大哥”,功能非常全面,支持读写.xls和.xlsx格式,能精细控制单元格样式、公式、图表等。它的核心模型是“全量加载”,对于复杂的、数据量不大的Excel操作游刃有余。

EasyExcel 是阿里巴巴开源的一个组件,它主打的就是“简单”和“省内存”。它的核心优势在于读写大文件。其原理是采用“逐行解析”的SAX模式,结合POI的底层解析能力,在读取时并不会一次性创建所有行的对象,而是通过监听器(Listener)一行行处理,因此内存占用非常稳定,几乎不会随着数据行数增加而暴涨。

那么,面对“大规模数据导出/导入”这个具体场景,我们该如何选择呢?如果你的文件只有几百几千行,或者对单元格样式、公式有非常复杂的操作,POI更合适。但如果你面临的是动辄几十万、上百万行的纯数据导入导出,EasyExcel在性能和内存消耗上具有压倒性优势。接下来,我们通过具体的代码示例来感受这种差异。

三、 实战演练:Apache POI处理大文件的挑战

为了公平对比,我们使用单一技术栈:Java 8 + Maven。首先,我们看看使用Apache POI的SXSSF(Streaming Usermodel API)来处理大文件导出。SXSSF是POI针对大文件提供的优化版本,它会在内存中只保留一部分行(一个滑动窗口),将之前的行写入临时文件,从而控制内存使用。

技术栈:Java 8 + Apache POI 5.x

import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import java.io.FileOutputStream;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 使用Apache POI SXSSF导出大规模数据示例
 * 模拟导出100万行,5列数据
 */
public class PoiLargeExcelExport {

    public static void main(String[] args) {
        // 记录开始时间
        long startTime = System.currentTimeMillis();
        // 创建一个SXSSFWorkbook,并设置滑动窗口大小为100行
        // 这意味着内存中最多保留100行数据,之前的行会被刷写到临时文件
        try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
            // 创建一个工作表
            SXSSFSheet sheet = workbook.createSheet("百万数据表");
            
            // 创建标题行
            Row headerRow = sheet.createRow(0);
            String[] headers = {"ID", "姓名", "年龄", "部门", "入职日期"};
            for (int i = 0; i < headers.length; i++) {
                Cell cell = headerRow.createCell(i);
                cell.setCellValue(headers[i]);
            }
            
            // 使用AtomicInteger作为线程安全的计数器
            AtomicInteger rowCounter = new AtomicInteger(1); // 从第1行开始(0行是标题)
            int totalRows = 1_000_000; // 模拟100万条数据
            
            // 批量写入数据
            for (int i = 0; i < totalRows; i++) {
                // 创建新行
                Row row = sheet.createRow(rowCounter.getAndIncrement());
                // 填充数据
                row.createCell(0).setCellValue(i + 1); // ID
                row.createCell(1).setCellValue("员工" + (i + 1)); // 姓名
                row.createCell(2).setCellValue(20 + (i % 40)); // 年龄
                row.createCell(3).setCellValue("部门" + ((i % 5) + 1)); // 部门
                row.createCell(4).setCellValue("2023-01-01"); // 入职日期
                
                // 每写入10000行,手动刷新一下数据到磁盘(非必须,但有助于观察)
                if (i % 10000 == 0) {
                    sheet.flushRows(100); // 将内存中的行刷写到临时文件
                }
            }
            
            // 将最终的工作簿写入文件
            try (FileOutputStream fos = new FileOutputStream("poi_large_export.xlsx")) {
                workbook.write(fos);
            }
            // 清理临时文件
            workbook.dispose();
            
            long endTime = System.currentTimeMillis();
            System.out.println("POI导出完成,耗时:" + (endTime - startTime) + " 毫秒");
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

代码解读与注意事项:

  1. SXSSFWorkbook(100):参数100定义了“行访问窗口大小”。这是POI优化内存的关键,但需要权衡:窗口太小会增加磁盘I/O,太大则内存占用高。
  2. flushRows():可以手动控制刷写时机,但在连续写入场景下,SXSSF会自动管理。
  3. 内存与磁盘的权衡:SXSSF通过生成临时文件来节省内存,在数据量极大时,可能会产生大量的磁盘读写,影响速度并占用磁盘空间。
  4. 样式限制:在SXSSF模式下,对已经刷写到磁盘的行进行样式修改会比较麻烦,通常建议在创建行时一次性设置好样式。

尽管SXSSF做了优化,但在导出超大规模数据(如500万行以上)时,其性能和临时文件管理仍可能成为瓶颈。而且,在读取大文件方面,POI的XSSF(对应.xlsx)依然采用全量内存模型,非常容易导致OOM。

四、 破局之道:EasyExcel的优雅解决方案

现在,让我们看看EasyExcel如何以更优雅的方式解决同样的问题。EasyExcel的核心思想是事件驱动和监听器。

技术栈:Java 8 + EasyExcel 3.x

首先,我们定义一个数据对象,这代表了Excel中的一行数据。

import lombok.Data; // 使用了Lombok简化代码,需引入依赖
import java.util.Date;

/**
 * 员工数据模型,对应Excel中的一行
 */
@Data
public class EmployeeData {
    // 使用EasyExcel的注解来定义列顺序和标题
    @ExcelProperty(value = "ID", index = 0)
    private Integer id;
    
    @ExcelProperty(value = "姓名", index = 1)
    private String name;
    
    @ExcelProperty(value = "年龄", index = 2)
    private Integer age;
    
    @ExcelProperty(value = "部门", index = 3)
    private String department;
    
    @ExcelProperty(value = "入职日期", index = 4)
    private Date joinDate;
}

接下来,我们进行大文件写入(导出)

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * 使用EasyExcel导出大规模数据示例
 * 演示分页查询模拟数据并写入
 */
public class EasyExcelLargeExport {

    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        String fileName = "easyexcel_large_export.xlsx";
        
        // 1. 初始化写入器
        try (ExcelWriterBuilder writerBuilder = EasyExcel.write(fileName, EmployeeData.class)) {
            // 2. 开始写入(这里会自动管理资源)
            writerBuilder.sheet("员工数据").doWrite(data -> {
                // 3. 这是一个数据生成器,可以分批产生数据,模拟分页查询数据库
                int totalPage = 1000; // 假设分1000页,每页1000条,共100万条
                for (int page = 0; page < totalPage; page++) {
                    // 模拟从数据库查询一页数据
                    List<EmployeeData> pageData = generatePageData(page, 1000);
                    // 将这一页数据提供给EasyExcel写入
                    data.accept(pageData);
                }
            });
        }
        
        long endTime = System.currentTimeMillis();
        System.out.println("EasyExcel导出完成,耗时:" + (endTime - startTime) + " 毫秒");
    }
    
    /**
     * 模拟生成一页数据
     * @param pageNum 页码
     * @param pageSize 每页大小
     * @return 一页员工数据列表
     */
    private static List<EmployeeData> generatePageData(int pageNum, int pageSize) {
        List<EmployeeData> list = new ArrayList<>(pageSize);
        int startId = pageNum * pageSize;
        for (int i = 0; i < pageSize; i++) {
            EmployeeData data = new EmployeeData();
            data.setId(startId + i + 1);
            data.setName("员工" + (startId + i + 1));
            data.setAge(20 + ((startId + i) % 40));
            data.setDepartment("部门" + (((startId + i) % 5) + 1));
            data.setJoinDate(new Date());
            list.add(data);
        }
        return list;
    }
}

代码解读:

  1. doWrite(Consumer<WriteData>):这是关键!它允许你以“流”的方式提供数据。你不需要一次性准备好所有100万条数据放在一个巨大的List里,而是可以分批次(比如每次从数据库查1000条)提供。EasyExcel会写完一批,释放一批,内存中始终只保持少量数据。
  2. 模型驱动:通过EmployeeData类与注解,清晰定义了映射关系,代码可读性高。
  3. 自动资源管理:使用try-with-resources语句,确保文件流正确关闭。

下面再看大文件读取(导入),这是EasyExcel优势最明显的场景。

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import java.util.ArrayList;
import java.util.List;

/**
 * 使用EasyExcel读取大规模Excel文件示例
 * 使用监听器逐行处理,内存友好
 */
public class EasyExcelLargeRead {

    public static void main(String[] args) {
        String fileName = "easyexcel_large_export.xlsx"; // 读取刚才生成的文件
        long startTime = System.currentTimeMillis();
        
        // 定义一个数据批处理监听器
        List<EmployeeData> cachedDataList = new ArrayList<>(); // 用于临时缓存
        EasyExcel.read(fileName, EmployeeData.class, new ReadListener<EmployeeData>() {
            /** 批处理大小,每积累到一定数量处理一次,然后清空列表 */
            private static final int BATCH_COUNT = 3000;
            
            /**
             * 每解析一行数据,会调用此方法
             * @param data 一行数据对应的对象
             * @param context 分析上下文
             */
            @Override
            public void invoke(EmployeeData data, AnalysisContext context) {
                cachedDataList.add(data);
                // 达到BATCH_COUNT时,处理一批数据,然后清空列表,防止内存占用过多
                if (cachedDataList.size() >= BATCH_COUNT) {
                    saveData(cachedDataList); // 模拟保存数据到数据库
                    cachedDataList.clear(); // 清空列表,释放内存
                }
            }
            
            /**
             * 所有数据解析完成后,会调用此方法
             * @param context 分析上下文
             */
            @Override
            public void doAfterAllAnalysed(AnalysisContext context) {
                // 确保最后一批不足BATCH_COUNT的数据也被处理
                if (!cachedDataList.isEmpty()) {
                    saveData(cachedDataList);
                    cachedDataList.clear();
                }
                System.out.println("所有数据解析并处理完毕!");
            }
            
            /**
             * 模拟将数据保存到数据库或进行其他业务处理
             * @param list 一批数据
             */
            private void saveData(List<EmployeeData> list) {
                // 这里可以是mybatis批量插入、写入消息队列等操作
                System.out.println("处理了 " + list.size() + " 条数据");
                // 实际业务中,这里执行数据库保存逻辑
            }
            
        }).sheet().doRead(); // 开始读取第一个工作表
        
        long endTime = System.currentTimeMillis();
        System.out.println("EasyExcel读取完成,总耗时:" + (endTime - startTime) + " 毫秒");
    }
}

代码解读:

  1. 监听器模式:这是EasyExcel的灵魂。ReadListener让你可以像流水线一样处理数据,来一行,处理一行,内存中不会堆积所有数据对象。
  2. 批处理:示例中我们设置了BATCH_COUNT=3000,每攒够3000条数据,就模拟一次数据库批量插入,然后清空缓存列表。这进一步优化了内存使用,并提升了批量操作的效率。
  3. 内存占用恒定:无论文件是1万行还是1000万行,内存占用主要取决于BATCH_COUNT的大小,而不会线性增长,从根本上避免了OOM。

五、 深入对比与优化指南

通过上面的示例,我们可以总结出一些核心的对比点和优化思路:

应用场景:

  • Apache POI (SXSSF):适用于需要复杂样式、公式、图表,且数据量在几十万到百万级之间的写入场景。对于大文件读取依然乏力。
  • EasyExcel大规模数据导入导出的首选。尤其适合百万行、千万行级别的纯数据读写,业务逻辑是“读取-处理-存储”或“查询-写入”的流水线模式。对样式支持相对基础。

技术优缺点:

  • Apache POI
    • 优点:功能极其全面,官方维护,社区成熟,可控性强。
    • 缺点:默认模式内存消耗大;SXSSF优化后仍依赖临时文件,有磁盘IO开销;API相对复杂。
  • EasyExcel
    • 优点:内存占用极低,性能优异(尤其读取),API简洁易用,与Spring集成方便。
    • 缺点:对Excel高级特性(如复杂样式、公式计算、图表)支持较弱;社区生态相对POI小一些。

注意事项:

  1. POI的临时文件:使用SXSSF时,注意监控服务器磁盘空间,大量并发导出可能写满磁盘。可以通过SXSSFWorkbook.setCompressTempFiles(true)尝试压缩临时文件。
  2. EasyExcel的监听器:监听器中的invoke方法不要做太耗时的同步操作,否则会成为读取瓶颈。对于数据库保存,强烈建议采用批处理。
  3. 数据类型转换:两者都需要注意Excel单元格类型与Java对象类型的匹配,日期、数字格式等容易出问题,要做好异常处理和数据校验。
  4. 线程安全:EasyExcel的监听器默认不是线程安全的,在并发读时要注意。通常每个读任务应创建独立的监听器实例。

内存优化核心思想: 无论是使用POI的SXSSF还是EasyExcel,优化内存的本质在于 “化整为零”“即用即弃”

  • 写入时:不要一次性组装所有数据的集合,而应通过分页查询、分批生成的方式,写一批,丢一批。
  • 读取时:不要试图把所有数据都拿到内存里再处理,而要像流水线一样,解析一行/一批,处理一行/一批,然后立刻丢弃。
  • 对象复用:在极限优化场景下,可以考虑在监听器或循环内复用数据对象,减少GC压力,但这会牺牲代码清晰度,需谨慎使用。

六、 总结

处理大规模Excel文件,已经从一种简单的功能实现,演变为对应用稳定性和性能的考验。Apache POI作为功能全面的老将,通过SXSSF模式在写入方面提供了可行的解决方案。而EasyExcel则凭借其新颖的监听器模型和流式处理思想,在大数据量读写,特别是导入场景下,展现了卓越的性能和内存控制能力,极大地简化了开发。

对于开发者来说,选择哪个工具不再是难题。面对复杂的、小数据量的Office文档操作,选Apache POI;面对海量数据的导入导出,毫不犹豫地选择EasyExcel。 更重要的是,理解其背后“流式处理”的思想,并将这种思想应用到其他大数据量处理的场景中,这才是我们技术探讨的最大价值。

希望这篇对比能帮助你在下次面对“百万数据导出”需求时,能够从容不迫,写出既高效又稳定的代码。