1、需求背景
为提升导出数据的性能,采用多线程的方式实现导出百万级别的数据到excel。
2、考虑前提
- 大数据量导出到文件,首先需要考虑的是内存溢出的场景:数据库读取数据到内存中、将数据写入到excel进行大量的IO操作。
- 考虑到一个文件数据过大,用户打开慢,体验不好。
针对这些问题的考虑,采用多线程的方式多个线程同时处理查询数据,一个线程生成一个excel,最后在合并数据返回,以达到提高效率的目的。
3、实现思路
(1)计算导出数据的总条数:dataTotalCount。
(2)合理设置每个excel的数据的数量(避免打开一个excel时间过长):LIMIT。
(3)计算出需要导出的excel个数(线程个数):count=dataTotalCount/ LIMIT + (dataTotalCount% LIMIT > 0 ? 1 : 0)。
(4)将分页、生成文件路径信息,初始化到一个队列里面,队列的大小是线程的数量,对每个文件开启一个线程,异步执行导出,文件全部导出结束,此时异步转成同步,将最终生成的excel文件生成zip压缩包。
4、代码实现
4.1、多线程批量导出excel工具类
核心点:此处采用并发包中的CountDownLatch做同步器,等子线程将文件全部导出后,再在主线程进行数据整合的操作,即导出压缩包。此处提升了导出的效率,比如子线程的执行时间大约20秒,假设子线程的数量是5个,此处并行处理大约需要20秒处理完成。如果采用原来串行执行的方式的话,处理这五个任务需要100秒的时间。
此处我设置每个excel的数据条数是4万条,这里的数量需要根据实际情况合理设置,否则在导出过程会出现内存溢出的情况。
package com.wm.file.util;
import com.wm.file.service.IAsynExportExcelService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import static com.wm.file.service.impl.IAsynExportExcelServiceImpl.DATA_TOTAL_COUNT;
/**
* @ClassName:AsynExcelExportUtil
* @Description: 多线程批量导出excel工具类
* @Author:Deamer
* @Date:2021/8/8 23:00
**/
@Slf4j
@Component
public class AsynExcelExportUtil {
// 定义导出的excel文件保存的路径
private String filePath = "C:\\Users\\Deamer\\Desktop\\export\\";
@Resource
private IAsynExportExcelService asynExportExcelService;
/**
* 每批次处理的数据量
*/
private static final int LIMIT = 40000;
// 保证线程安全:每个线程设置本地变量值
private static ThreadLocal<Queue<Map<String, Object>>> queueThreadLocal = new ThreadLocal<>();
/**
* 多线程批量导出 excel
*
* @param response 用于浏览器下载
* @throws InterruptedException
*/
public void threadExcel(HttpServletResponse response) {
long start = System.currentTimeMillis();
initQueue();
//异步转同步,等待所有线程都执行完毕返回 主线程才会结束
try {
Queue<Map<String, Object>> queue = queueThreadLocal.get();
CountDownLatch cdl = new CountDownLatch(queue.size());
while (queue.size() > 0) {
asynExportExcelService.excuteAsyncTask(queue.poll(), cdl);
}
cdl.await();
log.info("excel导出完成·······················");
//压缩文件
File zipFile = new File(filePath.substring(0, filePath.length() - 1) + ".zip");
FileOutputStream fos1 = new FileOutputStream(zipFile);
//压缩文件目录
ZipUtils.toZip(filePath, fos1, true);
//发送zip包
ZipUtils.sendZip(response, zipFile);
} catch (Exception e) {
log.error("excel导出异常", e);
} finally {
// 使用完ThreadLocal对象之后清除数据,防止内存泄露
queueThreadLocal.remove();
}
long end = System.currentTimeMillis();
log.info("任务执行完毕共消耗: " + (end - start) + "ms");
}
/**
* 初始化队列
*/
private void initQueue() {
// Queue是java自己的队列,是同步安全的
// 一个基于链接节点的无界线程安全的队列
Queue<Map<String, Object>> queue = new ConcurrentLinkedQueue<>();
long dataTotalCount = DATA_TOTAL_COUNT;// 数据的总数
int listCount = (int) dataTotalCount;
// 计算出多少页,即循环次数
int count = listCount / LIMIT + (listCount % LIMIT > 0 ? 1 : 0);
for (int i = 1; i <= count; i++) {
Map<String, Object> map = new HashMap<>();
map.put("page", i);
map.put("limit", LIMIT);
map.put("path", filePath);
//添加元素
queue.offer(map);
}
queueThreadLocal.set(queue);
}
}
这里需要注意线程安全问题:AsynExcelExportUtil是单例注入到Spring容器中,这导致存在多线程同时操作一个资源数据queue(存取分页信息的队列)。本人想到的保证线程安全的两个方法:
(1)将AsynExcelExportUtil注入容器的方式改为原型模式(scope=“prototype”),这样每次获取实例的时候,都会新建一个实例。
(2)使用ThreadLocal,每个线程访问queue时都会在自己的线程中生成一个副本,多线程各自操作自己的副本,保证线程安全。
本案例中使用的是ThreadLocal实现。
4.2、定义异步导出数据的接口
核心点:Async注解用于表示方法需要异步调用,此时Spring会使用后台的线程池来异步的执行它所注解的方法。
4.2.1、启动类加注解@EnableAsync
package com.wm.casetest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class CasetestApplication {
public static void main(String[] args) {
SpringApplication.run(CasetestApplication.class, args);
}
}
4.2.2、线程池配置:
package com.wm.casetest.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* @ClassName: AsyncTaskPoolConfig
* @Description:
* @Author: WM
* @Date: 2021-08-06 19:23
**/
@Configuration
@EnableAsync
public class AsyncTaskPoolConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
int i = Runtime.getRuntime().availableProcessors();
System.out.println("系统最大线程数:" + i);
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(i);
taskExecutor.setMaxPoolSize(i);
taskExecutor.setQueueCapacity(99999);
taskExecutor.setKeepAliveSeconds(60);
taskExecutor.setThreadNamePrefix("taskExecutor--");
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
taskExecutor.setAwaitTerminationSeconds(60);
return taskExecutor;
}
}
4.2.3、service接口:
package com.wm.casetest.service;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
/**
* @ClassName: IAsynExportExcelService
* @Description:
* @Author: WM
* @Date: 2021-08-06 20:05
**/
public interface IAsynExportExcelService {
/**
* 分批次异步导出数据
*
* @param countDownLatch
*/
void excuteAsyncTask(Map<String, Object> map, CountDownLatch countDownLatch);
}
实现类:
说明:为方便演示,此处我使用程序模拟创建了40万条数据,根据分页入参,在查询过程中会对数据进行分页。
package com.wm.file.service.impl;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import com.wm.file.entity.MsgClient;
import com.wm.file.service.IAsynExportExcelService;
import com.wm.file.util.MyExcelExportUtil;
import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.File;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
/**
* @ClassName: IAsynExportExcelServiceImpl
* @Description:
* @Author: WM
* @Date: 2021-08-06 20:06
**/
@Service
public class IAsynExportExcelServiceImpl implements IAsynExportExcelService {
// 假定数据量是40万
public static final long DATA_TOTAL_COUNT = 400000;
// 查询要导出的批次数据
static List<Object> list = new ArrayList<>();
static {
for (int i = 0; i < DATA_TOTAL_COUNT; i++) { //模拟库中一百万数据量
MsgClient client = new MsgClient();
client.setBirthday(new Date());
client.setClientName("小明xxxsxsxsxsxsxsxsxsxsx" + i);
client.setClientPhone("18797" + i);
client.setCreateBy("JueYue");
client.setId("1" + i);
client.setRemark("测试" + i);
list.add(client);
}
}
@Resource
private MyExcelExportUtil myExcelExportUtil;
@Override
@Async("taskExecutor")
public void excuteAsyncTask(Map<String, Object> map, CountDownLatch cdl) {
long start = System.currentTimeMillis();
int currentPage = (int) map.get("page");
int pageSize = (int) map.get("limit");
List subList = new ArrayList(page(list, pageSize, currentPage));
int count = subList.size();
System.out.println("线程:" + Thread.currentThread().getName() + " , 读取数据,耗时 :" + (System.currentTimeMillis() - start) + "ms");
StringBuilder filePath = new StringBuilder(map.get("path").toString());
filePath.append("线程").append(Thread.currentThread().getName()).append("-")
.append("页码").append(map.get("page")).append(".xlsx");
// 调用导出的文件方法
Workbook workbook = myExcelExportUtil.getWorkbook("计算机一班学生", "学生", MsgClient.class, subList, ExcelType.XSSF);
File file = new File(filePath.toString());
MyExcelExportUtil.exportExcel2(workbook, file);
long end = System.currentTimeMillis();
System.out.println("线程:" + Thread.currentThread().getName() + " , 导出excel" + map.get("page") + ".xlsx成功 , 导出数据:" + count + " ,耗时 :" + (end - start) + "ms");
// 执行完线程数减1
cdl.countDown();
System.out.println("剩余任务数 ===========================> " + cdl.getCount());
}
// 手动分页方法
public List page(List list, int pageSize, int page) {
int totalcount = list.size();
int pagecount = 0;
int m = totalcount % pageSize;
if (m > 0) {
pagecount = totalcount / pageSize + 1;
} else {
pagecount = totalcount / pageSize;
}
List<Integer> subList = new ArrayList<>();
if (pagecount < page) {
return subList;
}
if (m == 0) {
subList = list.subList((page - 1) * pageSize, pageSize * (page));
} else {
if (page == pagecount) {
subList = list.subList((page - 1) * pageSize, totalcount);
} else {
subList = list.subList((page - 1) * pageSize, pageSize * (page));
}
}
return subList;
}
}
4.2.3、导出文件的方法:
说明:导出文件中的方法上篇有做分析,这里不再赘述,我这里只贴了实现类中使用到了两个方法
package com.wm.casetest.util;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.TemplateExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.handler.inter.IExcelExportServer;
import com.wm.casetest.util.easypoi.ExcelStyleUtil;
import com.wm.casetest.util.easypoi.MyExcelExportService;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* @ClassName: ExcelExportUtil
* @Description: Excel导出工具类
* @Author: WM
* @Date: 2021-07-24 18:47
**/
@Slf4j
@Component
public class MyExcelExportUtil {
@Resource
private IExcelExportServer excelExportServer;
/**
* 小量数据允许导出的最大条数
*/
private static final Integer EXPORT_EXCEL_BASE_MAX_NUM = 100000;
public static int USE_SXSSF_LIMIT = 100000;
/**
* 获取导出的 Workbook对象
* 普通导出
*
* @param title 大标题
* @param sheetName 页签名
* @param object 导出实体
* @param list 普通导出传入的数据集合
* @param list 数据集合
* @return Workbook
*/
public static Workbook getWorkbook(String title, String sheetName, Class<?> object, List<?> list, ExcelType excelType) {
// 判断导出数据是否为空
if (list == null) {
list = new ArrayList<>();
}
// 判断导出数据数量是否超过限定值
// if (list.size() > EXPORT_EXCEL_BASE_MAX_NUM) {
// title = "导出数据行数超过:" + EXPORT_EXCEL_BASE_MAX_NUM + "条,无法导出!";
// list = new ArrayList<>();
// }
// 获取导出参数
ExportParams exportParams = new ExportParams(title, sheetName, excelType);
// 设置导出样式
exportParams.setStyle(ExcelStyleUtil.class);
// 设置行高
exportParams.setHeight((short) 8);
// 普通导出,输出Workbook流
// return ExcelExportUtil.exportExcel(exportParams, object, list);
return createExcel(exportParams, object, list);
}
public static void exportExcel2(Workbook workbook, File file) {
FileOutputStream out = null;
try {
out = new FileOutputStream(file);
// 输出表格
workbook.write(out);
} catch (IOException e) {
log.error("文件导出异常,详情如下:", e);
throw new RuntimeException("文件导出异常");
} finally {
try {
if (workbook != null) {
// 关闭输出流
workbook.close();
}
if (out != null) {
// 关闭输出流
out.close();
}
} catch (IOException e) {
log.error("文件导出异常,详情如下:", e);
}
}
}
}
4.2.4、执行结果:
控制台信息:
生成的压缩包:
因为导出40万条数据,每个excel是4万条数据,所以会生成10个文件。
5、总结
本节主要实现采用多线程的方式实现数据的导出,学会使用并发包中的CountDownLatch去实现,文件导出工具使用的是EasyPoi,怎么使用我在上一篇已讲,当然根据实际情况,导出工具可以选用EasyEcel,这里重点强调的是实现的一个方案和思想。