阅读一篇「定时任务框架选型」的文章时,一位网友的留言电到了我:
我看过那么多所谓的教程,大部分都是教“如何使用工具”的,没有多少是教“如何制作工具”的,能教“如何仿制工具”的都已经是凤毛麟角,中国软件行业,缺的是真正可以“制作工具”的程序员,而绝对不缺那些“使用工具”的程序员!......”这个业界最不需要的就是“会使用XX工具的工程师”,而是“有创造力的软件工程师”!业界所有的饭碗,本质就是“有创造力的软件工程师”提供出来的啊!
写这篇文章,想和大家从头到脚说说任务调度,希望大家读完之后,能够理解实现一个任务调度系统的核心逻辑。
1Quartz
Quartz是一款Java开源任务调度框架,也是很多Java工程师接触任务调度的起点。
下图显示了任务调度的整体流程:
Quartz的核心是三个组件。
任务:Job用于表示被调度的任务;触发器:Trigger定义调度时间的元素,即按照什么时间规则去执行任务。一个Job可以被多个Trigger关联,但是一个Trigger只能关联一个Job;调度器:工厂类创建Scheduler,根据触发器定义的时间规则调度任务。
上图代码中Quartz的JobStore是RAMJobStore,Trigger和Job存储在内存中。
执行任务调度的核心类是QuartzSchedulerThread。
调度线程从JobStore中获取需要执行的的触发器列表,并修改触发器的状态;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的集群模式可以水平扩展,也可以分布式调度,但需要业务方在数据库中添加对应的表,有一定的强侵入性。
有不少研发同学为了避免这种侵入性,也探索出分布式锁模式。
业务场景:电商项目,用户下单后一段时间没有付款,系统就会在超时后关闭该订单。
通常我们会做一个定时任务每两分钟来检查前半小时的订单,将没有付款的订单列表查询出来,然后对订单中的商品进行库存的恢复,然后将该订单设置为无效。
我们使用SpringSchedule的方式做一个定时任务。
Scheduled(cron="0*/2***?")publicvoiddoTask(){log.info("定时任务启动");//执行关闭订单的操作orderService.closeExpireUnpayOrders();log.info("定时任务结束");}
在单服务器运行正常,考虑到高可用,业务量激增,架构会演进成集群模式,在同一时刻有多个服务执行一个定时任务,有可能会导致业务紊乱。
解决方案是在任务执行的时候,使用Redis分布式锁来解决这类问题。
Scheduled(cron="0*/2***?")publicvoiddoTask(){log.info("定时任务启动");StringlockName="closeExpireUnpayOrdersLock";RedisLockredisLock=redisClient.getLock(lockName);//尝试加锁,最多等待3秒,上锁以后5分钟自动解锁booleanlocked=redisLock.tryLock(3,,TimeUnit.SECONDS);if(!locked){log.info("没有获得分布式锁:{}",lockName);return;}try{//执行关闭订单的操作orderService.closeExpireUnpayOrders();}finally{redisLock.unlock();}log.info("定时任务结束");}
Redis的读写性能极好,分布式锁也比Quartz数据库行级锁更轻量级。当然Redis锁也可以替换成Zookeeper锁,也是同样的机制。
在小型项目中,使用:定时任务框架(Quartz/SpringSchedule)和分布式锁(redis/zookeeper)有不错的效果。
但是呢?我们可以发现这种组合有两个问题:
定时任务在分布式场景下有空跑的情况,而且任务也无法做到分片;要想手工触发任务,必须添加额外的代码才能完成。
3ElasticJob-Lite框架
ElasticJob-Lite定位为轻量级无中心化解决方案,使用jar的形式提供分布式任务的协调服务。
应用内部定义任务类,实现SimpleJob接口,编写自己任务的实际业务流程即可。
publicclassMyElasticJobimplementsSimpleJob{
Overridepublicvoidexecute(ShardingContextcontext){switch(context.getShardingItem()){case0://dosomethingbyshardingitem0break;case1://dosomethingbyshardingitem1break;case2://dosomethingbyshardingitem2break;//casen:...}}}举例:应用A有五个任务需要执行,分别是A,B,C,D,E。任务E需要分成四个子任务,应用部署在两台机器上。
应用A在启动后,5个任务通过Zookeeper协调后被分配到两台机器上,通过QuartzScheduler分开执行不同的任务。
ElasticJob从本质上来讲,底层任务调度还是通过Quartz,相比Redis分布式锁或者Quartz分布式部署,它的优势在于可以依赖Zookeeper这个大杀器,将任务通过负载均衡算法分配给应用内的QuartzScheduler容器。
从使用者的角度来讲,是非常简单易用的。但从架构来看,调度器和执行器依然在同一个应用方JVM内,而且容器在启动后,依然需要做负载均衡。应用假如频繁的重启,不断的去选主,对分片做负载均衡,这些都是相对比较重的操作。
另外,ElasticJob的控制台是比较粗糙的,通过读取注册中心数据展现作业状态,更新注册中心数据修改全局任务配置。
4中心化流派
中心化的原理是:把调度和任务执行,隔离成两个部分:调度中心和执行器。调度中心模块只需要负责任务调度属性,触发调度命令。执行器接收调度命令,去执行具体的业务逻辑,而且两者都可以进行分布式扩容。
4.1MQ模式
先谈谈我在艺龙促销团队接触的第一种中心化架构。
调度中心依赖Quartz集群模式,当任务调度时候,发送消息到RabbitMQ。业务应用收到任务消息后,消费任务信息。
这种模型充分利用了MQ解耦的特性,调度中心发送任务,应用方作为执行器的角色,接收任务并执行。
但这种设计强依赖消息队列,可扩展性和功能,系统负载都和消息队列有极大的关联。这种架构设计需要架构师对消息队列非常熟悉。
4.2XXL-JOB
XXL-JOB是一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。
xxl-job2.3.0架构图
我们重点剖析下架构图:
▍网络通讯server-worker模型
调度中心和执行器两个模块之间通讯是server-worker模式。调度中心本身就是一个SpringBoot工程,启动会监听端口。
执行器启动后,会启动内置服务(EmbedServer)监听端口。这样双方都可以给对方发送命令。
那调度中心如何知道执行器的地址信息呢?上图中,执行器会定时发送注册命令,这样调度中心就可以获取在线的执行器列表。
通过执行器列表,就可以根据任务配置的路由策略选择节点执行任务。常见的路由策略有如下三种:
随机节点执行:选择集群中一个可用的执行节点执行调度任务。适用场景:离线订单结算。
广播执行:在集群中所有的执行节点分发调度任务并执行。适用场景:批量更新应用本地缓存。
分片执行:按照用户自定义分片逻辑进行拆分,分发到集群中不同节点并行执行,提升资源利用效率。适用场景:海量日志统计。
▍调度器
调度器是任务调度系统里面非常核心的组件。XXL-JOB的早期版本是依赖Quartz。
但在v2.1.0版本中完全去掉了Quartz的依赖,原来需要创建的Quartz表也替换成了自研的表。
核心的调度类是:JobTriggerPoolHelper。调用start方法后,会启动两个线程:scheduleThread和ringThread。
首先scheduleThread会定时从数据库加载需要调度的任务,这里从本质上还是基于数据库行锁保证同时只有一个调度中心节点触发任务调度。
Connectionconn=XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();connAutoCommit=conn.getAutoCommit();conn.setAutoCommit(false);preparedStatement=conn.prepareStatement("select*fromxxl_job_lockwherelock_name=schedule_lockforupdate");preparedStatement.execute();#触发任务调度(伪代码)for(XxlJobInfojobInfo:scheduleList){//省略代码}#事务提交conn.