核心框架
分布式定时任务核心要解决触发、调度、执行三个关键问题。其中触发指的是在在某一个特定的时间点触发任务,并开启该任务;调度指如何分配、管理在多台机器上触发的任务,如何;执行解决如何正常执行一个任务,并且在发生故障的时候如何进行恢复 由此可以得到三个组件:
- 触发器:Trigger,用于解析任务,生成触发任务
- 调度器:Scheduler,用于分配任务,管理任务生命周期
- 执行器:Executor,用于获取执行任务单元,执行任务逻辑 除此之外,还需要提供一个控制台(admin),提供任务管理和干预的功能。
该架构属于是 [[基本情况#业内定时任务框架|业内定时任务框架]] 的一个基础架构,这些架构可能不是完全按照这种方式搭建的,但是逻辑上的架构是一致的。
数据流
总体的数据流分为两个部分:任务创建和任务执行。
任务创建:首先用户定义任务的基本信息和触发任务的规则,随后把任务的代码上传到平台中,随后分布式定时任务会将该任务交给控制台 admin,admin 将刚创建的任务存储在任务 DB 中。
任务执行:控制台需要知道这个任务说明时间节点去执行,这依赖于触发器对任务的触发,并使用调度器对任务进行整体的协调,调度器将任务分派给执行器执行任务。
功能架构
- Admin 在功能上负责:对任务的原数据进行存储和管理、对任务进行分片、暂停和恢复以及监控警报。
- Trigger 使用 Scanner 对任务 DB 扫描,获取任务的触发时间,并使用消息队列消费任务
- Scheduler 对任务进行调度,并实现负载均衡、幂等控制等协调操作。
- Executor 获取到任务之后,真正开始执行任务。
控制台
基本概念
控制台主要包括以下几个信息:
- 任务,Job:记录任务的原数据
- 任务实例,JobInstance:由于一个任务可能执行多次,比如周期性任务等,所以和任务是一对多的关系
- 任务结果,JobResult:任务的输出结果,考虑到任务运行可能会失败,所以其与任务实例也是一对多的关系
- 任务历史,JobHistory:用户可以更改任务的信息,所以对于每个任务实例,其任务的元数据可以不同,因此使用任务历史存储以往的版本,以方便月之前运行的实例结果做对应。
重要名词
- 任务元数据:是用户对任务的属性进行定义,主要包括任务的基本信息、任务类型、调度时机、执行行为等,其状态机如下:
- 任务实例:是一个确定的 Job 的一次运行,其主要包括 Job_id,触发时间,状态和结果以及过程的信息,其状态机如下:
触发器
解决方案
方案一:扫描+延时消息
这一方案是字节和腾讯的一套分布式定时任务的方案,其操作流程如下图所示:
- 扫描器 Scanner 扫描任务 DB ,将任务交给处理器 processor 处理需要执行的任务。
- Processor 处理分析完任务时,将需要执行任务的信息发送给延时消息队列中,等到需要执行任务的时候发送给任务调度器。
- 修改已发送的任务的在 DB 中的状态,以防止任务被重复扫描执行。
- 到执行的时间,延时消息队列将任务信息发送给调度器。
方案二:Quartz方案——时间轮
时间轮是一种高效利用线程资源进行批量化调度的一种调度模型。时间轮是一个存储环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表。
关于时间轮,先来看看之前的方案:
- 使用一个链表存储任务,每个元素代表一个任务。这看似是一个比较简单的方案,其目标为遍历任务列表,从中找出当前时间点需要出发的任务列表。 此时查询的复杂度为 , 修改链表则只需要直接将任务插入链表尾部即可,复杂度为 。
- 对链表的结构进行优化,使用最小堆存储任务,按照执行时间排序,每个节点存储同执行时间的任务列表,因为只需要找到对靠近当前时间的任务即可。 在查询时,直接读取根结点即为最近任务,但是当输出当前堆顶元素后,需要调整堆为小根堆,其时间复杂度为 。 另外,由于小根堆是由数组实现的,但是任务所在时间是无限的,所以数组的长度也是无限的,这种数组的开销是很大的。
把视角回到时间轮,如果使用时间轮存储任务,每个节点存储同执行时间的任务,每个节点表示当前的时间刻度,当指针移动到某一刻度时,将执行当前刻度所在的任务。 但是以下的方案还是会出现问题:如果遇到延时时间为 10 分钟,那么该时间轮的刻度会不够用,一个比较简单的方案是在任务中添加 count 字段,其表示需要等待多少个轮回,当经过一次当前刻度时,将 count - 1,当 count 为 0 时,即可执行。
解决刻度不够的另外一个算法是使用多级时间轮,如下图为三级的时间轮,其分别为时轮、分轮、和秒轮。比如一个定时任务的时间为 02:02:01 则当时轮刻度到达 2 时,该任务会跃迁到分轮的 02 刻度,当分轮的指针到达 02 时,该任务会跃迁到秒轮的 01 刻度上,当秒轮到达 01 时,该任务执行。
多级时间轮可以进一步扩展,比如天、月等。
高可用性能
触发器的高可用的核心问题在于:
- 不同任务之间,任务的调度相互影响怎么办
- 负责扫描和触发的机器挂了怎么办 解决思路
- 存储上,不同国别、业务做资源隔离
- 运行时,不同国别、业务分开执行
- 部署时,采用多机房集群化部署,避免单点故障,通过数据库锁或分布式锁保证任务只被触发一次
但是避免单点故障的同时,会出现幂等性的问题,同一个任务被触发多次,会导致业务紊乱,一下提出两个方案:数据库行锁模式和分布式锁模式。
方案一:数据库行锁模式
在触发调用之前,更新数据库中 Joblnstance 的状态,成功抢锁的才会触发调度。但是这会导致多台机器竞争数据库锁,节点越多性能越差
方案二:分布式锁模式
使用 Redis 或者 Zookeeper 设置分布式锁,在触发调度之前,尝试抢占分布式锁,抢占成功后的机器触发任务,这种方案性能较高。
调度器
调度器主要解决资源来源、资源调度和任务执行等任务。
资源来源
资源有两种来源,分别是:
- 业务系统提供资源
- 优点:任务执行逻辑与业务系统共用同一份资源,利用率更高
- 缺点:
- 更容易发生定时任务脚本影响在线服务的事故,当定时任务的资源消耗比较多时,会与在线服务抢占资源,甚至可能其中一个直接挂了。
- 不能由定时任务平台控制扩缩容
- 定时任务平台提供资源
- 优点:
- 任务执行逻辑与业务系统提供的在线服务隔离,避免相互影响
- 可以支持优雅地扩缩容
- 缺点:
- 消耗更多机器资源
- 需要额外为定时任务平台申请接口调用权限,而不能直接继承业务系统的权限
- 优点:
资源调度
节点选择
有 3 中节点选择方式:
- 随机节点执行:选择集群中一个可用的执行节点执行调度任务,这种适用于定时对账等比较轻量级别的任务。
- 广播执行:在集群中的所有执行节点分发调度并执行,这种比较适合批量运营,比如清空集群中所有的日志文件。
- 分片执行:根据用户自定义分片逻辑进行拆分,分发到集群的不同节点并行执行,提高资源利用率,该方式适用于海量日志统计。
分片任务
分片任务是一个使用场景比较多的方式,比如统计海量数据等任务。 如下图所示,一个数据量比较庞大的任务会进行任务分片,根据业务的情况将 Map 任务分派给不同的执行器,并且处理业务数据的不同区段,如此可以提高集群的资源利用率。 一般来说,N 个执行器 Executor, M 个业务数据区段,最好 M>=N, 且 M 是 N 的整数倍。
高级特性
任务编排
使用有向无环图 DAG (Directed Acyclic Graph) 进行可视化任务编排
故障转移
在单机任务场景下,当一个执行器的任务失败,将会选择其他执行器进行任务调用。 对于分片任务来说,转移的工作其实差不多。分片任务基于[[一致性 hash ]] 策略分发任务,当某 Executor 发生异常情况时,调度器会分发给其他的 Executor。
高可用
由于调度器属于无状态服务,所以可以集群部署,消息队列将消息发布给不同的执行器中,并且使用其重试机制保障任务一定被调度。
执行器
执行器主要包括注册、执行、回调和心跳检测的功能,如下图。
- 执行器会在调度中心注册该机器,让调度器直到这一台执行器的存在,从而可以被调度,另外,执行器会基于注册中心实现执行器的弹性扩缩容。
- 执行器通过 JobHandler 实现任务的执行
- 执行器会对调度中心上报状态,相当于心跳检测。