1. Excel操作工具对比

最近在工作中遇到了数百万条信息的大数据量导入导出。如果用传统的逐条插入方式,不仅效率低下,还可能导致内存溢出。那么,今天我们就一起来分析一下如何解决这个问题。

1.1 Apache POI系列

我在项目中比较常用的 Excel 操作工具就是 POI,作为一个传统的技术工具,我们先来看看 POI 的不同版本及其优缺点。

  • HSSFWorkbook
    这个实现类是早期使用最多的对象,可以操作 Excel 2003 及以前的版本,文件扩展名为 .xls

    • 缺点:最多只能导出 65535 行数据,超出此限制会报错。

    • 优点:不会导致内存溢出。(数据量少于 7w 行时,内存一般足够,POI 先将数据加载到内存,再进行操作。)

  • XSSFWorkbook
    这个实现类在很多项目中仍在使用,适用于 Excel 2002 到 Excel 2007 版本,文件扩展名为 .xlsx

    • 优点:可以导出最多 1048576 行,16384 列,最多可导出 104 万条数据。

    • 缺点:随着数据行数增加,内存使用也大幅上升,容易导致内存溢出。因为 booksheetrowcell 等对象在写入 Excel 前,都是存放在内存中的。(不包括 Excel 样式等其他元素。)

  • SXSSFWorkbook
    这是 POI 3.8 之后的版本,支持操作 Excel 2007 及以后版本,文件扩展名为 .xlsx

    • 优点:一般不会出现内存溢出(通过将数据持久化到硬盘来节省内存,只有最新数据存放在内存中,适合存储百万条数据)。

    • 缺点

      • 部分数据已持久化到硬盘,无法访问,因此只能访问内存中存储的部分数据。

      • sheet.clone() 方法不再支持(由于持久化的原因)。

      • 不再支持公式求值,因为硬盘中的数据无法被加载到内存中进行计算。

      • 使用模板下载数据时,表头不能修改,因为数据已经写入硬盘。


1.2 EasyExcel(基于 POI 优化)

EasyExcel 采用了更加高效的流式读写技术,尤其适合处理大数据量的 Excel 导入导出。

  • 流式读写技术
    EasyExcel 采用 SAX 模式,逐行读写 Excel 数据,而不是一次性将整个文件加载到内存中。这种设计优化了内存占用,避免了传统方式中内存线性增长的问题,即使是百万级数据也能保持较低的内存消耗。例如,在读取时,只解析当前行的数据并立即释放内存,避免内存堆积。

  • 事件驱动模型
    基于观察者模式(Observer Pattern),EasyExcel 在解析过程中逐行触发事件(如 AnalysisEventListener)。开发者可以通过监听器实时处理每行数据并释放资源,这种机制显著降低了内存压力。

  • Java NIO 技术
    EasyExcel 底层结合了 Java 非阻塞 I/O(NIO)技术,优化了文件的读写效率。NIO 的高效通道(Channel)和缓冲区(Buffer)机制减少了磁盘 I/O 操作的耗时,提升了处理速度。

  • 内存管理优化

    • 逐行写入机制:写入时按行生成数据流,避免一次性生成全量数据的内存占用。

    • 缓存策略:通过智能缓存管理,减少重复对象的创建和垃圾回收频率,例如复用单元格样式对象。

    • 异步处理:支持多线程分片处理数据,结合及时资源释放。


2.遇到的核心问题是什么?

  • 问题 1:数据量极大,如果用传统 POI 方式内存溢出,且效率低。
    解决思路:只要使用对上面介绍的POI方式即可,主要问题就是原生的POI解决起来相当麻烦。EasyExcel可以很好的解决内存溢出的问题,

  • 问题 2:不能够循环一条条的进行数据插入;
    解决思路:将 Excel 中读取的数据存储到集合中,到达一定数量后直接批量插入数据库。

  • 问题 3:避免用 MyBatis 的批量插入,因为 MyBatis 的批量插入就是 SQL 循环;对于大数据量仍然非常慢。
    解决思路:使用 JDBC 批量插入,配合事务管理来完成批量数据插入。即使用 Excel 分批读取 + JDBC 分批插入 + 事务。

  • 问题 4:减少数据库交互次数。
    解决思路:例如,在插入仓库数据时,需要根据编码字段检查数据是否已存在。如果逐条查询数据库,会导致频繁交互,降低性能。可以将Excel中一批数据的编码字段预先收集,通过一次数据库查询批量检查是否存在


技术选型对比

方案 内存占用 耗时(100万条) 可靠性 复杂度
POI DOM 解析 极高 30 分钟以上
MyBatis 批量插入 中等 15 分钟
EasyExcel + JDBC 极低 3 分钟

3. 手把手实现百万数据导入

 百万数据的导入解决思路

  1. 分批读取 Excel 中的数据
    EasyExcel 提供了很好的分批处理能力。通过调整批次参数(例如 3000 行),我选择了 5w 的批次。

  2. 批量插入数据库
    将读取到的 20w 条数据进行批量插入,避免逐条循环插入。MyBatis 的批量插入效率低,建议使用 JDBC 批量插入。可以参考 MyBatis 批量插入和 JDBC 批量插入的性能对比。

  3. 使用 JDBC + 事务进行批量插入
    最终通过分批读取 + JDBC 分批插入 + 手动事务控制的方式,将数据导入数据库。

3.1 环境准备

pom.xml关键依赖

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.3.2</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.8</version>
</dependency>

Druid连接池配置: 

# druid.properties
druid.url=jdbc:mysql://localhost:3306/warehouse?rewriteBatchedStatements=true
druid.username=root
druid.password=123456
druid.initialSize=5
druid.maxActive=20
druid.maxWait=60000
druid.validationQuery=SELECT 1

 3.2 核心代码实现

1. Excel 数据映射对象
@Data
public class CangKuImportDTO {
    @ExcelProperty(index = 0)
    private String code;
    
    @ExcelProperty(index = 1)
    private String name;
    
    @ExcelProperty(index = 2)
    private Double areaZD;
    
    @ExcelProperty(index = 3)
    private Double areaJZ;
    
    @DateTimeFormat("yyyy-MM-dd HH:mm")
    @ExcelProperty(index = 4)
    private Date cangKuDate;
    
    // 其他字段...
}

 2. 数据监听器

/**
 * 增强型仓库数据导入监听器(用于EasyExcel处理百万级数据导入)
 * 功能:逐行读取Excel数据 → 校验 → 批量入库 → 错误处理 → 生成错误报告
 */
public class EnhancedCangKuListener extends AnalysisEventListener<CangKuImportDTO> {

    private static final Logger logger = LoggerFactory.getLogger(EnhancedCangKuListener.class);
    
    // 动态批次大小(默认初始值50000条,根据处理情况自动调整)
    private int batchSize = 50000;
    
    // 数据缓存列表(存储当前批次待处理的合法数据)
    private List<CangKuImportDTO> cachedList = new ArrayList<>(batchSize);
    
    // 仓库服务接口(用于调用数据库批量插入操作)
    private final CangKuService cangKuService;

    // 原子计数器(保证多线程安全):
    private final AtomicLong totalCounter = new AtomicLong(0);   // 总处理数据量统计
    private final AtomicLong successCounter = new AtomicLong(0); // 成功入库数据量统计

    // 错误记录列表(线程安全的同步列表,存储所有校验失败或插入失败的数据)
    private final List<CangKuImportDTO> errorRecords = Collections.synchronizedList(new ArrayList<>());

    public EnhancedCangKuListener(CangKuService cangKuService) {
        this.cangKuService = cangKuService;
    }

    /**
     * 核心处理方法:每读取一行Excel数据时触发
     * @param data    当前行的数据对象
     * @param context EasyExcel解析上下文(包含工作表、行号等信息)
     */
    @Override
    public void invoke(CangKuImportDTO data, AnalysisContext context) {
        if(validateData(data)) {
            cachedList.add(data);
            //当缓存数据量达到批次大小时触发批量插入
            if (cachedList.size() >= batchSize) {
                saveBatch();
                adjustBatchSize(); // 动态调整批次大小
            }
        } else {
            errorRecords.add(data);
        }
        //定期打印处理进度(每处理1万条打印一次)
        printProgress();
    }

    private boolean validateData(CangKuImportDTO data) {
        // 示例校验逻辑
        return StringUtils.isNotBlank(data.getCode()) 
            && data.getAreaZD() != null
            && data.getCangKuDate() != null;
    }

    /**
     * 批量保存数据到数据库(核心插入逻辑)
     */
    private void saveBatch() {
        if (!cachedList.isEmpty()) {
            try {
                cangKuService.batchInsert(cachedList);
                // 成功计数累加
                successCounter.addAndGet(cachedList.size());
            } catch (DataAccessException e) {
                logger.error("批次插入失败,失败数量:{}", cachedList.size(), e);
                errorRecords.addAll(cachedList);
            }
            //// 清空缓存列表(无论成功与否)
            cachedList.clear();
        }
    }

     /**
     * 动态调整批次大小(示例算法:根据处理情况自动优化)
     * 说明:如果批次过小会增加数据库交互次数,过大会导致内存压力
     */
    private void adjustBatchSize() {
        // 动态调整逻辑(示例)
        if (batchSize < 50000) {
            batchSize = Math.min(batchSize * 2, 50000);
        }
    }

    private void printProgress() {
        long total = totalCounter.incrementAndGet();
        if (total % 10000 == 0) {
            logger.info("已处理:{} 条,成功:{} 条,失败:{} 条", 
                total, successCounter.get(), errorRecords.size());
        }
    }
    /**
     * 所有数据解析完成后触发的收尾操作
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        saveBatch(); // 处理最后一批数据
        generateErrorReport(); // 生成错误报告
        logger.info("导入完成!总计:{} 条,成功:{} 条,失败:{} 条",
            totalCounter.get(), successCounter.get(), errorRecords.size());
    }

    private void generateErrorReport() {
        if (!errorRecords.isEmpty()) {
            String errorFilePath = "/tmp/import_errors_" + System.currentTimeMillis() + ".xlsx";
            EasyExcel.write(errorFilePath, CangKuImportDTO.class)
                    .sheet("错误数据")
                    .doWrite(errorRecords);
            logger.warn("错误数据已导出至:{}", errorFilePath);
        }
    }
}
3. Service 层实现
@Service
@RequiredArgsConstructor
public class CangKuService {

    private final JdbcTemplate jdbcTemplate;

    @Value("${spring.datasource.druid.batch-size:50000}")
    private int batchSize;

    private static final String INSERT_SQL = """
        INSERT INTO CangKu (
            Code, Name, AreaZD, AreaJZ, CangKu_Date, 
            CreateDate, Province, ProvinceId, 
            City, CityId, County, CountyId
        ) VALUES (
            ?, ?, ?, ?, ?, 
            ?, ?, ?, 
            ?, ?, ?, ?
        )
        """;

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void batchInsert(List<CangKuImportDTO> list) {
        jdbcTemplate.batchUpdate(INSERT_SQL, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                CangKuImportDTO item = list.get(i);
                ps.setString(1, item.getCode());
                ps.setString(2, item.getName());
                ps.setDouble(3, item.getAreaZD());
                ps.setDouble(4, item.getAreaJZ());
                ps.setTimestamp(5, new Timestamp(item.getCangKuDate().getTime()));
                ps.setTimestamp(6, new Timestamp(System.currentTimeMillis()));
                ps.setString(7, item.getProvince());
                ps.setInt(8, item.getProvinceId());
                ps.setString(9, item.getCity());
                ps.setInt(10, item.getCityId());
                ps.setString(11, item.getCounty());
                ps.setInt(12, item.getCountyId());
            }

            @Override
            public int getBatchSize() {
                return list.size();
            }
        });
    }
}

 4. 启动类配置

@RestController
@SpringBootApplication
public class DataImportApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataImportApplication.class, args);
    }

    @Autowired
    private CangKuService cangKuService;

    @PostMapping("/import")
    public ResponseEntity<?> importData(@RequestParam("file") MultipartFile file) {
        try {
            EasyExcel.read(file.getInputStream(), CangKuImportDTO.class, 
                    new EnhancedCangKuListener(cangKuService))
                    .sheet()
                    .doRead();
            
            return ResponseEntity.ok("导入请求已接受");
        } catch (IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("文件读取失败:" + e.getMessage());
        }
    }
}

 5. 增强版连接池配置(application.yml)

spring:
  datasource:
    druid:
      url: jdbc:oracle:thin:@localhost:1521:ORCL
      username: mrkay
      password: ******
      initial-size: 5
      min-idle: 5
      max-active: 50
      max-wait: 60000
      validation-query: SELECT 1 FROM DUAL
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      filters: stat,wall
      connect-properties:
        oracle.jdbc.J2EE13Compliant: true
      # 批量插入优化参数
      connection-properties:
        defaultExecuteBatch: 10000
        rewriteBatchedStatements: true
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐