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

问题

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

相关推荐
Adolf_19931 小时前
Flask-JWT-Extended登录验证, 不用自定义
后端·python·flask
叫我:松哥1 小时前
基于Python flask的医院管理学院,医生能够增加/删除/修改/删除病人的数据信息,有可视化分析
javascript·后端·python·mysql·信息可视化·flask·bootstrap
海里真的有鱼1 小时前
Spring Boot 项目中整合 RabbitMQ,使用死信队列(Dead Letter Exchange, DLX)实现延迟队列功能
开发语言·后端·rabbitmq
工业甲酰苯胺1 小时前
Spring Boot 整合 MyBatis 的详细步骤(两种方式)
spring boot·后端·mybatis
新知图书2 小时前
Rust编程的作用域与所有权
开发语言·后端·rust
wn5313 小时前
【Go - 类型断言】
服务器·开发语言·后端·golang
希冀1233 小时前
【操作系统】1.2操作系统的发展与分类
后端
GoppViper4 小时前
golang学习笔记29——golang 中如何将 GitHub 最新提交的版本设置为 v1.0.0
笔记·git·后端·学习·golang·github·源代码管理
爱上语文5 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people5 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端