Quartz集群实战与原理分析
一、简介
Quartz是一个优秀的调度框架,完全基于Java实现。具有以下几大特点:
(1)强大的调度功能,例如支持丰富多样的调度方法,可以满足各种常规及特殊需求;
(2)灵活的应用方式,例如支持任务和调度的多种组合方式,支持调度数据的多种存储方式;
(3)分布式和集群能力,Terracotta收购后在原来功能基础上作了进一步提升。
核心概念
Job 表示一个工作,要执行的具体内容。此接口中只有一个方法,如下:
void execute(JobExecutionContext context)
JobDetail 表示一个具体的可执行的调度程序,Job 是这个可执行程调度程序所要执行的内容,另外 JobDetail 还包含了这个任务调度的方案和策略。
Trigger 触发器,指定何时触发任务。
Scheduler 代表一个调度容器,一个调度容器中可以注册多个 JobDetail 和 Trigger。当 Trigger 与 JobDetail 组合,就可以被 Scheduler 容器调度了。
Quartz线程视图
在Quartz中,有两类线程,Scheduler调度线程和任务执行线程,其中任务执行线程通常使用一个线程池维护一组线程。
Scheduler调度线程主要有两个:执行常规调度的线程,和执行misfiredtrigger的线程。常规调度线程轮询存储的所有trigger,如果有需要触发的trigger,即到达了下一次触发的时间,则从任务执行线程池获取一个空闲线程,执行与该trigger关联的任务。Misfire线程是扫描所有的trigger,查看是否有misfiredtrigger,如果有的话根据misfire的策略分别处理(fire now OR wait for the next fire)。
Quartz Job数据存储
Quartz中的trigger和job需要存储下来才能被使用。Quartz中有两种存储方式:RAMJobStore,JobStoreSupport,其中RAMJobStore是将trigger和job存储在内存中,而JobStoreSupport是基于jdbc将trigger和job存储到数据库中。RAMJobStore的存取速度非常快,但是由于其在系统被停止后所有的数据都会丢失,所以在集群应用中,必须使用JobStoreSupport
二、Quartz集群架构
一个Quartz集群中的每个节点是一个独立的Quartz应用,它又管理着其他的节点。这就意味着你必须对每个节点分别启动或停止。Quartz集群中,独立的Quartz节点并不与另一其的节点或是管理节点通信,而是通过相同的数据库表来感知到另一Quartz应用的。
数据库准备
因为Quzrtz集群依赖于数据库,所以必须先创建数据库表,数据表示官方提供的,我用的是quartz2.3.0版本,有11张表,如下:
表信息介绍
qrtz_blob_triggers : 以Blob 类型存储的触发器。
qrtz_calendars存储Quartz的Calendar信息
qrtz_cron_triggers存储CronTrigger,包括Cron表达式和时区信息
qrtz_fired_triggers存储与已触发的Trigger相关的状态信息,以及相联Job的执行信息
qrtz_job_details存储每一个已配置的Job的详细信息
qrtz_locks存储程序的悲观锁的信息
qrtz_paused_trigger_grps存储已暂停的Trigger组的信息
qrtz_scheduler_state存储少量的有关Scheduler的状态信息,和别的Scheduler实例
qrtz_simple_triggers存储简单的Trigger,包括重复次数、间隔、以及已触的次数
qrtz_simprop_triggers 存储CalendarIntervalTrigger和DailyTimeIntervalTrigger两种类型的触发器
qrtz_triggers存储已配置的Trigger的信息
qrtz_locks就是Quartz集群实现同步机制的行锁表,包括以下几个锁:CALENDAR_ACCESS 、JOB_ACCESS、MISFIRE_ACCESS 、STATE_ACCESS 、TRIGGER_ACCESS
从故障实例中恢复Job
当一个Sheduler实例在执行某个Job时失败了,有可能由另一正常工作的Scheduler实例接过这个Job重新运行。要实现这种行为,配置给JobDetail对象的Job可恢复属性必须设置为true(job.setRequestsRecovery(true))。如果可恢复属性被设置为false(默认为false),当某个Scheduler在运行该job失败时,它将不会重新运行;而是由另一个Scheduler实例在下一次触发时间触发。Scheduler实例出现故障后多快能被侦测到取决于每个Scheduler的检入间隔(即2.3中提到的org.quartz.jobStore.clusterCheckinInterval)。
测试项目及配置
项目代码下载:https://github.com/feixiameiruhua/my-quartz-cluster.git
quartz.properties文件
# 固定前缀org.quartz# 主要分为scheduler、threadPool、jobStore、plugin等部分###调度器实例编号自动生成org.quartz.scheduler.instanceId = AUTO#调度器实例名称org.quartz.scheduler.instanceName = DefaultQuartzSchedulerorg.quartz.scheduler.rmi.export = falseorg.quartz.scheduler.rmi.proxy = falseorg.quartz.scheduler.wrapJobExecutionInUserTransaction = false# 实例化ThreadPool时,使用的线程类为SimpleThreadPoolorg.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool# threadCount和threadPriority将以setter的形式注入ThreadPool实例# 并发个数org.quartz.threadPool.threadCount = 5# 优先级org.quartz.threadPool.threadPriority = 5org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread = trueorg.quartz.jobStore.misfireThreshold = 5000# 默认存储在内存中#org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore#持久化org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX#开启分布式部署org.quartz.jobStore.isClustered = trueorg.quartz.jobStore.tablePrefix = QRTZ_org.quartz.jobStore.dataSource = qzDSorg.quartz.dataSource.qzDS.driver = com.mysql.jdbc.Driverorg.quartz.dataSource.qzDS.URL = jdbc:mysql://localhost:3306/quartz_test?useUnicode=true&characterEncoding=UTF-8&autoReconnect=trueorg.quartz.dataSource.qzDS.user = rootorg.quartz.dataSource.qzDS.password = 123456org.quartz.dataSource.qzDS.maxConnections = 10
配置文件说明
org.quartz.jobStore.isClustered = true
在集群中的每一个实例都必须有一个唯一的"instance id" ("org.quartz.scheduler.instanceId" 属性), 默认为AUTO就可以。还要有相同的"scheduler instance name" ("org.quartz.scheduler.instanceName"),也就是说集群中的每一个实例都必须使用相同的quartz.properties 配置文件。
调度任务代码
package com.fwmagic.quartz.schedule;import org.quartz.Job;import org.quartz.JobExecutionContext;import org.quartz.JobExecutionException;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import java.text.SimpleDateFormat;import java.util.Date;import java.util.ResourceBundle;public class SchedulerExecJob implements Job { private static Logger logger = LoggerFactory.getLogger(SchedulerExecJob.class); @Override public void execute(JobExecutionContext context) throws JobExecutionException { String jobName = context.getJobDetail().getKey().getName(); switch (jobName) { /*每5s执行一次*/ case "quartz_test1": System.err.println(getAddress()+" "+getDate()+"====>quartz_test1<===="); break; /*每5s执行一次*/ case "quartz_test2": System.err.println(getAddress()+" "+getDate()+"====>quartz_test2<===="); break; /*每5s执行一次*/ case "quartz_test3": System.err.println(getAddress()+" "+getDate()+"====>quartz_test3<===="); break; default: System.err.println(getAddress()+" "+getDate()+"====>other task<===="); break; } } public static String getDate(){ return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); } public static String getAddress(){ return "http://localhost:"+ResourceBundle.getBundle("application").getString("server.port"); }}
二、集群任务测试
项目中有三个任务,我用不同端口(三个服务同时启动,端口不一样,分别为:9099(ServerA),,9098(ServerB),9097(ServerC))在本机启动同一个项目,效果图如下:
当开启ServerA时,三个任务都在9099端口执行,控制台信息为:
开启ServerB后,有部分任务分配过来:
开启ServerC,集群全部启动的情况下,会有任务交错执行或者任务在同一台机器上执行的效果:
注意事项
Quartz实际并不关心你是在相同还是不同的机器上运行节点。当集群放置在不同的机器上时,称之为水平集群。节点跑在同一台机器上时,称之为垂直集群。对于垂直集群,存在着单点故障的问题。这对高可用性的应用来说是无法接受的,因为一旦机器崩溃了,所有的节点也就被终止了。对于水平集群,存在着时间同步问题。
节点用时间戳来通知其他实例它自己的最后检入时间。假如节点的时钟被设置为将来的时间,那么运行中的Scheduler将再也意识不到那个结点已经宕掉了。另一方面,如果某个节点的时钟被设置为过去的时间,也许另一节点就会认定那个节点已宕掉并试图接过它的Job重运行。最简单的同步计算机时钟的方式是使用某一个Internet时间服务器(Internet Time Server ITS)。
节点争抢Job问题
因为Quartz使用了一个随机的负载均衡算法, Job以随机的方式由不同的实例执行。Quartz官网上提到当前,还不存在一个方法来指派(钉住) 一个 Job 到集群中特定的节点。
三、集群源码分析
Quartz如何保证多个节点的应用只进行一次调度(即某一时刻的调度任务只由其中一台服务器执行)?,正如Quartz集群架构上的那副图,
Quartz的集群是在同一个数据库下, 由数据库的数据来确定调度任务是否正在执行, 正在执行则其他服务器就不能去执行该行调度数据。 这个跟很多项目是用Zookeeper做集群不一样, 这些项目是靠Zookeeper选举出来的的服务器去执行, 可以理解为Quartz靠数据库选举一个服务器来执行。
Quartz最主要的一个类QuartzSchedulerThread职责是触发任务, 是一个不断运行的Quartz主线程, 还是从这里入手了解集群原理。
QuartzSchedulerThread继承自Thread,实现了run方法,在run方法中调用了如下几个重要的方法,都进行了加锁的操作:
1、qsRsrcs.getJobStore().acquireNextTriggers【查找即将触发的Trigger】
2、sigLock.wait(timeUntilTrigger)【等待执行】
3、qsRsrcs.getJobStore().triggersFired(triggers)【执行】
4、qsRsrcs.getJobStore().releaseAcquiredTrigger(triggers.get(i)) 【释放Trigger】
以acquireNextTriggers为例,可以看到:
protected static final String LOCK_TRIGGER_ACCESS = "TRIGGER_ACCESS";//数据库锁名字
将锁名传入核心的加锁方法(executeInNonManagedTXLock)中:
protectedT executeInNonManagedTXLock( String lockName, TransactionCallback txCallback, final TransactionValidator txValidator) throws JobPersistenceException { boolean transOwner = false; Connection conn = null; try { if (lockName != null) { // If we aren't using db locks, then delay getting DB connection // until after acquiring the lock since it isn't needed. if (getLockHandler().requiresConnection()) { conn = getNonManagedTXConnection(); } //获取锁 transOwner = getLockHandler().obtainLock(conn, lockName); } if (conn == null) { conn = getNonManagedTXConnection(); } //回调执行 final T result = txCallback.execute(conn); try { commitConnection(conn); } catch (JobPersistenceException e) { rollbackConnection(conn); if (txValidator == null || !retryExecuteInNonManagedTXLock(lockName, new TransactionCallback () { @Override public Boolean execute(Connection conn) throws JobPersistenceException { return txValidator.validate(conn, result); } })) { throw e; } } Long sigTime = clearAndGetSignalSchedulingChangeOnTxCompletion(); if(sigTime != null && sigTime >= 0) { signalSchedulingChangeImmediately(sigTime); } return result; } catch (JobPersistenceException e) { rollbackConnection(conn); throw e; } catch (RuntimeException e) { rollbackConnection(conn); throw new JobPersistenceException("Unexpected runtime exception: " + e.getMessage(), e); } finally { try { //释放锁 releaseLock(lockName, transOwner); } finally { cleanupConnection(conn); } }}
通过这行代码查找锁是怎么来的
transOwner = getLockHandler().obtainLock(conn, lockName);
在JobStoreSupport的initialize方法中:
public void initialize(ClassLoadHelper loadHelper, SchedulerSignaler signaler) throws SchedulerConfigException { if (dsName == null) { throw new SchedulerConfigException("DataSource name not set."); } // If the user hasn't specified an explicit lock handler, then // choose one based on CMT/Clustered/UseDBLocks. if (getLockHandler() == null) { // If the user hasn't specified an explicit lock handler, // then we *must* use DB locks with clustering if (isClustered()) { setUseDBLocks(true); } //…… // 在初始化方法里面赋值了 setLockHandler(new StdRowLockSemaphore(getTablePrefix(), getInstanceName(), getSelectWithLockSQL())); } else { getLog().info( "Using thread monitor-based data access locking (synchronization)."); setLockHandler(new SimpleSemaphore()); } }}
在new StdRowLockSemaphore构造方法中
public StdRowLockSemaphore(String tablePrefix, String schedName, String selectWithLockSQL) { super(tablePrefix, schedName, selectWithLockSQL != null ? selectWithLockSQL : SELECT_FOR_LOCK, INSERT_LOCK);}
可以发现有两个锁名称:
public static final String SELECT_FOR_LOCK = "SELECT * FROM " + TABLE_PREFIX_SUBST + TABLE_LOCKS + " WHERE " + COL_SCHEDULER_NAME + " = " + SCHED_NAME_SUBST + " AND " + COL_LOCK_NAME + " = ? FOR UPDATE";public static final String INSERT_LOCK = "INSERT INTO " + TABLE_PREFIX_SUBST + TABLE_LOCKS + "(" + COL_SCHEDULER_NAME + ", " + COL_LOCK_NAME + ") VALUES (" + SCHED_NAME_SUBST + ", ?)";
数据库的qrtz_locks中存放两个锁的记录
可以看出采用了Quartz集群采用了悲观锁的方式对triggers表进行行加锁, 以保证任务同步的正确性。
当线程使用上述的SQL对表中的数据执行操作时,数据库对该行进行行加锁; 于此同时, 另一个线程对该行数据执行操作前需要获取锁, 而此时已被占用, 那么这个线程就只能等待, 直到该行锁被释放。