(四)并发编程实战:多线程+EasyExcel,20ms内极速导入百万级大数据报表!

引言

在编程领域,读和写之间总是如影随形,而在上一篇内容中,我们曾围绕着“百万量级的报表导出”这个话题做了详细展开,不过面对百万量级的报表解析场景时,原先的技术方案和代码逻辑还能复用吗?

对于大数据导入场景来说,所面临的也是性能问题和资源占用问题,因为数据量大,处理时间自然不短;也因为数据量大,所消耗的资源也自然不小,如何解决这两个问题:

  • 接口性能:通过异步形式处理报表导入,接口调用后及时响应,处理结果以回调形式通知给用户;
  • 资源占用:不要一次性解析所有数据,而是分批次解析,解析一批处理一批,从而降低资源占用。

从这段描述来看,处理的思路大致与上篇相同,可是不能完全复用,因为代码细节会存在差异,如果处理不够妥当,仍然会导致内存占用过高的问题出现,为什么这么说呢?下面一起来看看。

一、百万级报表导入场景分析

回想《EasyExcel深度实践篇》的内容,我们封装了一个通用版监听器:CommonListener,使用方式如下:

java

代码解读

复制代码

CommonListener<Panda1mReadModel> listener = new CommonListener<>(Panda1mReadModel.class); EasyExcel.read(file.getInputStream(), PandaReadModel.class, listener).sheet().doRead();

如果咱们上传一个百万级的excel文件,这时会怎么样?这个通用解析器会将所有数据全部解析出来,而后添加到内部的data集合中,这显然不够妥当,因为100W数据会占用较高的内存,来看实际情况:

拿着上篇导出的百万级excel文件丢进去,基于通用监听器来解析,单纯将数据读出来,使用的堆内存峰值达到970.83MB左右,如果同时出现多个并发导入请求,仍然会引发内存溢出!

当然,为了处理数据量级较大的导入场景,我们特意封装了一个分批处理监听器:BatchHandleListener,这个监听器允许我们读取一批数据、处理一批数据,避免一次性将所有数据读至内存造成过多的资源消耗。

1.1、分批处理监听器实战

前面提到的分批处理监听器,之前只是封装了,但是具体怎么用呢?来看例子:

整理了一份好像面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

需要全套面试笔记【点击此处即可】免费获取

java

代码解读

复制代码

@Override public void import1MExcelV2(MultipartFile file) { BatchHandleListener<Panda1mReadModel> listener = new BatchHandleListener<>(Panda1mReadModel.class, this::batchSavePandas); try { EasyExcelFactory.read(file.getInputStream(), Panda1mReadModel.class, listener).sheet(0).doRead(); } catch (IOException e) { log.error("导入百万熊猫数据出错:{}: {}", e, e.getMessage()); throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "网络繁忙,请稍后重试!"); } } @Override @Transactional(rollbackFor = Exception.class) public void batchSavePandas(List<Panda1mReadModel> excelData) { // 这里可以先实现数据行校验、业务有效性校验等逻辑,清洗后再将数据入库 List<Panda> pandas = mapperFacade.mapAsList(excelData, Panda.class); this.saveBatch(pandas); pandas.clear(); }

分批处理监听器与通用监听器的唯一区别就是:分批处理器在初始化的构造方法上多了一个入参,这个入参是Consumer类型的函数式接口对象,对应每批数据的业务处理方法。在上述案例中,直接将当前service里的batchSavePandas()批量保存方法传入了进去。

这个接口贴调用结果图了,感兴趣的可以自己downspring-boot-easy-excel源码去试试看,不用想都知道执行异常耗时,为啥?因为我拿着上篇导出的百万级excel文件丢进去,跑了六百多秒才到一半……

仔细一想,耗时的原因也能想明白,毕竟这个监听器会一批一批去解析数据,默认每批只会解析1000条数据,100W的文件总共对应1000批,平均每批数据解析、校验、落库加起来就算一秒,总共也需要1000s左右。不过在对资源占用却很小,毕竟每处理一批,就会new一个新的集合,旧集合很快被GC

1.2、大文件导入优化方案

我们直接来聊优化方案,既然数据是一批一批的解析,那咱们能否开启多个线程来解析同一个Excel文件呢?

1.2.1、多线程并发读取

声明:这小节可以跳过,最终并不会使用这种模式,因为多线程并发读取会存在一定程度的风险性,这里只是为了扩充知识点。

其实官方不建议使用多线程来对单文件进行并发读写,但如果上传的excel文件存在多个sheet,比如有五个sheet、每个sheet里有20W数据,你可以开五条线程分别去读不同sheet的数据,虽然运行过程中会有警告,但却能够正常读取数据,对应的代码如下:

 

java

代码解读

复制代码

List<PandaReadModel> pandas = new ArrayList<>(); // 第一种读取多Sheet的方式(存在一定隐患,数据可能读取不准确) CommonListener<PandaReadModel> listener = new CommonListener<>(PandaReadModel.class); ExcelReader excelReader = EasyExcel.read(fileName, PandaReadModel.class, listener) .excelType(ExcelTypeEnum.XLSX).build(); for (int i = 0; i < 3; i++) { final int index = i; // 这里请替换成线程池 new Thread(() -> { excelReader.read(EasyExcelFactory.readSheet(index).build()); pandas.addAll(listener.getData()); }); } excelReader.finish(); // 第二种读取多Sheet的方式(比较稳妥,但需要创建多个监听器和多次读取) for (int i = 0; i < 3; i++) { final int index = i; // 这里请替换成线程池 new Thread(() -> { CommonListener<PandaReadModel> listener1 = new CommonListener<>(PandaReadModel.class); EasyExcel.read(fileName, PandaReadModel.class, listener1).sheet(index).doRead(); pandas.addAll(listener1.getData()); }); }

上面这两种方式都能实现多线程并发读取同一个文件、不同Sheet功能,但两者都有个鸡肋点,就是必须得事先知道目标文件里的sheet数量,否则外部的for循环无法定义,其实之前低版本可以通过excelReader.getAnalysisContext().readSheetList()来拿到所有Sheet,可在后续高版本被废弃了……

不过如若上传的文件只有单Shett,或者是CSV格式,能否通过多线程并行读取数据呢?答案是也行,EasyExcel有个这样的API:

  • analysisContext.readSheetHolder().getApproximateTotalRowNumber():获取excel文件的总条数;

而读取时的headRowNumber()方法可以指定跳过前面N条数据,两者相结合,可以先基于总行数划分多个批次,再将其分配给不同线程,就能实现多线程解析不同范围的数据行。不过可惜,这两个方法在数据量较大时都会失真,使用起来存在一定的风险。

1.2.2、导入接口响应时间优化

抛开多线程解析不谈,导入接口响应缓慢,其实最简单、靠谱的方法就是上篇提到的异步化处理方案,即用户调用导入接口之后,就把报表导入任务丢给线程池去异步处理,而后直接给用户返回响应结果:

不过这里要注意的是,对于这种大型Excel导入的场景,实际上接口入参不应该接收MultipartFile这类文件对象,因为百万级的文件通常达到了几十、上百MB,直接传文件会大幅占用内存。尤其是任务不断递交到线程池,线程池没有空闲线程时会放到队列中,最终导致大量体积庞大的MultipartFile对象堆积,这可谓是一种变相的内存泄漏问题。

因此,更好的做法是,前端先将文件上传到OSS或文件服务器,再将可访问的链接传给导入接口即可,当线程池真正执行对应的报表导入任务时,才根据对应的地址去拉取文件流。当然,因为我个人没有OSS或文件服务器,这里就直接使用MultipartFile对象来演示了。

1.2.3、多线程优化处理性能

在及时给到用户响应后,下面再来做一些锦上添花的优化,当报表导入任务提交给线程池后,最终也只会有一条线程来负责执行整个导入逻辑,解析一批、处理一批、然后再开始下一批……,这个过程虽然节省资源,可无疑是特别缓慢的。

想要优化性能,可前面也分析过使用多线程来并发解析单个文件的可行性,得出的结论是存在风险,解析出的数据不一定准确。那……究竟该怎么优化,才能在兼顾资源占用问题的同时,还能保证可观的性能呢?好像陷入僵局了对吧?

实则不然,其实在真正的业务场景中,解析excel并不是真正的耗时项,真正耗费时间的是在数据被解析出来后,对数据行进行业务规则校验、清洗加工等步骤,所以我们优化的真正目标应该朝向这个“数据处理”环节,而不是数据解析这个环节。

把思路走对之后,再来谈优化手段,答案还是多线程,先上个整体流程图:

一条线程负责解析数据没有问题,当数据被解析出来后,可以通过多线程来优化“数据处理”的效率。不过实际上数据解析会特别快,如果不对解析速率进行控制,那在短时间内就会将整个文件解析完成,一百万数据又全塞进了并发线程池暂存,最终导致大量内存被蚕食。

综上,再来看图中的线程交互过程,解析线程读取到一批数据后,就会将数据丢给并发线程池处理,当并发处理的批数达到某个阈值时,说明并发线程池的消费速率达到了上限,这时解析线程会进入阻塞等待状态,直到并发线程池恢复消费能力为止。通过这种机制,就能做到即兼顾资源占用问题、又充分到了考虑到性能问题

1.2.4、官方提供的优化建议

除了上面提到的优化手段外,其实官方对于大文件读取也给出了几条优化建议:

①开启急速模式:如果文件最大也就一二十万条,并且excel文件也只有10~20MB左右,而且不会有很高的并发,并且内存资源也较大,这种情况下可以考虑开启极速模式。

这条建议足足有四个前置条件~,那究竟如何开启呢?如下:

 

java

代码解读

复制代码

// 强制使用内存存储,这样大概一个20M的excel使用150M(很多临时对象,所以100M会一直GC)的内存 EasyExcel.read().readCache(new MapCache());

没错,所谓的急速模式,就是在read()方法之后加一个readCache(new MapCache())参数,表示完全基于内存来读取Excel文件,这样性能自然会快很多(默认解析时会借助临时文件实现)。

②并发较高,并且都是超级大文件,可以自行调整缓存策略。

下面来看个官方给出的例子:

 

java

代码解读

复制代码

SimpleReadCacheSelector simpleReadCacheSelector = new SimpleReadCacheSelector(); simpleReadCacheSelector.setMaxUseMapCacheSize(5L); simpleReadCacheSelector.setMaxCacheActivateBatchCount(20); EasyExcel.read().readCacheSelector(simpleReadCacheSelector);

先来解释下上面设置的两个参数:

  • 第一个参数:共享字符串达到xxMB后,就采用文件存储(默认为5MB);
  • 第二个参数:放多少批数据在内存,默认20批。

这里重点说明下第二个参数,easyExcel在使用文件存储时,会把共享字符串拆分成100条一批,然后放到文件存储。解析excel文件时大概率是按照顺序来读取共享字符串,所以默认20批(两千条)数据放在内存,命中后直接返回,没命中去读文件。

所以这个参数比较重要,如果设置的比较小,就很难命中内存里的缓存数据,从而导致一直去读取文件,耗费性能;可如果设置的太大,共享字符串会占用过多的内存资源。那如何判断这个值要调整呢?来看官方的回复:

开启debug日志后,最后一次会输出Already put :4000000,大概可以得出值为400W,然后看Cache misses count:4001得到值为4K400W/4K=1000,这代表已经maxCacheActivateBatchCount已经非常合理(500~1000都还行),如果小于500问题就很大了,说明你需要调整该参数。

好了,这两条建议感兴趣的可以去试下,但我们后面不会去用,毕竟第一条的局限太大,第二条带来的性能提升也有限,而且还要经过多番调试,才能调出某一个业务场景下的最佳配置。

二、改造分批处理监听器

OK,目前针对大文件导入场景,定下来的基调就是“导入接口异步化+多线程处理数据”,实际上还有一些优化项,比如上篇提到的资源隔离,这里可以维护一个独立的数据库连接池,除开能与其他业务隔离,还可以基于原生JDBC手写性能更好的批量插入方法,毕竟原生的插入性能肯定比MyBatis、MP高上不少,感兴趣的可以去试试看,我比较懒就不搞了~

回过头来看这个方案,其他的都好说,这个并发阈值怎么实现呢?首先来看看这个阈值放在哪里合适?如果定义成静态的全局变量,那么该阈值会对所有文件生效,这并非我们所看到的,我们希望的是:每个导入文件都有独立的并发阈值,不同文件并发解析时互不干扰。沿着这个思路往下推导,Excel导入离不开什么?监听器,每次导入都会new一个监听器,所以将这个阈值与监听器绑定即可。

有了方向之后,接着来实现一下这个并发阈值控制,咋实现?答案是Semaphore信号量,改造后的分批处理监听器如下:

 

java

代码解读

复制代码

@Slf4j public class ParallelBatchHandleListener<T> extends AnalysisEventListener<T> { /* * 每批的处理行数(可以根据实际情况做出调整) * */ private static int BATCH_NUMBER = 1000; /* * 临时存储读取到的excel数据 * */ private List<T> data; private int rows, batchNo; private boolean validateSwitch = true; /* * 每批数据的业务逻辑处理器 * */ private final BiConsumer<List<T>, ParallelBatchHandleListener<T>> businessHandler; /* * 并发阈值控制器 * */ private Semaphore concurrentThreshold; /* * 用于校验excel模板正确性的字段 * */ private final Field[] fields; private final Class<T> clazz; public ParallelBatchHandleListener(Class<T> clazz, BiConsumer<List<T>, ParallelBatchHandleListener<T>> handle) { // 通过构造器为字段赋值,用于校验excel文件与模板是否匹配 this(clazz, handle, BATCH_NUMBER); } public ParallelBatchHandleListener(Class<T> clazz, BiConsumer<List<T>, ParallelBatchHandleListener<T>> handle, int batchNumber) { // 通过构造器为字段赋值,用于校验excel文件与模板十分匹配 this.clazz = clazz; this.fields = clazz.getDeclaredFields(); // 初始化临时存储数据的集合,及外部传入的业务方法 this.businessHandler = handle; BATCH_NUMBER = batchNumber; this.data = new ArrayList<>(BATCH_NUMBER); } /* * 读取到excel头信息时触发,会将表头数据转为Map集合(用于校验导入的excel文件与模板是否匹配) * 注意点1:当前校验逻辑不适用于多行头模板(如果是多行头的文件,请关闭表头验证); * 注意点2:使用当前监听器的导入场景,模型类不允许出现既未忽略、又未使用ExcelProperty注解的字段; * */ @Override public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) { if (validateSwitch) { ExcelUtil.validateExcelTemplate(headMap, clazz, fields); } } /* * 每成功解析一条excel数据行时触发 * */ @Override public void invoke(T row, AnalysisContext analysisContext) { data.add(row); // 判断当前已解析的数据是否达到本批上限,是则执行对应的业务处理 if (data.size() >= BATCH_NUMBER) { // 更新读取到的总行数、批次数 rows += data.size(); batchNo++; // 如果开启了并发阈值控制,则先获取许可后再触发业务逻辑 if (null != concurrentThreshold) { try { concurrentThreshold.acquire(); } catch (InterruptedException e) { log.error("阻塞等待获取许可被中断,{}:{}", e, e.getMessage()); return; } } // 触发业务逻辑处理 this.businessHandler.accept(data, this); // 处理完本批数据后,使用新List替换旧List,旧List失去引用后会很快被GC data = new ArrayList<>(BATCH_NUMBER); } } /* * 所有数据解析完之后触发 * */ @Override public void doAfterAllAnalysed(AnalysisContext analysisContext) { // 因为最后一批可能达不到指定的上限,所以解析完之后要做一次收尾处理 if (data.size() != 0) { this.businessHandler.accept(data, this); // 更新读取到的总行数、批次数,以及清理集合辅助GC rows += data.size(); batchNo++; data.clear(); } } /* * 关闭excel表头验证 * */ public void offValidate() { this.validateSwitch = false; } /* * 初始化并发阈值(传入的数字则是允许并发处理的批次数) * */ public void initThreshold(int threshold) { if (null == this.concurrentThreshold) { this.concurrentThreshold = new Semaphore(threshold); } } public Semaphore getConcurrentThreshold() { return concurrentThreshold; } public int getRows() { return rows; } public int getBatchNo() { return batchNo; } }

相较于最初版的分批处理监听器,本次改造多了一个属性以及方法,即:

 

java

代码解读

复制代码

/* * 并发阈值控制器 * */ private Semaphore concurrentThreshold; /* * 初始化并发阈值(传入的数字则是允许并发处理的批次数) * */ public void initThreshold(int threshold) { if (null == this.concurrentThreshold) { this.concurrentThreshold = new Semaphore(threshold); } } public Semaphore getConcurrentThreshold() { return concurrentThreshold; }

在监听器内部有一个concurrentThreshold属性,它代表着并发阈值,类型为Semaphore信号量。其次,该属性并未没有放在构造方法的形参里,也就意味着:这个并发阈值机制是插件式的,你可以选择用,也可以选择不用,如果要使用,则可以调用initThreshold()方法来初始化并发阈值。

除开增加了concurrentThreshold相关的代码外,改造后的监听器还有一点不同,即业务处理器businessHandler的类型从Consumer变成了BiConsumer,两者的区别是后者能够承载两个入参,这里将第二个参数指定成了ParallelBatchHandleListener,即当前的并行监听器,主要是为了传递后业务处理方法使用。

PS:其实也不一定要使用这种参数传递的形式,来将监听器对象传播给业务方法,实际上也可以使用InheritableThreadLocal来做个上下文,只不过后续会结合线程池使用,就会出现一定的数据污染问题,但也可以通过阿里增强的transmittable-thread-local来解决问题,不过还是那句话,我懒,感兴趣的可以自己去尝试~

三、百万级报表导入实战

百万级报表导入的方案已经确定,监听器也已经改造完成,下面就来逐步将提到的方案进行落地,首先来定义一个接口:

 

java

代码解读

复制代码

@PostMapping("/import/v5") public ServerResponse<Long> importExcelV5(MultipartFile file) { if (null == file) { throw new BusinessException(ResponseCode.FILE_IS_NULL); } return ServerResponse.success(pandaService.import1MExcelV3(file)); }

这里最终返回了Long,即报表任务的ID,而import1MExcelV3()方法则是具体的导出实现:

 

java

代码解读

复制代码

@Override public Long import1MExcelV3(MultipartFile file) { // 先插入一条报表导入的任务记录 ExcelTask excelTask = new ExcelTask(); excelTask.setTaskType(ExcelTaskType.IMPORT.getCode()); excelTask.setHandleStatus(TaskHandleStatus.WAIT_HANDLE.getCode()); excelTask.setExcelUrl("实际请将传入的excel链接存入该字段"); excelTask.setCreateTime(new Date()); excelTaskService.save(excelTask); Long taskId = excelTask.getTaskId(); // 将报表导入任务提交给异步线程池 ThreadPoolTaskExecutor asyncPool = TaskThreadPool.getAsyncThreadPool(); // 必须用try包裹,因为线程池已满时任务被拒绝会抛出异常 try { asyncPool.submit(() -> { handleImportTask(taskId, file); }); } catch (RejectedExecutionException e) { // 记录等待恢复的状态 log.error("递交异步导入任务被拒,线程池任务已满,任务ID:{}", taskId); ExcelTask editTask = new ExcelTask(); editTask.setTaskId(taskId); editTask.setHandleStatus(TaskHandleStatus.WAIT_TO_RESTORE.getCode()); editTask.setErrorMsg("等待重新载入线程池被调度!"); editTask.setExceptionType("异步线程池任务已满"); editTask.setUpdateTime(new Date()); excelTaskService.updateById(editTask); } return taskId; }

这个方法的实现特别简单,首先插入了一条报表导入任务记录,接着将任务提交给了异步线程池去执行,如果线程池任务已满,则将前面插入的报表任务状态改为”等待恢复“,接着来看handleImportTask()方法:

 

java

代码解读

复制代码

/* * 处理报表导入任务 * 说明:实际场景不需要传File,而是基于taskId获取文件链接解析 * */ private void handleImportTask(Long taskId, MultipartFile file) { long startTime = System.currentTimeMillis(); log.info("处理报表导入任务开始,编号:{},时间戳:{}", taskId, startTime); excelTaskService.updateStatus(taskId, TaskHandleStatus.IN_PROGRESS); ExcelTask editTask = new ExcelTask(); editTask.setTaskId(taskId); ParallelBatchHandleListener<Panda1mReadModel> listener = new ParallelBatchHandleListener<>(Panda1mReadModel.class, this::concurrentHandlePandas); listener.initThreshold(5); try { EasyExcelFactory.read(file.getInputStream(), Panda1mReadModel.class, listener).sheet(0).doRead(); } catch (IOException e) { log.error("导入百万熊猫数据出错:{}: {}", e, e.getMessage()); editTask.setHandleStatus(TaskHandleStatus.FAILED.getCode()); editTask.setExceptionType("导入时获取文件流出错"); editTask.setErrorMsg(e.getMessage()); editTask.setUpdateTime(new Date()); excelTaskService.updateById(editTask); return; } log.info("处理报表导入任务结束,编号:{},导出耗时(ms):{}", taskId, System.currentTimeMillis() - startTime); // 如果执行到最后,说明excel导出成功,将状态推进到导出成功 editTask.setHandleStatus(TaskHandleStatus.SUCCEED.getCode()); editTask.setUpdateTime(new Date()); excelTaskService.updateById(editTask); }

这段代码相对也不复杂,整体做了四件事情:

  • ①先将报表导入任务推进到进行中状态;
  • ②创建一个并行分批处理监听器,并指定业务处理方法为concurrentHandlePandas()
  • ③从file里获取输入流,正式触发Excel文件解析动作;
  • ④如果读取文件输入流出错,旧将状态推到”失败“,反之则推进到”成功“。

这里最重要的其实是concurrentHandlePandas()这个方法,这个方法什么时候会被调用呢?回想前面监听器的invoke()方法:

 

java

代码解读

复制代码

@Override public void invoke(T row, AnalysisContext analysisContext) { data.add(row); // 判断当前已解析的数据是否达到本批上限,是则执行对应的业务处理 if (data.size() >= BATCH_NUMBER) { // 更新读取到的总行数、批次数 rows += data.size(); batchNo++; // 如果开启了并发阈值控制,则先获取许可后再触发业务逻辑 if (null != concurrentThreshold) { try { concurrentThreshold.acquire(); } catch (InterruptedException e) { log.error("阻塞等待获取许可被中断,{}:{}", e, e.getMessage()); return; } } // 触发业务逻辑处理 this.businessHandler.accept(data, this); // 处理完本批数据后,使用新List替换旧List,旧List失去引用后会很快被GC data = new ArrayList<>(BATCH_NUMBER); } }

当解析的数据条数达到每批上限时,就会调用对应的业务处理器,不过在触发业务处理方法执行之前,首先会检查有没有开启并发阈值控制,如果开启了,则会先获取一个许可,成功拿到许可才会触发业务处理器,这个阈值在前面的handleImportTask()方法中有设置,即:

 

java

代码解读

复制代码

listener.initThreshold(5);

这说明当前文件的并发阈值为5,即同时允许五批解析到的数据触发业务处理器。因为每次触发前会先扣一个许可,总共只有5个,一旦扣光就只能阻塞等待某一批数据处理结束,来看concurrentHandlePandas()方法:

 

java

代码解读

复制代码

/* * 并发处理解析到的熊猫数据 * */ @SuppressWarnings("all") private void concurrentHandlePandas(List<Panda1mReadModel> excelData, ParallelBatchHandleListener<Panda1mReadModel> listener) { ThreadPoolTaskExecutor concurrentPool = TaskThreadPool.getConcurrentThreadPool(); concurrentPool.submit(() -> { // 这里可以对数据行进行业务规则校验、清洗加工处理等逻辑处理 List<Panda> pandas = mapperFacade.mapAsList(excelData, Panda.class); this.saveBatch(pandas); pandas = null; // 释放占用的许可 listener.getConcurrentThreshold().release(); }); }

该方法额外简单,就是向并发线程池提交批量落库的任务,当某批数据全部落库后,就会通过listener去释放当前批次占用的许可。

结合前面监听器invoke()方法里的获取许可一起理解,concurrentThreshold的许可被扣光,解析线程会陷入阻塞等待状态;而当某条并发线程处理完一批数据,又会释放对应批次占用的许可,这个许可一旦被释放,前面阻塞的解析线程就会被唤醒,从而继续解析文件并提交批次处理任务给并发线程池。

上述这个效果,就是咱们原定方案的预期,下面来测试看看:

导入接口性能

接口调用在500ms内返回了响应,至于为啥要500ms呢?因为这里上传了一个几十MB的大报表文件,主要耗时是在文件传输这里,如果以文件链接作为接口入参,导入接口的RT能控制在20ms以内。下面再看看资源占用方面:

并行分批处理监听器资源

在上面每批数据落库时,都对集合做了一次全量拷贝,把List<Panda1mReadModel>转换为List<Panda>,就算这样单次导入的峰值也不过378.01MB。反观一开始使用通用监听器去解析数据,只是单纯读下数据就窜到970.83MB的峰值。

两次结果一对比,显然这回资源占用降低了不少,最后来总结一下三种监听器:

  • 通用监听器:每次会将整个文件全部读取完毕,然后再对数据进行处理,资源占用高;
  • 分批处理监听器:每次从文件里读取一批数据处理,处理完再读下一批,资源占用低但是很耗时;
  • 并行分批处理监听器:每次从文件里读取一批数据,就丢给并发线程去处理,性能和资源占用都兼顾。

综上,最后的并行分批处理监听器可谓是鱼和熊掌兼得之。同时,如果你觉得处理速度还不够快,完全可以通过调整concurrentThreshold的许可数(并发阈值)来加速,这个值越大,处理的性能越快,但耗费的资源越高,反之同理。

四、总结

看到这里,百万级报表导入篇也走进了尾声,其实这篇里的许多内容都在沿用上篇的概念,毕竟两个场景十分类似,只不过代码细节实现有所不同。但不管是大报表导出,还是大报表导入,我们都未曾打破官方给出的”不允许多条线程对单个文件进行并发读写“这条建议,而是深入分析问题场景,真正定位到了耗时项在做优化。

毕竟对于真实的业务场景来说,读写一个百万级的excel文件,硬件到位的前提下,EasyExcel框架只需花费几十秒左右,这个性能完全够了。真正导致性能缓慢的原因,还是对数据本身的处理环节,如导出时的数据加工、组装、计算,导入时的规则校验、清洗、加工等,而咱们两篇文章中,结合多线程技术着重优化了这个环节。

其次,为了保障用户体验感,咱们设计了一套异步处理+结果回调的方案,能够让用户点击”导出/导入“按钮第一时间内得到响应,避免按钮点击后一直转圈圈、白屏等状况发生。

最后,咱们还通过多种手段,来限制了并发处理的报表任务数,确保同一时间内,不会因为并发处理的报表过多而造成OOM问题。并且还支持自行调整参数,来选择要性能还是要资源,以此满足不同业务场景下,性能与资源之间的抉择问题。

  • 17
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值