手写 xxl-job 01

361 阅读6分钟

1.jpg

先放架构图,本次我们要实现的内容是 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() 函数,然后宕机了。没来及更新定时任务时间,等再次启动的时候,会再次执行。

第二个问题,就比较麻烦了。需要考虑各种情况。

  1. nowTime > TriggerNextTime + 5s,这种情况的任务呢,就是调度服务启动的时候,数据库内的任务已经好久没调度了。针对这种情况的任务,xxl-job 里面有一个策略,可以设置为立即执行一次或者啥都不干。
  2. nowTime > TriggerNextTime,这种请求就是任务过期了。但是还没超过一个调度周期。
    1. 调度一次,然后刷新下次任务执行时间
    2. 刷新后的时间仍然小于下次调度时间。TriggerNextTime < nowtime + 5s
    3. 将任务放入时间轮
  3. 本次调度周期内的任务,直接放入时间轮,并刷新下次任务执行时间。

宕机是没有办法避免的,所以只能考虑各种情况,保证每个任务能够在规定的时间执行。

问题三

还有就是这个休眠 5s,假设有个每秒执行的定时任务,那么如果每 5s 才会查询一次数据库,那么这个任务执行周期不就成了 5s 而不是每秒。

第三个问题,在解决第二个问题的时候,我们已经看到了时间轮这个概念。这个就是用来解决第三个问题的。

什么是时间轮?

时间轮.png

时间轮,可以想象为就是一个钟表,每个刻度上都有一个链表,用来存储任务。等指针指向某一个刻度时该刻度的任务则会被执行(异步执行,不能耽误指针转动)。

时间轮的刻度不够了怎么办?

先说明 xxl-job 中的时间轮刻度为 60,就是每秒执行一次。

因为每次从数据库中取出的数据,都是近期即将执行的,理论上来说不会超过 60s 。

超过的只有调度服务宕机好久,重启上线。但是这种情况,并不会放入时间轮中,只会根据策略执行一次或者不执行,然后就重新刷新下次执行时间了。

言归正传,刻度不够了咋办?

  1. 直接增加刻度,但是如果有任务两周后执行,按秒算刻度的话就得 1209600 个刻度,太庞大了。
  2. 不增加刻度,增加 概念。假设刻度是 60,两周后就是 1209600 秒后执行,那么需要转 20160 轮之后就可以执行了。所以在每个任务上都维护一个 字段,等指针指向该刻度时,取出所有任务并遍历,找到 为 0 的任务执行。将不为 0 的任务 -1 ,等待下次指针指到该刻度时,再次处理。这个方案的缺点就是每次都要遍历所有任务,性能不太好
  3. 多层轮,增加几个轮。月轮周轮天轮等。 月轮刻度是 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