杰瑞科技汇

Java Excel大数据导入如何高效处理?

核心挑战:为什么POI会OOM?

Apache POI提供了两种主要的API来操作Excel:

Java Excel大数据导入如何高效处理?-图1
(图片来源网络,侵删)
  1. 用户模型XSSFWorkbook (.xlsx) 和 HSSFWorkbook (.xls)。

    • 原理:它会将整个Excel文件(包括所有单元格、样式、公式等)全部加载到内存中,形成一个类似DOM树的结构。
    • 缺点:对于大文件,内存消耗巨大,一个几MB的Excel文件可能需要几百MB甚至上GB的内存,当数据量超过可用内存时,必然会抛出 OutOfMemoryError
    • 适用场景:小文件处理,或者需要对Excel进行复杂读写操作(如修改样式、公式)的场景。
  2. SAX (事件) 模型XSSFSAX 结合使用。

    • 原理:它不会一次性加载整个文件,而是像解析XML一样,按行读取Excel文件,当解析到一行数据时,会触发一个事件,你可以在这个事件中处理当前行的数据,处理完后该行的数据就会被丢弃,从而极大地减少了内存占用。
    • 优点:内存占用非常小,几乎是恒定的,非常适合处理大文件。
    • 缺点:API相对复杂,不能直接修改或写入Excel(只能读取)。
    • 适用场景大数据导入、导出是它的最佳用武之地。

Apache POI SAX模式 (经典方案)

这是处理大数据最经典、最可靠的方式之一,我们将使用 XSSFSAX 来解析 .xlsx 文件。

添加依赖

确保你的 pom.xml 中有 poipoi-ooxml 依赖。

Java Excel大数据导入如何高效处理?-图2
(图片来源网络,侵删)
<dependencies>
    <!-- Apache POI Core -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi</artifactId>
        <version>5.2.4</version> <!-- 使用较新版本 -->
    </dependency>
    <!-- Apache POI for OOXML (.xlsx) format -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>5.2.4</version>
    </dependency>
    <!-- 如果需要处理旧版.xls,还需要 poi-scratchpad -->
</dependencies>

实现SAX解析器

我们需要创建一个继承自 org.apache.poi.xssf.eventusermodel.XSSFReader 的解析器,并实现 org.xml.sax.helpers.DefaultHandler 来处理解析事件。

import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFReader.SheetIterator;
import org.apache.poi.xssf.model.SharedStringsTable;
import org.apache.poi.xssf.usermodel.XSSFRichTextString;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
import org.xml.sax.helpers.XMLReaderFactory;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
public class ExcelSaxParser {
    private SharedStringsTable sst;
    private String lastContents;
    private boolean nextIsString;
    private int sheetIndex = -1;
    private List<String> rowValues = new ArrayList<>();
    public void parse(InputStream inputStream) throws Exception {
        OPCPackage pkg = OPCPackage.open(inputStream);
        XSSFReader r = new XSSFReader(pkg);
        sst = r.getSharedStringTable();
        XMLReader parser = XMLReaderFactory.createXMLReader();
        parser.setContentHandler(new SheetHandler());
        // 遍历所有Sheet
        SheetIterator sheets = (SheetIterator) r.getSheetsData();
        while (sheets.hasNext()) {
            sheetIndex++;
            InputStream sheetStream = sheets.next();
            InputSource sheetSource = new InputSource(sheetStream);
            parser.parse(sheetSource);
            sheetStream.close();
        }
    }
    private class SheetHandler extends DefaultHandler {
        private boolean firstRow = true; // 是否是第一行(表头)
        private int currentColumn = -1;
        @Override
        public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
            // c => 单元格
            if (qName.equals("c")) {
                // 获取单元格的索引,如 A1, B2
                String cellIndex = attributes.getValue("r");
                currentColumn = this.getCellIndex(cellIndex);
                // 如果下一个元素是 SST 的索引,则说明本单元格的值是字符串
                String cellType = attributes.getValue("t");
                if (cellType != null && cellType.equals("s")) {
                    nextIsString = true;
                } else {
                    nextIsString = false;
                }
            }
            // 清空上次内容
            lastContents = "";
        }
        @Override
        public void endElement(String uri, String localName, String qName) throws SAXException {
            // v => 单元格的值
            if (qName.equals("v")) {
                if (nextIsString) {
                    // 如果是 SST 的索引,则从 SST 中获取字符串
                    int idx = Integer.parseInt(lastContents);
                    lastContents = new XSSFRichTextString(sst.getItemAt(idx)).getString();
                }
                // 将单元格的值存入行数据列表
                if (currentColumn >= rowValues.size()) {
                    for (int i = rowValues.size(); i <= currentColumn; i++) {
                        rowValues.add("");
                    }
                }
                rowValues.set(currentColumn, lastContents.trim());
            }
            // row => 行结束
            else if (qName.equals("row")) {
                // 在这里处理一行数据
                processRow(rowValues, firstRow);
                firstRow = false;
                // 清空行数据,准备下一行
                rowValues.clear();
                currentColumn = -1;
            }
        }
        @Override
        public void characters(char[] ch, int start, int length) throws SAXException {
            lastContents += new String(ch, start, length);
        }
        // 根据单元格坐标 (如 "A1", "B2") 获取列索引 (0, 1, 2...)
        private int getCellIndex(String cellRef) {
            String ref = cellRef.replaceAll("\\d", "");
            int index = 0;
            for (int i = 0; i < ref.length(); i++) {
                char c = ref.charAt(i);
                index = index * 26 + (c - 'A' + 1);
            }
            return index - 1;
        }
    }
    // 处理一行数据的回调方法
    private void processRow(List<String> rowValues, boolean isHeader) {
        if (isHeader) {
            System.out.println("处理表头: " + rowValues);
            // 这里可以解析表头,建立列名和字段的映射关系
        } else {
            System.out.println("处理数据行: " + rowValues);
            // 在这里将 rowValues 写入数据库
            // saveToDatabase(rowValues);
        }
    }
}

使用示例

import java.io.FileInputStream;
public class Main {
    public static void main(String[] args) {
        String filePath = "path/to/your/large_file.xlsx";
        try (InputStream inputStream = new FileInputStream(filePath)) {
            ExcelSaxParser parser = new ExcelSaxParser();
            parser.parse(inputStream);
            System.out.println("Excel文件解析完成!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

EasyExcel (阿里巴巴开源,强烈推荐)

EasyExcel是阿里巴巴开源的一款针对Excel处理的项目,它解决了POI内存占用高的问题,底层也使用了SAX模式,它的API设计得非常友好,极大地降低了使用门槛。

添加依赖

<dependencies>
    <!-- easyexcel 核心依赖 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>easyexcel</artifactId>
        <version>3.3.2</version> <!-- 使用较新版本 -->
    </dependency>
</dependencies>

定义数据模型

为了方便数据映射,我们需要创建一个与Excel表头对应的Java类。

import com.alibaba.excel.annotation.ExcelProperty;
// 对应Excel的每一行数据
public class UserData {
    // "姓名" 是Excel中的表头
    @ExcelProperty("姓名")
    private String name;
    // "年龄" 是Excel中的表头
    @ExcelProperty("年龄")
    private Integer age;
    // "邮箱" 是Excel中的表头
    @ExcelProperty("邮箱")
    private String email;
    // Getters and Setters (必须要有)
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Integer getAge() { return age; }
    public void setAge(Integer age) { this.age = age; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    @Override
    public String toString() {
        return "UserData{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", email='" + email + '\'' +
                '}';
    }
}

创建监听器并读取

EasyExcel通过监听器模式来逐行读取数据,非常灵活。

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import java.util.ArrayList;
import java.util.List;
public class EasyExcelReadDemo {
    // 模拟数据库存储
    private static List<UserData> userDataList = new ArrayList<>();
    public static void main(String[] args) {
        String fileName = "path/to/your/large_file.xlsx";
        // 这里 需要指定读用哪个class去读,然后读取第一个sheet
        EasyExcel.read(fileName, UserData.class, new UserDataListener()).sheet().doRead();
    }
    /**
     * 模拟一个DAO,DAO暂时不会进行数据库操作
     */
    public void saveData(UserData userData) {
        userDataList.add(userData);
        System.out.println("存储数据: " + userData);
        // 实际项目中,这里应该调用你的Service或Mapper将数据存入数据库
        // 为了演示,我们每100条提交一次
        if (userDataList.size() % 100 == 0) {
            // batchSaveToDatabase();
            userDataList.clear();
        }
    }
    /**
     * 数据的监听器,用于接收解析的数据
     */
    public static class UserDataListener extends AnalysisEventListener<UserData> {
        // 每隔5条存储数据库,实际使用中可以3000条,然后清理list ,方便内存回收
        private static final int BATCH_COUNT = 5;
        // 缓存的数据
        private List<UserData> cachedDataList = new ArrayList<>(BATCH_COUNT);
        @Override
        public void invoke(UserData data, AnalysisContext context) {
            // System.out.println("解析到一条数据: " + data);
            cachedDataList.add(data);
            // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据量太大导致OOM
            if (cachedDataList.size() >= BATCH_COUNT) {
                saveData();
                // 存储完成清理 list
                cachedDataList.clear();
            }
        }
        @Override
        public void doAfterAllAnalysed(AnalysisContext context) {
            // 这里也要保存数据,确保最后遗留的数据也存储到数据库
            saveData();
            System.out.println("所有数据解析完成!");
        }
        private void saveData() {
            for (UserData data : cachedDataList) {
                saveData(data);
            }
        }
    }
}

EasyExcel的优势:

  • API简单:无需手动编写复杂的SAX解析逻辑,通过注解和监听器就能快速上手。
  • 内存优化:底层基于SAX,内存占用极低,官方宣称读取100M的Excel文件内存消耗不到1M。
  • 功能强大:支持多种数据格式、自定义转换器、多Sheet读取等。
  • 活跃社区:由阿里巴巴维护,文档完善,问题响应快。

数据库直接导入 (最高效)

如果数据量极其庞大(例如千万级),并且对导入性能有极致要求,可以考虑先将Excel文件转换为数据库支持的格式(如CSV),然后利用数据库的批量导入工具。

步骤:

  1. 转换格式:使用脚本(如Python的pandas库)或工具将 .xlsx 文件另存为 .csv 文件,CSV是纯文本格式,体积更小,处理更快。
  2. 使用数据库工具
    • MySQL: 使用 LOAD DATA INFILE 命令,这是MySQL官方提供的最高效的数据导入方式,速度极快。
    • PostgreSQL: 使用 COPY 命令。
    • Oracle: 使用 SQL*Loader 工具。
    • SQL Server: 使用 bcp 实用工具。

示例 (MySQL):

假设你有一个 users 表,结构和CSV文件对应。

-- 1. 准备好CSV文件,users.csv
-- 2. 执行LOAD DATA INFILE命令
LOAD DATA INFILE '/path/to/users.csv'
INTO TABLE users
FIELDS TERMINATED BY ','  -- 字段分隔符,CSV通常是逗号
ENCLOSED BY '"'          -- 字段包围符,CSV通常是双引号
LINES TERMINATED BY '\n' -- 行分隔符
IGNORE 1 ROWS;           -- 忽略第一行(表头)

优点:

  • 性能最高:数据库工具经过高度优化,直接在文件系统和数据库之间传输数据,绕过了应用层。
  • 减少应用负载:Java应用不需要消耗CPU和内存来解析和插入数据。

缺点:

  • 灵活性差:无法在导入前进行复杂的数据校验、清洗或转换,逻辑必须前置到转换脚本中。
  • 平台依赖:需要数据库服务器有相应的文件访问权限,且不同数据库语法不同。

性能优化与最佳实践

无论选择哪种方案,以下几点都能显著提升导入性能:

  1. 数据库批量插入

    • 绝对禁止在循环中执行 INSERT INTO ... VALUES (...);,这是性能杀手。
    • 使用 JDBC Batch:在代码中调用 addBatch()executeBatch()
    • 使用 INSERT INTO ... VALUES (...), (...), ...; 语法,一次性插入多行数据,这是最有效的方式之一。
  2. 关闭数据库自动提交: 在批量插入开始前,设置 connection.setAutoCommit(false);),在批量操作结束后,手动提交 connection.commit();

  3. 分批次处理: 不要将所有数据都加载到内存中再一次性写入数据库,像EasyExcel的监听器一样,每读取一定数量的行(如500或1000条)就执行一次批量插入,然后清空内存,再继续下一批。

  4. 数据校验与清洗

    • 在数据写入数据库前,进行必要的格式校验(如日期格式、数字格式)、空值校验、业务规则校验。
    • 可以将校验失败的数据记录到日志文件或另一个Excel中,方便后续处理。
  5. 关闭数据库索引和外键约束: 对于超大数据导入,可以考虑先临时禁用目标表的索引和约束,导入完成后再重建,这能极大提升插入速度。(操作需谨慎,并确保数据质量)

  6. 多线程处理: 如果数据可以按行或按块独立处理,可以考虑使用多线程来并行处理数据读取和写入,但要注意线程安全和数据库连接池的管理。

总结与选择

方案 优点 缺点 适用场景
POI SAX模式 成熟稳定,社区资源多,不依赖第三方库 API复杂,代码量多,需要自己处理细节 对第三方库有洁癖,或已有POI项目需要扩展
EasyExcel API简单易用,内存占用极低,功能强大 依赖第三方库 绝大多数Java大数据导入场景的首选
数据库直接导入 性能最高,应用负载最低 灵活性差,无法进行复杂业务处理 数据量极大(千万级),对导入速度有极致要求,且数据预处理逻辑简单

推荐路径:

  • 新项目首选:直接使用 EasyExcel,它能让你用最少的代码实现高效、稳定的大数据导入,是业界的最佳实践。
  • 现有POI项目:如果现有项目已经深度使用了POI,且遇到OOM问题,可以考虑升级到POI的SAX模式进行重构。
  • 极限性能场景:如果数据量大到无法忍受任何应用层开销,且数据预处理逻辑简单,可以考虑 数据库直接导入
分享:
扫描分享到社交APP
上一篇
下一篇