分布式任务调度探索
定时任务是大家在开发中一个不可避免的业务,比如在一些电商系统中可能会定时给用户发送生日券,一些对账系统中可能会定时去对账。如果服务只有一台机子,那直接用ScheduledExecutorService就可以了,但是现在服务一般是集群部署,这个时候,ScheduledExecutorService很多时候就没法解决问题,因为我们需要保证只执行一次,同时,我们还需要保证高可用,任务监控等等。
因此,鉴于这些场景,出现了较多的分布式调度框架,本文将对也就主流的开源调度器进行分析对比,然后总结出分布式调度器的系统设计关键点。
需求分析
设计一个分布式调度器,首先需要明确面临哪些场景,以及需要解决哪些问题。按照需求特点,我们将其分为功能性需求和非功能性需求。
功能性需求
调度器功能性需求.png功能性需求,主要包括:
- 任务触发调度功能:支持单机调度、支持集群调度、支持精确触发实践调度、支持实践表达式实践调度、支持API调度
- 任务编排功能:支持JOB依赖关系设置、支持JOB级联关系触发
- 支持大任务拆分和并行处理功能:支持将大任务拆分成多级子任务、支持应用集群并行执行子任务。
- 任务可视化管理
非功能性需求
调度器非功能性需求.png对于调度器,一般需要满足:高可用、高性能、支持水平处理等能力,我们将这些能力成为非功能性需求。
开源框架分析
Java Timer
在ScheduledExecutorService未出现前,在业务开发中如果遇到执行一些简单定时任务的需求,为了避免做一些看起来复杂的控制逻辑,一般考虑使用 Timer 来实现定时任务的执行,下面给出一个最简单用法的例子:
Timer timer = new Timer();
TimerTask timerTask = new TimerTask() {
@Override
public void run() {
// scheduledExecutionTime() 返回此任务最近开始执行的时间
Date date = new Date(this.scheduledExecutionTime());
System.out.println("timeTask run " + date);
}
};
// 从现在开始每间隔 1000 ms 计划执行一个任务
timer.schedule(timerTask, 0, 1000);
Timer现在已经不建议使用了,推荐使用ScheduledExecutorService,为什么不建议使用Timer了?原因如下:
- Timer底层是使用单线程来处理多个Timer任务,这意味着所有任务实际上都是串行执行,前一个任务的延迟会影响到之后的任务的执行;
- 由于单线程的缘故,一旦某个定时任务在运行时,产生未处理的异常,那么不仅当前这个线程会停止,所有的定时任务都会停止;
- Timer任务执行是依赖于系统绝对时间,系统时间变化会导致执行计划的变更。
由于上述缺陷,尽量不要使用Timer, idea中也会明确提示,使用ScheduledThreadPoolExecutor替代Timer 。
Java ScheduledExecutorService
ScheduledExecutorService对于Timer的缺陷进行了修补,首先ScheduledExecutorService内部实现是ScheduledThreadPool线程池,可以支持多个任务并发执行。
对于某一个线程执行的任务出现异常,也会处理,不会影响其他线程任务的执行,另外ScheduledExecutorService是基于时间间隔的延迟,执行不会由于系统时间的改变发生变化。
当然,ScheduledExecutorService也有自己的局限性:只能根据任务的延迟来进行调度,无法满足基于绝对时间和日历调度的需求。
Spring Task
spring task 是spring自主开发的轻量级定时任务框架,不需要依赖其他额外的包,配置较为简单,此处使用注解配置:
image.png
Spring Task 本身不支持持久化,也没有推出官方的分布式集群模式,只能靠开发者在业务应用中自己手动扩展实现,无法满足可视化,易配置的需求。
Quartz
Quartz框架是Java领域最著名的开源任务调度工具,也是目前事实上的定时任务标准,几乎全部的开源定时任务框架都是基于Quartz核心调度构建而成。
image.png关键概念
- Scheduler:任务调度器,是执行任务调度的控制器。本质上是一个计划调度容器,注册了全部Trigger和对应的JobDetail, 使用线程池作为任务运行的基础组件,提高任务执行效率。
- Trigger:触发器,用于定义任务调度的时间规则,告诉任务调度器什么时候触发任务,其中CronTrigger是基于cron表达式构建的功能强大的触发器。
- Calendar:日历特定时间点的集合。一个trigger可以包含多个Calendar,可用于排除或包含某些时间点。
- JobDetail:是一个可执行的工作,用来描述Job实现类及其它相关的静态信息,如Job的名称、监听器等相关信息。
- Job:任务执行接口,只有一个execute方法,用于执行真正的业务逻辑。
- JobStore:任务存储方式,主要有RAMJobStore和JDBCJobStore,RAMJobStore是存储在JVM的内存中,有丢失和数量受限的风险,JDBCJobStore是将任务信息持久化到数据库中,支持集群。
实现细节
1.Quartz如何保证任务只在一个节点运行?
答案是使用了数据库锁。在quartz的集群解决方案里有张表scheduler_locks,quartz采用了悲观锁的方式对triggers表进行行加锁,以保证任务同步的正确性。一旦某一个节点上面的线程获取了该锁,那么这个Job就会在这台机器上被执行,同时这个锁就会被这台机器占用。同时另外一台机器也会想要触发这个任务,但是锁已经被占用了,就只能等待,直到这个锁被释放。之后会看trigger状态,如果已经被执行了,则不会执行了。
简单地说,quartz的分布式调度策略是以数据库为边界资源的一种异步策略。各个调度器都遵守一个基于数据库锁的操作规则从而保证了操作的唯一性。同时多个节点的异步运行保证了服务的可靠。但这种策略有自己的局限性:集群特性对于高CPU使用率的任务效果很好,但是对于大量的短任务,各个节点都会抢占数据库锁,这样就出现大量的线程等待资源。这种情况随着节点的增加会越来越严重。
2.在集群的哪个节点上运行,Quartz是如何进行选取的?
随缘的。集群中各个节点做到时间同步很重要。如果待触发的任务少且运行快,那么很可能一直在时间最早的那一个节点上执行。
3.任务太多,而线程池中配置的线程太少时怎么办?
没事,反正选取Trigger的时候也会考虑空闲线程的数量,空闲线程少的话实例就少选取几个Trigger来执行。
Quartz不足
Quartz通过故障转移和负载均衡实现了任务的高可用,通过数据库的锁机制来确保任务执行的唯一性,但是集群特性仅仅只是用来HA,节点数量的增加并不会提升单个任务的执行效率,不能实现水平扩展。
总结起来,其缺陷和不足在于:
1)需要把任务信息持久化到业务数据表,和业务有耦合;
2)调度逻辑和执行并存于同一个项目中,在机器性能固定的情况下,业务和调度之间不可避免的会相互影响;
3)quartz集群模式下,是通过数据库独占锁来唯一获取任务,任务执行并没有实现完善的负载均衡;
轻量级神器 XXL-JOB
XXL-JOB是一个轻量级分布式任务调度平台,主打特点是平台化,易部署,开发迅速、学习简单、轻量级、易扩展,代码仍在持续更新中。
“调度中心”是任务调度控制台,平台自身并不承担业务逻辑,只是负责任务的统一管理和调度执行,并且提供任务管理平台, “执行器” 负责接收“调度中心”的调度并执行,可直接部署执行器,也可以将执行器集成到现有业务项目中。 通过将任务的调度控制和任务的执行解耦,业务使用只需要关注业务逻辑的开发。
主要提供了任务的动态配置管理、任务监控和统计报表以及调度日志几大功能模块,支持多种运行模式和路由策略,可基于对应执行器机器集群数量进行简单分片数据处理。
功能特性
主要功能特性如下:
-
简单灵活
提供Web页面对任务进行管理,管理系统支持用户管理、权限控制; 支持容器部署;支持通过通用HTTP提供跨平台任务调度; -
丰富的任务管理功能
支持页面对任务CRUD操作; 支持在页面编写脚本任务、命令行任务、Java代码任务并执行; 支持任务级联编排,父任务执行结束后触发子任务执行; 支持设置任务优先级; 支持设置指定任务执行节点路由策略,包括轮询、随机、广播、故障转移、忙碌转移等; 支持Cron方式、任务依赖、调度中心API接口方式触发任务执行 -
高性能
调度中心基于线程池多线程触发调度任务,快任务、慢任务基于线程池隔离调度,提供系统性能和稳定性; 任务调度流程全异步化设计实现,如异步调度、异步运行、异步回调等,有效对密集调度进行流量削峰; -
高可用
任务调度中心、任务执行节点均 集群部署,支持动态扩展、故障转移 支持任务配置路由故障转移策略,执行器节点不可用是自动转移到其他节点执行 支持任务超时控制、失败重试配置 支持任务处理阻塞策略:调度当任务执行节点忙碌时来不及执行任务的处理策略,包括:串行、抛弃、覆盖策略; -
易于监控运维
支持设置任务失败邮件告警,预留接口支持短信、钉钉告警; 支持实时查看任务执行运行数据统计图表、任务进度监控数据、任务完整执行日志;
系统设计
设计思路
将调度行为抽象形成“调度中心”公共平台,而平台自身并不承担业务逻辑,“调度中心”负责发起调度请求;将任务抽象成分散的JobHandler,交由“执行器”统一管理,“执行器”负责接收调度请求并执行对应的JobHandler中业务逻辑;因此,“调度”和“任务”两部分可以相互解耦,提高系统整体稳定性和扩展性;
image.png系统组成
-
调度模块(调度中心):负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码。调度系统与任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务模块;支持可视化、简单且动态的管理调度信息,包括任务新建,更新,删除,任务报警等,所有上述操作都会实时生效,同时支持监控调度结果以及执行日志,支持执行器Failover
-
执行模块(执行器):负责接收调度请求并执行任务逻辑。任务模块专注于任务的执行等操作,开发和维护更加简单和高效;接收“调度中心”的执行请求、终止请求和日志请求等
系统HA设计
1、调度中心高可用
调度中心支持多节点部署,基于数据库行锁保证同时只有一个调度中心节点触发任务调度,参考com.xxl.job.admin.core.thread.JobScheduleHelper#start
2、任务调度高可用
-
路由策略
调度中心基于路由策略路由选择一个执行器节点执行任务,XXL-JOB提供了如下路由策略保证任务调度高可用; -
忙碌转移策略
下发任务前向执行器节点发起rpc心跳请求查询是否忙碌,如果执行器节点返回忙碌则转移到其他执行器节点执行(参考 com.xxl.job.admin.core.route.strategy.ExecutorRouteBusyover); -
阻塞处理策略
当执行器节点存在多个相同任务id的任务未执行完成,则需要基于阻塞策略对任务进行取舍:
串行策略:默认策略,任务进行排队、丢弃旧任务策略、丢弃新任务策略(参考:com.xxl.job.core.biz.impl.ExecutorBizImpl#run) -
故障转移策略
下发任务前向执行器节点发起rpc心跳请求查询是否在线,如果执行器节点没返回或者返回不可用则转移到其他执行器节点执行 (参考com.xxl.job.admin.core.route.strategy.ExecutorRouteFailover)
工作原理
XXL-JOB的工作原理如下如所示:
工作原理
Elastic-Job
Elastic-Job是一个分布式调度解决方案,由两个相互独立的子项目Elastic-Job-Lite和Elastic-Job-Cloud组成。Elastic-Job-Lite定位为轻量级无中心化解决方案,使用jar包的形式提供分布式任务的协调服务。Elastic-Job-Cloud使用Mesos + Docker的解决方案,额外提供资源治理、应用分发以及进程隔离等服务。
原理解析
框架Saturn
Saturn是唯品会开源的一个分布式任务调度平台,在Elastic Job的基础上进行了改造。
SIA-TASK
是宜信开源的分布式任务调度平台。
分布式调度关键点
分布式调度核心要素.png分布式调度一般分为三部分,分别是:任务调度器、任务执行器、任务。详细如下:
- 任务调度器不关心业务逻辑,只关心任务的触发策略、失败策略、路由策略、阻塞处理策略
- 任务执行器只需要监听任务触发接口,按要求执行任务,成功或失败时异步通知任务调度器
- 任务的基本属性需要包含任务ID、触发策略、失败策略、路由策略、阻塞处理策略、创建时间、创建用户、任务参数、任务当前抢占调度器、任务状态(调度中、执行中、执行完成)
高可用
为了避免单点故障,任务调度系统通常需要通过集群实现系统高可用,同时通过扩展提高系统的任务负载量上限。
鉴于任务调度系统的特殊性,“调度”和“执行”两个模块需要均支持集群部署,由于职责不同,因此各自集群侧重点也有有所不同。
“调度器”集群,目标为避免调度模块单点故障。同时,集群节点需要通过锁或命名服务保证单个任务的单次触发,只在其中一个节点上生效,以防止任务的重复触发。
调度器为何采用集群而不是主从?
主从模式只能提供HA的特性,不能提高提高系统的任务负载量上限。
主从需要实现选举算法,保证CP或者AP
集群只需要将状态都迁移到全局的存储器中(例如DB),任务可以采用抢占机制(全局锁),抢占后在任务后标识任务状态即可。
“执行器”集群:目标为避免任务模块单点故障。进一步可以通过自定义路由策略实现Failover等高级功能,从而在执行器某台机器节点故障时自动转移不会影响到任务的正常触发执行。
失败处理
这里的失败策略指的是业务发生失败的处理策略,而不是因为节点故障导致任务没有执行完成导致的失败
任务失败是一种很常见的情况,当任务失败时有两点非常重要,一个是快速发现问题,另一个是及时解决问题。
任务业务逻辑千差万别,如索引同步、pv统计、订单超时处理等等。任务失败可能会导致非常严重的后果,比如索引同步任务失败可能导致搜索不匹配,pv统计失败可能导致打点报表的生成,订单超时处理任务的失败可能导致商品库存的大量无效占用等等。
针对上述情况,通常有几种处理方案:
- 失败告警(快速发现问题):任务失败时,主动向任务负责人发送告警通知,如邮件、短信等方式。这是一种常用的处理方案,原理和实现都比较简单。负责人接收的告警邮件时,通过人工的方式进行故障处理,如手动触发一次任务执行。
- 失败重试(快速解决问题):任务失败时,调度中心主动尝试触发一次重试任务。优点在于不需要人为接入,重试在一定程度上可以大大提高任务的成功率。但是,失败重试需要注意限制重试次数,否则将会导致”失败-重试-失败”的死循环,造成资源浪费。
路由策略
由于任务执行器存在多个实例,调度器如何选择任务执行器同样是个问题。为每个任务配置不同的路由策略(为配置则使用默认路由策略)常见的策略如下:
- 随机策略
- 轮训策略
- 任务IDHash策略
- 背压策略(需要调度器与执行器通讯报告执行器未执行任务数量)
- 阻塞处理策略
阻塞策略
在调度比较密集,而执行器来不及处理的情况下,任务阻塞策略可以指导执行器快速处理阻塞的触发请求。常见的阻塞策略有以下几种:
- 单机串行(默认):调度请求进入执行器后,调度请求进入FIFO队列并以串行方式运行;
- 丢弃后续调度:调度请求进入执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败;
- 覆盖之前调度:调度请求进入执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务;
分布式锁实现
对于分布式定时任务系统来说,最重要的是分布式锁,实现方式有三种:
- 基于数据库的实现方式:唯一索引原理,比如一个定时任务一天执行一次,我们就以日期作为唯一索引,谁第一个把当天日期插入成功,谁就有资格执行;
- 基于Redis的实现方式
- 基于zk的实现方式