定时器
定时器的使用场景
1、简单的数据计算任务(成就系统的排名)、数据修复
2、解决缓存不一致问题(社区业务后台也会有写入,但服务端无法及时感知数据库数据变动,这种情况下,社区采用增量定时器定期扫表观察数据变动情况,及时刷新缓存,尽量避免缓存不一致带来的问题)
3、刷新本地缓存数据(像一般配置类数据,数据不会经常变化,数据量小,访问量又十分巨大,有时会采用定时器刷到本地缓存的方式,保证接口效率)
对于问题 2,也可以换一种思路,比如订阅 mysql 的 binlog,即采用 jins 监听的方式,感知数据变动,同步缓存
使用分类:
1、所有机器都会执行
这种场景一般是需要刷新本地缓存,因为每台机器都会有一份缓存,所以需要所有机器都执行
2、单机房只有一台机器执行
这种场景一般是刷新具有单机房维度的中间件(比如普通 redis 集群数据),不需要每台机器都执行
3、所有机房中只有一台机器执行
这种场景一般是服务是异地多活的方式部署,需要刷新底层数据库数据
定时器应该支持什么样的能力?
1、弹性调度,实现负载均衡与高可用
2、数据分片,提升任务并发效率
3、故障失效转移,提升任务可靠性
4、支持多种作业类型:简单作业、流式作业、数据流作业、脚本作业
历史方案
1. Crontab+SQL
#crm
0 2 * * * /xxx/mtcrm/shell/mtcrm_daily_stat.sql //每天凌晨 2:00 执行统计
30 7 * * * /xxx/mtcrm/shell/mtcrm_data_fix.sql //每天早上 7:30 执行数据修复
该方案存在以下问题:
- 直接访问数据库,各系统业务接口没有重用。
- 完成复杂业务需求时,会引入过多中间表。
- 业务逻辑计算完全依赖 SQL,增大数据库压力。
- 任务失败无法自动恢复。
linux 的 Crontab 只支持分钟级别的配置
cron 是 Linux 中的一个定时任务机制。cron 表示一个在后台运行的守护进程,crontab 是一个设置 cron 的工具,所有的定时任务都写在 crontab 文件中。
2. Python+SQL
def connectCRM():
return MySQLdb.Connection("host1", "uname", "xxx", "crm", 3306, charset="utf8")
def connectTemp():
return MySQLdb.Connection("host1", "uname", "xxx", "temp", 3306, charset="utf8")
该方案存在问题:
- 直接访问数据,需要理解各系统的数据结构,无法满足动态任务问题,各系统业务接口没有重用。
- 无负载均衡。
- 任务失败无法恢复。
- 在 JAVA 语言开发中出现异构,且很难统一到自动部署系统中。
3. Spring+JDK Timer
<task:scheduler id="taskScheduler" pool-size="5" />
<task:scheduled-tasks scheduler="taskScheduler">
<task:scheduled ref="accountStatusTaskScanner" method="execute" cron="0 0 1 * * ?" />
</task:scheduled-tasks>
该方案存在问题:
- 步骤复杂、分散,任务量增大的情况下,很难扩展
- 使用写死服务器 Host 的方式执行 task,存在单点风险,负载均衡手动完成。
- 应用重启,任务无法自动恢复。
简单定时器实现伪代码
就是简单利用 while 循环实现
// 启动定时器
public void start() {
while (true) {
// 记录当前时间
long startTime = System.currentTimeMillis();
// 执行定时器任务
task.run();
// 计算执行时间
long elapsedTime = System.currentTimeMillis() - startTime;
// 等待剩余时间
long sleepTime = interval - elapsedTime;
if (sleepTime > 0) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
实现定时器任务队列的数据结构
其目的在于能直接取出下一次将要执行的任务,所以说是一个有序的队列结构
1、小顶堆(本质上在于对任务的排序,这样看来的话,Java 中的优先级队列、TreeSet 等有顺序的结构都可以算作)
2、时间轮
分布式定时器的基本角色
1、作业任务(我们会实现一个接口,这个实现类就是我们的作业任务)
2、触发器(定义任务的执行时间和频率,或者应该是作业配置?)
3、调度器(调度执行任务)
4、作业配置中心(注册中心、数据库等共享存储系统)
5、事件监听器
Quarz
官方文档: www.quartz-scheduler.org/documentati…
核心代码块
QUarzSchedulerThread
while (!halted.get()) {
// 取可用线程数
int availThreadCount = qsRsrcs.getThreadPool().blockForAvailableThreads();
//如果可用线程数量足够那么查看 30 秒内需要触发的触发器。如果没有的
//话那么就是 30 后再次扫描,其中方法中三个参数 idleWaitTime 为如果
//没有的再次扫描的时间,第二个为最多取几个,最后一个参数
//batchTimeWindow,这个参数默认是 0,同样是一个时间范围,如果
//有两个任务只差一两秒,而执行线程数量满足及 batchTimeWindow 时间
//也满足的情况下就会两个都取出来
triggers = qsRsrcs.getJobStore().acquireNextTriggers(now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()),qsRsrcs.getBatchTimeWindow());
// 查找是否有未来 2ms 内将要执行的任务
long triggerTime = triggers.get(0).getNextFireTime().getTime();
long timeUntilTrigger = triggerTime - now;
while(timeUntilTrigger > 2) {
now = System.currentTimeMillis();
timeUntilTrigger = triggerTime - now;
}
// 更新 trigger 状态
List<TriggerFiredResult> bndle = qsRsrcs.getJobStore().triggersFired(triggers);
// 创建 JobRunShell 并执行
for(int i = 0;i < res.size();i++){
JobRunShell shell = qsRsrcs.getJobRunShellFactory().createJobRunShell(bndle);
shell.initialize(qs);
qsRsrcs.getThreadPool().runInThread(shell);
}
}
Quarz 有两种形式的存储,一种是内存形式,即 RAMJobStore,一种是 JobStoreSupport,默认实现是数据库
在 QUarzSchedulerThread 中的 run 方法中,triggers = qsRsrcs.getJobStore().acquireNextTriggers(now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()),qsRsrcs.getBatchTimeWindow()); 会获取触发器,即我们需要关注的是 acquireNextTriggers 方法的实现
RAMJobStore
在 RAMJobStore 类中,有一个属性 timeTrigger,数据结构是 TreeSet,其排序规则优先级是执行时间>优先级>名称,可以在 Trigger.TriggerTimeComparator 看到实现逻辑
JobStoreSupport
这个默认实现是采用数据库的形式,在 StdJDBCDelegate.selectTriggerToAcquire 方法中,可以看到其中查询的 SQL 语句,order by 执行时间 asc, 优先级 desc
Elastic-Job
如何使用
官网: shardingsphere.apache.org/elasticjob/…
1、设置注册中心
// zk 地址和 namespace 应该进行灵活开放
CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(new ZookeeperConfiguration("zk_host:2181", "my-job"));
regCenter.init();
2、创建作业配置
// 作业的基本配置内容
private static LiteJobConfiguration createJobConfiguration() {
// 定义作业核心配置
JobCoreConfiguration simpleCoreConfig = JobCoreConfiguration.newBuilder("demoSimpleJob", "0/15 * * * * ?", 10).build();
// 定义 SIMPLE 类型配置
SimpleJobConfiguration simpleJobConfig = new SimpleJobConfiguration(simpleCoreConfig, MyElasticJob.class.getCanonicalName());
// 定义 Lite 作业根配置
return LiteJobConfiguration.newBuilder(simpleJobConfig).build();
}
3、启动作业
new JobScheduler(createRegistryCenter(), createJobConfiguration()).init();
架构图
Elastic-Job 对 Quarz 的扩展原理
Elastic-Job 底层实现还是 Quarz,但 Quarz 的分布式协调方案采用的是数据库或者 RAM 内存,如何将数据库替换为 zk 是一个关键点
JobScheduler 类中的 init 会执行以下逻辑
1、想 zk 注册作业任务的一些配置信息
2、构建 quarz 所需要的一些 JobDetail 等信息
3、启动调度任务
createJobDetail 方法中,会构建一个 LiteJob,实现了 quarz 的 Job 接口,并包装为一个 JobDetail,在 JobDetail 中,Elastic-Job 会想 jobDataMap 中存放一个 jobFacade(这个是控制读写 zk 的门面类),一个 elasticJob(这个是实际的作业任务)
当 quarz 开始执行任务时,会调用 JobRunShell.run 方法,这个方法中,最终会调用到 LiteJob 的方法,在这个过程中,会利用 jobFacade 读取 zk 相关配置,执行任务,也就完成了对于 quarz 的扩展
如何实现分片
分片能力实现的实质是忘共享存储系统设置各个运行实例的分片任务数据(哪些实例可以执行哪个任务分片,分片总数是多少),实例在运行时获取分片配置数据,然后由业务程序自行控制分片执行逻辑
Elastic-Job 实现任务分片的主要步骤是:
- Elastic-Job 在启动时,会启动一个监听器,用来判断是否需要重新分片。如果有服务器上下线或者作业配置变化,就会触发重新分片的事件。
ListenManager#startAllListeners
在 Elastic-Job 注册启动信息时,会启动监听器(SchedulerFacade.registerStartUpInfo --- > ListenerManager.startAllListeners ---> ShardingListenerManager.start),注册 ShardingTotalCountChangedJobListener 与 ListenServersChangedJobListener,这两个定时器是 TreeCacheListener(curator 的事件监听器)子类,实现对于分片节点总数与分片节点上下线的感知,会更新 zk 的分片标记信息
Elastic-Job 在执行任务之前,会获取分片的上下文信息,包括分片总数、当前分片项、作业参数等。如果需要重新分片,主服务器会执行分片算法,其他从服务器会等待直到分片完成。
- 获取当前可用的实例,获取当前可用实例,首先获取 namespace/jobname/instances 目录下的所有子节点,并且判断该实例节点的 IP 所在服务器是否可用,namespace/jobname/servers/ip 节点存储的值如果不是 DISABLE,则认为该节点可用
- 如果不需要重新分片(namespace/jobname/leader/sharding /necessary 节点不存在, 即没有打上重新分片的标记)或当前不存在可用实例,则返回
- 判断是否是主节点,如果当前正在进行主节点选举,则阻塞直到选主完成,如果当前节点不是主节点,则等待主节点分片结束。分片是否结束的判断依据是 namespace/jobname/leader/sharding/necessary 节点存在或 namespace/jobnameleader/sharding/processing 节点存在(表示正在执行分片操作),如果分片未结束,使用 Thread.sleep 方法阻塞 100 毫米后再试。如果分片结束则 return
- 能够走到这一步,说明是主节点。主节点在执行分片之前,首先等待该批任务全部执行完毕,判断是否有其他任务在运行的方法是判断是否存在 namespace/jobname/sharding/{分片 item}/running,如果存在,则使用 Thread.sleep(100),然后再判断创建临时节点 namespace/jobname/leader/sharding/processing 节点,表示分片正在执行重置分片信息。先删除 namespace/jobname/sharding/{分片 item}/instance 节点,然后创建 namespace/jobname/sharding/{分片 item}节点(如有必要)。然后根据当前配置的分片总数量,如果当前 namespace/jobname/sharding 子节点数大于配置的分片节点数,则删除多余的节点(从大到小删除)
- 获取配置的分片算法类,常用的分片算法为平均分片算法(AverageAllocationJobShardingStrategy)
- 在一个事务内创建相应的分片实例信息 namespace/jobname/{分片 item}/instance,节点存放的内容为 JobInstance 实例的 ID
Elastic-Job 支持分片策略:平均分配、根据 IP 哈希值和自定义策略
Elastic-Job 保证单个分片项只在一个节点运行,通过在 zk 中创建 running 节点来标识该分片项正在运行。如果有其他节点尝试领取该分片项,会发现 running 节点已存在,就会放弃执行。
如何实现故障转移
通过 FailoverListenerManager 实现(SchedulerFacade.registerStartUpInfo --- > ListenerManager.startAllListeners ---> FailoverListenerManager.start)
基本逻辑:
1、如果某个实例宕机,会在 leader 节点下面创建 failover 节点以及 items 节点
2、每个存活着的任务实例都会收到 zk 节点丢失的事件,哪个分片失效也已经在 leader 节点的 failover 子节点下。所以这些或者的任务实例就会争抢这个分片任务来执行。为了保证不重复执行,elastic-job 使用了 curator 的 LeaderLatch 类来进行选举执行。在获得执行权后,就会在 sharding 节点的分片上添加 failover 节点,并写上任务实例,表示这个故障任务迁移到某一个任务实例上去完成
数据流作业
这个定义的数据流作业任务和我们一般理解的数据流任务是有区别的,其实更应该是批量的作业处理
数据流任务是指从数据源抓取(fetchData)和处理(processData)数据的任务。要实现数据流任务,需要实现 DataflowJob 接口,实现数据源抓取(fetchData)和处理(processData)数据的任务方法
在 DataflowJobExector 中,实现逻辑非常简单
CloudJob
如何实现云平台的接入与控制
文档: cloud.oppoer.me/docsCenter/…
1、ElasticJobPacket.getElasticJobPacket 是一个静态方法,在进实例加载时,会进行 http 调用,查询云平台的配置信息(这个配置信息主要是一些 key、秘钥、地址,用于 http 连接请求),最后会将结果生成为 ElasticJobPacket,这个类就会存放配置信息、作业信息等
2、CloudJobBootstrapConfiguration 实现了 spring 的 BeanPostProcessor 接口,bean 实例化的过程中,会从拿到 ElasticJobPacket,通过其中之前设置的一些属性信息,设置 elastic-job 的信息、调度任务
3、ScheduleJobBootstrapStarterRunner 实现了 spring 的 CommandLineRunner 接口,在 spring 完成容器的启动后,会进行调用,也就会启动定时器
CloudJob 与其他定时器的对比
扩展
操作系统的时钟
大部分 PC 机中有两个时钟源,他们分别叫做 RTC(Real Time Clock,实时时钟) 和 OS(操作系统)时钟。
RTC(Real Time Clock,实时时钟)也叫做 CMOS 时钟,它是 PC 主机板上的一块芯片(或者叫做时钟电路),它靠电池供电,即使系统断电也可以维持日期和时间。由于独立于操作系统所以也被称为硬件时钟,它为整个计算机提供一个计时标准,是最原始最底层的时钟数据。
OS 时钟产生于 PC 主板上的定时/计数芯片(8253/8254),由操作系统控制这个芯片的工作,OS 时钟的基本单位就是该芯片的计数周期。在开机时操作系统取得 RTC 中的时间数据来初始化 OS 时钟,然后通过计数芯片的向下计数形成了 OS 时钟,所以 OS 时钟并不是本质意义上的时钟,它更应该被称为一个计数器。OS 时钟只在开机时才有效,而且完全由操作系统控制,所以也被称为软时钟或系统时钟。
Linux 的 OS 时钟的物理产生原因是可编程定时/计数器产生的输出脉冲,这个脉冲送入 CPU,就可以引发一个中断请求信号,我们就把它叫做时钟中断。
Linux 中用全局变量 jiffies 表示系统自启动以来的时钟滴答数目。每个时钟滴答,时钟中断得到执行。
时钟中断执行的频率很高:100 次/秒(Linux 设计者将一个时钟滴答(tick)定义为 10ms),时钟中断的主要工作是处理和时间有关的所有信息、决定是否执行调度程序。和时间有关的所有信息包括系统时间、进程的时间片、延时、使用 CPU 的时间、各种定时器,进程更新后的时间片为进程调度提供依据,然后在时钟中断返回时决定是否要执行调度程序。
在单处理器系统中,每个 tick 只发生一次时钟中断。在对应的中断处理程序中完成更新系统时间、统计、定时器、等全部功能。而在多处理器系统下,时钟中断实际上是分成两个部分:
- 全局时钟中断,系统中每个 tick 只发生一次。对应的中断处理程序用于更新系统时间和统计系统负载。
- 本地时钟中断,系统中每个 tick 在每个 CPU 上发生一次。对应的中断处理程序用于统计对应 CPU 和运行于该 CPU 上的进程的时间,以及触发对应 CPU 上的定时器。
于是,在多处理器系统下,每个 tick,每个 CPU 要处理一次本地时钟中断;另外,其中一个 CPU 还要处理一次全局时钟中断。
更新系统时间:在 Linux 内核中,全局变量 jiffies_64 用于记录系统启动以来所经历的 tick 数。
每次进入时钟中断处理程序(多处理器系统下对应的是全局时钟中断)都会更新 jiffies_64 的值,正常情况下,每次总是给 jiffies_64 加 1。
而时钟中断存在丢失的可能。内核中的某些临界区是不能被中断的,所以进入临界区前需要屏蔽中断。
当中断屏蔽取消的时候,硬件只能告诉内核是否曾经发生了时钟中断、却不知道已经发生过多少次。
于是,在极端情况下,中断屏蔽时间可能超过 1 个 tick,从而导致时钟中断丢失。
如果计算机上的时钟振荡器有很高的精度,Linux 内核可以读振荡器中的计数器,通过比较上一次读的值与当前值,以确定中断是否丢失;如果发现中断丢失,则本次中断处理程序会给 jiffies_64 增加相应的计数。
但是如果振荡器硬件不允许(不提供计数器、或者计数器不允许读、或者精度不够),内核也没法知道时钟中断是否丢失了。
内核中的全局变量 xtime 用于记录当前时间(自 1970-01-01 起经历的秒数、本秒中经历的纳秒数)。xtime 的初始值就是内核启动时从 RTC 读出的。
在时钟中断处理程序更新 jiffies_64 的值后,便更新 xtime 的值。通过比较 jiffies_64 的当前值与上一次的值(上面说到,差值可能大于 1),决定 xtime 应该更新多少。
系统调用 gettimeofday(对应库函数 time 和 gettimeofday)就是用来读 xtime 变量的,从而让用户程序获取系统时间。
实现定时器:既然已知每个 tick 是 10ms,用 tick 来做定时任务统计再好不过。无论是内核还是应用系统其实都有大量的定时任务需求,这些定时任务类型不一,但是都是依赖于 tick。
JDK Timer 实现原理与思想
底层数据实现:最小堆(可在源码 Timer.TaskQueue.add 中查看数组如何实现最小堆)
如何实现到期获取任务、执行任务?
1、启动 TimerThread 调度线程(只会有一个调度线程)----TimerThread.run
空队列阻塞
获取最先开始的一个任务,加锁修改任务状态信息(执行状态,下次执行时间,是否需要再次入队等等)
任务执行
2、设置 TimerTask 即调度任务的属性(下次调度时间、间隔周期、执行状态),调度任务入队(这块会实现最小堆),取下一个开始的任务,如果是当前任务,则时间唤醒任务队列
1、首先 Timer 对调度的支持是基于绝对时间的,而不是相对时间,所以它对系统时间的改变非常敏感。
2、其次 Timer 线程是不会捕获异常的,如果 TimerTask 抛出的了未检查异常则会导致 Timer 线程终止,同时 Timer 也不会重新恢复线程的执行,他会错误的认为整个 Timer 线程都会取消。同时,已经被安排单尚未执行的 TimerTask 也不会再执行了,新的任务也不能被调度。故如果 TimerTask 抛出未检查的异常,Timer 将会产生无法预料的行为
3、Timer 在执行定时任务时只会创建一个线程任务,如果存在多个线程,若其中某个线程因为某种原因而导致线程任务执行时间过长,超过了两个任务的间隔时间,会导致下一个任务执行时间滞后
ScheduledThreadPoolExecutor
底层数据实现:PriorityBlockingQueue
提交的任务类 ScheduledFutureTask 实现了 Comparable 接口,利用执行时间进行排序
时间轮在 Netty 中的应用(HashedWheelTimer)
底层数据实现:HashedWheelTimer
HashedWheelTimer 核心属性
1、Work
是一个线程,实际执行调度任务的线程
do{
从 timeouts 队列中,最多拿一千个任务,计算应该放在时间轮的位置,层级数,将任务放到 buckets 中
遍历当前 buckets 的任务列表,如果判断任务可以执行,就直接执行,否则层级数-1
}while()
2、HashedWheelBucket
即 HashedWheelTimer.wheel 属性类型,默认 512
3、tickDuration
代表每个 HashedWheelBucket 间隔的时间,默认 100ms
4、timeouts
未加入时间轮的任务队列,MpscQueue,HashedWheelTimer 实现了 Timer 接口,接口的 newTimeout 定义了如何添加一个任务,就是将任务放到 timeouts 队列中
MpscQueue:多生产者、单消费者队列模型----PlatFormDependent.newMpscQueue
可以参考资料: juejin.cn/post/696973…
核心:CAS+volatile 无锁化提升并发度, 链表的方式进行扩容,提升扩容效率,无需数据迁移
其中如何无锁化保持高性能,如何用字节填充的技巧优化 CPU 硬件缓存 Cache Line 都值得学习
参考文档:
cloudJob: www.infoq.cn/article/hhp…