工程经验 - 任务调度架构的设计与思考

75 10~13 min

几年前,我曾在博客分享过一种周期性任务调度的设计。本篇文章会从架构设计上讨论任务调度的设计,以及这种设计如何应付我碰到的业务问题。当然,永远求真务实,力求简单。

Language: Java

概览

首先,让我们先探讨一下,现在的 Big Fella 都干了啥事情,就拿 PowerJob 和 XXL-Job 举例,一图说明:

graph LR subgraph 任务调度中心 subgraph 执行器 end subgraph 调度器 end end subgraph 业务客户端 执行器1 执行器2 执行器3 end 执行器1 -. 注册 .-> 执行器 执行器2 -. 注册 .-> 执行器 执行器3 -. 注册 .-> 执行器

对于作为使用者的你来说,你需要做的事情:

  • 编写执行器注册信息

  • 编写调度器的配置信息

  • 编写执行器代码(业务的任务执行逻辑),根据框架给出的格式决定任务的调度方式

启动服务,执行器会去调度器自我注册,调度器会根据注册的信息,自行调度任务,去调用注册的执行器客户端,触发任务逻辑。

任务如何异步调度?任务如何持久化?任务调度资源如何最大化利用?任务调度高可用?这些都不需要考虑,作为业务应用层,你只需要关注自身的业务处理逻辑,异步运行以及调度、资源等问题都不需要考虑了

初版设计

在我们的业务系统中有很多数据处理是需要长时间运行的,比如数据同步任务、运行状态监控任务、数据转换任务等,因此在产品功能设计上,其就是以一个个任务为载体体现的,举例:

graph LR 用户 --> 新建数据同步任务 用户 --> 查看数据同步任务进度条 用户 --> 终止数据同步任务 用户 --> 删除数据同步任务

我们的需求:提供一套任务调度的解决方案,支持业务的任务统一定义阶段:execute() 、success()、error()、stop()、resume();支持任务的状态查询

考虑到 XXL-JOB 等框架的部署资源和配置复杂度,我们那段时间是基于 JDK 线程池以及内部的队列,简单设计了一个任务调度器用于支撑需求

graph TD A[任务调度器] --> B[核心线程池] A --> C[调度执行器] A --> D[任务记录保存] B -->|最大500线程| E[线程池配置] E --> F[拒绝策略: CallerRunPolicy] E --> G[队列: 无缓冲] C --> H[反射执行器] H --> I[使用Runnable接口] I --> J[委托给线程池] J --> K[执行execute方法] D --> L[任务记录] L --> M[任务状态] L --> N[任务参数] L --> O[其他元数据]

这个任务调度器,说的直白点,就是一个 Java 类,即,我们提供给业务的方案:一个 Java 类(调度器),以及执行器 Base 抽象类,业务使用的时候:

  • 定义任务逻辑:继承 Base 执行器,Override 其中的 execute()、success() ,写自定义任务逻辑,包括任务执行,成功的处理,执行失败处理等

  • 在业务功能接口中注册一个任务:准备好任务的参数,使用调度器 add 一个任务,调度器会在线程池中异步执行此任务

就这样简单的设计,支撑了我们几年的业务使用,也没有大问题,蛮稳定的(当然和 B 端业务并发小也有关系)

对比友商,我们的设计共同点和异同点:对于任务调度,同样也分为调度和执行两大部分,但我们的调度器和执行器是在一个节点(一个进程)内的,友商拆分为不同的进程。

当任务调度器和执行器在一个进程内,有个很大的好处是:共享内存数据,没有通信问题,通信直接走函数调用。这算啥好处?还真是个好处。从部署的角度来看,在多云环境中,执行器(即不同的业务服务)可能在不同的网络,执行器想要获得任务调度的能力,就必须和调度器打通,这就是部署成本。

但是从架构设计角度来看,调度器就不应该和执行器放一起:

  • 可扩展性:执行器是任务具体执行的地方,需要大量资源,我们可以按需扩展执行器节点,但如果调度器绑在一起,那会连着调度器一起扩展了

  • 容错性:如果调度器和执行器在同一个进程中,一旦进程崩溃,调度和执行功能都会受到影响。分开部署可以提高系统的容错性,即使一个组件出现问题,另一个组件仍然可以继续工作。

更好的设计

友商现在的做的很多任务调度都挺不错了,在国外这种项目反而更少一些,大多数时候一个线程池 Or Quartz 就能完成需求了。

但友商现有的做法有个很蠢的设计:部署任务调度中心的时候,需要将执行器的 ip 和端口注册到调度器。这么做的意图在于,调度器在触发任务的时候,就得回调执行器节点,才能在执行器节点上运行任务。这么做的原因是基于短连接去做的。也就是说,如果有自建长连接,或者自建发布订阅,那就不需要关注这个事情了

现在设想一下,任务调度中心该有的样子

  • 部署:任务调度中心一键 docker 部署

  • 可视化:提供 console board 界面,看到现在的任务状态,以及可重复执行等

  • 使用方式:提供 Client SDK,按接口实现任务,注册任务,提交任务,查询任务等接口

先看看进程视图:

第一种设计,基于 MQ Or Redis 任意具备 Pub-Sub 功能的组件去做通信,因为 Pub-Sub 的原理其实就是长连接,做强 ACK + Pub-Sub 的机制,从而用来调度器和执行器的分离

graph TD subgraph Scheduler A[Job-Scheduler] --> B[Pub-Sub-Component] end subgraph Executor B --> C[Job-Executor-1] B --> D[...] B --> E[Job-Client-n] end

第二种设计,基于长连接自建的方式去做,又客户端建立 TCP 连接到服务端,然后服务端拿到长连接可以不需要知道执行器的具体的 IP,即可完成调度,停止任务等各种操作

graph TD subgraph Scheduler A[Job-Scheduler] end subgraph Executor A <--> C[Job-Executor-1] A <--> D[...] A <--> E[Job-Executor-n] end

剩下的设计就是将初版设计在这个进程视图中做好对应的实现了


0