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

问题

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

相关推荐
杜杜的man32 分钟前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*33 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu34 分钟前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s35 分钟前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子37 分钟前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
想进大厂的小王1 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea
2402_857589362 小时前
SpringBoot框架:作业管理技术新解
java·spring boot·后端
一只爱打拳的程序猿2 小时前
【Spring】更加简单的将对象存入Spring中并使用
java·后端·spring