在编程领域,读和写之间总是如影随形,而在上一篇内容中,我们曾围绕着“百万量级的报表导出”这个话题做了详细展开,不过面对百万量级的报表解析场景时,原先的技术方案和代码逻辑还能复用吗?
对于大数据导入场景来说,所面临的也是性能问题和资源占用问题,因为数据量大,处理时间自然不短;也因为数据量大,所消耗的资源也自然不小,如何解决这两个问题:
- 接口性能:通过异步形式处理报表导入,接口调用后及时响应,处理结果以回调形式通知给用户;
- 资源占用:不要一次性解析所有数据,而是分批次解析,解析一批处理一批,从而降低资源占用。
从这段描述来看,处理的思路大致与上篇相同,可是不能完全复用,因为代码细节会存在差异,如果处理不够妥当,仍然会导致内存占用过高的问题出现,为什么这么说呢?下面一起来看看。
一、百万级报表导入场景分析
回想《EasyExcel深度实践篇》的内容,我们封装了一个通用版监听器:CommonListener,使用方式如下:
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、分批处理监听器实战
前面提到的分批处理监听器,之前只是封装了,但是具体怎么用呢?来看例子:
@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()
批量保存方法传入了进去。
这个接口贴调用结果图了,感兴趣的可以自己down下spring-boot-easy-excel
源码去试试看,不用想都知道执行异常耗时,为啥?因为我拿着上篇导出的百万级excel文件丢进去,跑了六百多秒才到一半……
仔细一想,耗时的原因也能想明白,毕竟这个监听器会一批一批去解析数据,默认每批只会解析1000条数据,100W的文件总共对应1000批,平均每批数据解析、校验、落库加起来就算一秒,总共也需要1000s左右。不过在对资源占用却很小,毕竟每处理一批,就会new一个新的集合,旧集合很快被GC。
1.2、大文件导入优化方案
我们直接来聊优化方案,既然数据是一批一批的解析,那咱们能否开启多个线程来解析同一个Excel文件呢?
1.2.1、多线程并发读取
声明:这小节可以跳过,最终并不会使用这种模式,因为多线程并发读取会存在一定程度的风险性,这里只是为了扩充知识点。
其实官方不建议使用多线程来对单文件进行并发读写,但如果上传的excel文件存在多个sheet,比如有五个sheet、每个sheet里有20W数据,你可以开五条线程分别去读不同sheet的数据,虽然运行过程中会有警告,但却能够正常读取数据,对应的代码如下:
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左右,而且不会有很高的并发,并且内存资源也较大,这种情况下可以考虑开启极速模式。
这条建议足足有四个前置条件~,那究竟如何开启呢?如下:
// 强制使用内存存储,这样大概一个20M的excel使用150M(很多临时对象,所以100M会一直GC)的内存
EasyExcel.read().readCache(new MapCache());
没错,所谓的急速模式,就是在read()
方法之后加一个readCache(new MapCache())
参数,表示完全基于内存来读取Excel文件,这样性能自然会快很多(默认解析时会借助临时文件实现)。
②并发较高,并且都是超级大文件,可以自行调整缓存策略。
下面来看个官方给出的例子:
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得到值为4K,400W/4K=1000,这代表已经maxCacheActivateBatchCount已经非常合理(500~1000都还行),如果小于500问题就很大了,说明你需要调整该参数。
好了,这两条建议感兴趣的可以去试下,但我们后面不会去用,毕竟第一条的局限太大,第二条带来的性能提升也有限,而且还要经过多番调试,才能调出某一个业务场景下的最佳配置。
二、改造分批处理监听器
OK,目前针对大文件导入场景,定下来的基调就是“导入接口异步化+多线程处理数据”,实际上还有一些优化项,比如上篇提到的资源隔离,这里可以维护一个独立的数据库连接池,除开能与其他业务隔离,还可以基于原生JDBC手写性能更好的批量插入方法(关闭事务且手动记录异常批次),毕竟原生的插入性能肯定比MyBatis、MP高上不少,感兴趣的可以去试试看,我比较懒就不搞了~
回过头来看这个方案,其他的都好说,这个并发阈值怎么实现呢?首先来看看这个阈值放在哪里合适?如果定义成静态的全局变量,那么该阈值会对所有文件生效,这并非我们所看到的,我们希望的是:每个导入文件都有独立的并发阈值,不同文件并发解析时互不干扰。沿着这个思路往下推导,Excel导入离不开什么?监听器,每次导入都会new一个监听器,所以将这个阈值与监听器绑定即可。
有了方向之后,接着来实现一下这个并发阈值控制,咋实现?答案是Semaphore信号量,改造后的分批处理监听器如下:
@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;
}
}
相较于最初版的分批处理监听器,本次改造多了一个属性以及方法,即:
/*
* 并发阈值控制器
* */
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来解决问题,不过还是那句话,我懒,感兴趣的可以自己去尝试~
三、百万级报表导入实战
百万级报表导入的方案已经确定,监听器也已经改造完成,下面就来逐步将提到的方案进行落地,首先来定义一个接口:
@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()
方法则是具体的导出实现:
@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()
方法:
/*
* 处理报表导入任务
* 说明:实际场景不需要传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()
方法:
@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()
方法中有设置,即:
listener.initThreshold(5);
这说明当前文件的并发阈值为5,即同时允许五批解析到的数据触发业务处理器。因为每次触发前会先扣一个许可,总共只有5个,一旦扣光就只能阻塞等待某一批数据处理结束,来看concurrentHandlePandas()
方法:
/*
* 并发处理解析到的熊猫数据
* */
@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问题。并且还支持自行调整参数,来选择要性能还是要资源,以此满足不同业务场景下,性能与资源之间的抉择问题。