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,这里重点强调的是实现的一个方案和思想。
关于文件导出的demo链接:
https://github.com/Deamer1102/ExportExcelData

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐