在采用JDBC-Based JobStore 的前提下,Quartz支持集群部署。每一个Scheduler任务调度服务都可以作为集群中的一个节点,节点之间并不互相通讯,正常情况下每一个节点只知道自己、并不知道其他节点的存在,各节点都通过和同一个数据库通讯从而实现集群。
集群部署后,作业可以被任意一个Scheduler节点调度,任一个节点失败或down机后,该节点负责的任务会委派给其他正常节点接管。
Quartz集群的配置
Quratz集群通过参数org.quartz.jobStore.isCluster配置,Quartz初始化的过程中参数被StdSchedulerFactory读取之后赋值给JobStoreSupport的成员变量isClustered,从而使当前Scheduler变成Quartz集群的一个节点。
StdSchedulerFactory读取org.quartz.jobStore.isCluster参数的方式有必要说一下,因为在读源码找相应配置的时候还是费了点周折的,最后还是在JobStoreSupport中看到了:
/**
* <p>
* Set whether this instance is part of a cluster.
* </p>
*/
@SuppressWarnings("UnusedDeclaration") /* called reflectively */
public void setIsClustered(boolean isClustered) {
this.isClustered = isClustered;
}
受那句注释called reflectively启发才找到,原来StdSchedulerFactory是通过反射机制设置的:
tProps = cfg.getPropertyGroup(PROP_JOB_STORE_PREFIX, true, new String[] {PROP_JOB_STORE_LOCK_HANDLER_PREFIX});
try {
setBeanProps(js, tProps);
} catch (Exception e) {
initException = new SchedulerException("JobStore class '" + jsClass
+ "' props could not be configured.", e);
throw initException;
}
读取到org.quartz.jobStore下的所有配置后,调用setBeanProps(js, tProps)通过反射机制为JobStore设置了配置文件中所有的相关属性。这种方式起码比一个个属性硬读进来设置要来的优雅多了,也灵活多了。
只有配置启用集群,Scheduler启动之后才会通过调用JobStoreSupport的schedulerStarted方法启动ClusterManager线程。
ClusterManager线程
ClusterManager负责进行集群节点的心跳检测、failover处理。
通过设置参数org.quartz.jobStore.isCluster=true启用集群后,Scheduler启动完成后会调用JobStoreSupport的schedulerStarted方法启动ClusterManager线程。
每一个节点都可以通过参数指定clusterCheckinInterval - 心跳检测周期,默认7500L毫秒。
节点以clusterCheckinInterval频率向数据库报到:更新qrtz_scheduler_state表当前节点的最后checkin时间。
在checkin的同时会检查qrtz_scheduler_state表中超过约定时间周期仍然未向数据库“报到”的节点,标记为failover节点,交给failover处理环节。
集群节点的基础属性
我们首先需要对“节点”做一个基础的了解。
Quartz集群环境下的节点其实就是调度器Scheduler,由于节点并不知道其他节点的存在,所以每一个节点不管是单机部署、还是集群部署,工作方式其实没有区别。单机和集群的区别在于:集群方式下会启动集群管理线程ClusterManager,单机不启动。
每一个节点在向JDBC-Based JobStore注册自己的时候,都会有两个属性:
- instanceId:可以配置指定,也可以配置为Auto,由Quartz自动生成一个Id,每一个节点必须有唯一的instanceId
- sched_name:调度器name,不要求唯一,不同节点可以注册为相同的sche_name(从而实现集群)
这里需要明确一下这两个字段在Quartz相关表中对应的字段名,instanceId体现在qrtz_scheduler_state表中,字段名是instance_name,sched_name体现在qrtz_triggers、qrtz_job_details...等几乎所有的Quartz业务表中,字段名sched_name。
集群环境下每一节点在注册job和trigger的时候,以当前节点的sched_name写入数据库中。
每一个节点的调度线程在调度作业的时候,只调度当前节点的作业,也就是triggers表中sched_name等于当前节点的sched_name的触发器。
每一个节点都按照自己的逻辑调度执行任务,不存在一个中心节点或者管理节点,因此也就不存在主动的负载均衡机制,作业可以被具有相同sched_name的任一节点触发执行,数据库是所有节点之间唯一的信息共享渠道。
所以Quartz以集群方式工作的前提条件有两个:一个是开启集群参数,另一个是集群节点的sched_name相同。如果每个节点都以不同的sched_name配置的话,他们之间是达不到集群的效果的!
节点的负载机制
Quartz集群环境下Scheduler节点之间并不通讯,不存在中心节点,所以Quartz集群并没有load balance的机制。
那么Quratz集群环境下节点的负载是怎么分配的呢?
要了解Quartz集群环境下的多个节点之间的负载机制,我们首先需要了解Quartz集群下的“节点”具体是怎么工作的。
由于Quartz调度器在单机和集群部署环境下的工作方式没有区别,所以我们其实在前面的文章 JDBC-Based JobStore 中的“作业的调度”部分已经详细分析过作业的调度过程了。
集群环境下节点在调度任务的时候,靠的就是“共享的数据库”以及“锁机制”来确保作业的正常调度的。
作业调度进程在获取Triggers前首先加锁,比如acquireNextTriggers方法需要对qrtz_lock表的“TRIGGER_ACCESS”行上锁,上锁之后其他节点如果要获取Triggers的话就必须等待当前节点释放锁。在当前节点获取Trigger、执行作业、执行过程中以及执行完成后修改Triggers状态、执行前插入以及执行后删除fired_triggers表,都是在锁定qrtz_lock表的状态下执行的。直到作业最后执行完成、所有数据库操作都结束之后,才会最终释放锁。
所以,我们可以看到,在整个作业执行过程中,其他集群节点是没有机会参与的,包括Cluater_manager的failOver操作也被锁定在外、必须等待的。
Quartz的集群环境就是在这个“共享数据库”+锁机制这样一套机制下维持正常运转的。
只不过不同的操作需要不同的锁,作业调度进程可能会锁定qrtz_lock表的“TRIGGER_ACCESS”行,Cluster_manager线程的Checkin操作可能会锁定qrtz_lock表的“STATE_ACCESS”行,其他操作可能会锁表。具体获取什么样的锁是需要综合考虑性能和安全问题的。
每一个节点就这样不辞劳苦去和数据库“抢活”,如果资源被锁定了就等待,否则如果能拿到锁就开始干活!
所以我们可以说,不存在一个中心节点进行协调、分配负载的情况下,Quartz集群下的各节点靠着自己的“自觉”(其实是每个节点的负载)抢活干,谁抢到是谁的!
集群的failover处理
了解failOver机制之前,我们需要再复习一遍Quartz的作业调度过程:
- 以当前调度器sched_name获取triggers中需要被触发的触发器,有两个主要条件,一个是触发器的下次触发时间(在30秒内),另一个是状态=WAITING
- 将满足条件的触发器状态修改为ACQUIRED
- 对获取到的待触发的触发器做二次判断,如果确认触发(时间满足、状态保持ACQUIRED没变化),则修改触发器状态为EXECUTING
- 触发器写入qrtz_fired_triggers表
- 作业执行完成后,根据触发器的下次触发时间、以及执行结果更新triggers中的触发器状态(如果仍然需要被触发的话状态为WAITING),当前触发器从qrtz_fired_triggers表删除
failOver机制和上述作业调度过程密切相关:
- 通过方法findFailedInstances获取已经失联的节点,主要包括两部分内容:超过时间间隔要求没有在qrtz_scheduler_state表进行checkIn的节点,以及在qrtz_fired_triggers中存在、但是在qrtz_scheduler_state不存在的节点(Quartz称之为孤儿节点)
- 获取到这些节点的所有的qrtz_fired_triggers中的数据,因为我们知道如果作业执行完成的话,触发器是要从qrtz_fired_triggers中删除的,既然节点已经失联那么qrtz_fired_triggers中的数据应该就是该节点的“未尽事业”
- 逐条处理qrtz_fired_triggers中的数据,如果状态是BLOCKED/PAUSED_BLOCKED(获取出来状态从WAITING变为ACQUIRED之后,还没来得及执行,被其他作业阻塞了),则释放当前trigger绑定的作业相关的所有触发器在triggers表中的状态:PAUSED_BLOCKED->PAUSED,BLOCKED->WAITING
- 如果状态是ACQUIRED,说明当前触发器被获取到之后、还没有执行,节点就挂了,因此只要回复当前触发器再triggers表中的状态为WAITING就OK了
- 否则,当前Trigger的状态就是EXECUTING,这种情况比较复杂,因为作业已经开始执行了、节点挂了,我们很难知道他是在作业执行完成之后、没来得及更新状态、没来得及删除fired_triggers挂掉的,还是作业根本就没有开始执行、或者执行到一半挂掉了。
- 这种情况下Quartz给应用一个选项:设置作业的shouldRecover属性,设置为true的话则为当前触发器再生成一个一次性触发任务,状态设置为WAITING等待触发器调度。
- 如果当前Job设置了DisallowsConcurrentExecution,则释放被自己BLOCK的其他触发器(PAUSED_BLOCKED->BLOCKED,BLOCKED->WAITING)
- 从qrtz_fired_triggers表中删除当前trigger
- 最后检查当前trigger在qrtz_triggers表中的状态是否是COMPLETE,如果是的话,从triggers表中删除当前trigger,如果当前trigger绑定的job只有当前trigger这一个trigger的话,同时从job_details表中删除该job
failOver就处理完了。
总结一下节点的checkIn和failOver过程:
- 每一个节点向数据库定时报到
- 报到的同时检查是否有失联的节点
- 对失联节点的遗留工作做移交处理
- 需要特别注意的是,工作移交并不会指定哪一个节点接手,自己也不去接手,只是恢复触发器状态
- 只要恢复状态其实就够了,任一节点都有可能接手
JobStoreSupport#recoverJobs
补充一个知识点:recoverJobs,这是JDBC-Based JobStore的一个特性,指的是由于服务停机或重启导致错误的触发器(比如触发器的状态不正常)的恢复。
recoverJobs在单机Scheduler启动后调用,集群环境的checkIn和failOver过程包含了这一逻辑,所以集群环境不需要处理。
逻辑不复杂:
- Triggers表中的状态BLOCKED、ACQUIRED恢复为WAITING
- Triggers表中的状态PAUSED_BLOCKED恢复为PAUSED
- recoverMisfiredJobs:调用Misfire逻辑,处理错过触发时间的触发器
- qrtz_fired_triggers表中的遗留数据处理:如果绑定的job设置为需要RECOVERY,则重新生成一个Trigger写入Triggers表
- 删除Triggers表中状态为COMPLETE的记录
- 清空qrtz_fired_triggers
小结
Quartz涉及到的大部分知识点都从源码角度分析过了,后面如果发现有什么遗漏的话,再查漏补缺。
Thanks a lot!
上一篇 Quartz - JDBC-Based JobStore事务管理及锁机制
下一篇 Runable和Callable的区别?你必须要搞清楚Thread以及FutureTask!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。