在数据平台中,每天都会有上万个任务进行流转,如何准确,实时的完成任务,是非常关键的一步。公司在发展的过程经历了azikaban->airflow->dataflow(自研airflow支持k8s)->kepler的一个过程。目前使用的调度系统任务3w+,日执行10w+。上线0事故,非常稳定的运行。
底层调度器用的是quartz,写调度系统之前肯定要了解调度器的源码。于是记录下。
Quartz是Java领域著名的开源任务调度工具。Quartz提供了极为广泛的特性如持久化任务,集群和分布式任务等,其特点如下:
- 完全由Java写成,方便集成(Spring)
- 伸缩性
- 负载均衡
- 高可用性
使用方式
我们的使用姿势是 springboot+quartz
配置文件
quartz.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
quartz.org.quartz.threadPool.threadCount=10
quartz.org.quartz.threadPool.threadPriority=5
quartz.org.quartz.scheduler.instanceName=kepler
quartz.org.quartz.scheduler.instanceId=AUTO
quartz.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
quartz.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
quartz.org.quartz.jobStore.useProperties=false
quartz.org.quartz.jobStore.tablePrefix=QRTZ_
quartz.org.quartz.jobStore.isClustered=true
quartz.org.quartz.jobStore.misfireThreshold=1800000
quartz.org.quartz.jobStore.clusterCheckinInterval=5000
quartz.org.quartz.jobStore.dataSource=quartzDataSource
quartz.org.quartz.dataSource.quartzDataSource.provider=hikaricp
quartz.org.quartz.dataSource.quartzDataSource.poolName=hikari-quartz
quartz.org.quartz.dataSource.quartzDataSource.driver=${mysql.driver-class-name}
quartz.org.quartz.dataSource.quartzDataSource.URL=${mysql.jdbc-url}
quartz.org.quartz.dataSource.quartzDataSource.user=${mysql.username}
quartz.org.quartz.dataSource.quartzDataSource.password=${mysql.password}
quartz.org.quartz.dataSource.quartzDataSource.maximumPoolSize=${mysql.maximum-pool-size}
quartz.org.quartz.dataSource.quartzDataSource.connectionTestQuery=${mysql.connection-test-query}
quartz.org.quartz.dataSource.quartzDataSource.connectionTimeout=${mysql.connection-timeout}
quartz.org.quartz.dataSource.quartzDataSource.idleTimeout=${mysql.idle-timeout}
quartz.org.quartz.dataSource.quartzDataSource.maxLifetime=${mysql.max-lifetime}
Scheduler sched = SchedulerFactory.getScheduler(config);
JobDetail jobDetail = JobBuilder.newJob(jobClass)
.withIdentity(jobName, HM_JOB)//任务名称和组构成任务key
.build();
jobDetail.getJobDataMap().putAll(data);
// 触发器
SimpleTrigger trigger = TriggerBuilder.newTrigger()
.withIdentity(jobName, HM_TRIGGER)//触发器key
.startAt(DateBuilder.futureDate(1, IntervalUnit.SECOND))
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(interval)
.repeatForever())
.build();
sched.scheduleJob(jobDetail, trigger);
// 启动
if (!sched.isShutdown()) {
sched.start();
}
工作原理
Quartz是通过对用户暴露出Scheduler来进行任务的操作,它可以把任务JobDetail和触发器Trigger加入任务池中,可以把任务删除,也可以把任务停止,scheduler把这些任务和触发器放到一个JobStore中,这里jobStore有内存形式的也有持久化形式的,当然也可以自定义扩展成独立的服务。
- Job就是自定义业务的接口,里面就一个execute方法,线程运行Job时会把JobDataMap封装到JobExecutionContext里作为execute方法的参数,jobdetail是对job的封装,里面有Job的class,对应的数据, 名称,分组等
- Trigger是触发器,job下次什么时候执行存放在trigger中
- QuartzSchedulerResources相当于调度的资源存放器,包含了JobStore, ThreadPool等资源,调度都是通过 QuartzSchedulerResources获取相关属性的。
- jobStore是任务和触发器存储地方,它里面提供大量类似于增删改的操作任务方法。
- QuartzSchedulerThread是一个调度线程,ThreadPool是一个执行线程池。里面有workerThread来执行用户任务
源码分析
根据上面的使用流程,逐个来分析源码过程
首先获取Scheluer
Scheduler scheduler = StdSchedulerFactory.getScheduler(config);
StdSchedulerFactory根据配置文件来生成Scheduler。
1.
public Scheduler getScheduler() throws SchedulerException {
//参数没传的话,则读取默认的配置
if (cfg == null) {
//初始化配置文件
initialize();
}
SchedulerRepository schedRep = SchedulerRepository.getInstance();
Scheduler sched = schedRep.lookup(getSchedulerName());
if (sched != null) {
if (sched.isShutdown()) {
schedRep.remove(getSchedulerName());
} else {
return sched;
}
}
// 第二步:初始化,生成scheduler
sched = instantiate();
return sched;
}
instantiate()
方法内部做了非常多的初始化,代码将近800行。需要初始化的参数为
JobStore js = null;
ThreadPool tp = null;
QuartzScheduler qs = null;
DBConnectionManager dbMgr = null;
String instanceIdGeneratorClass = null;
Properties tProps = null;
String userTXLocation = null;
boolean wrapJobInTx = false;
boolean autoId = false;
long idleWaitTime = -1;
long dbFailureRetry = 15000L; // 15 secs
String classLoadHelperClass;
String jobFactoryClass;
ThreadExecutor threadExecutor;
具体干的内容为
-
Get Scheduler Properties 获取配置
-
判断是不是remote scheduler方式即rmi方式
-
判断是不是jmxschedule
-
生成InstanceIdGenerator
-
Get ThreadPool Properties
-
Get JobStore Properties
-
设置DataSources/SchedulerPlugins/JobListeners/TriggerListeners
-
Get ThreadExecutor Properties
-
启动进程 Fire everything up 包括设置QuartzSchedulerResources(包含创建QuartzScheduler实例所需的所有资源(JobStore,ThreadPool等))
整个组装需要初始化很多东西,所以用到了FactoryBean的方式。
再祖装的过程中
qs = new QuartzScheduler(rsrcs, idleWaitTime, dbFailureRetry);
是非常重要的一步
QuartzScheduler 这是Quartz的核心,它是org.quartz.Scheduler接口的间接实现,包含调度org.quartz.Jobs,注册org.quartz.JobListener实例等的方法
点进去发现
public QuartzScheduler(QuartzSchedulerResources resources,
long idleWaitTime, @Deprecated long dbRetryInterval)
throws SchedulerException {
this.resources = resources;
if (resources.getJobStore() instanceof JobListener) {
addInternalJobListener((JobListener) resources.getJobStore());
}
//初始化了一个线程,并且放到了ThreadExecutor中执行。这个线程是用来调度任务的线程
this.schedThread = new QuartzSchedulerThread(this, resources);
ThreadExecutor schedThreadExecutor = resources.getThreadExecutor();
schedThreadExecutor.execute(this.schedThread);
if (idleWaitTime > 0) {
this.schedThread.setIdleWaitTime(idleWaitTime);
}
jobMgr = new ExecutingJobsManager();
addInternalJobListener(jobMgr);
errLogger = new ErrorLogger();
addInternalSchedulerListener(errLogger);
signaler = new SchedulerSignalerImpl(this, this.schedThread);
getLog().info("Quartz Scheduler v." + getVersion() + " created.");
}
获取到之后scheduleJob
进入scheduleJob内部
其实就是进入到了上面提到的QuartzScheduler内部中的方法
其中核心代码是
就是将最新一次的执行时间写入到trigger表,然后让调度线程扫描到之后进行执行任务。
scheduler.start()
核心代码为:
//启动jobStore
this.resources.getJobStore().schedulerStarted(); //将线程置位启动状态
schedThread.togglePause(false);
上面的启动jobstroe的代码为
主要干了一些恢复任务,设置misfire的处理规则。这个时候任务就真正的调度起来了。刚才调度的任务就会被扫描到。
调度器起来之后是如何扫描的呢
上面也提到了,在初始化Scheluer
的时候初始化了一个线程 QuartzSchedulerThread
这个线程就是用来调度器。
代码很长,主要就干了三件事
public void run() {
boolean lastAcquireFailed = false;
while (!halted.get()) {
......
int availThreadCount = qsRsrcs.getThreadPool().blockForAvailableThreads();
if(availThreadCount > 0) {
......
//调度器在trigger队列中寻找30秒内一定数目的trigger(需要保证集群节点的系统时间一致)
triggers = qsRsrcs.getJobStore().acquireNextTriggers(
now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());
......
//触发trigger
List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);
......
//释放trigger
for (int i = 0; i < triggers.size(); i++) {
qsRsrcs.getJobStore().releaseAcquiredTrigger(triggers.get(i));
}
}
}
由此可知,QuartzScheduler调度线程不断获取trigger,触发trigger,释放trigger.下面分析trigger的获取过程,qsRsrcs.getJobStore()返回对象是JobStore,集群环境配置如下:org.quartz.impl.jdbcjobstore.JobStoreTX
JobStoreTX继承自JobStoreSupport,而JobStoreSupport的acquireNextTriggers、triggersFired、releaseAcquiredTrigger方法负责具体trigger相关操作,都必须获得TRIGGER_ACCESS锁。核心逻辑在executeInNonManagedTXLock方法中:
protected <T> T executeInNonManagedTXLock(
String lockName,
TransactionCallback<T> txCallback, final TransactionValidator<T> txValidator) throws JobPersistenceException {
boolean transOwner = false;
Connection conn = null;
try {
if (lockName != null) {
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<Boolean>() {
@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);
}
}
}
由上代码可知Quartz集群基于数据库锁的同步操作流程如下图所示:
一个调度器实例在执行涉及到分布式问题的数据库操作前,首先要获取QUARTZ_LOCKS表中对应的行级锁,获取锁后即可执行其他表中的数据库操作,随着操作事务的提交,行级锁被释放,供其他调度实例获取。集群中的每一个调度器实例都遵循这样一种严格的操作规程。
getLockHandler()方法返回的对象类型是Semaphore,获取锁和释放锁的具体逻辑由该对象维护
public interface Semaphore {
boolean obtainLock(Connection conn, String lockName) throws LockException;
void releaseLock(String lockName) throws LockException;
boolean requiresConnection();
}
该接口的实现类完成具体操作锁的逻辑,在JobStoreSupport的初始化方法中注入的Semaphore具体类型是StdRowLockSemaphore
StdRowLockSemaphore的源码
public class StdRowLockSemaphore extends DBSemaphore {
//锁定SQL语句
public static final String SELECT_FOR_LOCK = "SELECT * FROM "
+ TABLE_PREFIX_SUBST + TABLE_LOCKS + " WHERE " + 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 + ", ?)";
//指定锁定SQL
protected void executeSQL(Connection conn, String lockName, String expandedSQL) throws LockException {
PreparedStatement ps = null;
ResultSet rs = null;
try {
ps = conn.prepareStatement(expandedSQL);
ps.setString(1, lockName);
......
rs = ps.executeQuery();
if (!rs.next()) {
throw new SQLException(Util.rtp(
"No row exists in table " + TABLE_PREFIX_SUBST +
TABLE_LOCKS + " for lock named: " + lockName, getTablePrefix()));
}
} catch (SQLException sqle) {
} finally {
...... //release resources
}
}
}
//获取QRTZ_LOCKS行级锁
public boolean obtainLock(Connection conn, String lockName) throws LockException {
lockName = lockName.intern();
if (!isLockOwner(conn, lockName)) {
executeSQL(conn, lockName, expandedSQL);
getThreadLocks().add(lockName);
}
return true;
}
//释放QRTZ_LOCKS行级锁
public void releaseLock(Connection conn, String lockName) {
lockName = lockName.intern();
if (isLockOwner(conn, lockName)) {
getThreadLocks().remove(lockName);
}
......
}
每当要进行与某种业务相关的数据库操作时,先去QRTZ_LOCKS表中查询操作相关的业务对象所需要的锁,在select语句之后加for update来实现。例如,TRIGGER_ACCESS表示对任务触发器相关的信息进行修改、删除操作时所需要获得的锁。这时,执行查询这个表数据的SQL形如:
select * from QRTZ_LOCKS t where t.lock_name='TRIGGER_ACCESS' for update
扫描的源码在acquireNextTriggers中
真正执行的sql是
SELECT
TRIGGER_NAME,
TRIGGER_GROUP,
NEXT_FIRE_TIME,
PRIORITY
FROM
qrtz_TRIGGERS
WHERE
SCHED_NAME = 'schedulerFactoryBean'
AND TRIGGER_STATE = 'WAITING'
AND NEXT_FIRE_TIME <= (now + idleWaitTime)
AND (
MISFIRE_INSTR = -1
OR (
MISFIRE_INSTR != -1
AND NEXT_FIRE_TIME >= (now - misfireThreshold)
)
)
ORDER BY NEXT_FIRE_TIME ASC, PRIORITY DESC
这个SQL语句是要查询出:未来30s内将要执行的任务,且MISFIRE_INSTR为-1(MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY),或者MISFIRE_INSTR不为-1,但是,NEXT_FIRE_TIME错过的执行时间不能超过阀值60s。影响misFire执行策略的另一个参数就是misfireThreshold,配置文件quartz.properties中,对应org.quartz.jobStore.misfireThreshold: 60000,单位毫秒。也就是说:如果【错过时间】不超过60s都不算是misFire,不执行misFire策略,依次执行错过的任务时间点;【错过时间】超过60s按misFire策略执行。
#### 参考
1. [Quartz应用与集群原理分析](https://tech.meituan.com/2014/08/31/mt-crm-quartz.html)