分布式任务调度

前言

任务调度可以说是所有系统都必须要依赖的一个中间系统,主要负责触发一些需要定时执行的任务。传统的非分布式系统中,只需要在应用内部内置一些定时任务框架,比如spring整合quartz,就可以完成一些定时任务工作。在分布式系统中,这样做的话,就会面临任务重复执行的问题(多台服务器都会触发)。另外,随着公司项目的增加,也需要一个统一的任务管理中心来解决任务配置混乱的问题。

公司的任务调度系统经历了两个版本的开发,1.0 版本始于 2013 年,主要解决当时各个系统任务配置不统一,任务管理混乱的问题,1.0 版本提供了一个统一的任务管理平台。2.0 版本主要解决 1.0 版本存在的单点问题。

任务调度系统 1.0

1.0 版本的任务调度系统架构如下图1:由一台服务器负责管理所有需要执行的任务,任务的管理与触发动作都由该机器来完成,通过内置的 quartz 框架,来完成定时任务的触发,在配置任务的时候,指定客户端 ip 与端口,任务触发的时候,根据配置的路由信息,通过 http 消息传递的方式,完成任务指令的下达。

这里存在一个比较严重的问题,任务调度服务只能部署一台,所以该服务成为了一个单点,一旦宕机或出现其他什么问题,就会导致所有任务无法执行。

图 1

任务调度系统 2.0

2.0 版本主要为了解决 1.0 版本存在的单点问题,即将任务调度服务端调整为分布式系统,改造后的项目结构如下图2:需要改造调度服务端,使其能够支持多台服务器同时存在。这带来一个问题,多台调度服务器中,只能有一台服务器允许发送任务(如果所有服务器都发任务的话,会导致一个任务在一个触发时间被触发多次),所以需要一个 Leader,只有 Leader 才有下达任务执行命令的权限。其他非 Leader 服务器这里称为 Flower,Flower 机器没有执行任务的权限,但是一旦 Leader 挂掉,需要从所有 Flower 机器中,重新选举出一个新的 Leader,继续执行后续任务。

图 2

另外一个问题是,如果某一个应用,比如说资产中心系统,我们有 A,B,C 三台机器,在凌晨12点要执行一个任务, 调度系统要如何发现 A,B,C 三台机器 ?如果 B 机器在12点的时候,恰好宕机,调度系统又要如何识别出来? 其实就是一个服务发现的问题。

群首选举

当多台任务调度服务器同时存在时,如何选举一个 Leader,是面临的第一个问题。比较成熟的算法如:基于 paxos 一致性算法的 zookeeper、Raft 一致性算法等等都可以实现。在该项目中,采用的是一个简单的办法,基于 zookeeper 的临时(ephemeral)节点功能。

zookeeper 的节点分为2类,持久节点和临时节点,持久节点需要手动 delete 才会被删除,临时节点则不需要这样,当创建该临时节点的客户端崩溃或者与 zookeeper 的连接断开,则该客户端创建的所有临时节点都会被删除。

zookeeper 另外一个功能:监视点。某一个连接到 zookeeper 的客户端,可以监视一个 zookeeper 节点的变化,比如 exists 监视操作,会监视节点是否存在,如果节点被删除,那么客户端将会收到一条通知。

基于临时节点和监视点这两个特性,可以采用 zookeeper 实现一个简单的群首选举功能:每一台任务调度服务器启动的时候,都尝试创建群首选举节点,并在节点中写入当前机器 IP,如果节点创建成功,则当前机器为 Leader。如果节点已经存在,检查节点内容,如果数据不等于当前机器 IP,则监视该节点,一旦节点被删除,则重新尝试创建群首选举节点。

图 3

使用 zookeeper 临时节点做群首选举的缺陷:有的时候,即使某一台任务调度服务器能够正常连接到 zookeeper,也并不表示该机器是可用的,比如一个极端场景,服务器无法连接到数据库,但是可以正常连接到 zookeeper,这个时候,基于 zookeeper 的临时节点功能,是无法剥离这一台异常机器的(但是可以通过一些手段处理这个问题,比如本地开发一套自检程序,检测所有可能导致服务不可用的异常,如数据库异常等等,一旦自检程序失败,则不再发送 zookeeper 心跳包,从而剥离异常机器)。

脑裂问题

群首选举中,我们选举出了一个 Leader,我们也希望系统中只有一个 Leader,但是在一些特殊情况下,会出现多个 Leader 同时发号施令的现象,即脑裂问题。

有以下几种情况会导致出现脑裂问题:

zookeeper 本身集群配置有问题,导致 zookeeper 本身脑裂了。

同一个集群里面的多个服务器的 zookeeper 配置不一致。

同一个 IP,部署了多台任务调度服务器。

任务调度服务主备切换时候的瞬时脑裂问题。

其中前三个属于配置问题,应用程序没有办法解决。

第四个主备切换时候的瞬时脑裂,具体场景如下图4:

图 4

现象:

A 先连接上了 zookeeper,并成功创建 /leader 节点。

t1: A 与 zookeeper 失去连接, 此时 A 依然认为自己是 Leader。

t2: zookeeper 发现 A 超时,所以删除 A 的所有临时节点,包括 /leader 节点。由于此时B 正在监视 /leader 节点,故 zookeeper 在删除该节点的同时,也会通知 B 服务器,B 收到通知之后立即尝试创建 /leader 节点。

t3: B 创建 /leader 节点成功,当选为 Leader。

t4: A 网络恢复,重新访问 ZK 时,发现失去 Leader 权限,更新本地 Leader-Flag = false。

可以看出

如果 A 机器,在 T1 发现无法连接到 zookeeper 之后,如果不失效本地 Leader 权限,那么,在 T3-T4 时间段内,就有可能会出现脑裂现象,即 A、B 两台机器同时成为了Leader。(这里 A 发现超时之后,之所以不立即失效 Leader 权限,是出于系统可用性的一个权衡:尽可能减少没有 Leader 的时间。因为一旦 A 发现超时,马上就失效Leader 权限的话,会导致 T1-T3 这一段时间,没有任何一个 Leader 存在,相比于出现2个 Leader 来说,没有 Leader 的影响更严重)。

脑裂出现的原因很多,一些配置性问题导致的脑裂,无法通过程序去解决,脑裂现象无法完全避免,必须通过其他方式保障系统在脑裂情况下的数据一致性。

系统采用的是基于数据库的唯一主键约束:任务每一次触发,都会有一个触发时间(Schedule Time),该时间精确到秒,如果对于同一个任务,每一次触发执行的时候,在数据库插入一条任务执行流水,该流水表使用任务触发时间 + 任务 Id 来作为唯一主键,即可避免脑裂时带来的影响。两台服务器如果同时触发任务,且都具有 Leader 权限,此时,其中一台服务器会因为数据库唯一主键约束,导致任务执行失败,从而取消执行。(由于在分布式环境下,多台 Legends 服务器时钟可能会有一些误差, 如果任务触发时间过短,还是有可能出现并发执行的问题:A 机器执行01秒的任务,B 机器执行02秒的任务。所以不建议任务的触发时间过短)。

发现存活的客户端

服务端发送任务之前,需要知道有哪些服务器是存活的,具体实现方式如下:

应用服务器客户端启动成功之后,会向 zookeeper 注册本机 IP(即创建临时节点)

任务调度服务器通过监视 /clients 节点的子节点数据,来发现有哪些机器是可用(这里通过监视点来永久监视客户端节点的变化情况)。

当该系统有任务需要发送的时候,调度服务器只需要查询本地缓存数据,就可以知道有哪些机器是存活状态,之后根据任务配置的策略,发送任务到 zookeeper 中指定客户端的待执行任务列表中即可。

图 5

任务执行流程

任务触发的具体流程如下图6:

图 6

流程说明:

Quartz 框架触发任务执行 (如果发现当前机器非 Leader,则直接结束)。

服务器查询本地缓存数据,找到对应的应用的存活服务器列表,并根据配置的任务触发策略,选取可以执行的客户端。

向 ZK 中,需要执行任务的客户端所对应的任务分配节点(/assign)写入任务信息 。

应用服务器的发现分配的任务,获取任务并执行任务。

这里存在一个问题:在任务数据发送到 zk 之后,如果存活的客户端立即死亡要如何处理?因为任务调度服务器一直在监视客户端注册节点的变化,一旦一台应用服务器死亡,任务调度服务器会收到一条客户端死亡的通知,此时,可以检测该客户端对应的任务分配节点下,是否有已经分配,但是还未来得及执行的任务,如果有,则删除任务节点,回收未处理的任务,再重新将该任务分配到其他存活服务器执行即可(这里客户端执行任务的操作是,先删除 zookeeper 中的任务节点,之后再执行任务,如果一个任务节点已经被删除,则表示该任务已经成功下达,由于删除操作只有一个 zk 客户端能够执行成功,故任务要么被服务端回收,要么被客户端执行)。

这个问题引申的还有一些其他问题,比如任务调度服务发现应用服务器死亡,回收该应用服务器未执行的任务之后,突然断电或者失去了 Leader 权限,导致内存数据丢失,此时会造成任务漏发现象。

任务变更的信息流

当一个用户在任务调度服务器后台修改或新增一个任务时,任务数据需要同步到所有的任务调度服务器,由于任务数据保存在 DB,ZK 以及每个调度服务器的内存中,任务数据的一致性,是任务更新时要处理的主要问题。

任务数据的更新顺序如图7所示:

图 7

用户连接到集群中的某一台 Server, 对任务数据做修改,提交。

Server 接收到请求之后,先更新 DB 数据 ( version + 1 )。

异步提交 ZK 数据变动( zookeeper 数据更新也是强制乐观锁更新的模式) 。

所有 Server 中的 JOB Watcher 监控到 ZK 中的任务 数据发生了变化,重新查询 ZK 并更新本地 Quartz 中的内存数据。

由于 2,3,4 三步更新,都采用了乐观锁更新的模式,且所有任务数据的变动,都是按照一致的更新顺序操作,所以解决了并发更新的问题。另外这里之所以要采用异步更新zookeeper 的原因,是由于 zookeeper 客户端程序是单线程模式,任何同步的代码,都会阻塞所有的异步调用,从而降低整个系统的性能,另外也有 SessionExpired 的风险( zookeeper 一个重量级的异常)。

三步操作,任何一步都有可能失败,但是又无法做到强一致性,所以只能采用最终一致性来解决数据不一致的问题。采用的方案是用一个内置线程,查询5分钟内有过更新的任务数据,之后对三处数据做一个比对验证,以使数据达到一致。

另外这里也可以调整为:zookeeper 不存储任务数据,只在任务数据有更新的时候,发送给所有服务器任务有更新的通知即可,调度服务器接受到通知之后,直接查询 DB 数据即可,数据只保存在 DB 与各个调度服务器。

实践总结

任务调度系统 1.0 版本解决了公司的任务管理混乱的问题,提供了一个统一的任务管理平台。2.0 版本解决了 1.0 版本存在的单点问题,任务的配置也相对更简单,但是有一点过度依赖 zookeeper,编码的时候应用层与会话层也没有做好解耦,总的来说还是有很多可以优化的地方。

作者简介

卢云,铜板街资金端后台开发工程师,2015年12月加入团队,目前主要负责资金团队后端的项目开发。

---------- END ----------

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180831G0POBJ00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券