(三)大促后两百万笔订单要导出,点了按钮一直转圈圈,我该怎么办?

8,201 阅读32分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

引言

前两篇关于EasyExcel的文章中,已经全面讲述了它的核心API,以及分享了常规场景下的导入导出实战,可在大数据量级的背景加持下,如果再通过之前的导入导出方式来处理,内存资源会被疯狂消耗,同时得到的性能反馈也不理想。

回想我首次接触大报表导出需求的情景,那是一个风和日丽的日子,周年庆的大促活动走到尾声,活动期间未出任何故障,并且大促取得了非常不错的成绩,技术也好,运营也罢,脸上洋溢着乐呵呵的笑容。
也正是这个宣告完美收官的时候,业务团队的大领导发话,需要将大促活动的订单进行清洗,并导出给他们用来支撑走查对账、制作图表、数据分析、运营优化等,而整个活动期间产生了接近200W笔订单,当时没多想就直接写了个清洗导出逻辑,结果一试接口直接卡死、服务内存狂飙……

综上,既不想看到内存溢出的局面出现,也不想看到点击导入/导出按钮后一直转圈圈,这就得咱们从方案设计、编码层面进行控制,具体怎么做呢?诸位且听我慢慢道来。

PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,近期需要找工作的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!

一、普通方式导出百万级报表

由于不同配置的机器下,取得得的性能表现并不同,后续会多次测试百万级数量的大文件导出,因此在最前面贴出个人的硬件配置及环境:

  • CPU:i7-12700H,十四核二十线程(6大核+8小核);
  • 内存:双通道DDR5、频率4800MHz;
  • 磁盘:三星SSD(RAID0模式);
  • 环境:JDK1.8、MySQL8.0.13

为了模拟百万级数据导出的场景,前提是表里得有这么多数据,所以咱们先来插入100W条数据,这里就用《EasyExcel深度实践篇》里的熊猫表了,为了快速插入可以使用存储过程来完成:

-- 创建一个插入100w数据的存储过程
DELIMITER //
DROP PROCEDURE IF EXISTS batch_insert_1m_panda;
CREATE PROCEDURE batch_insert_1m_panda()
BEGIN
    DECLARE i INT DEFAULT 1;
    -- 使用统一事务能有效提高插入效率
    START TRANSACTION;

    WHILE i <= 1000000 DO
        insert into panda(id, name, nickname, unique_code, sex, height, birthday, pic, level, motto, address, create_time) values
        (i, CONCAT('竹子',i,'号'), CONCAT('小竹',i,'号'), CONCAT('P', i), 0, '178.88', '2018-08-08', NULL, '高级', CONCAT('报数: ', i), CONCAT('地球村',i,'号'), now());
        SET i = i + 1;
    END WHILE;

    COMMIT;
END //
DELIMITER;

-- 调用存储过程插入100w熊猫数据
CALL batch_insert_1m_panda();

如果电脑配置不错,两百秒内就可以跑完这个存储过程,有了数据支撑后,下面来使用之前封装的通用导出方法,试着将导出下百万量级的excel文件:

@Data
public class Panda1mExportVO implements Serializable {
    private static final long serialVersionUID = 1L;

    @ExcelProperty("ID")
    private Long id;

    @ExcelProperty("姓名")
    private String name;

    @ExcelProperty("昵称")
    private String nickname;

    @ExcelProperty("编码")
    private String uniqueCode;

    @ExcelProperty("性别")
    private Integer sex;

    @ExcelProperty("身高")
    private BigDecimal height;

    @ExcelProperty("出生日期")
    @DateTimeFormat("yyyy-MM-dd")
    private Date birthday;

    @ExcelProperty("等级")
    private String level;

    @ExcelProperty("座右铭")
    private String motto;

    @ExcelProperty("所在地址")
    private String address;
}

@Override
public void export1mPandaExcel(HttpServletResponse response) {
    List<Panda1mExportVO> pandas = baseMapper.select1mPandas();
    String fileName = "百万级熊猫数据-" + System.currentTimeMillis();
    try {
        ExcelUtil.exportExcel(Panda1mExportVO.class, pandas, fileName, ExcelTypeEnum.XLSX, response);
    } catch (IOException e) {
        log.error("百万级熊猫数据导出出错,{}:{}", e, e.getMessage());
        throw new BusinessException("数据导出失败,请稍后再试!");
    }
}

代码中的Panda1mExportVO模型类总共有十个字段,而后直接将所有表数据查询出来并进行导出,下面来看这种最简单粗暴的方式效果咋样:

接口耗时51s

接口耗时31s

其实从这个接口耗时来看还行,百万量级的excel文件导出未做任何优化,冷启动第一次51s左右,第二次竟然能够在半分钟左右导出完毕,最终文件大小为49.28MB,好像完全能接受呀!

但要注意,这是我在本地机器上测试出来的结果,最开始基于个人2c4g的云数据库测试,光前面那个存储过程就跑了2781.26s;其次当我本地调用导出接口后,查询SQL直接连接超时……

除开机器配置原因外,还有就是Java服务与数据库同处内网环境,避免了走公网的延迟及开销。当然,最关键的一点原因是:我直接在select1mPandas()对应的SQL里查了全表,一次性将一百万数据读了回来,这样虽然快是快了,但放到线上场景显然不现实,来看对比:

性能对比

本地搭建的数据库和云数据库,分别执行查询全表的语句后,性能竟相差71倍……,而个人机器查询百万级全表只花了不到四秒,这是许多线上数据库难以达到的硬件配置和性能表现。所以,这种查询大表的SQL语句,在线上不仅仅可能会出现连接超时,而且还会大量占用资源从而影响其他业务,来看前面导出所耗费的Java资源:

Java资源开销

在未对JVM堆设限的情况下,申请的最大堆空间达到2.2GB左右,而使用的堆空间最大≈1.35GB,可实际导出的文件才49.28MB呀!一次导出请求就要这么大内存,多请求几次这谁受得了?

线上系统为了避免堆空间动态伸缩造成的抖动,通常都会给Java应用分配固定的堆内存,因此,一旦提交的导出请求过多,就会直接引发内存溢出……,这个结果显然并非我们所预期的。所以,使用普通方式来导出百万级的报表显然并不现实,那么究竟该怎么办呢?下面聊聊处理方案。

二、大数据量级处理方案

回顾上阶段的导出案例,大数据量级的导出场景主要存在两个大问题:

  • 响应时间:一旦调用导出接口会直接卡死,等待几十分钟才会有结果,性能表现极差;
  • 资源占用:触发导出动作后,Java进程的内存直线飙升直到溢出,对资源开销极大。

这两个问题换到实际业务中,前者影响用户体验感,在界面上的呈现就是点击导出按钮后一直转圈圈,直到触发网关超时熔断对应请求报错。而后者更严重,会导致整个应用不可用,毕竟任何一个系统/服务不可能只有报表导出功能,当触发导出动作造成OOM后,当前应用会直接宕机陷入瘫痪。

因此,大数据量级的报表处理,最重要的并不是性能,而是怎样让用户体验感不会变差,以及不会出现故障牵连整个应用!只要能够做好这两点,就算导出再慢也没有关系,所以该怎么做呢?一个一个问题来看。

2.1、提升用户体验感

如何优化用户体验感?最简单粗暴的方法是从根源解决问题,听我的!直接把用户表清空,没有用户就不需要关心体验感~

天才

好了,话回正题,用户体验感变差是因为接口响应很慢,那如果接口响应快了是不是体验感就好了?一提到让接口变快,异步这个词就不自觉的浮现在脑海。

因为我们只需要让接口响应时间变快,所以当接口被调用后,就可以直接给调用方返回结果,你说这不对吧?这快是快了,可是excel文件呢?!?这是报表导出场景呀!别急,该给的东西我还能少了不成?

虽然第一时间内没有给用户返回excel文件,但我们可以通过“回调”的形式给到用户呀!这样至少不会让用户看着页面转圈圈,所以最终的整体逻辑图如下:

异步回调方案

之前是用户触发报表导出后,就直接调用后端接口来同步等待excel文件返回,现在来看这个异步回调方案的完整流程:

  • ①用户点击报表导出按钮,前端会携带对应参数来调用后端的报表导出接口;
  • ②收到对应请求后,先往任务表新增一条待处理的报表记录,并获取对应的ID
  • ③再将本次的报表处理任务提交给线程池或MQ,而后将前面的ID返回;
  • ④前端收到响应后弹出引导窗口,将用户引导至报表处理中心,或引导刷新结果。

正如上述所说,这就是改造后的报表导出接口整个流程,不过由于后端给前端推送消息技术成本不算小,所以这里选择了引导用户刷新或跳转“报表处理中心”页面查看。当然,前端也可以在弹窗未关闭的情况下,每间隔一段时间就拿着返回的ID,自动调用“查询任务详情”的接口来获取报表处理进度,如果已经处理成功,则可以提示用户下载已生成的报表。

PS:这种方案有个前提,必须要有OSS对象存储或自己搭建文件服务器,因为报表生成后不可能再以流形式返回给前端,而是上传到文件服务器返回链接,前端直接通过链接下载即可。

前面讲述了导出接口的主流程,而根据条件查库、生成excel文件的动作则是异步处理,因为调用导出接口时插入了一条待处理的任务,然后才将任务提交。因此,当线程池、MQ开始处理时,可以先将任务状态改为“处理中”,当报表生成结束后,可以根据情况将状态推进到“成功或失败”的终态。如果是成功,需要将生成的文件上传到文件服务器,并将链接回填到对应的报表任务记录中。

2.1.1、excel任务表设计

下面来设计下前面提到的任务表,其实非常简单,对应的建表语句如下:

CREATE TABLE `excel_task` (
  `task_id` bigint NOT NULL AUTO_INCREMENT COMMENT '任务ID',
  `task_type` int NOT NULL COMMENT '任务类型,0:导出,1:导入',
  `handle_status` tinyint NOT NULL DEFAULT '0' COMMENT '处理状态,0:待处理,1:处理中,2:成功,3:失败',
  `excel_url` varchar(255) DEFAULT NULL COMMENT 'excel链接',
  `trace_id` varchar(255) DEFAULT NULL COMMENT '链路ID',
  `request_params` varchar(2048) DEFAULT NULL COMMENT '请求参数',
  `exception_type` varchar(255) DEFAULT NULL COMMENT '异常类型',
  `error_msg` varchar(2048) DEFAULT NULL COMMENT '异常描述',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '修改时间',
  PRIMARY KEY (`task_id`) USING BTREE
) 
ENGINE=InnoDB AUTO_INCREMENT=1 
DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci 
ROW_FORMAT=DYNAMIC COMMENT='报表任务表';

这张表总共有十个字段,不过其中大部分为辅助字段,主要关心其中几个字段即可:

  • task_id:任务ID,后续用于查询对应报表任务的执行结果;
  • task_type:用于兼容导入、导出两种报表任务;
  • handle_status:任务的处理状态,会随时间流转推进;
  • excel_url:导入时为待解析的文件链接,导出时是生成的文件链接。

而剩下的字段则是用于记录报表处理失败的相关信息,方便后续排查问题以及进行重试。这里主要说下excel_url字段,正常情况下,不管是导入还是导出,都应该基于文件服务器或对象存储来做中转,尤其是导入场景,因为处理动作异步来触发,上传的待解析excel文件,不可能放在内存或MQ里临时存储,毕竟它的体积太大了,容易把内存撑爆。

2.1.2、报表任务体系

设计好表结构后,根据前面给出的方案,我们再来看一些后续会用到的类,首先是两个枚举:

@Getter
@AllArgsConstructor
public enum ExcelTaskType {
    EXPORT(0, "导出"),
    IMPORT(1, "导入"),
    ;

    private final Integer code;
    private final String desc;
}

@Getter
@AllArgsConstructor
public enum TaskHandleStatus {
    WAIT_HANDLE(0, "待处理"),
    IN_PROGRESS(1, "处理中"),
    SUCCEED(2, "处理成功"),
    FAILED(3, "处理失败"),
    WAIT_TO_RESTORE(4, "等待恢复")
    ;

    private final Integer code;
    private final String desc;
}

第一个是任务类型枚举,第二个是任务状态枚举,一眼就能看明白就不过多废话了,关于实体类、mapper、service层的代码,可以通过代码快速生成,这里就此省略,重点注意这个变更任务状态的service方法即可:

public void updateStatus(Long taskId, TaskHandleStatus status) {
    baseMapper.updateStatusById(taskId, status.getCode());
}

这个两个方法后续会用于推进报表任务的执行状态,下面再来看看如何优化资源占用率。

2.2、优化资源占用率

Java资源开销

回到这张资源监控图,由于生成excel属于IO密集型操作,所以对CPU资源消耗并不高。来看内存面板,使用峰值最高来到了1.35G,这显然是不合适的,怎么办呢?

仔细分析内存消耗高的原因,其实就是因为一次性将100W数据查出来了,解决的方式就是分成多个批次查询,比如一次查2000条数据处理,处理完成后再查第二个批次,以此类推……。不过这种方案的前提,是写入Excel时也需要支持流式写入,来看之前的导出代码:

EasyExcelFactory.write(response.getOutputStream(), clazz).doWrite(excelData);

这里在doWrite()方法中一次性传入了所有等待写出的数据,如果只能这么做,分批查询就没了意义,毕竟所有分批查出来的数据还是得暂留在内存中,等所有数据就绪后方能写出。

当然,EasyExcel官方显然也想到了这个问题,所以它是支持流式写入的,即:读取一批数据,写入一批数据,后续支持在excel文件后追加写入数据,不过具体怎么玩,会在后面的案例中进行演示。

除了要通过分批+流式写入来优化内存消耗,同时还要限制并发文件数,即:在同一时间内,并行处理的报表任务数,如果不对这里进行限制,在大报表任务较多的情况下,也有可能导致内存溢出风险。

2.3、大报表处理细节事项

优化了用户体验感,控制好了资源利用率,已经定下了大报表处理的基调,至于慢的问题怎么解决?其实慢就慢点也无所谓,毕竟使用者本身(如运营、客户等)也多少知道这个数据量级,不可能和普通导出一样迅速。所以,在不改变基调的情况下优化性能,属于锦上添花而不是必须项。

2.3.1、使用多线程技术

多线程技术是优化性能缓慢的大杀器,这就相当于搬砖,一个人搬一堆砖得一天一夜,换成24个人来就只需一小时。基于前面给出的方案,多线程该怎么用?

首先,用户提交的导出请求已经走了异步执行,假设这里的异步方案是线程池而并非MQ,那么,一个报表任务理论上会交给一条线程去处理,具体的过程:

  • 根据指定的数量分批查询数据,并将数据加工为导出所需的格式;
  • 以流形式源源不断的往同一个excel文件中写入加工后的数据。

如果改为多线程处理,假设这里有100W数据,我们可以开五条线程,给每条线程分配20W数据处理,每条线程分别往不同的sheet写入数据,从而将导出性能提升五倍!

该方案理论上没问题,但实际走不通,Why?因为EasyExcel官网明确写着“不支持单个文件的并发读写”,它很有可能导致读写错误。经过个人的实现后,单文件的并发读取是支持的,不过读取过程中会出现警告,而并发写入则会导致生成的文件损坏,的确走不通。

PS1:想要实现单个文件的多线程并发写入,可以选择转战POI,通过它更丰富的API能够实现,不过这时就得直面内存占用如何解决的问题了,这反而更难处理,所以本文不考虑,感兴趣的小伙伴可以自行研究。
PS2:你也可以不使用POI,只要业务方能接受,也可以选择通过多线程生成多个20W行数据的excel文件,并将其压缩成一个zip包返回给用户。

既然单文件的多线程写入走不通,难道就不能用多线程了吗?还是可以,因为整个导出分为两步,可以通过多线程来并发查询、加工数据,如下:

多线程导出

真实业务场景中,写入数据这个动作本身并不耗时间,更耗费时间的反而是数据查询、加工处理这个动作,所以上面这种模式也能大幅提升性能(不过资源开销就是单线程的N倍,N为线程数)。

2.3.2、选择合适的导出格式

前面确认好了多线程模式,还有一个容易让人忽略、但又比较重要的细节,就是选择合适的excel文件类型,先来三种类型的区别:

  • xls文件:‌单sheet最大行数支持65536行,最大支持256列;
  • xlsx文件:‌单sheet最大行数支持1048576行,最大支持16384列;
  • csv文件:本质是纯文本格式,理论上行、列无上限,列默认使用,英文逗号分割。

首先可以排除xls格式,因为它单个Sheet可容纳的最大行太小了,无法满足大数据导出需求。其次是xlsx格式,它‌单个Sheet能容纳一百万行左右,可惜EasyExcel不支持并发写入,而这种格式体积较大时,打开会格外漫长,也并非最优解。

最后看到csv格式,它其实并不是一种excel格式,而是一种纯文本格式,只是可以用Excel软件打开罢了,大家可以试着将一个csv文件拖进高级文本编辑器(比如IDEA、sublime等),就会发现它是一条纯粹的文本数据,每列之间默认用,分割。

相较于写xls、xlsx这种纯Excel文件,往csv文本文件写数据更快,而且最终得到的文件打开速度更快,所以它也是大文件导出场景下的首选格式(不过由于它是纯文本文件,所以并不能支持多Sheet结构)~

2.3.3、游标解决深分页问题

前面提到将数据分批处理,而通常数据分批的做法就是分页,MySQL中的分页通常会依赖limit实现,如:

-- 跳过前面两千条数据,向后获取两千条数据返回
select * from panda where is_deleted = 0 limit 2000, 2000

这种方式在正常情况下没啥问题,可是当数据量较大时,越到后面的页码查询会越慢,因为limit的执行原理是先将所有满足条件的数据查出来,然后再跳过前面指定的行数,向后获取给定的行数,到最后这种深分页查询无异于查一次全表。

数据量大出现的深分页问题会影响查询效率,这也会降低大报表导出的性能,怎么办?最好通过游标来解决此问题,不过这里的游标并不是让你写存储过程的游标,这个游标特指一个可以代表数据行数的字段,比如案例中的id列就可以作为游标,分批查询可改为:

select * from panda where is_deleted = 0 and id > 2000 limit 2000

经过此番改造后,就不会存在深分页的性能问题了,几乎能够保证每次查询的性能一致。

2.3.4、做好业务资源隔离

再来聊聊另一个话题,如果线上存在大报表处理的功能,那么最好实现资源隔离,这里所谓的资源包括线程池、连接池等各项珍贵资源,为什么要隔离呢?因为大报表处理会长时间占用资源,不做隔离会造成其他业务陷入“资源饥饿”状态,无资源可用长时间阻塞

实现资源隔离后,比如连接池,那处理大报表操作时,会从独立的连接池获取数据库连接,这时并不占用通用连接池的连接名额,自然就不会影响其他的业务。当然,这里所谓的不影响,也只是但从这一个层面来谈的,因为报表处理功能和其他业务处理,本质上还是部署在一个应用,对于内存、CPU等资源来说,同样会造成一定影响,不过2.2阶段已经提到了控制手段。

最后。如果你的业务会存在“大促、活动”这种间接性并发时,记得给报表处理功能加上一个开关,例如:大促期间资源紧张,不允许导出xxx数据,这也是一种服务降级的手段,即:活动期间将所有资源腾给核心业务使用,暂停报表处理业务节省资源

三、百万级报表导出优化

好了,前面将整个方案已经阐述完毕,但古话说的话,纸上谈来终绝浅,绝知此事要躬行,下面进入实战环节,首先来自定义两个线程池,用于满足异步和并行的需求。

3.1、自定义线程池

自定义线程池可以选择使用JDK原生的ThreadPoolExecutor类,不过我项目是基于SpringBoot来构建的,所以可以使用Spring进一步封装的线程池类,如下:

public class TaskThreadPool {

    /*
     * 并发比例
     * */
    public static final int concurrentRate = 3;

    /*
     * 核心线程数
     * */
    private static final int ASYNC_CORE_THREADS = 3, CONCURRENT_CORE_THREADS = ASYNC_CORE_THREADS * concurrentRate;

    /*
     * 最大线程数
     * */
    private static final int ASYNC_MAX_THREADS = ASYNC_CORE_THREADS + 1, CONCURRENT_MAX_THREADS = ASYNC_MAX_THREADS * concurrentRate;

    /*
     * 队列大小
     * */
    private static final int ASYNC_QUEUE_SIZE = 2000, CONCURRENT_QUEUE_SIZE = 20000;

    /*
     * 线程池的线程前缀
     * */
    public static final String ASYNC_THREAD_PREFIX = "excel-async-pool-", CONCURRENT_THREAD_PREFIX = "excel-concurrent-pool-";

    /*
     * 空闲线程的存活时间(单位秒),三分钟
     * */
    private static final int KEEP_ALIVE_SECONDS = 60 * 3;

    /*
     * 拒绝策略:如果队列、线程数已满,本次提交的任务返回给线程自己执行
     * */
    public static final ThreadPoolExecutor.AbortPolicy ASYNC_REJECTED_HANDLER =
            new ThreadPoolExecutor.AbortPolicy();
    public static final ThreadPoolExecutor.CallerRunsPolicy CONCURRENT_REJECTED_HANDLER =
            new ThreadPoolExecutor.CallerRunsPolicy();
    /*
     * 异步线程池
     * */
    private volatile static ThreadPoolTaskExecutor asyncThreadPool, concurrentThreadPool;

    /*
     * DCL单例式懒加载:获取异步线程池
     * */
    public static ThreadPoolTaskExecutor getAsyncThreadPool() {
        if (asyncThreadPool == null) {
            synchronized (TaskThreadPool.class) {
                if (asyncThreadPool == null) {
                    asyncThreadPool = new ThreadPoolTaskExecutor();
                    asyncThreadPool.setCorePoolSize(ASYNC_CORE_THREADS);
                    asyncThreadPool.setMaxPoolSize(ASYNC_MAX_THREADS);
                    asyncThreadPool.setQueueCapacity(ASYNC_QUEUE_SIZE);
                    asyncThreadPool.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
                    asyncThreadPool.setThreadNamePrefix(ASYNC_THREAD_PREFIX);
                    asyncThreadPool.setWaitForTasksToCompleteOnShutdown(true);
                    asyncThreadPool.setRejectedExecutionHandler(ASYNC_REJECTED_HANDLER);
                    asyncThreadPool.initialize();
                    return asyncThreadPool;
                }
            }
        }
        return asyncThreadPool;
    }

    /*
     * DCL单例式懒加载:获取并发线程池
     * */
    public static ThreadPoolTaskExecutor getConcurrentThreadPool() {
        if (concurrentThreadPool == null) {
            synchronized (TaskThreadPool.class) {
                if (concurrentThreadPool == null) {
                    concurrentThreadPool = new ThreadPoolTaskExecutor();
                    concurrentThreadPool.setCorePoolSize(CONCURRENT_CORE_THREADS);
                    concurrentThreadPool.setMaxPoolSize(CONCURRENT_MAX_THREADS);
                    concurrentThreadPool.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
                    concurrentThreadPool.setQueueCapacity(CONCURRENT_QUEUE_SIZE);
                    concurrentThreadPool.setThreadNamePrefix(CONCURRENT_THREAD_PREFIX);
                    concurrentThreadPool.setWaitForTasksToCompleteOnShutdown(true);
                    concurrentThreadPool.setRejectedExecutionHandler(CONCURRENT_REJECTED_HANDLER);
                    concurrentThreadPool.initialize();
                    return concurrentThreadPool;
                }
            }
        }
        return concurrentThreadPool;
    }
}

这个类中自定义了asyncThreadPool、concurrentThreadPool两个线程池,各自的作用为:

  • asyncThreadPool异步线程池:用于满足调用导出接口时,异步执行报表导出逻辑;
  • concurrentThreadPool并发线程池:用于执行导出逻辑时,并发查询要导出的批次数据。

理解两个线程池的作用后,这里还要说明下里面的参数,这些参数并非盲目配置的,先来看异步线程池的参数:

  • ASYNC_CORE_THREADS:核心线程数,为3说明正常情况只会有三条线程;
  • ASYNC_MAX_THREADS:最大线程池,为4说明极端情况可以开启四条线程;
  • ASYNC_QUEUE_SIZE:队列容量,这里为2000,可以根据实际需求调整;
  • KEEP_ALIVE_SECONDS:空闲线程的存活时间,没有任务需要处理时,存活三分钟;
  • ASYNC_THREAD_PREFIX:异步线程的名称前缀,方便后续查看日志与排查问题;
  • ASYNC_REJECTED_HANDLER:拒绝策略,当线程池的任务已满时抛出异常。

当然,这几个线程池参数都是老八股了,我真正想介绍的并非这点,还记得前面提到的”限制并发文件数“嘛?限制提交的所有导出请求,都会交由这个异步线程池来处理,这意味着:异步线程池的最大线程数,就是最大的并发文件数

好了,接着来看并发线程池,大家仔细观察会发现,并发线程池的大部分参数都是结合concurrentRate这个变量来控制的,这个变量是含义是”并发比例“,目前设置的是3,具体啥意思呢?

并发比例代表着异步线程与并发线程的比例,目前是3,即:处理同一个导出任务时,每条异步线程与并发线程的比例为300%一个导出任务会有三条线程来负责从数据库查询数据

上面这个比例大家可以视情况调整,但不建议调整的过大,因为线程属于系统的珍贵资源,过多反而会引起CPU频繁切换。除此之外,并发线程池与异步线程池的重要区别还有一点:

  • CONCURRENT_REJECTED_HANDLER:拒绝策略,线程池已满时,将提交的任务交给提交任务的线程执行。

当并发线程池任务已满时,如果再向其中递交任务,则会要求提交任务的线程自己来执行,这样可以避免线程池已满导致的数据漏查问题。

3.2、百万级导出优化实战

OK,定义好线程池后,下面开始编码实战,这里的接口十分简单,即:

@PostMapping("/export/v6")
public ServerResponse<Long> exportExcelV6() {
    return ServerResponse.success(pandaService.export1mPandaExcelV2());
}

因为我是直接导出全表数据,所以没有入参,如果你要根据条件来导出数据,则可以根据实际情况做出调整。再来看出参,最终会返回一个Long值,这个值就对应着报表任务ID,前端拿到这个ID后可以给出弹窗不断刷新结果。

接着来看导出的service()方法,如下:

@Override
public Long export1mPandaExcelV2() {
    // 先插入一条报表任务记录
    ExcelTask excelTask = new ExcelTask();
    excelTask.setTaskType(ExcelTaskType.EXPORT.getCode());
    excelTask.setHandleStatus(TaskHandleStatus.WAIT_HANDLE.getCode());
    excelTask.setCreateTime(new Date());
    excelTaskService.save(excelTask);
    Long taskId = excelTask.getTaskId();

    // 将报表导出任务提交给异步线程池
    ThreadPoolTaskExecutor asyncPool = TaskThreadPool.getAsyncThreadPool();

    // 必须用try包裹,因为线程池已满时任务被拒绝会抛出异常
    try {
        asyncPool.submit(() -> {
            handleExportTask(taskId);
        });
    } catch (RejectedExecutionException e) {
        // 记录等待恢复的状态
        log.error("递交异步导出任务被拒,线程池任务已满,任务ID:{}", taskId);
        ExcelTask editTask = new ExcelTask();
        editTask.setTaskId(taskId);
        editTask.setHandleStatus(TaskHandleStatus.WAIT_TO_RESTORE.getCode());
        editTask.setExceptionType("异步线程池任务已满");
        editTask.setErrorMsg("等待重新载入线程池被调度!");
        editTask.setUpdateTime(new Date());
        excelTaskService.updateById(editTask);
    }

    return taskId;
}

整段代码其实并不复杂,正如一开始给出的方案一样,先插入一条报表任务记录,再将任务递交给异步线程池,最后将报表任务ID返回了出去。不过这里有个细节,即try/catch了递交任务的代码,为啥?

因为异步线程池的拒绝策略是抛出异常,一旦线程池任务满了,再调用submit()方法就会报错,对应的导出任务就会丢失,为了防止任务丢失,这里会更新下报表任务的状态,将其变为”等待恢复“状态,后续线程池资源空闲后,可以捞起来重新投递处理。

接着来看最为核心的handleExportTask()方法,该方法最终会由asyncPool里的线程执行,如下:

private void handleExportTask(Long taskId) {
    long startTime = System.currentTimeMillis();
    log.info("处理报表导出任务开始,编号:{},时间戳:{}", taskId, startTime);
    // 开始执行时,先将状态推进到进行中
    excelTaskService.updateStatus(taskId, TaskHandleStatus.IN_PROGRESS);

    // 需要修改的报表对象
    ExcelTask editTask = new ExcelTask();
    editTask.setTaskId(taskId);

    // 查询导出的总行数,如果为0,说明没有数据要导出,直接将任务推进到失败状态
    int totalRows = baseMapper.selectTotalRows();
    if (totalRows == 0) {
        editTask.setHandleStatus(TaskHandleStatus.FAILED.getCode());
        editTask.setExceptionType("数据为空");
        editTask.setErrorMsg("对应导出任务没有数据可导出!");
        editTask.setUpdateTime(new Date());
        excelTaskService.updateById(editTask);
        return;
    }

    // 总数除以每批数量,并向上取整得到批次数
    int batchRows = 2000;
    int batchNum = totalRows / batchRows + (totalRows % batchRows == 0 ? 0 : 1);
    // 总批次数除以并发比例,并向上取整得到并发轮数
    int concurrentRound = batchNum / TaskThreadPool.concurrentRate
            + (batchNum % TaskThreadPool.concurrentRate == 0 ? 0 : 1);;

    log.info("本次报表导出任务-目标数据量:{}条,每批数量:{},总批次数:{},并发总轮数:{}", 
                totalRows, batchRows, batchNum, concurrentRound);

    // 提前创建excel写入对象(这里可以替换成上传至文件服务器)
    String fileName = "百万级熊猫数据-" + startTime + ".csv";
    ExcelWriter excelWriter = EasyExcelFactory.write(fileName, Panda1mExportVO.class)
            .excelType(ExcelTypeEnum.CSV)
            .build();
    // CSV文件这行其实可以不需要,设置了也不会生效
    WriteSheet writeSheet = EasyExcelFactory.writerSheet(0, "百万熊猫数据").build();

    // 根据计算出的并发轮数开始并发读取表内数据处理
    AtomicInteger cursor = new AtomicInteger(0);
    ThreadPoolTaskExecutor concurrentPool = TaskThreadPool.getConcurrentThreadPool();
    for (int i = 1; i <= concurrentRound; i++) {
        CountDownLatch countDownLatch = new CountDownLatch(TaskThreadPool.concurrentRate);
        final CopyOnWriteArrayList<Panda1mExportVO> data = new CopyOnWriteArrayList<>();
        for (int j = 0; j < TaskThreadPool.concurrentRate; j++) {
            final int startId = cursor.get() * batchRows + 1;
            concurrentPool.submit(() -> {
                List<Panda1mExportVO> pandas = baseMapper.selectPandaPage((long) startId, batchRows);
                if (null != pandas && pandas.size() != 0) {
                    data.addAll(pandas);
                }
                countDownLatch.countDown();
            });
            cursor.incrementAndGet();
        }

        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            editTask.setHandleStatus(TaskHandleStatus.FAILED.getCode());
            editTask.setExceptionType("导出等待中断");
            editTask.setErrorMsg(e.getMessage());
            editTask.setUpdateTime(new Date());
            excelTaskService.updateById(editTask);
            return;
        }
        excelWriter.write(data, writeSheet);
        // 手动清理每一轮的集合数据,用于辅助GC
        data.clear();
    }

    log.info("处理报表导出任务结束,编号:{},导出耗时(ms):{}", 
            taskId, System.currentTimeMillis() - startTime);
    // 完成写入后,主动关闭资源
    excelWriter.finish();

    // 如果执行到最后,说明excel导出成功,将状态推进到导出成功
    editTask.setHandleStatus(TaskHandleStatus.SUCCEED.getCode());
    editTask.setExcelUrl(fileName);
    editTask.setUpdateTime(new Date());
    excelTaskService.updateById(editTask);
}

一眼看下来,逻辑稍微有点点复杂,下面先给出整体流程:

  • ①当开始处理对应的报表任务时,会先将状态推进到”处理中“;
  • ②查询当前任务需要导出的总条数,为零则直接推进到”失败”状态;
  • ③这一步是前置变量的运算,得到了多个后续依赖的值:
    • batchRows:每批处理的数量,这里写死为2000
    • batchNum:批次数,即总条数要分为多少批查询;
    • concurrentRound:并发轮数,每轮并发处理3批;
  • ④提前创建了excel写对象、文件名及游标,后续用于追加写入:
    • 注意:我没有OSS,这里直接选择存到了本地,大家可根据实际情况调整;
  • ⑤遍历并发轮数,开始以每轮三批的速率,向并发线程池投递任务;
  • ⑥异步线程阻塞等待三批数据返回,拿到后将数据写入到excel文件;
  • ⑦不断重复⑤、⑥步骤,最终将所有目标数据查出并写入到excel文件。
  • ⑧当数据导出完成后收尾,将任务推到“成功”状态,并回填可访问的链接。

大家可以结合这个流程多看几遍代码,毕竟代码中用到了线程池CountDownLatch写时复制并发容器原子类这些并发工具,接触较少的小伙伴看起来或许比较迷糊。

OK,现在挑些重点来聊一聊,主要是⑤、⑥这两步,在这里通过AtomicInteger定义了一个游标,主要是用于计算起始的ID,它会随着批次不断更新,而使用原子类型的原因,是为了保障并发线程更新游标时的线程安全

其次,遍历并发轮数时,每一轮都会向concurrentPool并发线程池投递三个查询任务,这意味着会出现三条线程去同时处理数据(实际业务中可以替换成带有清洗逻辑的方法),每次投递后也会更新游标确保数据不会重复。然后CountDownLatch来实现线程通信,每次初始化的信号量都是 并发线程的数量

每当一条线程获取到数据后,就会将数据添加进CopyOnWriteArrayList类型的data集合,并且会扣除一个信号量,而外面的异步线程(主线程)在投递完三个任务后,会调用await()方法等待唤醒,当三条并发线程将数据都查出来后,countDownLatch对应的信号量会变为0,这时异步线程就会被唤醒。

唤醒异步线程后,它会将data追加写入到excel文件,写入完成后会清理data集合,以此来辅助GC更快的识别垃圾对象。处理完前面两两步后,又会开启下一轮并发处理,如此不断反复,直至所有数据导出完毕为止。

3.3、多线程优化测试

经过上面的优化后,接着来看看导出接口的性能,以及导出时的资源占用情况,先调用下接口:

惊人性能

回想最开始,调用接口后需要等待半分钟,现在仅需16ms!用户点击导出按钮后,眼睛还没眨完就有结果响应了!再来看看实际的导出耗时:

导出耗时

这里调用了三次导出接口,三次导出的真实耗时均在11.5s左右,这也远比一开始的性能要好上许多。同时,第二次、第三次接口是连续调用的,这主要是为了模拟并发导出场景,而目前的逻辑也能正常兼容并发导出,最后来看资源占用情况:

资源开销

相较于最开始的1.35GB,导出单个文件的占用内存仅用390.61MB左右,而同时导出两个百万级文件的内存占用也才930.26MB左右,而且这还是内存充足、未频繁触发GC的情况,实际上可以做到更低。

PS:如果没有限制JVM堆内存,并且机器内存充足时,JVM不会立马选择触发GC,而是继续向操作系统动态申请内存,所以图中的已使用的内存才没有及时释放,而且最终导出的CSV文件有103MB

好了,前面聊到的关于百万级导出方案已经落地,不过这里对于之前说的连接池资源没有隔离,感兴趣的可以自行实现。

3.4、宕机任务恢复机制

因为我们这里选择了线程池来异步处理报表任务,而并不是MQ这类中间件,所以,一旦服务发生宕机、重启,提交给线程池的所有任务都会丢失,对应的报表任务永远不会被处理,这明显不合理。

为了使整个报表体系更加稳妥,我们应该设计一个任务恢复机制,怎么恢复呢?其实很简单,有两种方案:

  • 定时器:定时扫描表内待处理、待恢复的任务,并重新提交给线程池;
  • 启动器:项目启动时,通过Spring预留的初始化钩子来重新递交未处理的任务。

通过这些方式,不仅能够将宕机后丢失的任务重新载入,而且前面递交后被推进到“等待恢复”状态的任务也能被重新处理。不过上面两种方式我推荐结合使用,通过定时器来扫描等待恢复的任务,通过启动器来恢复宕机丢失的任务,两者结合方能构建出更加稳健的报表任务处理体系。

PS:感兴趣的可以自行落地,这两个方案实现起来并不难,定时扫描可以通过框架去实现,也可以通过Java自带的定时器实现;而启动时自动加载未处理的任务更简单,Spring提供了六七种方式来实现,如ApplicationRunner、CommandLineRunner、@EventListener、@PostConstruct……

四、总结

至此,本文围绕着“百万量级的报表导出”这个话题,从一开始的场景复现,到性能与资源占用问题分析,以及如何优化的方案设计,再到最后的方案落地进行了全方位阐述,而这套方案不仅仅只适用于大报表导出场景,但凡是执行缓慢、资源占用过高的场景,都可以套入类似的思想去加以解决。

如果资源占用过高,想要控制内存使用情况,那么可以调小并发线程数、每批的处理条数,不过这会使得导出耗时变长。反之,如果想要让导出的性能更好、时间更短,可以加大并发线程数,不过这会使得资源占用变高。鱼和熊掌不可兼得,要性能还是要资源全凭诸位自己把握。

搞定了报表导出场景后,而面对百万级报表导入又该如何是好呢?关于这点,咱们就在下篇中进行展开啦~

所有文章已开始陆续同步至微信公众号:竹子爱熊猫,想在手机上便捷阅读的小伙伴可搜索关注~