如何实现一个任务调度系统(一)
阅读一篇「定时任务框架选型」的文章时,一位网友的留言电到了我:
我看过那么多所谓的教程,大部分都是教“如何使用工具”的,没有多少是教“如何制作工具”的,能教“如何仿制工具”的都已经是凤毛麟角,中国 软件行业,缺的是真正可以“制作工具”的程序员,而绝对不缺那些“使用工具”的程序员!...... ”这个业界最不需要的就是“会使用XX工具的工程师”,而是“有创造力的软件工程师”!业界所有的饭碗,本质就是“有创造力的软件工程师”提供出来的啊!
写这篇文章,想和大家从头到脚说说任务调度,希望大家读完之后,能够理解实现一个任务调度系统的核心逻辑。1 Quartz
Quartz是一款Java开源任务调度框架,也是很多Java工程师接触任务调度的起点。
下图显示了任务调度的整体流程:
Quartz的核心是三个组件。
◆ 任务:Job 用于表示被调度的任务;
◆ 触发器:Trigger 定义调度时间的元素,即按照什么时间规则去执行任务。一个Job可以被多个Trigger关联,但是一个Trigger 只能关联一个Job;
◆ 调度器 :工厂类创建Scheduler,根据触发器定义的时间规则调度任务。
上图代码中Quartz 的JobStore是 RAMJobStore,Trigger 和 Job 存储在内存中。
执行任务调度的核心类是 QuartzSchedulerThread 。1.调度线程从JobStore中获取需要执行的的触发器列表,并修改触发器的状态;
2.Fire触发器,修改触发器信息(下次执行触发器的时间,以及触发器状态),并存储起来。
最后创建具体的执行任务对象,通过worker线程池执行任务。
接下来再聊聊 Quartz 的集群部署方案。
Quartz的集群部署方案,需要针对不同的数据库类型(MySQL , ORACLE) 在数据库实例上创建Quartz表,JobStore是: JobStoreSupport 。
这种方案是分布式的,没有负责集中管理的节点,而是利用数据库行级锁的方式来实现集群环境下的并发控制。
scheduler实例在集群模式下首先获取{0}LOCKS表中的行锁,Mysql 获取行锁的语句:{0}会替换为配置文件默认配置的QRTZ_。sched_name为应用集群的实例名,lock_name就是行级锁名。Quartz主要有两个行级锁触发器访问锁 (TRIGGER_ACCESS) 和 状态访问锁(STATE_ACCESS)。
这个架构解决了任务的分布式调度问题,同一个任务只能有一个节点运行,其他节点将不执行任务,当碰到大量短任务时,各个节点频繁的竞争数据库锁,节点越多性能就会越差。
2 分布式锁模式
Quartz的集群模式可以水平扩展,也可以分布式调度,但需要业务方在数据库中添加对应的表,有一定的强侵入性。
有不少研发同学为了避免这种侵入性,也探索出分布式锁模式。
业务场景:电商项目,用户下单后一段时间没有付款,系统就会在超时后关闭该订单。
通常我们会做一个定时任务每两分钟来检查前半小时的订单,将没有付款的订单列表查询出来,然后对订单中的商品进行库存的恢复,然后将该订单设置为无效。
我们使用Spring Schedule的方式做一个定时任务。
@Scheduled(cron = "0 */2 * * * ? ")
public void doTask() {
log.info("定时任务启动");
//执行关闭订单的操作
orderService.closeExpireUnpayOrders();
log.info("定时任务结束");
}
在单服务器运行正常,考虑到高可用,业务量激增,架构会演进成集群模式,在同一时刻有多个服务执行一个定时任务,有可能会导致业务紊乱。
解决方案是在任务执行的时候,使用Redis 分布式锁来解决这类问题。
@Scheduled(cron = "0 */2 * * * ? ")
public void doTask() {
log.info("定时任务启动");
String lockName = "closeExpireUnpayOrdersLock";
RedisLock redisLock = redisClient.getLock(lockName);
//尝试加锁,最多等待3秒,上锁以后5分钟自动解锁
boolean locked = redisLock.tryLock(3, 300, TimeUnit.SECONDS);
if(!locked){
log.info("没有获得分布式锁:{}" , lockName);
return;
}
try{
//执行关闭订单的操作
orderService.closeExpireUnpayOrders();
} finally {
redisLock.unlock();
}
log.info("定时任务结束");
}
Redis的读写性能极好,分布式锁也比Quartz数据库行级锁更轻量级。当然Redis锁也可以替换成Zookeeper锁,也是同样的机制。
在小型项目中,使用:定时任务框架(Quartz/Spring Schedule)和 分布式锁(redis/zookeeper)有不错的效果。
但是呢?我们可以发现这种组合有两个问题:
1.定时任务在分布式场景下有空跑的情况,而且任务也无法做到分片;
2.要想手工触发任务,必须添加额外的代码才能完成。
3 ElasticJob-Lite 框架
ElasticJob-Lite 定位为轻量级无中心化解决方案,使用 jar 的形式提供分布式任务的协调服务。
应用内部定义任务类,实现SimpleJob接口,编写自己任务的实际业务流程即可。
public class MyElasticJob implements SimpleJob {
@Override
public void execute(ShardingContext context) {
switch (context.getShardingItem()) {
case 0:
// do something by sharding item 0
break;
case 1:
// do something by sharding item 1
break;
case 2:
// do something by sharding item 2
break;
// case n: ...
}
}
}
举例:应用A有五个任务需要执行,分别是A,B,C,D,E。任务E需要分成四个子任务,应用部署在两台机器上。应用A在启动后, 5个任务通过 Zookeeper 协调后被分配到两台机器上,通过Quartz Scheduler 分开执行不同的任务。
ElasticJob 从本质上来讲 ,底层任务调度还是通过 Quartz ,相比Redis分布式锁 或者 Quartz 分布式部署 ,它的优势在于可以依赖 Zookeeper 这个大杀器 ,将任务通过负载均衡算法分配给应用内的 Quartz Scheduler容器。
从使用者的角度来讲,是非常简单易用的。但从架构来看,调度器和执行器依然在同一个应用方JVM内,而且容器在启动后,依然需要做负载均衡。应用假如频繁的重启,不断的去选主,对分片做负载均衡,这些都是相对比较重的操作。
ElasticJob 的控制台通过读取注册中心数据展现作业状态,更新注册中心数据修改全局任务配置。从一个任务调度平台的角度来看,控制台功能还是偏孱弱的。