【架构实战】百万级Excel数据导入的“坑”与“填坑”指南(下):多线程与批量入库全代码实战

             

前言

大家好,这里是程序员阿亮!这一篇我们继续讲解EasyExcel在百万数据导入时的实战!

本系列的《上篇》中,我们深度剖析了百万级 Excel 导入面临的“三座大山”:OOM(内存溢出)、龟速的性能以及脆弱的错误处理。我们也确立了以 EasyExcel 为核心,结合多线程读取多 Sheet 和 数据库批量插入 的整体架构蓝图。

理论再完美,最终都要落实在代码上。本篇,我们将化身“架构施工队”,一步步手写出这套高性能的数据导入流水线。

如果你面临类似的需求,这篇文章的代码结构可以直接作为你系统中的脚手架。

核心步骤一:环境与依赖准备

工欲善其事,必先利其器。我们首先需要在 Spring Boot 工程中引入相关的依赖。这里我们主要依赖 EasyExcel 来解析 Excel,依赖 MyBatis 来进行数据库的高效批量操作。

<dependencies>
    <!-- 引入阿里 EasyExcel:千万级数据处理的神器 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>easyexcel</artifactId>
        <version>3.3.2</version> <!-- 建议使用最新稳定版 -->
    </dependency>

    <!-- 数据库连接和 MyBatis(这里以 MyBatis-Plus 为例,原生 MyBatis 亦可) -->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.2</version>
    </dependency>
    <!-- 你的 JDBC 驱动等其他依赖省略... -->
</dependencies>

核心步骤二:打造流水线的“质检员”—— 自定义 ReadListener

在 EasyExcel 的设计模式中,ReadListener 是绝对的灵魂。它就像是站在流水线旁的质检员兼打包员:读一行、验一行、攒一批、存一波。

架构排坑警告(高频踩坑点):
千万不要把 ReadListener 作为一个 Spring 容器中的单例 Bean(不要加 @Component 注解)!因为我们的 Listener 中维护了 List<T> batch 这样的状态(Stateful)。如果多线程共享同一个 Listener,会导致严重的并发安全问题(例如 ArrayList 越界、数据错乱)。
正确做法:每次解析一个 Sheet,都要 new 一个全新的 Listener 实例。 由于 Listener 不是 Spring Bean,我们需要的 MyDataService 需要通过构造函数手动注入。

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;

/**
 * 自定义的 ReadListener,用于处理从 Excel 读取的数据
 * 注意:此类不能被 Spring 管理(不能加 @Component),每次读取都要 new 一个新实例!
 */
public class MyDataModelListener implements ReadListener<MyDataModel> {

    private static final Logger log = LoggerFactory.getLogger(MyDataModelListener.class);

    // 1. 设置批量处理的数据大小(黄金分割点,不宜太大也不宜太小,1000-3000较合适)
    private static final int BATCH_SIZE = 1000;
    
    // 2. 用于暂存读取的数据,直到达到批量大小
    private List<MyDataModel> batch = new ArrayList<>();

    // 3. 业务层 Service(用于入库和校验)
    private MyDataService myDataService;

    // 构造函数:由于自己 new 出来的,无法 @Autowired,需要手动把 Service 传进来
    public MyDataModelListener(MyDataService myDataService) {
        this.myDataService = myDataService;
    }

    /**
     * 【核心流水线】每读取一行 Excel 数据,都会自动调用此方法
     */
    @Override
    public void invoke(MyDataModel data, AnalysisContext context) {
        // 步骤 A:检查数据的合法性及有效性(例如去重校验、格式校验)
        if (validateData(data)) {
            // 有效数据添加到 list 中暂存
            batch.add(data);
        } else {
            // 无效数据处理:记录日志、或者写入异常表,坚决不抛出异常中断整个流程
            log.warn("第 {} 行数据校验失败,被跳过。数据: {}", context.readRowHolder().getRowIndex(), data);
        }

        // 步骤 B:当暂存的列表达到指定的批次大小时,执行批量数据库插入操作
        if (batch.size() >= BATCH_SIZE) {
            processBatch();
        }
    }

    /**
     * 【收尾工作】所有数据读取完成之后调用此方法
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 非常重要:因为最后面的数据可能凑不够 1000 条,必须要在这里把剩余的残存数据处理掉!
        if (!batch.isEmpty()) {
            processBatch();
        }
        log.info("当前 Sheet 的所有数据解析并入库完毕!");
    }

    /**
     * 数据校验逻辑(伪代码演示)
     */
    private boolean validateData(MyDataModel data) {
        // 调用 mapper 方法来检查数据库中是否已存在该数据(根据唯一索引判断)
        int count = myDataService.countByColumn1(data.getColumn1());
        // count为0表示不存在,返回true允许插入
        return count == 0;
    }

    /**
     * 【容错与重试机制】处理一批数据
     */
    private void processBatch() {
        int retryCount = 0;
        // 重试逻辑:大批量插入偶尔会遇到网络抖动或死锁死锁超时,重试 3 次机制是非常有必要的
        while (retryCount < 3) {
            try {
                // 尝试批量插入数据库
                myDataService.batchInsert(batch);
                // 插入成功,清空这批数据,以便下一次收集
                batch.clear();
                break; // 成功则跳出重试循环
            } catch (Exception e) {
                retryCount++;
                log.error("批量插入失败,正在进行第 {} 次重试...", retryCount, e);
                if (retryCount >= 3) {
                    // 如果重试 3 次仍然失败,应当把这批数据记录到错误日志或者一张失败记录表中,供人工排查
                    log.error("连续3次插入失败!请人工介入处理。数据: {}", batch);
                    // 切记:这里不建议直接抛出异常导致整个大任务崩溃!清空继续下一批。
                    batch.clear(); 
                }
            }
        }
    }
}

代码亮点解析:

  1. 防 OOM 设计: batch.clear() 是核心。不管读取多少百万数据,常驻内存的只有这 1000 条对象,GC 回收极快,永远不会 OOM。

  2. 韧性设计(Retry): 批量插入难免遇到瞬时的 DB 压力。我们用了一个 while 循环实现了轻量级的 3 次重试机制。3 次依然失败则记录日志丢弃,保障了整体导入任务不会因为某一小批数据的脏数据或异常而全盘崩溃

核心步骤三:发动机点火 —— 多线程并发解析多 Sheet

在上篇中我们科普了,单 Sheet 无法承载百万数据,必须分 Sheet。而多 Sheet 天然适合多线程并发读取

我们要使用 Java 的 ExecutorService 线程池,派发多个子线程,每个线程负责读取一个特定的 Sheet。

下面是 ExcelImporterService 的实现:

import com.alibaba.excel.EasyExcel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

@Service
public class ExcelImporterService {

    private static final Logger log = LoggerFactory.getLogger(ExcelImporterService.class);

    @Autowired
    private MyDataService myDataService;

    /**
     * 执行导入任务的主入口
     */
    public void doImport() {
        // Excel 文件的绝对路径 (实际业务中可能是从 OSS 下载的流或前端上传的 MultipartFile)
        String filePath = "/users/workspace/excel/million_data_test.xlsx";
        
        // 假设已知该 Excel 有 20 个 Sheet (可以动态通过 EasyExcel API 先获取 Sheet 数量)
        int numberOfSheets = 20; 
        long startTime = System.currentTimeMillis();

        // 1. 创建一个固定大小的线程池,大小与 Sheet 数量相同 (或者根据 CPU 核心数动态调整)
        ExecutorService executor = Executors.newFixedThreadPool(numberOfSheets);

        // 2. 遍历所有的 Sheets,把每个 Sheet 的解析当做一个独立的 Task 提交给线程池
        for (int sheetNo = 0; sheetNo < numberOfSheets; sheetNo++) {
            // 注意:在 Java Lambda 表达式中使用的局部变量必须是 final 或 effectively final
            int finalSheetNo = sheetNo;

            // 向线程池提交一个任务
            executor.submit(() -> {
                try {
                    log.info("线程 {} 开始读取 Sheet: {}", Thread.currentThread().getName(), finalSheetNo);
                    
                    // 使用 EasyExcel 读取指定的 sheet
                    // 注意这里的核心点:new MyDataModelListener(myDataService) 
                    // 我们为每一个 Sheet(线程) 都 new 了一个专属的 Listener,绝不串线!
                    EasyExcel.read(filePath, MyDataModel.class, new MyDataModelListener(myDataService))
                             .sheet(finalSheetNo) // 精准指定要读取的 sheet 序号 (0开始)
                             .doRead();           // 开始读取操作
                             
                    log.info("线程 {} 完成读取 Sheet: {}", Thread.currentThread().getName(), finalSheetNo);
                } catch (Exception e) {
                    log.error("读取 Sheet {} 发生异常", finalSheetNo, e);
                }
            });
        }

        // 3. 启动线程池的关闭序列:不再接收新任务,但会继续执行已提交的任务
        executor.shutdown();

        // 4. 主线程阻塞等待,直到所有的多线程任务全部完成,或者在等待超时时被中断
        try {
            // 等待所有的线程把 20 个 Sheet 全部解析并入库完毕
            executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
        } catch (InterruptedException e) {
            // 如果等待过程中线程被中断,打印异常信息
            e.printStackTrace();
        }

        long endTime = System.currentTimeMillis();
        log.info("恭喜!百万级数据多线程读取并导入完成。总耗时: {} 秒", (endTime - startTime) / 1000);
    }
}

经过线上实测,在使用 4C8G 的机器、同网段 MySQL 的配置下,通过这种方式读取 100 万行数据(分装在多个 Sheet 中)并成功入库,耗时通常控制在 100 秒左右(不到两分钟),性能炸裂!

核心步骤四:打破 I/O 瓶颈 —— MyBatis 批量插入配置

前面我们在 Listener 里凑齐了 1000 条数据交给了 myDataService.batchInsert(batch)。
这里千万不要在 Service 里写个 for 循环去 insert!for 循环单条插入意味着要和数据库建立 1000 次网络交互,你的性能会瞬间崩塌。

我们要利用 MyBatis 的 <foreach> 标签,将 1000 条数据拼装成一条超长的 INSERT SQL 语句,一次网络传输搞定!

1. Service 层的事务控制

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class MyDataService {

    @Autowired
    private MyDataMapper myDataMapper;

    /**
     * 使用 Spring 的事务管理进行批量插入
     * 注意:这里的事务仅仅包裹这 1000 条数据的入库。
     */
    @Transactional(rollbackFor = Exception.class)
    public void batchInsert(List<MyDataModel> batch) {
        // 使用 MyBatis Mapper 进行批量插入
        myDataMapper.batchInsert(batch);
    }

    public int countByColumn1(String column1) {
        return myDataMapper.countByColumn1(column1);
    }
}

2. Mapper 接口定义

import org.apache.ibatis.annotations.Mapper;
import java.util.List;

@Mapper
public interface MyDataMapper {
    // 接收 List 集合作为参数
    void batchInsert(List<MyDataModel> dataList);
    
    int countByColumn1(String column1);
}

3. Mapper.xml 中的终极绝招:<foreach>

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yourpackage.mapper.MyDataMapper">

    <!-- 批量插入,拼接 SQL:INSERT INTO table (...) VALUES (...), (...), (...) -->
    <insert id="batchInsert" parameterType="java.util.List">
        INSERT INTO hollis_test_table_name (column1, column2, column3)
        VALUES
        <!-- collection="list" 对应入参的 List 集合,item="item" 代表当前遍历的对象 -->
        <foreach collection="list" item="item" index="index" separator=",">
            (
             #{item.column1}, 
             #{item.column2}, 
             #{item.column3}
            )
        </foreach>
    </insert>

    <!-- 校验数据是否存在的简单查询 -->
    <select id="countByColumn1" resultType="int">
        SELECT COUNT(*) FROM hollis_test_table_name WHERE column1 = #{column1}
    </select>

</mapper>

备注提示:MySQL 的 max_allowed_packet 参数决定了单条 SQL 的最大体积。1000 条数据的批量插入通常不会超标,如果你的单行字段极多,请适当调小 BATCH_SIZE。

总结

至此,一套抗压、极速、高容错的百万级 Excel 导入方案就全部开发完成了。回顾这套代码,我们实际上打出了一套完美的“组合拳”:

  1. 防 OOM: 利用 EasyExcel 基于磁盘流的事件驱动读取模型(ReadListener)。

  2. 多核压榨: 利用 ExecutorService 线程池并发读取无关联的多 Sheet。

  3. 减少网络 I/O: 利用内存 List 缓存分批,加上 MyBatis 的 <foreach> 批量 INSERT。

  4. 容错与可用性: 抛弃了大事务,采用“分批短事务 + try-catch 失败重试 + 脏数据日志补偿”的策略。

最后的实战避坑锦囊(划重点):

  • 线程池与数据库连接池的博弈: 如果你的 Sheet 有 20 个,线程池配了 20 个并发线程。由于同一时刻有 20 个线程可能都在同时调用 batchInsert,你的 MySQL 数据库连接池(如 HikariCP)的核心连接数必须要大于等于 20,否则会导致线程获取不到数据库连接而死锁等待!

  • Listener 的状态隔离: 再次强调,不要把 MyDataModelListener 交给 Spring 托管!一定要 new!

  • 关于唯一性约束(Idempotent): 并发插入时,非常容易引发数据库的唯一索引冲突(Duplicate Key)。不仅要在 Java 代码层做 count 校验(注意并发条件下的校验可能失效),强烈建议在数据库表加上“唯一索引(Unique Index)” 作为兜底防御,或者使用 INSERT IGNORE 语法,用物理手段杜绝脏数据。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值