一、概述
调度器是系统中负责任务调度的服务,它根据任务的配置信息,在指定的时间点派发任务给执行器,它负责管理和调度系统中的所有任务。
二、整体架构
1. 模块架构

2. 部署架构

3. 调度器的依赖
调度器仅依赖于MySQL数据库,除此之外不依赖于任何第三方服务,部署成本低。
三、核心模块设计
1. 多租户的设计
- 多租户的设计目的是为了实现多个业务线共享一套调度系统,每个业务线可以独立配置租户的资源和权限,实现资源的隔离和权限的控制。执行器在注册时,需要携带租户标识以标明自己所属的租户,后续可在管理平台做权限的管控。
- 对于含有多区域业务系统的企业,例如国内多条业务线、海外多条业务线,建议每个区域部署一套独立的调度平台,因为跨区、跨州的网络通信不确定性较大。
2. 调度器的管理
- 调度器支持集群的部署,保障高可用的同时,还实现了负载均衡的效果,当调度任务量大、应用规模大的时候,建议多几个节点。为了保证最小高可用,建议至少部署2个节点。
- 多节点部署时需要修改服务ID,在配置文件
application.properties
中配置server.id
,需要确保每个节点ID是唯一的,另外需要检查server.port
是否被占用,如果你把多个节点部署到同一台机器的话。但不建议这么做,因为这达不到高可用的目的。 - 调度器启动时会有3秒一次的心跳,用于标识自己在线,如果超过10秒没有心跳,则判定为下线,集群的调度器列表可以在管理平台上查看。
3. 执行器的管理
- 执行器启动时,会向调度器注册自己的信息,包括执行器的IP、端口、名称、标签、执行器的描述等元数据信息,启动后会保持3秒一次的心跳,并且每30秒会注册一次执行器元数据信息。
- 执行器的关闭前,会向调度器发送下线请求,下线成功后,执行器会从调度器的列表中移除,任务不会再派发给这个执行器。同时调度器会每3秒检测一次执行器的状态,如果超过10秒没有心跳,则判定为下线。
- 临界条件下,如果调度器派发任务给正在关闭的执行器,那么执行器会响应一个已下线的错误码,调度器会将此执行器置为下线状态,同时会重新派发任务给其他执行器。
4. 任务的管理
- 执行器启动时,会向调度器全量注册自己的任务信息,任务的注册机制与执行器的逻辑保持一致,启动后会保持3秒一次的心跳,并且每30秒会注册一次执行器元数据信息。
- 当某个任务下线时,执行器会以特定的错误码通知调度器,调度器会将此任务置为停止状态,后续将不再派发该任务给执行器,表示该任务已不再使用。
- 当新的迭代新增任务时,上线后可在管理平台看到任务的详细信息,但此时还未启动,需要手动启动任务,才会开始任务的调度,启动前需要确保所有的执行器都已经部署完成,避免调度时误把任务下线。
- 任务的注册会以租户ID、应用ID和任务类方法全限定名作为唯一标识,已经存在的任务不会被覆盖,以确保注册后可以在管理平台随意修改任务的配置信息。
5. 通信的设计
- 调度器与执行器之间的通信采用HTTP协议,执行器内部会启动一个基于Jetty的内嵌式Web服务,调度器也会基于Web服务开放OpenAPI接口,供执行器交互。
- 与长连接协议不同的是,此类场景下,HTTP协议会更加简单,实现起来也更加容易,未来支持多执行器SDK客户端时,更加简便。
6. 任务调度的设计
6.1 锁的设计
- 在一些锁竞争不大也不频繁的场景下,为了减少对第三方系统的依赖,系统内的分布式锁采用了MySQL来实现,主要用在定期统计数据、定期生成任务日志等场景。
- 其余一些互斥的场景则使用版本号的方式实现乐观锁,尽可能避免行锁。
6.2 预先生成任务日志
任务日志是任务调度的最小粒度,一个任务每一次调度都会生成一条任务日志,它会根据任务的配置信息,预先生成好未来执行的任务日志,以便于节省调度时间。任务日志的生成步骤大致如下:
- 每5秒钟就检查一次是否需要生成任务日志,符合生成条件时则继续生成。
- 先获得分布式锁,以确保集群环境下只有一个节点在生成任务日志。
- 批量获取启动状态的任务,并交给线程池生成任务日志。
- 生成任务日志时,会预先生成3分钟内将要执行的任务,可以通过
schedulers.taskPreGenerationMaxTimeMinutes
修改配置,如果不是特别理解内部机制,建议不做修改。 - 生成好的任务日志,会处于初始化的状态,等待执行器获取到内存的调度队列中。
6.4 任务日志的状态
- 初始化:任务日志创建成功,等待调度器派发。
- 队列中:任务日志已被调度器放入内存队列,等待调度器派发给执行器执行。
- 调度中:任务日志已被调度器派发给执行器,等待执行器执行的结果。
- 执行成功:任务执行成功,任务日志已被执行器返回。
- 执行失败:任务执行失败,可在详情中查看详细原因。
- 取消执行:一般是调度前修改了任务详情,或者停止了应用,则会被取消。
- 任务过期:任务已经超过了调度时间,并且过期策略是丢弃,表示任务日志已被丢弃。
- 执行失败,已丢弃:任务执行失败,并且失败策略是丢弃,表示任务日志已被丢弃。
- 执行失败,重试中:表示上一次任务执行失败,正在重试中。
6.4 内存调度队列
- 调度器每5秒钟会从数据库获取
初始化
和失败重试
状态的任务日志放到内存队列中,它是一个优先队列,队列的元素会按照执行时间从近到远排序,以便于任务日志的获取。 - 内存队列的最大容量通过
schedulers.taskQueueCount
控制,避免单实例内存队列过大,影响系统的整体性能和稳定性。 - 获取到任务日志后,会通过乐观锁的方式,将任务状态变更为
队列中
的状态,并等待任务到时间被调度。
6.5 任务的调度
- 调度器启动后,会固定一个线程不停地从内存队列获取待执行的任务日志,并检查是否达到了提前调度的条件,默认是提前3秒钟调度,可以通过
schedulers.beforeInterval
配置修改,默认情况下不建议修改。 - 当任务日志达到提前调度条件后,会将任务日志的状态变更为
调度中
的状态,并将任务日志提交到调度线程池,调度线程池会根据任务的预设策略派发给执行器执行。
6.6 调度策略
在任务将要派发给执行器执行时,根据预设策略会有不同的调度行为,目前支持以下几种策略:
- 随机策略:从当前在线的执行器中随机选择一个执行器,如果没有在线的执行器,则会进入失败策略的处理中。
- 分片策略:获取所有在线的执行器,每个执行器都会派发任务,并且携带分片参数(page和total),以便于使用方做大任务的分片处理。 选定好调度策略之后,会通过通过HTTP协议调用执行器暴露的WEB服务接口,将任务派发给执行器执行。
6.7 失败策略
不管因何种原因导致的调度失败,都会进入失败策略的处理中,失败策略的处理会根据预设的策略进行处理,失败的原因可以通过管理平台的任务日志详情看,目前支持以下几种策略:
- 失败后丢弃:任务执行失败,任务日志会被丢弃。
- 失败后重试:任务执行失败,任务日志会被重新放入内存队列,等待下次调度。
在失败重试的场景下,可以配置最大的重试次数,默认是5次,超过最大重试次数后,任务日志会被丢弃,并记录信息。
7. 数据的清理
下线的执行器、任务的调度日志、统计的数据等,为了避免系统内数据一直无限增长,需要定期清理这些数据,避免系统内数据量过大,影响系统的性能。数据的保留时间可以通过data.maxRetainDays
修改,默认保存5天。
8. 安全性的设计
执行器与调度器之间的交互接口,均采用签名校验的方式,以保证数据的安全性。签名秘钥由调度器的配置决定,执行器需要保持一致。接入方在接入时,可以按需自定义,以免和大众一样,签名验证算法简要概括如下:
- 发送端将待签名参数按照参数名称的字典序排序。
- 将排序后的参数按照key=value的格式拼接成一个字符串。
- 将拼接后的字符串和秘钥进行MD5加密。
- 将加密后的MD5值作为签名参数,添加到请求参数中。
- 接收端将请求参数中的签名参数取出,按照相同的算法进行签名。
- 将签名后的MD5值与请求参数中的签名参数进行比较,如果一致,则认为请求是合法的。
另外,调度器会管理两套签名秘钥,一套用于管理后台的UI交互,一套用于执行器的SDK交互,以保证安全性。如果要修改管理后台的UI秘钥,则需要自行构建一下。
9. 权限的设计
权限支持两种维度,一种是租户级别的权限,一种是接口级别的权限,可以满足业务线权限隔离的需求,接口级别的权限,可以满足某些用户只能查看,不能编辑任务的需求,使用时可以按需开不同的权限。
10. 调度器的优雅关闭
优雅关闭的设计目的是为了调度器关闭时可以做好收尾工作,规避因关闭、重启导致数据丢失和不一致性的问题。简单来说,在调度器关闭时,会有以下的行为:
- 停止生成新的任务日志。
- 停止获取任务日志到内存队列。
- 等待所有的任务日志派发完成。
- 停止调度器的心跳,优雅的关闭各个线程池,等待线程池中的任务执行完成。
为了达到上述的效果,避免收到关闭命令时,网络通道、数据库连接等相关资源已关闭,调度器修改了ShutdownHook
的默认行为,优雅下线的动作会先于系统默认的hook执行,因此确保关闭前所依赖的资源可用。细节可查看源码cn.horace.cronjob.commons.utils.shutdown.ShutdownHookManager
。