java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > java导出Excel

java导出Excel实现八倍效率优化的方法详解

作者:稻草人2222

这篇文章主要为大家详细介绍了java导出Excel实现八倍效率优化的相关知识,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下

背景

老后台系统的数据记录导出Excel功能被财务,运营吐槽难用有时候甚至用不了,我负责重构老后台系统代码,刚好把Excel导出功能重新优化设计一下。接下来我会分析当前问题,针对优化性能,进行代码分层解藕实现优雅封装 尽可能在提高10秒同步流返回最大的记录条数

关键字: ES滚动查询,读写分离,阻塞队列,生产消费模型,工厂模式与策略接口,抽象模板与资源控制

原代码逻辑

     public ResponseEntity export(PrizeDto param) {
            param.setPageNum(1);
            param.setPageSize(10000);
            ESPageEntity<ESPrizeVo> result = null;
            try {
                result = esUtil2.query(authCode, indexName, fuzzyQueryOrder, fuzzyQueryOrderVersion, param, ESPrizeVo.class);
            } catch (Throwable ex) {
                log.error(ex.getMessage(), ex);
            }
            ExcelUtil<ESPrizeVo> util = new ExcelUtil<>(ESPrizeVo.class);
            List<ESPrizeVo> list = JSON.parseArray(JSON.toJSONString(result.getList()), ESPrizeVo.class);
            return util.exportExcel(list, "数据记录", null);
        }
        
        //util.exportExcel 基本与若以框架自带的ExcelUtils一致

这一看,代码确实简单粗暴,因为数据是存在ES库中的,公司的ES库 分页搜索最多支持到第一万条数据,所以代码简单粗暴的直接一次查1w条

缺点可以说相当大,首先导出数据不支持一万条以上的数量,第二就是整个效率低下,查询与写记录是同步阻塞。第三就是内存还有待优化,一个list 最大情况下会保存一万条记录。

光1W条记录导出用了15s,效率实在太低

用户的诉求是希望能够导出2W条左右的数据,接下来开始进行优化分析

优化点

1.首先要突破1W条记录数量限制需要使用ES的滚动查询,同时需要确定好没次滚动的数量,太长会导致单次查询较慢,同时list元素过多占用堆内存过多,滚动数量太少则会增加滚动次数,整体效率慢

2.优化整体效率,因为使用了滚动查询,那么可以进行读写分离,每次滚动的数据交给异步线程池进行异步的写Excel。

3.优化内存空间占用,这里主要涉及到读写分离时中间的缓冲池的容量大小,这个容量最好能过做到写数据不需要空等待数据进来,生产数据不会出现缓存池满了阻塞的情况。还有一个写Excel操作的配置,配置SXSSFWorkbookrowAccessWindowSize 属性为每次滚动查询的大小,同时我还优化了ES的查询模板,减少了运营与财务不需要的数据字段,大幅降低了单条数据的大小

整体设计

先上架构图:

可以看到我通过数据范围的大小分为了两种处理器,一种是同步流返回处理器,面对数据量较小范围的数据进行同步导出,而数据量较大的则需要完全异步后端处理,前端再轮训或者后端主动通知的方法告知其文件下载。(1.5w 其实可以调整至5w,能过实现5秒处理)

这两种处理器的处理逻辑其实是类似的,全部都采用了生产消费模型,实现读写异步的功能。在代码上封装统一提交任务与消费数据两个主要功能。

代码细节

ES 滚动查询封装

es滚动主要分为三个步骤,第一个步骤是 发送第一次滚动查询请求,ES内部会生成一份查询数据快照。API: POST /<index_name>/_search?scroll=<timeout> timeout 是这个快照保存的时间,可以设置为1m 即一分钟,请求体需要有一个size,即每次滚动的大小。请求返回一个scrollID,以及第一次滚动查询的数据!

{
  "size": 100,       // 每次滚动返回的文档数量
  "query": {
    "match_all": {}  // 你的查询条件,可以是任何复杂的查询
  },
  "sort": ["_doc"]   // 【最佳实践】使用 `_doc` 排序以获得最高效的检索性能
}

第二个步骤是开始滚动查询数据,API: POST /_search/scroll 你需要使用第一步返回的scrollId 作为请求体去请求,不需要携带查询模板。这个步骤会再返回一个新的scrollId,然后在用新的scrollID 重复步骤二即开始滚动数据,需要注意此步骤不可并发,即使有些获取的scrollId是相同的。

{
  "scroll": "1m",    // 【重要】可以重新设置 scroll 的超时时间,重置倒计时
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}

删除查询快照,在滚动获取数据为空的时候需要去请求ES删除这次的查询快照,如果不请求,那么会在设置的timeout超时时间后自动删除。API: DELETE /_search/scroll

封装初始化滚动查询,以及后续的滚动,使用TransmittableThreadLocal存储scrollId

public class ESScrollUtil extends ESUtil implements EsSearch {
​
    @Value("${es.xxx.order.authCode}")
    private String authCode;
​
    @Value("${es.xxx.order.indexName}")
    private String indexName;
​
    public static final TransmittableThreadLocal<String> localScrollId = new TransmittableThreadLocal<>();
  
    // 每次滚动完进行TL清除
    @Override
    public void cleanLocalScrollId() {
        localScrollId.remove();
    }
​
    public <R extends BaseESResult> ScrollRes<R> getFirstScrollId(Class<R> clazz, String templateName, String templateVersion, Object body) {
        ParameterizedTypeImpl type = new ParameterizedTypeImpl(new Type[]{clazz}, null, ESPageEntity.class);
        ScrollRes<R> baseESResultScrollRes = super.queryForScroll(authCode, indexName, templateName, templateVersion, body, type);
        localScrollId.set(baseESResultScrollRes.getScrollId());
        return baseESResultScrollRes;
    }
​
   
    public <R extends BaseESResult> ScrollRes<R> nextScrollPage(Class<R> clazz){
        if (StringUtilsExt.isEmpty(localScrollId.get())) {
            log.error("请先调用getFirstScrollId方法获取scrollId");
            throw new CustomException("请先调用getFirstScrollId方法获取scrollId");
        }
        ParameterizedTypeImpl type = new ParameterizedTypeImpl(new Type[]{clazz}, null, ESPageEntity.class);
        ScrollRes<R> res = super.queryForScrollNextPage(authCode, indexName, localScrollId.get(), type);
        localScrollId.set(res.getScrollId());
        return res;
    }
}

这样在业务处理代码只需要执行 getFirstScrollId 然后 while 执行 nextScrollPage 不需要处理scrollId赋值的问题

基本Excel导出处理夫类,封装滚动任务执行以及Excel写入任务,内部使用阻塞队列实现生产消费模型

@Slf4j
public class BaseExcelExporter {
    // clazz缓存, key为类名, value为字段名和注解的map
    protected static final HashMap<Class<?>, List<Excel>> cacheExcelInfo = new HashMap<>();
​
    // 解析Excel缓存, key为 className + "_" + 注解name, value为字段
    protected static final HashMap<String,Field> cacheField = new HashMap<>();
​
    protected static ExecutorService executorService;
​
    static {
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        // 两倍最大并发数满足了最大处理情况(单次oss处理器需要两个线程),需要使用包装Ttl线程池,继承ScrollID
        threadPoolTaskExecutor.setCorePoolSize(MAX_CONCURRENCY*2);
        threadPoolTaskExecutor.setMaxPoolSize(MAX_CONCURRENCY*2);
        threadPoolTaskExecutor.setQueueCapacity(0);
        threadPoolTaskExecutor.setThreadNamePrefix("excel-exporterThread-");
        threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        threadPoolTaskExecutor.initialize();
        executorService = TtlExecutors.getTtlExecutorService(threadPoolTaskExecutor.getThreadPoolExecutor());
    }
    /**
     * 提交ES查询任务,将查询结果放入阻塞队列,异常或查询数据为空时将空数据放入队列
     * @param task 滚动查询任务
     * @param taskResQueue 结果阻塞队列
     * @param <R> 结果类型
     */
    protected <R extends BaseESResult> void commitEsQueryTask(Supplier<ScrollRes<R>> task,
                                                              BlockingQueue<List<R>> taskResQueue){
        try {
            executorService.submit(() -> {
                try {
                    for (;;){
                        ScrollRes<R> taskResult = task.get();
                        List<R> res = taskResult.getRes();
                        if(res.isEmpty()){
                            log.debug("任务已完成,退出循环");
                            taskResQueue.put(new ArrayList<>());
                            return;
                        }
                        taskResQueue.put(res);
                    }
                }catch (Exception e) {
                    log.error("ES滚动查询出现异常", e);
                }
            });
        } catch (RejectedExecutionException e) {
            log.error("Excel导出线程池已满,无法提交任务", e);
            throw e;
        }
    }
​
    protected <R extends BaseESResult> void syncWriteTaskResult(String handlerName, Class<R> clazz,
                                                                BlockingQueue<List<R>> dataQueue,
                                                                Sheet sheet, CellStyle dateCellStyle){
        long start;
        try {
            for (;;){
                List<R> taskRes = dataQueue.poll(9, TimeUnit.SECONDS);
                if(taskRes == null){
                    log.error("【{}】获取阻塞队列数据超时! 线程退出阻塞等待", handlerName);
                    break;
                }
                log.info("主线程获取到阻塞队列数据,数据大小:{} 当前队列剩余数据批数:{}",taskRes.size(), dataQueue.size());
                // 获取的list为空则结束了(可能任务执行异常了)
                if(taskRes.isEmpty()){
                    log.info("Excel导出任务已完成,主线程退出循环");
                    break;
                }
                // 写入数据
                start = System.currentTimeMillis();
                writeRows(sheet, taskRes, clazz, dateCellStyle);
                log.info("主线程写入数据耗时:{} ms", System.currentTimeMillis() - start);
            }
        } catch (InterruptedException e) {
            log.error("【{}】 消费线程被中断,线程池繁忙", handlerName, e);
            throw new CustomException("系统导出繁忙中,请稍后重试");
        }
    }
​
    public void analysisExcelAnno(Class<?> clazz){
        List<Excel> excels = cacheExcelInfo.computeIfAbsent(clazz, k -> new ArrayList<>());
        //获取字段上的注解
        Field[] declaredFields = clazz.getDeclaredFields();
        for (Field declaredField : declaredFields) {
            declaredField.setAccessible(true);
            Excel excel = declaredField.getAnnotation(Excel.class);
            if (excel != null) {
                excels.add(excel);
                cacheField.put(clazz.getSimpleName()+"_"+excel.name(), declaredField);
            }
        }
    }
​
    /**
     * 获取excel表头, 值为 {@link Excel} 注解的 name 属性, 顺序与字段顺序一致
     * 如果是第一次导出该类则会进行 {@link #analysisExcelAnno(Class)} 解析
     * @param clazz 待导出的类
     * @return 表头
     */
    public String[] getExcelHeader(Class<?> clazz){
        List<Excel> excels = cacheExcelInfo.getOrDefault(clazz, null);
        if (excels == null) {
            analysisExcelAnno(clazz);
            excels = cacheExcelInfo.get(clazz);
        }
        return excels.stream().map(Excel::name).toArray(String[]::new);
    }
​
    public Field getFieldByExcelName(Class<?> clazz, String name){
        Field field = cacheField.get(clazz.getSimpleName()+"_"+name);
        if(field == null){
            throw new CustomException("未找到对应的字段,请检查是否执行analysisExcelAnno(clazz) 过该类");
        }
        return field;
    }
​
    /**
     * 写入数据行,需要注意写入后list数据已被清空,无法再次使用
     * @param sheet 工作表
     * @param data 待写入数据
     * @param clazz 数据类型
     * @param dateCellStyle 单元格样式
     */
    public void writeRows(Sheet sheet, List<?> data, Class<?> clazz, CellStyle dateCellStyle){
        String[] headers = getExcelHeader(clazz);
        // 获取当前行数,判断是否需要创建表头
        int currentRowNum = sheet.getPhysicalNumberOfRows();
        // 如果当前行数为 0,说明表头还未创建,需要创建表头
        if (currentRowNum == 0) {
            Row headerRow = sheet.createRow(0); // 第 0 行作为表头行
            for (int i = 0; i < headers.length; i++) {
                // 创建表头单元格,值为注解的 name 属性
                Cell cell = headerRow.createCell(i);
                cell.setCellValue(headers[i]);
                // 设置每个列的宽度
                Excel excel = cacheExcelInfo.get(clazz).get(i);
                sheet.setColumnWidth(i, (int) ((excel.width() + 0.72) * 256));
            }
            currentRowNum++; // 表头占用第 0 行,所以数据从第 1 行开始
        }
​
        // 写入数据行
        for (Object item : data) {
            Row dataRow = sheet.createRow(currentRowNum++); // 从当前行开始写入数据
            for (int j = 0; j < headers.length; j++) {
                Cell cell = dataRow.createCell(j); // 创建每列单元格
                Field field = getFieldByExcelName(clazz,headers[j]); // 根据表头名称获取字段
                try {
                    Object value = field.get(item); // 获取字段值
                    if (value instanceof Date) {
                        // 如果是日期类型,设置日期值和样式
                        cell.setCellValue((Date) value);
                        cell.setCellStyle(dateCellStyle);
                    } else {
                        // 其他类型直接转换为字符串
                        cell.setCellValue(value == null ? "" : value.toString());
                    }
                } catch (IllegalAccessException e) {
                    log.error("获取字段值异常", e);
                    throw new CustomException("获取字段值异常");
                }
            }
        }
        // 显式清空辅助jvm回收
        data.clear();
    }
}

Excel导出接口,对Excel handler 的抽象接口

    public interface ExcelExportHandler {
        // 导出接口,执行此方法即完成了excel导出
        public <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, ScrollRes<R> firstScroll, Supplier<ScrollRes<R>> nextScroll, SXSSFWorkbook workbook, CellStyle dateCellStyle);
        // 判断该处理器是否支持处理该滚动查询,这里通过res的count即文档数量来选择同步返回流处理器还是Oss异步处理器
        public Boolean support(ScrollRes<?> scrollRes);
    }
    ​

同步流Excel导出处理器-简单导出处理器

    ​
    /**
     * 简单Excel导出处理器,支持导出数据量小于40000的数据。
     * 主线程写入数据,子线程请求ES进行滚动查询,提高导出效率。
     */
    @Service
    @Slf4j
    public class SampleExportHandler extends BaseExcelExporter implements ExcelExportHandler {
    ​
        @Override
        public <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, ScrollRes<R> firstScroll,
                                                              Supplier<ScrollRes<R>> nextScroll, SXSSFWorkbook workbook,
                                                              CellStyle dateCellStyle) {
            ServletOutputStream outputStream = null;
            try {
                Sheet sheet = workbook.createSheet("Sheet1");
                // 设置一个较小值,size 即为缓冲池的滚动批次大小,这里最多允许两个滚动批次结果存在队列中
                BlockingQueue<List<R>> dataQueue = new LinkedBlockingQueue<>(2);
                // 提交Es滚动查询任务至线程池中
                commitEsQueryTask(nextScroll, dataQueue);
                // 后写入第一次滚动的数据
                writeRows(sheet, firstScroll.getRes(), clazz, dateCellStyle);
                // 主线程消费阻塞队列的写入数据
                syncWriteTaskResult("SampleExcel导出处理器",clazz, dataQueue, sheet, dateCellStyle);
                HttpServletResponse response = getResponse();
                // 获取输出流
                outputStream = response.getOutputStream();
                // 将工作簿写入输出流
                workbook.write(outputStream);
                // 刷新输出流
                outputStream.flush();
                return new ExcelHandleRes(true);
            } catch (Exception e){
                log.error("导出 Excel 时发生异常",e);
                return new ExcelHandleRes(false);
            }
            finally {
                if(outputStream != null){
                    try {
                        outputStream.close();
                    } catch (Exception e) {
                        log.error("outputStream 关闭时发生异常, ", e);
                    }
                }
            }
        }
    ​
        private HttpServletResponse getResponse() {
            HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            try {
                response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("export.xlsx", "UTF-8"));
            } catch (UnsupportedEncodingException e) {
                throw new RuntimeException(e);
            }
            return response;
        }
    ​
        @Override
        public Boolean support(ScrollRes<?> scrollRes) {
            return scrollRes.getCount() <= 40000;
        }
    ​
    }
    ​

两个处理器的代码是相似的,这里就不重复写了。小细节是先去提交滚动查询任务至线程池中然后再去写入第一次滚动数据,如果顺序相反则第一次写入数据是阻塞读任务的。

导出工厂,通过第一次滚动查询的结果数量 调用excelExportHandler.support 找到合适的处理器

    @Component
    public class ExcelExportHandlerFactory {
    ​
        @Autowired
        private List<ExcelExportHandler> handlers;
    ​
        public ExcelExportHandler buildHandler(ScrollRes<?> scrollRes) {
            for (ExcelExportHandler handler : handlers) {
                if (handler.support(scrollRes)) {
                    return handler;
                }
            }
            return null;
        }
    }

Exccel 导出工具, 直接用于service 调用

    public class LargeExcelUtil implements ApplicationContextAware {
        private static ExcelExportHandlerFactory excelExportHandlerFactory;
    ​
        public static final int MAX_CONCURRENCY = 2;
        // 最大并发数量
        private static final AtomicInteger atomicInteger = new AtomicInteger(MAX_CONCURRENCY);
    ​
        /**
         * 一定要设置分页大小!
         * @param clazz 返回结果的类型,必须使用 {@link Excel} 注解
         * @param esSearch es查询工具类
         * @param templateName es查询模板名称
         * @param templateVersion es查询模板版本
         * @param body es查询参数
         * @return
         * @param <R>
         */
        public static <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, EsSearch esSearch, String templateName, String templateVersion, Object body, Integer pageSize) {
            if(atomicInteger.decrementAndGet() < 0){
                atomicInteger.incrementAndGet();
                throw new CustomException("系统导出繁忙中,请稍后重试");
            }
            // 通过第一次滚动获取scrollID以及滚动数据量再通过数据量获取处理器来执行导出
            ScrollRes<R> firstScrollId = esSearch.<R>getFirstScrollId(clazz, templateName, templateVersion, body);
            ExcelExportHandler excelExportHandler = excelExportHandlerFactory.buildHandler(firstScrollId);
            if (excelExportHandler == null) {
                throw new RuntimeException("待导出数据太多了,暂不支持的导出");
            }
            SXSSFWorkbook workbook = null;
            try {
                // 创建 SXSSFWorkbook工作簿,内存最大保存pageSize个数据, 开启压缩临时文件减少磁盘空间,但不要开启使用共享字符会提高一倍多的写入时间
                workbook = new SXSSFWorkbook(null,pageSize,true,false);
                CellStyle dateCellStyle = workbook.createCellStyle();
                // 格式化日期数据
                CreationHelper creationHelper = workbook.getCreationHelper();
                dateCellStyle.setDataFormat(creationHelper.createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss"));
                return excelExportHandler.export(clazz, firstScrollId, () -> esSearch.nextScrollPage(clazz), workbook, dateCellStyle);
            }
           finally {
                // 因为使用了localStorage存储scrollID,使用完后必须清除
                esSearch.cleanLocalScrollId();
                atomicInteger.incrementAndGet();
                try {
                    if(workbook != null){
                        // 先尝试关闭工作簿同时释放了内存与临时文件
                        workbook.close();
                    }
                } catch (IOException e) {
                    log.error("SXSSFWorkbook close 失败,错误信息", e);
                }finally {
                    if(workbook != null){
                        // 如果关闭工作簿失败则释放临时文件
                        workbook.dispose();
                    }
                }
            }
        }
    ​
        @Override
        public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
            excelExportHandlerFactory = applicationContext.getBean(ExcelExportHandlerFactory.class);
        }
    }

实现效果

机器2C4G 每次滚动数量2000 时 写入 与 读取的耗时比较接近,下图中ES滚动查询 平均比写入多大概50ms

在导出4W条数据时,接口总耗时6911ms

总结 (偷个懒)

1.突破数据量限制 - 引入 ES 滚动查询 (Scroll API)

动作:替换原有的简单分页查询,采用 ES 滚动查询机制。

关键设计

2.提升处理效率 - 生产消费模型 & 异步化

动作:将 数据查询(生产)Excel 写入(消费) 分离。

关键设计

3.优化内存占用 - 流式处理与缓存

动作:避免大数据量的整体驻留。

关键设计

4.架构扩展性 - 策略模式与工厂模式

动作:根据数据量大小智能选择不同的处理策略。

关键设计

定义 ExcelExportHandler 接口:统一导出处理器的行为。

实现不同处理器

工厂模式自动选择:通过 ExcelExportHandlerFactory 根据第一次查询的结果总数,自动选择最合适的处理器。

5.资源保护 - 限流与清理

动作:防止系统过载和资源泄漏。

关键设计

优化成果

代码设计模式与架构总结

1.BaseExcelExporter(公共父类) - 模板方法模式 & 资源复用

设计意图抽取共性,封装流程。将导出过程中不变的核心算法骨架(生产-消费模型)与可变的实现细节(具体如何查询、如何写入)分离。

实现要点

模板方法commitEsQueryTasksyncWriteTaskResult 这两个 protected 方法定义了“提交查询任务”和“消费写入数据”的标准流程。子类只需调用它们即可完成核心逻辑,无需关心多线程同步和队列管理的复杂细节。

公共资源

好处:极大减少了子类的代码量,确保了所有导出器行为的一致性,并且将性能优化手段(缓存、线程池)集中在父类,便于维护。

2.ExcelExportHandler(接口类) - 策略模式

设计意图定义标准,开放扩展。声明一个通用的导出契约,将不同的导出算法(如同步流、异步OSS)抽象为不同的策略,使它们可以相互替换。

实现要点

好处

3.ExcelExportHandlerFactory(工厂类) - 工厂模式

设计意图对象创建与使用分离。负责根据业务规则(数据量)自动选择并创建具体的策略实现对象。

实现要点

好处

4.LargeExcelUtil(Utils类) - 外观模式 & 资源管理

设计意图提供简洁的高层接口,整合复杂子系统。它不再是传统的静态工具类,而是一个集成了复杂流程和资源管理的门户类(Facade)

实现要点

外观模式:对外提供一个非常简单的 export 静态方法,内部却整合了参数校验、并发控制、ES查询初始化、处理器工厂、工作簿创建、资源清理等一整套复杂流程。对调用者而言,导出功能变得非常简单。

资源管理

实现了 ApplicationContextAware:这是一个巧妙的设计,让这个静态工具类能够获取到 Spring 容器中的 Bean(ExcelExportHandlerFactory),解决了静态方法无法直接注入 Spring Bean 的问题

5.ESScrollUtil(Utils类) - 职责单一 & 封装

设计意图封装复杂细节,提供友好API。将ES滚动查询的三个步骤(初始化、滚动、清理)及其资源管理(TransmittableThreadLocal)封装起来。

实现要点

总结:架构图景

总结:架构图景

以上就是java导出Excel实现八倍效率优化的方法详解的详细内容,更多关于java导出Excel的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文