分布式定时任务实现原理 | 青训营笔记

242 阅读9分钟

核心框架

分布式定时任务核心要解决触发、调度、执行三个关键问题。其中触发指的是在在某一个特定的时间点触发任务,并开启该任务;调度指如何分配、管理在多台机器上触发的任务,如何;执行解决如何正常执行一个任务,并且在发生故障的时候如何进行恢复 由此可以得到三个组件:

  • 触发器:Trigger,用于解析任务,生成触发任务
  • 调度器:Scheduler,用于分配任务,管理任务生命周期
  • 执行器:Executor,用于获取执行任务单元,执行任务逻辑 除此之外,还需要提供一个控制台(admin),提供任务管理和干预的功能。

该架构属于是 [[基本情况#业内定时任务框架|业内定时任务框架]] 的一个基础架构,这些架构可能不是完全按照这种方式搭建的,但是逻辑上的架构是一致的。

image.png

数据流

总体的数据流分为两个部分:任务创建和任务执行。 任务创建:首先用户定义任务的基本信息和触发任务的规则,随后把任务的代码上传到平台中,随后分布式定时任务会将该任务交给控制台 admin,admin 将刚创建的任务存储在任务 DB 中。 任务执行:控制台需要知道这个任务说明时间节点去执行,这依赖于触发器对任务的触发,并使用调度器对任务进行整体的协调,调度器将任务分派给执行器执行任务。 image.png|500

功能架构

  • Admin 在功能上负责:对任务的原数据进行存储和管理、对任务进行分片、暂停和恢复以及监控警报。
  • Trigger 使用 Scanner 对任务 DB 扫描,获取任务的触发时间,并使用消息队列消费任务
  • Scheduler 对任务进行调度,并实现负载均衡、幂等控制等协调操作。
  • Executor 获取到任务之后,真正开始执行任务。 image.png|650

控制台

基本概念

控制台主要包括以下几个信息:

  • 任务,Job:记录任务的原数据
  • 任务实例,JobInstance:由于一个任务可能执行多次,比如周期性任务等,所以和任务是一对多的关系
  • 任务结果,JobResult:任务的输出结果,考虑到任务运行可能会失败,所以其与任务实例也是一对多的关系
  • 任务历史,JobHistory:用户可以更改任务的信息,所以对于每个任务实例,其任务的元数据可以不同,因此使用任务历史存储以往的版本,以方便月之前运行的实例结果做对应。

image.png|500 重要名词

  • 任务元数据:是用户对任务的属性进行定义,主要包括任务的基本信息、任务类型、调度时机、执行行为等,其状态机如下:

image.png|300

  • 任务实例:是一个确定的 Job 的一次运行,其主要包括 Job_id,触发时间,状态和结果以及过程的信息,其状态机如下:

image.png|450

触发器

解决方案

方案一:扫描+延时消息

这一方案是字节和腾讯的一套分布式定时任务的方案,其操作流程如下图所示:

  1. 扫描器 Scanner 扫描任务 DB ,将任务交给处理器 processor 处理需要执行的任务。
  2. Processor 处理分析完任务时,将需要执行任务的信息发送给延时消息队列中,等到需要执行任务的时候发送给任务调度器。
  3. 修改已发送的任务的在 DB 中的状态,以防止任务被重复扫描执行。
  4. 到执行的时间,延时消息队列将任务信息发送给调度器。

image.png|400

方案二:Quartz方案——时间轮

时间轮是一种高效利用线程资源进行批量化调度的一种调度模型。时间轮是一个存储环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表。

image.png|475 关于时间轮,先来看看之前的方案:

  1. 使用一个链表存储任务,每个元素代表一个任务。这看似是一个比较简单的方案,其目标为遍历任务列表,从中找出当前时间点需要出发的任务列表。 此时查询的复杂度为 O(n)O(n), 修改链表则只需要直接将任务插入链表尾部即可,复杂度为 O(1)O(1)
  2. 对链表的结构进行优化,使用最小堆存储任务,按照执行时间排序,每个节点存储同执行时间的任务列表,因为只需要找到对靠近当前时间的任务即可。 在查询时,直接读取根结点即为最近任务,但是当输出当前堆顶元素后,需要调整堆为小根堆,其时间复杂度为 O(logn)O(logn)。 另外,由于小根堆是由数组实现的,但是任务所在时间是无限的,所以数组的长度也是无限的,这种数组的开销是很大的。

把视角回到时间轮,如果使用时间轮存储任务,每个节点存储同执行时间的任务,每个节点表示当前的时间刻度,当指针移动到某一刻度时,将执行当前刻度所在的任务。 但是以下的方案还是会出现问题:如果遇到延时时间为 10 分钟,那么该时间轮的刻度会不够用,一个比较简单的方案是在任务中添加 count 字段,其表示需要等待多少个轮回,当经过一次当前刻度时,将 count - 1,当 count 为 0 时,即可执行。

image.png|500 解决刻度不够的另外一个算法是使用多级时间轮,如下图为三级的时间轮,其分别为时轮、分轮、和秒轮。比如一个定时任务的时间为 02:02:01 则当时轮刻度到达 2 时,该任务会跃迁到分轮的 02 刻度,当分轮的指针到达 02 时,该任务会跃迁到秒轮的 01 刻度上,当秒轮到达 01 时,该任务执行。 多级时间轮可以进一步扩展,比如天、月等。

image.png|450

高可用性能

触发器的高可用的核心问题在于:

  • 不同任务之间,任务的调度相互影响怎么办
  • 负责扫描和触发的机器挂了怎么办 解决思路
  • 存储上,不同国别、业务做资源隔离
  • 运行时,不同国别、业务分开执行
  • 部署时,采用多机房集群化部署,避免单点故障,通过数据库锁或分布式锁保证任务只被触发一次

但是避免单点故障的同时,会出现幂等性的问题,同一个任务被触发多次,会导致业务紊乱,一下提出两个方案:数据库行锁模式和分布式锁模式。

方案一:数据库行锁模式

在触发调用之前,更新数据库中 Joblnstance 的状态,成功抢锁的才会触发调度。但是这会导致多台机器竞争数据库锁,节点越多性能越差

image.png|475

方案二:分布式锁模式

使用 Redis 或者 Zookeeper 设置分布式锁,在触发调度之前,尝试抢占分布式锁,抢占成功后的机器触发任务,这种方案性能较高。

image.png|500

调度器

调度器主要解决资源来源、资源调度和任务执行等任务。

资源来源

资源有两种来源,分别是:

  • 业务系统提供资源
    • 优点:任务执行逻辑与业务系统共用同一份资源,利用率更高
    • 缺点:
      • 更容易发生定时任务脚本影响在线服务的事故,当定时任务的资源消耗比较多时,会与在线服务抢占资源,甚至可能其中一个直接挂了。
      • 不能由定时任务平台控制扩缩容
  • 定时任务平台提供资源
    • 优点:
      • 任务执行逻辑与业务系统提供的在线服务隔离,避免相互影响
      • 可以支持优雅地扩缩容
    • 缺点:
      • 消耗更多机器资源
      • 需要额外为定时任务平台申请接口调用权限,而不能直接继承业务系统的权限

资源调度

节点选择

有 3 中节点选择方式:

  • 随机节点执行:选择集群中一个可用的执行节点执行调度任务,这种适用于定时对账等比较轻量级别的任务。
  • 广播执行:在集群中的所有执行节点分发调度并执行,这种比较适合批量运营,比如清空集群中所有的日志文件。
  • 分片执行:根据用户自定义分片逻辑进行拆分,分发到集群的不同节点并行执行,提高资源利用率,该方式适用于海量日志统计。

分片任务

分片任务是一个使用场景比较多的方式,比如统计海量数据等任务。 如下图所示,一个数据量比较庞大的任务会进行任务分片,根据业务的情况将 Map 任务分派给不同的执行器,并且处理业务数据的不同区段,如此可以提高集群的资源利用率。 一般来说,N 个执行器 Executor, M 个业务数据区段,最好 M>=N, 且 M 是 N 的整数倍。

image.png

高级特性

任务编排

使用有向无环图 DAG (Directed Acyclic Graph) 进行可视化任务编排

image.png|450

故障转移

在单机任务场景下,当一个执行器的任务失败,将会选择其他执行器进行任务调用。 对于分片任务来说,转移的工作其实差不多。分片任务基于[[一致性 hash ]] 策略分发任务,当某 Executor 发生异常情况时,调度器会分发给其他的 Executor。

高可用

由于调度器属于无状态服务,所以可以集群部署,消息队列将消息发布给不同的执行器中,并且使用其重试机制保障任务一定被调度。

image.png|550

执行器

执行器主要包括注册、执行、回调和心跳检测的功能,如下图。

  • 执行器会在调度中心注册该机器,让调度器直到这一台执行器的存在,从而可以被调度,另外,执行器会基于注册中心实现执行器的弹性扩缩容
  • 执行器通过 JobHandler 实现任务的执行
  • 执行器会对调度中心上报状态,相当于心跳检测

image.png|575