在实现定时弹性伸缩的时候,遇到需要配置定时任务的场景,按照云原生应用架构,应该将这类状态放到中间件中。苦于TbSchedule和Elastic-Job这类的任务管理服务都无法满足需求:
- 语言特性方面,都是Java一派的,不支持golang;
- 弹性伸缩规则需要做到能够动态添加删除、也就是任务的cron expression不是固定配置死的;
于是,决定自己实现一套分布式的定时任务管理服务,定位为中间件。
需求与设计
主要从下面几个方面来考虑系统的设计:
高可用
既然叫做”分布式定时任务”,就应该保障不会因为单台节点的宕机而影响整体的可用性。高可靠
既然定位为中间件,就需要担负起相应的重担,在保障高可用的前提下,能承载住业务层下放的大并发定时任务,不丢任务数据,不丢失回调消息。作为和黑系统,有定时任务必触发回调;有回调必保障可达。可扩展
在负荷变重时能够横向扩展,从而保障稳定可靠。性能
需要从以下指标来评判系统性能:任务下发响应时间、定时任务承载量(吞吐量)、定时任务回调触发时延、拓扑变动任务再均衡时间。一致性
定时任务存储的一致性、节点拓扑信息的一致性、任务调度的一致性等。
架构设计
总体架构如下图所示,接下来逐个分解各模块的作用和详细设计。
作用
接口层(API layer)
- 外部访问的Rest接口实现,主要提供对定时任务的下发、删除、更改以及查询等操作;
- 另外,基于任务调度逻辑实现对已请求的代理,将对具体任务的操作代理到任务所在节点执行。
任务调度(job management)
- 基于底层当前节点拓扑,完成任务的调度;
- 当拓扑变更时,实现任务的重新分配和资源的均衡。
拓扑管理(topology management)
- 集群中节点的状态管理,变更后上报等操作。
数据访问层(abstract storage)
- 基于用于配置初始化底层物理存储;
- 对上层提供抽象的存储封装,并将操作转化为对实际存储驱动的调用;
- 底层存储支持MySQL和Raft-Log两种。
定时任务驱动(cronjob driver)
- 实现将当前节点定时任务下放到操作系统的集中管理。
回调逻辑(CallBack Logic)
- 定时任务触发后,执行响应回调逻辑
- 支持两种回调方式:HTTP/gRPC
- 当对接服务注册中心Consul,支持客户端负载均衡
- 回调支持重试队列,按照配置规则重试
核心模块设计
拓扑管理(gossip协议)
基于gossip协议,当集群中节点有不健康时,其他节点一旦发现能够快速在集群中洪泛该消息,从而让所有节点能够迅速感知到新拓扑,达到拓扑状态的一致性。当拓扑变更时,会执行回调函数,从而保障对拓扑敏感的模块能够快速响应变化。
具体可以访问之前介绍过的Serf。
任务调度(一致性哈希)
任务调度分两块:
- 基于一致性哈希结果来分配定时任务到不同的节点
在节点拓扑结构稳定的情况下,哈希环均衡的将任务分配到各物理节点;如果任务被分配到当前节点,还会负责调用对应的驱动代码将任务配置到操作系统层。
该层也向接口层提供对接的访问接口;当更改任务的请求到达某个节点的接口层,哈希算法基于任务ID计算出该任务是否被分配到本节点;如果不在该节点上,就会触发对应的代理逻辑,将操作请求代理到对应节点。 - 当节点拓扑变化时,重新均衡定时任务到各节点
当拓扑变化时,节点从持久化存储中读取任务列表,并基于新的哈希环调度任务。
数据持久化(抽象存储层、mysql、raft)
数据持久化层包含抽象层和具体的存储介质实现,抽象层主要为业务层提供统一的封装,让底层的存储对上层透明。对于底层存储接口,MySql是比较稳妥的方案,也是借鉴了apollo的思路。重点是raft的支持比较繁琐,当前是基于etcd-raft来实现的。
Raft-Log
- 该模块将raft协议封装到了抽象的node里面,对外部暴露propose和configChange的channel用于下发日志和配置变更;
- 该模块启动的时候需要指定集群中所有的node,以及当前node的id信息,显得过于繁琐;
- 向raft propose的数据,是否有被commit需要在业务逻辑中实现,这块也比较麻烦;
- 就当前所知,raft是不支持向key提交delete和覆盖操作的,这块仍在研究中。
任务回调(回调队列、多种模式)
其实是想实现一个类似于prometheus的alertManager一样的功能,当任务触发的时候,可以支持各种类型的回调操作,当前暂时支持了HTTP和gRpc。
由于需要对接外部系统,而且外部系统很多没有做对应的服务端负载均衡,因此设计基于consul来监听target endpoints列表的客户端负载均衡。相对来讲HTTP是比较好实现的,对于gRpc就相对更加繁琐一点,这块刚好之前有分析过gRpc,就将内容更新之前的文章中了。具体见浅析gRPC
接口层
当更改删除任务的时候,基于任务ID来调用任务调度的逻辑,哈希得到任务所在的节点,并代理请求到对应节点做处理;
当创建任务的时候,可能此时请求中并没有带任务ID,系统会尝试为其生成一个uuid,如果该uuid哈希之后的结果不在该节点上,那就重新生成uuid,知道最终为其分配的任务ID保障任务落在该节点上。这是为了避免引入集中是的ID生成服务,提高性能而刻意设计的,这样设计的前提是认为分布式定时任务外层已经有对应的LB或者客户端已经做了负载均衡。
源码
当前系统已基于golang实现,并运行良好,源码地址, 请联系我开放github权限!