实战分享:多线程并行导出百万级数据到 Excel​

在实际开发中,我们经常会遇到 “导出大量数据到 Excel” 的需求。如果数据量达到几十万甚至上百万,单线程导出不仅耗时久,还可能导致接口超时。本文将分享一种 "多线程并行查询 + 单线程写入"的方案,通过合理利用多线程提升导出效率,同时保证线程安全。

一、需求背景与痛点​

场景​

需要导出系统中所有学生的基本信息(包含姓名、学号、性别、班级等字段),数据量约 100 万条。​

痛点​

  • 单线程查询 + 写入:耗时过长(实测需要 15 分钟以上),远超接口超时时间​
  • 内存溢出:一次性加载所有数据到内存,容易触发 OOM​
  • 线程安全:多线程直接操作 Excel 可能导致数据错乱​
  • 异常处理:部分数据查询失败时,如何保证整体流程不中断​

二、技术方案设计​

核心思路:"分批次并行查询,单线程顺序写入"​

方案优势​

  • 并行查询:利用线程池同时发起多个数据库查询,减少总查询时间​
  • 分批处理:避免一次性加载大量数据到内存​
  • 单线程写入:规避多线程操作 Excel 的线程安全问题​
  • 异常隔离:单个批次查询失败不影响其他批次​

关键技术​

  • 线程池(ThreadPoolTaskExecutor):控制并发数​
  • 阻塞队列(BlockingQueue):线程间安全传递数据​
  • 计数器(CountDownLatch):同步等待所有查询完成​
  • EasyExcel:轻量级 Excel 处理框架,减少内存占用​

三、核心代码实现​

1. 线程池配置​

首先需要配置一个自定义线程池,控制并发查询的线程数量(核心参数需根据服务器性能和数据库连接池大小调整):

@Configuration
public class ThreadPoolConfig {

    @Bean("exportTaskExecutor")
    public Executor exportTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8); // 核心线程数:根据CPU核心数设置(通常为核心数*2)
        executor.setMaxPoolSize(16); // 最大线程数:不超过数据库连接池最大连接数
        executor.setQueueCapacity(100); // 任务队列容量
        executor.setKeepAliveSeconds(60); // 空闲线程存活时间
        executor.setThreadNamePrefix("export-thread-"); // 线程名前缀:便于日志追踪
        // 拒绝策略:当线程池满时,让提交任务的线程执行(避免任务丢失)
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 等待所有任务完成后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.initialize();
        return executor;
    }
}

2. DAO 层:分页查询数据

通过分页查询拆分大量数据,每次查询一个批次(这里设置为 5000 条 / 批):

@Repository
public interface StudentDao {

    /**
     * 分页查询学生数据
     * @param offset 偏移量(从0开始)
     * @param pageSize 每页大小
     */
    @Query(value = "SELECT id, name, student_no, gender, class_name, create_time " +
                   "FROM student_info " +
                   "LIMIT :offset, :pageSize",
           nativeQuery = true)
    List<StudentExcelDTO> findByPage(
        @Param("offset") int offset,
        @Param("pageSize") int pageSize
    );

    /**
     * 查询总条数
     */
    @Query(value = "SELECT COUNT(1) FROM student_info", nativeQuery = true)
    int countTotal();
}

注意:分页 SQL 的语法是LIMIT offset, pageSize,其中offset = (页码-1)*pageSize,页码从 1 开始。

3. 服务层:多线程调度核心逻辑​

服务层是整个功能的核心,负责协调多线程查询和数据写入:

@Service
public class StudentExportService {

    private final StudentDao studentDao;
    private final ThreadPoolTaskExecutor exportTaskExecutor;

    // 注入线程池(指定bean名称)
    public StudentExportService(StudentDao studentDao,
                               @Qualifier("exportTaskExecutor") ThreadPoolTaskExecutor exportTaskExecutor) {
        this.studentDao = studentDao;
        this.exportTaskExecutor = exportTaskExecutor;
    }

    /**
     * 导出学生数据到Excel
     */
    public void exportToExcel(HttpServletResponse response) throws Exception {
        // 1. 初始化参数
        int pageSize = 50000; // 每批查询50000条
        int total = studentDao.countTotal(); // 总数据量
        int pageTotal = (total + pageSize - 1) / pageSize; // 总批次数(向上取整)
        log.info("开始导出学生数据,总条数:{},总批次数:{}", total, pageTotal);

        // 2. 设置响应头(Excel格式)
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setHeader("Content-Disposition", 
                          "attachment;filename=student_data_" + System.currentTimeMillis() + ".xlsx");

        // 3. 初始化线程通信组件
        BlockingQueue<List<StudentExcelDTO>> dataQueue = new LinkedBlockingQueue<>(pageTotal); // 数据队列
        CountDownLatch countDownLatch = new CountDownLatch(pageTotal); // 计数器:等待所有查询完成

        // 4. 多线程并行查询数据
        for (int i = 1; i <= pageTotal; i++) {
            final int pageNum = i; // 当前页码(从1开始)
            exportTaskExecutor.execute(() -> {
                long startTime = System.currentTimeMillis();
                String threadName = Thread.currentThread().getName();
                try {
                    // 计算偏移量:(页码-1)*每页大小
                    int offset = (pageNum - 1) * pageSize;
                    List<StudentExcelDTO> data = studentDao.findByPage(offset, pageSize);
                    log.info("[{}] 第{}批查询完成,耗时{}ms,数据量{}", 
                            threadName, pageNum, System.currentTimeMillis()-startTime, data.size());
                    dataQueue.put(data); // 放入队列
                } catch (Exception e) {
                    log.error("[{}] 第{}批查询失败", threadName, pageNum, e);
                    try {
                        dataQueue.put(Collections.emptyList()); // 错误标记:空列表
                    } catch (InterruptedException ex) {
                        Thread.currentThread().interrupt();
                    }
                } finally {
                    countDownLatch.countDown(); // 计数器减1
                }
            });
        }

        // 5. 监控线程:等待所有查询完成后放入结束标记
        new Thread(() -> {
            try {
                countDownLatch.await(); // 等待所有查询任务完成
                dataQueue.put(Collections.emptyList()); // 结束标记:空列表
                log.info("所有查询任务已完成,放入结束标记");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }).start();

        // 6. 单线程写入Excel(EasyExcel)
        try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), StudentExcelDTO.class).build()) {
            WriteSheet sheet = EasyExcel.writerSheet("学生信息表").build();
            int processedBatches = 0;
            boolean hasError = false;

            // 从队列取数据并写入
            while (true) {
                List<StudentExcelDTO> data = dataQueue.take(); // 阻塞等待数据

                // 处理结束标记
                if (data.isEmpty()) {
                    if (processedBatches == pageTotal) {
                        break; // 所有批次处理完成,退出循环
                    } else {
                        hasError = true; // 有错误批次
                        continue;
                    }
                }

                // 写入数据
                excelWriter.write(data, sheet);
                processedBatches++;
                log.info("第{}批数据写入完成,累计处理{}条", 
                        processedBatches, processedBatches * pageSize);
            }

            // 处理结果
            if (hasError) {
                log.error("导出完成,但部分批次数据异常");
                // 可在这里设置响应提示(如通过response写入错误信息)
            } else {
                log.info("全部数据导出完成,总批次数:{},总条数:{}", pageTotal, total);
            }
        }
    }
}

四、关键技术细节解析​

1. 为什么用 “多线程查询 + 单线程写入”?​

  • 多线程查询:数据库查询是 IO 密集型操作,多线程并行查询能显著减少总查询时间(实测 50 万数据从 15 分钟降至 2 分钟)​
  • 单线程写入:EasyExcel 的ExcelWriter不是线程安全的,多线程写入会导致数据错乱;单线程写入虽然串行,但 IO 开销小,性能足够​

2. 线程间如何通信?​

  • 通过阻塞队列(LinkedBlockingQueue) 实现生产者 - 消费者模式:
  • 生产者:线程池中的查询线程,生产数据后放入队列​
  • 消费者:主线程,从队列中取数据并写入 Excel​
  • 阻塞特性:当队列空时,take()方法会阻塞等待,避免空轮询浪费资源​

3. 如何处理异常和结束条件?​

  • 异常标记:查询失败时,向队列放入Collections.emptyList()(空列表),主线程识别后标记错误状态​
  • 结束标记:所有查询完成后,放入Collections.emptyList(),主线程检查 “已处理批次数 = 总批次数” 时退出循环​
  • 计数器(CountDownLatch):确保所有查询线程完成后才放入结束标记,避免主线程提前退出​

4. 分页查询的 SQL 为什么要这么写?​

MySQL 的分页语法是LIMIT offset, pageSize:​
offset:偏移量(从 0 开始),计算公式为(pageNum-1)*pageSize​
pageSize:每页大小​
例如:​
第 1 页:LIMIT 0, 5000(取前 5000 条)​
第 2 页:LIMIT 5000, 5000(从第 5001 条开始取 5000 条)​
注意:当页码很大时(如第 1000 页),offset会导致全表扫描,可优化为 “基于 ID 分页”(需 ID 有序):

SELECT * FROM student_info WHERE id > lastId LIMIT 5000

5. 线程池参数如何调优?​

核心参数需根据实际环境调整:​

  • corePoolSize:核心线程数,建议设为CPU核心数 * 2(IO 密集型任务可适当增大)​
  • maxPoolSize:最大线程数,不能超过数据库连接池的最大连接数(避免连接耗尽)​
  • queueCapacity:任务队列容量,建议设为总批次数的 1.5 倍​
  • 拒绝策略:推荐CallerRunsPolicy,让提交任务的线程执行,避免任务丢失​

五、性能优化与扩展​

1. 性能优化点​

  • 数据库索引:在分页查询的过滤条件字段上建立索引(如class_name),避免全表扫描​
  • 查询重试:对失败的查询批次添加重试机制(建议最多 3 次,每次间隔 1s)​
  • 批量写入:EasyExcel 默认会缓存数据,可通过registerWriteHandler设置批量写入条数(如 1000 条刷盘一次)​
  • 减少字段:只查询 Excel 需要的字段,避免不必要的字段传输和内存占用​

2. 功能扩展​

  • 断点续传:记录已导出的批次,支持从失败位置继续导出​
  • 压缩传输:对 Excel 文件进行 GZIP 压缩,减少网络传输量​
  • 异步导出:前端发起导出请求后返回任务 ID,通过轮询查询导出状态,完成后下载​
  • 多 sheet 导出:当数据量超过 100 万时,分多个 sheet 页存储(Excel 单 sheet 最大支持 1048576 行)​

六、总结​

本文介绍的多线程 Excel 导出方案,通过合理拆分任务、利用多线程并行查询、单线程安全写入,在保证稳定性的前提下大幅提升了导出效率。核心是理解 “哪些操作可以并行(查询)、哪些必须串行(写入)”,以及如何通过队列和计数器实现线程协同。​

实际开发中,需根据数据量大小、服务器性能、数据库承载能力灵活调整参数,才能达到最佳效果。​
如果你的项目中也有大量数据导出需求,不妨试试这个方案,欢迎在评论区分享你的优化经验!

Logo

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

更多推荐