最近做了一个导出功能,需要将页面筛选条件之后的数据全部导出到excel中,每个分公司只能导出自己账号权限下的数据不会很多,但是如果是总公司不进行任何筛选直接导出全部数据大概会有17万左右的数据,后期可能还会更多,但是前端超时时间是30s。由于easyexcel不支持并发写入,所以只能从数据库方面入手。

自定义线程池

由于需要使用到多线程,最好自定义一个导出专用的线程池不要影响到其他功能


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.ThreadPoolExecutor;

@Configuration
@EnableAsync
public class ThreadPoolConfiguration {
    
    /**
     * 导出任务线程池
     * @return
     */
    @Bean("exportTaskExecutor")
    public ThreadPoolTaskExecutor exportTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 核心线程数
        executor.setCorePoolSize(5);
        // 最大线程数
        executor.setMaxPoolSize(10);
        // 等待队列数
        executor.setQueueCapacity(0);
        // 非核心线程空闲存活时间
        executor.setKeepAliveSeconds(60);
        // 线程名称
        executor.setThreadNamePrefix("export-task-");
        
        // 拒绝策略:由主线程继续执行被拒绝的任务
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        
        // 线程池初始化
        executor.initialize();
        return executor;
    }
}

多线程分页查询数据库并导出

// 设置文本内省
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
        // 设置字符编码
        response.setCharacterEncoding("utf-8");
        String fileTimeStr = DateUtil.format(DateUtil.date(), "yyyyMMddHHmm");
        String fileName = URLEncoder.encode("清单导出_" + fileTimeStr, StandardCharsets.UTF_8);
        // 设置响应头
        response.setHeader("Content-disposition", "filename=" + fileName + ".xlsx");
        
        // 使用try-with-resources确保资源释放
        log.info("开始添加多线程导出");
        try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), ExportItemListVo.class)
                .registerWriteHandler(new HorizontalCellStyleStrategy(CustomExcelStyleUtil.getHeadStyle(),CustomExcelStyleUtil.getContentStyle()))
                .build()
        ) {
            WriteSheet writeSheet = EasyExcel.writerSheet("标的清单").build();
            String finalCompanyCode = companyCode;
            // 提交当前批次任务
            List<Future<List<ExportItemListVo>>> batchFutures = new LinkedList<>();
            
            // 分页查询并写入
            for (int pageNum = 1; pageNum <= totalPages; pageNum++) {
                // 分页
                Page<ExportItemListVo> page = new Page<>(pageNum,
                        pageSize);
                page.setSearchCount(false);
                if (totalPages == 1) {
                    // 当只有一页则不分页  加速查询
                    batchFutures.add(exportTaskExecutor.submit(() -> itemMapService.exportItemList(params, finalCompanyCode, null)));
                } else {
                    batchFutures.add(exportTaskExecutor.submit(() -> itemMapService.exportItemList(params, finalCompanyCode, page)));
                }
            }
            log.info("开始写入excel");
            while (!batchFutures.isEmpty()) {
                Future<List<ExportItemListVo>> future = batchFutures.remove(0);
                excelWriter.write(future.get(30, TimeUnit.SECONDS), writeSheet);
            }
        }

多线程导出工具类

由于之前的写法过于重复,所以手动写了一个工具类简化流程


import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

@Slf4j
@Component
public class ExcelExportUtil {
    
    @Autowired
    @Qualifier("exportTaskExecutor")
    private ThreadPoolTaskExecutor exportTaskExecutor;
    
    @Builder
    @Getter
    public static class ExportConfig<T> {
        private final Class<T> clazz;
        private final String sheetName;
        private final String fileNamePrefix;
        @Builder.Default
        private final long pageSize = 20_000L;
    }
    
    /**
     * 数据源接口,返回 MyBatis-Plus Page 对象
     * 调用方负责把 searchCount 设置好后传入,工具类不感知
     */
    @FunctionalInterface
    public interface PageDataSupplier<T> {
        Page<T> fetchPage(Page<T> page);
    }
    
    public <T> void export(
            HttpServletResponse response,
            ExportConfig<T> config,
            PageDataSupplier<T> dataSupplier
    ) throws IOException, ExecutionException, InterruptedException {
        
        setResponseHeaders(response, config.getFileNamePrefix());
        
        try (ExcelWriter excelWriter = buildExcelWriter(response, config)) {
            WriteSheet writeSheet = EasyExcel.writerSheet(config.getSheetName()).build();
            
            // ① 第一页:searchCount=true,获取总数和第一批数据
            Page<T> firstPage = new Page<>(1, config.getPageSize(), true);
            Page<T> firstResult = dataSupplier.fetchPage(firstPage);
            long totalPages = firstResult.getPages();
            log.info("[ExcelExport] 文件={} 总数={} 总页数={}", config.getFileNamePrefix(), firstResult.getTotal(), totalPages);
            
            if (totalPages == 0) {
                return;
            }
            
            // ② 第一页数据直接写入,同时提交后续页任务
            excelWriter.write(firstResult.getRecords(), writeSheet);
            log.info("[ExcelExport] 第1页写入完成,数量={}", firstResult.getRecords().size());
            
            if (totalPages > 1) {
                List<Future<Page<T>>> futures = new LinkedList<>();
                for (int pageNum = 2; pageNum <= totalPages; pageNum++) {
                    final int currentPage = pageNum;
                    futures.add(exportTaskExecutor.submit(() -> {
                        Page<T> page = new Page<>(currentPage, config.getPageSize(), false);
                        return dataSupplier.fetchPage(page);
                    }));
                }
                // ③ 按顺序写入后续批次
                int batch = 2;
                while (!futures.isEmpty()) {
                    Page<T> result = futures.remove(0).get();
                    excelWriter.write(result.getRecords(), writeSheet);
                    log.info("[ExcelExport] 第{}页写入完成,数量={}", batch++, result.getRecords().size());
                }
            }
        }
        
        log.info("[ExcelExport] 文件={} 导出完成", config.getFileNamePrefix());
    }
    
    private void setResponseHeaders(HttpServletResponse response, String fileNamePrefix) {
        String fileName = URLEncoder.encode(fileNamePrefix, StandardCharsets.UTF_8);
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
        response.setCharacterEncoding("utf-8");
        response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx");
    }
    
    private <T> ExcelWriter buildExcelWriter(HttpServletResponse response, ExportConfig<T> config) throws IOException {
        return EasyExcel.write(response.getOutputStream(), config.getClazz())
                .registerWriteHandler(new HorizontalCellStyleStrategy(CustomExcelStyleUtil.getHeadStyle(), CustomExcelStyleUtil.getContentStyle()))
                .build();
    }
}

使用工具类最终效果

controller

@PostMapping(value = "/export-item-list")
    public void exportItemListCount(
            @RequestHeader("Authorization") String authorization,
            @Valid @RequestBody SearchItemListDto params
            , HttpServletResponse response) {
        String companyCode = checkTokenUtil.getCompanyCode(authorization);
    
        itemMapService.exportItemList(params, companyCode, response);
    }

service

@Override
    @SneakyThrows
    public void exportItemList(SearchItemListDto params, String companyCode, HttpServletResponse response) {
        String fileTimeStr = DateUtil.format(DateUtil.date(), "yyyyMMddHHmm");
        String fileName = URLEncoder.encode("GIS系统标的上图_清单导出_" + fileTimeStr, StandardCharsets.UTF_8);
        String sheetName = "标的清单";
    
        ExcelExportUtil.ExportConfig<ExportItemListVo> config = ExcelExportUtil.ExportConfig.<ExportItemListVo>builder()
                .clazz(ExportItemListVo.class)
                .sheetName(sheetName)
                .fileNamePrefix(fileName)
                .build();
    
        excelExportUtil.export(
                response,
                config,
                page -> itemMapMapper.exportItemList(params, companyCode, page)
        );
    }