调研-分布式任务调度(crontab)

本文谈谈分布式crontab的调研初步结论和思考。

我对分布式crontab有4个核心诉求:

  • 高可用
  • 分布式可扩展
  • 可视化
  • 防并发执行

“调度”是分布式系统一个很大的课题,以前工作接触过调度系统,其维护状态机和分布式调用情况下各种异常case太多,实现时很难证明程序是正确的,只能说基本正确。

调度定时任务也是如此,因此在调研开源项目时我会特别关注分布式的容错这方面的原理与实现是否靠谱,并且在思考如何设计这样一套系统时也尽量绕开”分布式一致性问题”,尽可能找到一个折衷后简单的方案。

下面简单梳理一下几个架构方案,应该都是有开源项目或者公司级项目做过相关实践的。

架构方向

初级方向

先把单机crontab规则拿到数据库里保存,这样解决了可视化编辑的问题。

既然cron配置迁移到mysql,那么最好自己实现一个agent取代crontab:

  • agent需要实时同步读取mysql中cron规则的变动
  • agent需要根据cron规则唤起子进程执行任务

这样可以很快实现一个可视化的crontab。

为了比crontab可用性更高,可以让agent基于zk/etcd选主,这样就可以在2台机器上部署agent,其中只有一个会真正的调度,故障时会提升为主进行调度。

如果单机调度的任务太多,硬件资源不足怎么办?这就是扩展性问题。

在该方案下,解决扩展性可以再部署一套集群(2台机器),然后将一部分任务放到这个集群里面去;同时,管理界面上需要引入集群分组的概念,让任务可以指定在哪个集群运行。

这个方案在百度noah监控平台里有类似的体现。

中级方向

初级方向在扩展性方面存在问题,因为它把调度和执行混在一起了。

调度本身资源需求不高,但是调度本身的高可用需求导致1台机器总是闲着,任务执行全部压在一台机器上,整体利用率不高。

中级方向试图将调度和执行拆分,调度仍旧保持高可用1主1备,执行则隔离到独立的一层节点。

调度可以简单的将到期任务发给消息队列,然后由执行节点根据自己的负载获取队列中的任务并执行;任务执行完成后,执行节点要把执行结果汇报到数据库中。

这里因为涉及多个模块的网络通讯,因此开始出现了分布式问题。

首先,调度发任务给消息队列可能异常(异常是指:发送成功还是失败是未知的),执行发送结果给数据库可能异常。

如果调度系统不关心同一个任务并发执行,也不关心少量的执行丢失,那么上述异常均可忽略。

这样,就实现了一个很简单的调度与执行解耦,但前提是忽略了异常处理。

高级方向

quartz二次开发

quartz是一个JAVA开源项目,历史悠久。

它支持集群部署高可用,每个节点都参与任务调度和执行。

首先,每个节点每次启动会自己生成唯一的会话ID,此后会定时的发送心跳到mysql,证明自己在线。

其次,每个节点在调度逻辑中,首先去mysql获取一把任务悲观锁,相当于独占集群的调度入口,在同一个事务中从任务表中扫出即将超时的任务,将这些任务的归属更新为自己的会话ID,此后提交事务,即得到了一批即将执行的独占任务。

此后,quartz在进程内执行这些到期的任务,并再次获取悲观锁,将完成执行的任务归还回任务池,以便任务可以被下次调度周期抢占。

对于高可用问题,quartz的每个节点内都有一个线程定时的扫描mysql里的心跳记录,如果某个会话ID太长时间没有心跳则认为对应节点宕机。quartz这时会从mysql独占一把会话悲观锁,同时独占之前那把任务悲观锁,然后将任务表中属于宕机的会话的任务状态全部重置,整体提交事务。

quartz虽然很有名,但是仍旧存在一致性问题。因为quartz在调度任务时需要对mysql中任务状态进行更新,既然是网络调用就可能异常,而粗略的扫了quartz的调度核心代码发现,对于数据库访问抛出异常这种情况,quartz基本是假设了SQL没有执行成功,这个假设导致quartz内存状态机和mysql状态可能出现不一致,从而导致一些任务被hang住再也无法被调度,这个问题可以从github issue上找到很多。

国内开源项目xxl-job、elastic-job都是基于quartz做任务调度,在外围解耦出一个执行层,因此不知道它们对quartz这种调度核心本身的缺陷是如何修复的,或者压根没有处理。

elastic-job

当当开源项目,没有使用quartz自带的集群模式,而是单机quartz模式基于zookeeper来选主,保障高可用。

quartz调度后,elastic-job利用ZK来分发任务,即在ZK中向worker指派对应的任务,这样worker可以近乎实时的从ZK感知到任务并执行它。

执行结果的反馈可能也是基于ZK通知,这一块暂时没有深入去看。

总之,该模式需要leader角色与ZK进行复杂通讯,包括任务派发,任务重做(worker宕机),代码不会很简单,可靠性只有去读源码才知道了。

这个项目没有web console,是专门给JAVA程序用的。

xxl-job

个人开发,基于quartz自带的集群模式。

额外开发了一层worker执行层,quartz与worker之间基于RPC通讯进行任务派发和结果反馈。

根据作者的迭代日志来看,xxl-job目前调度中心需要等待任务结果返回,若没有返回则会导致任务block,可见异常处理可能有限,后续还会自己实现调度替换quartz,看样不是很稳定。

这个项目有web console,支持执行SHELL脚本。

cronsun

GO项目,实现了调度的去中心化,这与quartz完全不一样,成功绕过了分布式一致性问题。

cronsun可以集群部署多个等价agent,连接到etcd注册监听。

用户通过web console管理任务,任务被存储到etcd,从而所有agent可以实时感知任务变化。

每个agent都拥有完整的任务列表,各自按照任务的cron表达式周期性的调度。

agent在执行一个到期任务前,会首先去etcd创建一把乐观锁,每个任务ID对应一把锁;加锁成功的agent可以执行该任务,而加锁失败的agent则计算任务下次执行时间,继续后续调度;任务执行完成后,agent会释放etcd上对应的锁记录,这样下次任务到期又可以被某agent抢占执行。

这种去调度中心的模式,要求各个agent时钟一致,这样才能最大限度避免一个任务因为时钟不一致导致被多个agent接连抢占调度多次。

不过好在,一般任务不怕调度多次,只怕并发调度,在分布式锁保护下基本是可以避免并发调度的,除非etcd不稳定无法通讯,导致lease租约过期锁失效。

cronsun agent唯一写操作就是去抢占锁,而抢锁请求异常(是否抢占成功未知)的情况下只要agent不对lease续租,那么过一会锁就会失效,下次任务就可以被调度,不会导致任何一致性的问题。

另外,cronsun基于etcd还实现了2个特性:

  1. 立即执行任务:agent监听一个叫做/once的目录变化,web console通过在once目录内put一个以任务ID为key,机器ID为value的键值,就可以触发agent立即执行一次对应任务,执行完成对应k/v留在etcd里不需要删除,因为agent对once只监听变化,不在乎原先的值。
  2. 执行失败报警:web console会监听一个叫做/notice的目录变化,agent执行任务失败后会去/notice目录下put一个以任务ID为key,报错信息为value的兼职,这样就触发了web console获取到这个失败提示,然后给用户发送邮件。

可见,在cronsun项目里,etcd充当了3类角色:

  1. 任务的实时同步:基于etcd的get与watch实现。
  2. 防任务并发执行:基于etcd的if和lease,实现分布式乐观锁。
  3. 通知机制:基于etcd的watch,实现对增量事件的响应。

总结

一般的开源项目过于纠结任务下发、结果上报的成功率,导致分布式通讯和状态处理趋于复杂。

参考cronsun,可以使用HA的调度中心来做定时器触发,基于消息队列做任务下发,基于消费者做任务执行,基于etcd乐观锁实现防止并发,基于etcd做一次性触发,可以实现一个容错率很高的调度系统,我觉得这种实现方向更容易把控,难度也很小。

 

 

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~