全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2024-12-01_大促后两百万笔订单要导出,点了按钮一直转圈圈,我该怎么办?

您的位置:首页 >> 新闻 >> 行业资讯

大促后两百万笔订单要导出,点了按钮一直转圈圈,我该怎么办? 点击关注公众号,“技术干货”及时达! 引言前两篇关于EasyExcel的文章中,已经全面讲述了它的核心API,以及分享了常规场景下的导入导出实战,可在大数据量级的背景加持下,如果再通过之前的导入导出方式来处理,内存资源会被疯狂消耗,同时得到的性能反馈也不理想。 ?回想我首次接触大报表导出需求的情景,那是一个风和日丽的日子,周年庆的大促活动走到尾声,活动期间未出任何故障,并且大促取得了非常不错的成绩,技术也好,运营也罢,脸上洋溢着乐呵呵的笑容。 也正是这个宣告完美收官的时候,业务团队的大领导发话,需要将大促活动的订单进行清洗,并导出给他们用来支撑走查对账、制作图表、数据分析、运营优化等,而整个活动期间产生了接近200W笔订单,当时没多想就直接写了个清洗导出逻辑,结果一试接口直接卡死、服务内存狂飙…… ?综上,既不想看到内存溢出的局面出现,也不想看到点击导入/导出按钮后一直转圈圈,这就得咱们从方案设计、编码层面进行控制,具体怎么做呢?诸位且听我慢慢道来。 ?PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,近期需要找工作的小伙伴可以点击:https://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文件: @Datapublic 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;} @Overridepublic void export1mPandaExcel(HttpServletResponse response) { ListPanda1mExportVO 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@AllArgsConstructorpublic enum ExcelTaskType { EXPORT(0, "导出"), IMPORT(1, "导入"), ; private final Integer code; private final String desc;} @Getter@AllArgsConstructorpublic 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 ServerResponseLong exportExcelV6() { return ServerResponse.success(pandaService.export1mPandaExcelV2());}因为我是直接导出全表数据,所以没有入参,如果你要根据条件来导出数据,则可以根据实际情况做出调整。再来看出参,最终会返回一个Long值,这个值就对应着报表任务ID,前端拿到这个ID后可以给出弹窗不断刷新结果。 接着来看导出的service()方法,如下: @Overridepublic 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 CopyOnWriteArrayListPanda1mExportVO data = new CopyOnWriteArrayList(); for (int j = 0; j TaskThreadPool.concurrentRate; j++) { final int startId = cursor.get() * batchRows + 1; concurrentPool.submit(() - { ListPanda1mExportVO 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……。 ?四、总结至此,本文围绕着“百万量级的报表导出”这个话题,从一开始的场景复现,到性能与资源占用问题分析,以及如何优化的方案设计,再到最后的方案落地进行了全方位阐述,而这套方案不仅仅只适用于大报表导出场景,但凡是执行缓慢、资源占用过高的场景,都可以套入类似的思想去加以解决。 如果资源占用过高,想要控制内存使用情况,那么可以调小并发线程数、每批的处理条数,不过这会使得导出耗时变长。反之,如果想要让导出的性能更好、时间更短,可以加大并发线程数,不过这会使得资源占用变高。鱼和熊掌不可兼得,要性能还是要资源全凭诸位自己把握。 搞定了报表导出场景后,而面对百万级报表导入又该如何是好呢?关于这点,咱们就在下篇中进行展开啦~ 点击关注公众号,“技术干货”及时达! 阅读原文

上一篇:2021-06-30_508人决战,北大占绝对优势,2021阿里全球数学决赛真题发布! 下一篇:2021-11-04_又一年,“平凡万圣节”把你的生活cos了个遍

TAG标签:

18
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价