本文来自网易云社区。redis
时间轮实现
时间轮是一种环形的数据结构,分红多个格。
每一个格表明一段时间,时间越短,精度越高。
每一个格上用一个链表保存在该格的过时任务。
指针随着时间一格一格转动,并执行相应格子中的到期任务。
名词解释:算法
- 时间格:环形结构中用于存放延迟任务的区块
- 指针:指向当前操做的时间格,表明当前时间
- 格数:时间轮中时间格的个数
- 间隔:每一个时间格之间的间隔,表明时间轮能达到的精度
- 总间隔:当前时间轮总间隔,等于格数*间隔,表明时间轮能表达的时间范围
单表时间轮数据库

以上图为例,假设一个格子是1秒,则整个时间轮能表示的时间段为8s, 若是当前指针指向2,此时须要调度一个3s后执行的任务,须要放到第5个格子(2+3)中,指针再转3次就能够执行了。
单表时间轮存在的问题是:
格子的数量有限,所能表明的时间有限,当要存放一个10s后到期的任务怎么办?这会引发时间轮溢出。
有个办法是把轮次信息也保存到时间格链表的任务上。数据结构

若是任务要在10s后执行,算出轮次10/8 round等1,格子10%8等于2,因此放入第二格。
检查过时任务时应当只执行round为0的任务,链表中其余任务的round减1。
带轮次单表时间轮存在的问题是:
若是任务的时间跨度很大,数量很大,单层时间轮会形成任务的round很大,单个格子的链表很长,每次检查的量很大,会作不少无效的检查。怎么办?
分层时间轮架构

过时任务必定是在底层轮中被执行的,其余时间轮中的任务在接近过时时会不断的降级进入低一层的时间轮中。
分层时间轮中每一个轮都有本身的格数和间隔设置,当最低层的时间轮转一轮时,高一层的时间轮就转一个格子。
分层时间轮大大增长了可表示的时间范围,同时减小了空间占用。
举个例子:
上图的分层时间轮可表达8 8 8=512s的时间范围,若是用单表时间轮可能须要512个格子, 而分层时间轮只要8+8+8=24个格子,若是要设计一个时间范围是1天的分层时间轮,三个轮的格子分别用2四、60、60便可。
工做原理:
时间轮指针转动有两种方式:异步
- 根据本身的间隔转动(秒钟轮1秒转1格;分钟轮1分钟转1格;时钟轮1小时转1格)
- 经过下层时间轮推进(秒钟轮转1圈,分钟轮转1格;分钟轮转1圈,时钟轮转1格)
指针转到特定格子时有两种处理方式:数据库设计
- 若是是底层轮,指针指向格子中链表上的元素均表示过时
- 若是是其余轮,将格子上的任务移动到精度细一级的时间轮上,好比时钟轮的任务移动到分钟轮上
举个例子: 分布式
- 算出任务应该放在秒钟轮的第5个格子
- 在秒钟轮指针进行5次转动后任务会被执行
- 算出该任务的延迟时间已经溢出秒钟轮
- 50/8=6,因此该任务会被保存在分钟轮的第6个格子
- 在秒钟轮走了6圈(6*8s=48s)以后,分钟轮的指针指向第6个格子
- 此时该格子中的任务会被降级到秒钟轮,并根据50%8=2,任务会被移动到秒钟轮的第2个格子
- 在秒钟轮指针又进行2次转动后(50s)任务会被执行
- 算出该任务的延迟时间已经溢出分钟轮
- 250/8/8=3,因此该任务会被保存在时钟轮的第3个格子
- 在分钟轮走了3圈(3*64s=192s)以后,时钟轮的指针指向第3个格子
- 此时该格子中的任务会被降级到分钟轮,并根据(250-192)/8=7,任务会被移动到分钟轮的第7个格子
- 在秒钟轮走了7圈(7*8s=56s)以后,分钟轮的指针指向第7个格子
- 此时该格子中的任务会被降级到秒钟轮,并根据(250-192-56)=2,任务会被移动到秒钟轮的第2个格子
- 在秒钟轮指针又进行2次转动后任务会被执行
优势:性能
- 高性能(插入任务、删除任务的时间复杂度均为O(1),DelayQueue因为涉及到排序,插入和移除的复杂度是O(logn))
缺点:线程
- 数据是保存在内存,须要本身实现持久化
- 不具有分布式能力,须要本身实现高可用
- 延迟任务过时时间受时间轮总间隔限制
对于超出范围的任务可放在一个缓冲区中(可用队列、redis或数据库实现),等最高时间轮转到下一格子就从缓冲中取出符合范围的任务落到时间轮中。
好比:
- 算出该任务的延迟时间已经溢出时间轮
- 因此任务被保存到缓冲队列中
- 在时钟轮走了1格以后,会从缓冲队列中取知足范围的任务落到时间轮中
- 缓冲队列中的全部任务延迟时间均需减去64s,任务A减去64s后是536s,依然大于时间轮范围,因此不会被移出队列
- 在时钟轮又走了1格以后,任务A减去64s是536-64=472s,在时间轮范围内,会被落入时钟轮
以前的设计(DB/DelayQueue/ZooKeeper)
调度系统提供任务操做接口供业务系统提交任务、取消任务、反馈执行结果等。
针对dubbo调用,将任务抽象成JobCallbackService接口,由业务系统实现并注册成服务。
总体架构

数据库:
内存队列:
- 实际为DelayQueue,延迟任务精确触发的机制由它保证
- 只存储将来N分钟内过时且最多1000个任务
ZooKeeper:
- 管理整个调度集群
- 存储调度节点信息
- 存储节点分片信息
主节点:
调度节点:
- 提供dubbo、http接口供业务系统调用,用于提交任务、取消任务、反馈执行结果等
- 从ZK注册中心获取当前节点的分片信息,再从数据库拉取即将过时的数据放到DelayQueue
- 调用业务系统注册的回调服务接口,发起调度请求
- 接收业务系统的反馈结果,更新执行结果,移除任务或发起重试
业务系统:
- 做为被调度的服务须要实现回调接口JobCallbackService,并注册为dubbo服务提供者
- 在须要延迟任务的场景调用调度系统接口操做任务
数据库设计

表说明
- job_callback_service:服务配置表,配置业务回调服务,包括服务协议、回调服务、重试次数
- job_delay_task:延迟任务表,用于存储延迟任务,包括任务分片号、回调服务、调用总次数、失败数、任务状态、回调参数等
- job_delay_task_execlog:延迟任务执行表,记录调度系统发起的每一次回调
- job_delay_task_backlog:延迟任务调度结果表,记录任务最终状态等信息
主从切换
利用ZooKeeper临时序列节点特性,序号最小的节点为主节点,其余节点为从节点。
主节点监听集群状态,集群状态发生变化时从新分片。
从节点监听序号比它小的兄弟节点,兄弟节点发生变化从新寻找和创建监听关系。

数据分片

任务状态

- delay:延迟任务提交后的初始状态
- ready:过时时间已到,消息推入就绪队列的状态
- running:业务订阅消息,收到消息开始处理的状态
- finished:业务处理成功
- failed:业务处理失败
主要流程

服务加载
- 从DB读取服务配置
- 根据配置动态构造Consumer对象并添加到Spring容器中
提交任务
- 业务系统经过dubbo或http接口提交任务
- 判断任务过时时间是否在一个扫描周期内
- 若是是,
- 设置分片号(从当前节点所负责的分片随机获取)
- 添加到内存队列
- 任务保存到job_delay_task表
- 若是否,
- 设置分片号(根据分片总数和随机算法算出分片号)
- 任务保存到delay_task表
定时器
- 由一个线程管理
- 根据配置的扫描间隔设置定时器的执行周期
- 根据当前时间和扫描间隔算出该时段的过时时间X-Delay
- 从DB获取过时时间在X-Delay以前的全部任务,并放到DelayQueue
调度任务
- 由一个线程池管理
- 全部线程都阻塞在DelayQueue的方法take
- take到任务,从DB中获取任务,判断是否存在
- 若是不在,什么也不作(任务已执行成功或已被删除)
- 若是存在,判断调用次数是否超过设置
- 若是不超
- 调用业务回调服务
- 从任务中取出调用的服务配置
- 从容器中获取对应的Consumer对象
- 异步调用业务回调服务
- 设置下次重试时间,记录调用日志job_delay_task_execlog
- 若是超过,将任务转移到job_delay_task_backlog
任务反馈
- 更新任务调用结果
优势
缺点
- 略微复杂
- 须要将服务配置动态生成为Consumer对象
- 增长新的服务须要通知全部调度节点刷新
- 存在必定的耦合性(直接调用业务服务,协议耦合),若是接入系统是thrift协议呢?
- 须要处理任务的重试
- 调度系统直接回调业务服务,若是业务服务不可用可能会形成盲目重试,不能很好的控制流量(调度系统不知道业务服务的处理能力)
若是引入MQ,使用MQ来解耦服务调用的协议,保证任务的重试,并由消费方根据本身的处理能力控制流量会不会更好呢?
另外一种方案(DB/DelayQueue/ZooKeeper/MQ)
总体架构

数据库设计

主要流程

调度任务
- 由一个线程池管理
- 全部线程都阻塞在DelayQueue的take方法
- take到任务,从DB中获取任务,判断是否存在
- 若是不在,什么也不作(任务已执行成功或已被删除)
- 若是存在,将任务转移到job_delay_task_execlog;往消息队列投递消息
缺点
须要业务系统依赖于MQ
本文来自网易云社区,经做者陈志良受权发布。
原文:延迟任务调度系统(技术选型与设计)