先放架构图,本次我们要实现的内容是 xin-job-admin 中的调度逻辑。
啥是调度呢?就是定时任务啥时候执行,怎么执行,都是调度器说了算。
简单的说就是,调度器从数据库中找到需要执行的任务,然后根据任务的信息,找到合适的执行器去执行任务。
最简单的定时任务
我们想一想,实现一个最简单的定时任务,我们是怎么实现的呢?
for {
// 业务逻辑
f()
time.Sleep(time.Hour) // 休眠一个小时
}
如上代码所示,我们实现了一个每一个小时执行一次的定时任务。 但这样存在很大的问题,定时的时间也是定死的。没办法调整时间。
好一点的定时任务
for {
time.Sleep(5 * time.Second) // 休眠5s
db.Find(jobs) // 数据库查询任务
if len(jobs) > 0{
for _,job := range jobs {
if job.TriggerNextTime <= time.Now().UnixMilli() {
// 任务执行时间小于当前时间
f()
refreshNextValidTime(job) // 刷新定时任务时间
db.Update(job)
}
}
}
}
我们又优化了一个版本,休眠时间我们改为了 5s 执行一次。任务也是从数据库取出来的了。执行任务的时间会根据任务执行时间进行判断触发。只要我们修改了数据库信息,任务执行的时间就可以改变了。
但是还有一个问题就是,虽然定时任务的时间可以修改了。但是任务的业务逻辑还是相同的。每个任务都是执行同一个函数 f()
又好了一点的定时任务
我们再来修改一个版本
for {
time.Sleep(5 * time.Second) // 休眠5s
db.Find(jobs) // 数据库查询任务
if len(jobs) > 0{
for _,job := range jobs {
if job.TriggerNextTime <= time.Now().UnixMilli() {
// 任务执行时间小于当前时间
f(job)
refreshNextValidTime(job) // 刷新定时任务时间
db.Update(job)
}
}
}
}
func f(job){
http.Get(job.address + "/run?job="+job) // 伪代码
}
f() 函数,我们修改为了将任务信息发送给远程执行器。执行器接收到请求后去执行业务逻辑。
到这一步,我们就实现了业务逻辑与调度器的解耦。
问题来了
重点来了,上面的代码看似实现了调度逻辑,但是存在巨大的问题
问题一
数据查询了所有的任务,这其中肯定有大部分是不需要立即执行的,可能是一个小时或者更久之后才会执行,这样就很浪费了。
这个问题好解决,我们只需要查询未来 5s 内需要执行的任务即可。
select * from xxl_job where trigger_next_time <= now() + 5s -- 伪代码
问题二
没有任何容错机制,假设定时任务已经执行完了 f() 函数,然后宕机了。没来及更新定时任务时间,等再次启动的时候,会再次执行。
第二个问题,就比较麻烦了。需要考虑各种情况。
nowTime > TriggerNextTime + 5s,这种情况的任务呢,就是调度服务启动的时候,数据库内的任务已经好久没调度了。针对这种情况的任务,xxl-job 里面有一个策略,可以设置为立即执行一次或者啥都不干。nowTime > TriggerNextTime,这种请求就是任务过期了。但是还没超过一个调度周期。- 调度一次,然后刷新下次任务执行时间
- 刷新后的时间仍然小于下次调度时间。
TriggerNextTime < nowtime + 5s - 将任务放入时间轮
- 本次调度周期内的任务,直接放入时间轮,并刷新下次任务执行时间。
宕机是没有办法避免的,所以只能考虑各种情况,保证每个任务能够在规定的时间执行。
问题三
还有就是这个休眠 5s,假设有个每秒执行的定时任务,那么如果每 5s 才会查询一次数据库,那么这个任务执行周期不就成了 5s 而不是每秒。
第三个问题,在解决第二个问题的时候,我们已经看到了时间轮这个概念。这个就是用来解决第三个问题的。
什么是时间轮?
时间轮,可以想象为就是一个钟表,每个刻度上都有一个链表,用来存储任务。等指针指向某一个刻度时该刻度的任务则会被执行(异步执行,不能耽误指针转动)。
时间轮的刻度不够了怎么办?
先说明 xxl-job 中的时间轮刻度为 60,就是每秒执行一次。
因为每次从数据库中取出的数据,都是近期即将执行的,理论上来说不会超过 60s 。
超过的只有调度服务宕机好久,重启上线。但是这种情况,并不会放入时间轮中,只会根据策略执行一次或者不执行,然后就重新刷新下次执行时间了。
言归正传,刻度不够了咋办?
- 直接增加刻度,但是如果有任务两周后执行,按秒算刻度的话就得 1209600 个刻度,太庞大了。
- 不增加刻度,增加
轮概念。假设刻度是 60,两周后就是 1209600 秒后执行,那么需要转20160轮之后就可以执行了。所以在每个任务上都维护一个轮字段,等指针指向该刻度时,取出所有任务并遍历,找到轮为 0 的任务执行。将不为 0 的任务 -1 ,等待下次指针指到该刻度时,再次处理。这个方案的缺点就是每次都要遍历所有任务,性能不太好。 - 多层轮,增加几个轮。
月轮,周轮,天轮等。 月轮刻度是 30,单位为天。 周轮刻度是 7,单位为天。 天轮刻度为 24,单位为小时。 假设一个任务为每个月的 7 号 13 点执行,那么该任务则先放入月轮中刻度为7的任务链表中,等指针指向 7 时,任务被取出,放入天轮中 13 刻度的链表中,天轮指针指向 13 时则开始真正执行任务。这个方案没有之前的遍历链表的缺点,但是复杂度增高
再次言归正传,xxl-job 时怎么用时间轮解决问题的?
根据现在的时间周期进行调度,除非是每秒一次不然无法做到误差非常小,而且后期如果调度服务为集群部署,还会有分布式锁,导致调度性能比较差,误差更大。
使用时间轮,则可以通过周期性的调度,将一个调度周期内的任务,缓存到时间轮内,由时间轮去触发任务,从而达到没有误差。
xxl-job 中的时间轮,使用的是并发安全的 map 存储的。代码还是很简单的。
var ringData sync.Map // 时间轮缓存
// 时间轮协程
func ringFunc() {
for {
// 睡眠
// 每秒触发一次,触发时间花费不到 1s,剩余的时间睡眠等待过去。
time.Sleep(time.Duration(1000-time.Now().UnixMilli()%1000) * time.Millisecond)
var ringItemData []int
// 当前秒
nowSecond := time.Now().Second()
// 循环两次,取出当前秒任务以及上一秒任务。
// 兜底,以防上一秒任务没有执行
for i := 0; i < 2; i++ {
tempData, ok := ringData.LoadAndDelete((nowSecond + 60 - i) % 60)
if ok {
if v, ok := tempData.([]int); ok {
ringItemData = append(ringItemData, v...)
}
}
}
if len(ringItemData) > 0 {
for i := 0; i < len(ringItemData); i++ {
// 触发
// TODO 枚举
trigger.AddTrigger(ringItemData[i], "CRON", -1, "", "", "")
}
// 清空 ringItemData
ringItemData = nil
}
}
}
```
本文所有代码
xin-job: golang 实现 xin-job 垃圾版 (gitee.com) 下的 xin-job-01.tar.gz