实战分享:多线程并行导出百万级数据到 Excel
实战分享:多线程并行导出百万级数据到 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 导出方案,通过合理拆分任务、利用多线程并行查询、单线程安全写入,在保证稳定性的前提下大幅提升了导出效率。核心是理解 “哪些操作可以并行(查询)、哪些必须串行(写入)”,以及如何通过队列和计数器实现线程协同。
实际开发中,需根据数据量大小、服务器性能、数据库承载能力灵活调整参数,才能达到最佳效果。
如果你的项目中也有大量数据导出需求,不妨试试这个方案,欢迎在评论区分享你的优化经验!
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)