本文为稀土掘金技术社区首发签约文章,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
模型类总共有十个字段,而后直接将所有表数据查询出来并进行导出,下面来看这种最简单粗暴的方式效果咋样:
其实从这个接口耗时来看还行,百万量级的excel
文件导出未做任何优化,冷启动第一次51s
左右,第二次竟然能够在半分钟左右导出完毕,最终文件大小为49.28MB
,好像完全能接受呀!
但要注意,这是我在本地机器上测试出来的结果,最开始基于个人
2c4g
的云数据库测试,光前面那个存储过程就跑了2781.26s
;其次当我本地调用导出接口后,查询SQL
直接连接超时……
除开机器配置原因外,还有就是Java
服务与数据库同处内网环境,避免了走公网的延迟及开销。当然,最关键的一点原因是:我直接在select1mPandas()
对应的SQL
里查了全表,一次性将一百万数据读了回来,这样虽然快是快了,但放到线上场景显然不现实,来看对比:
本地搭建的数据库和云数据库,分别执行查询全表的语句后,性能竟相差71
倍……,而个人机器查询百万级全表只花了不到四秒,这是许多线上数据库难以达到的硬件配置和性能表现。所以,这种查询大表的SQL
语句,在线上不仅仅可能会出现连接超时,而且还会大量占用资源从而影响其他业务,来看前面导出所耗费的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、优化资源占用率
回到这张资源监控图,由于生成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……
。
四、总结
至此,本文围绕着“百万量级的报表导出”这个话题,从一开始的场景复现,到性能与资源占用问题分析,以及如何优化的方案设计,再到最后的方案落地进行了全方位阐述,而这套方案不仅仅只适用于大报表导出场景,但凡是执行缓慢、资源占用过高的场景,都可以套入类似的思想去加以解决。
如果资源占用过高,想要控制内存使用情况,那么可以调小并发线程数、每批的处理条数,不过这会使得导出耗时变长。反之,如果想要让导出的性能更好、时间更短,可以加大并发线程数,不过这会使得资源占用变高。鱼和熊掌不可兼得,要性能还是要资源全凭诸位自己把握。
搞定了报表导出场景后,而面对百万级报表导入又该如何是好呢?关于这点,咱们就在下篇中进行展开啦~
所有文章已开始陆续同步至微信公众号:竹子爱熊猫,想在手机上便捷阅读的小伙伴可搜索关注~