问题
在工作中经常遇到"数据导入导出"这样的需求,比如前端上传一个 excel 文档,服务端解析文件后把数据写入到 DB 中,或者前端传入查询参数,服务端把查询结果写入 excel 返回给前段。
在业务刚开始数据量比较小的时候,这两种做法都是可行的,可是随着业务发展,这两种做法暴露出了以下问题:
1、上传或导出的数据量越越来越大,接口响应越越来越慢,有时会出现接口超时。
2、越来越多的业务中有导入和导出需求,重复的读取 excel、写入 excel 代码开始在多个 service 中出现,无法统一管理。
我们就曾遇到过这样一个线上问题:前端调用我们的服务的接口下载文件,接口执行了 20s,请求超时,用户页面报错。我们服务网关配置的超时时间都是 15s,虽然可以通过增大这个参数来临时解决问题,但是为了一个接口超时,修改整条链路上的配置有点得不偿失。
关键还是要对"导入导出"接口做优化。刚开始我们主要集中在代码层面的优化,比如避免在循环中解析数据或操作数据库、使用本地缓存保存临时数据、使用 EasyExcel 代替 POI 解析文件等。
但是这些优化举措,只是降低了接口超时的几率,不能彻底解决问题。
后来,我们把"下载接口"拆分成"创建下载任务、获取下载任务结果"两个步骤,彻底解决了单个接口请求超时的问题。
下面仅讨论"下载文件"接口,"上传文件"的设计思路和实现与之类似。
分析问题
"导出数据"一共分为三个步骤:
1、client 发起请求,等待 server 执行下载逻辑
2、server 执行下载逻辑
3、server 返回数据
其中有变化的就是在"第 2 步",不同的需求下载的数据量不同、逻辑复杂度不同,最终影响了整个接口的响应时间。所以,我们要做的是,把第 2 步从整个过程中独立出来,减少第 2 步中复杂业务逻辑对接口的影响。
图 1. 原导出步骤
我们知道,client 每发出一个请求,服务端都会使用一个独立的线程来处理该请求。如果"第 2 步"比较耗时,就会导致 client 一直阻塞直到线程返回结果。因此,要隔离第 2 步对接口的影响,最直接的方法就是,使用一个新的线程来异步执行复杂的下载逻辑。
如果 server 在接收到请求的时候,使用异步线程来执行下载逻辑,client 该怎么得到这个异步线程的执行结果以及获得文件下载地址?
在日常生活中我们是怎么"导出数据"或者说"下载数据"的?比如,我们打开一个下载软件开始下载资源,我们不会一直盯着下载页面盯着,而是在软件里创建了一个"下载任务",我们只需要隔一段时间打开页面看一下即可。
同理,在"导出数据"这个需求中,前端就是发起下载的"用户",服务端就是执行下载的"软件",前端只需要在服务端创建一个"下载任务",然后轮询任务的执行结果即可。
图 2. 异步化后的导出步骤
设计方案
要解决的问题有两个:避免接口超时、避免重复导出代码。前面我们使用"异步任务"解决了第一个问题,如何解决第二个问题?如果每个业务导出数据时,是否需要再写一遍"创建任务、查询任务结果"的逻辑?
异步化后的导出步骤,可以抽象为两步"创建任务、执行任务":
- 创建任务的时候,保存前端传入的导出参数,每个业务场景下传入的参数是不一样的
- 执行任务的时候,根据保存的参数,执行不同的查询和下载逻辑
在创建导出任务时,需要保存两个属性:
- 业务类型:表示不同的业务导出需求
- 参数内容:记录前端传入的导出参数,可以统一转换成字符串保存
在执行导出任务时:
- 需要根据"业务类型"选择不同的"处理器"来执行任务
- "处理器"解析出任务中的参数类型
这里可以使用"策略模式"来组织所有"处理器",如下图所示
图 3. 导出任务的创建和执行
上述过程描述如下:
1、client 提交一个 task
2、Server 保存 task 到 repository,返回 taskId 给 client,并把 task 提交到异步线程池 Exectutors 中执行
3、异步线程根据 task 的"业务类型"从 handlerContext 中取出具体的 handler 来执行任务
4、handler 执行完任务后,更新 task 的状态
5、在这个过程中,client 一直轮训 task 的状态
前端轮询的步骤如下:
图 4. 前端轮询过程
通过建立一个通用的 task,服务端只需要提供"创建任务、查询任务结果"这两个接口即可;通过使用策略模式,每次有新的导出需求,服务端只需要实现对应的 handler 即可,提升了代码的可扩展性。
实现
有了前面的设计,需要定义的有:handler 接口及其 handler 抽象类、导出 task 对象、taskRepository。
代码如下所示:
java
public interface ExportTaskHandler {
ExportTaskType support();
void process(ExportTask exportTask);
}
java
@Slf4j
public abstract class AbstractExportTaskHandler implements ExportTaskHandler {
public void process(ExportTask task) {
try {
ExportDataResultDTO result = doProcess(task);
if (Objects.isNull(result)) {
return;
}
if (result.isSuccess()) {
task.success(result.getDownloadUrl());
log.info("[ExportTask] 导出任务成功, taskId:{}", task.getTaskId().getId());
} else {
log.info("[ExportTask] 导出任务失败, taskId:{} detailInfo:{}", task.getTaskId().getId(), result);
task.fail("任务导出失败, remoteTaskId: " + result.getTaskId()
+ " code:" + result.getErrorCode()
+ " msg:" + result.getErrorMessage());
}
} catch (Throwable ex) {
log.error("[ExportTask] 导出任务异常, taskId:{}", task.getTaskId().getId(), ex);
task.fail("导出任务异常,msg: " + ex.getMessage());
}
}
protected abstract ExportDataResultDTO doProcess(ExportTask task);
}
在业务代码里,只需要实现 AbstractExportTaskHandler
中的 doProcess
方法即可。
java
public interface ExportTaskRepository {
ExportTaskId nextIdentity();
void save(ExportTask task);
ExportTask getById(ExportTaskId taskId);
}
ExportTaskRepository
执行提供保存、查询 接口。
示例代码地址:gitee.com/callmekeybo...
小结
使用异步化导出任务的方式来解决"导出数据接口超时"问题,每次开发新业务,只需要创建新的 handler 即可,提升了代码的可重用性、可扩展性。
client 不断轮询 task 结果,会给服务端造成一定的压力,可以通过 websocket 建立长链接来解决,等后面有需要的时候再优化。