工作实践:数据导入导出的异步任务优化

问题

在工作中经常遇到"数据导入导出"这样的需求,比如前端上传一个 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 建立长链接来解决,等后面有需要的时候再优化。

相关推荐
柏油6 小时前
MySQL InnoDB 行锁
数据库·后端·mysql
咖啡调调。6 小时前
使用Django框架表单
后端·python·django
白泽talk7 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务
摆烂工程师7 小时前
全网最详细的5分钟快速申请一个国际 “edu教育邮箱” 的保姆级教程!
前端·后端·程序员
一只叫煤球的猫7 小时前
你真的会用 return 吗?—— 11个值得借鉴的 return 写法
java·后端·代码规范
Asthenia04127 小时前
HTTP调用超时与重试问题分析
后端
颇有几分姿色7 小时前
Spring Boot 读取配置文件的几种方式
java·spring boot·后端
AntBlack7 小时前
别说了别说了 ,Trae 已经在不停优化迭代了
前端·人工智能·后端
@淡 定8 小时前
Spring Boot 的配置加载顺序
java·spring boot·后端