手写 xxl-job 03

287 阅读8分钟

1.jpg

先放架构图

假设 example 是个集群,任务不能重复执行,所以需要有一个负载均衡策略。由 admin 根据负载均衡策略选择服务发起网络请求触发任务。

xxl-job 这个概念的名字为路由策略。

路由策略

xxl-job 中路由策略有:第一个、最后一个、轮询、随机、一致性HASH、最不经常使用、最近最久未使用、故障转移、忙碌转移。

定义接口

// Router 路由策略/负载均衡策略
type Router interface {
	// router 路由
	// addressList 执行器地址
	// jobId 任务ID
	Router(jobId int, addressList []string) (address string, err error)
}

第一个

// RouteFirst 第一个
type RouterFirst struct{}

func (r *RouterFirst) Router(jobId int, addressList []string) (address string, err error) {
	return addressList[0], nil
}

最后一个

// RouteLast 最后一个
type RouterLast struct{}

func (r *RouterLast) Router(jobId int, addressList []string) (address string, err error) {
	return addressList[len(addressList)-1], nil
}

随机

// RouteRandom 随机一个
type RouterRandom struct{}

func (r *RouterRandom) Router(jobId int, addressList []string) (address string, err error) {
	i := rand.Intn(len(addressList))
	return addressList[i], nil
}

轮询

最简单的轮询方案就是取余方案。

// 伪装代码
num := atomic.Int64 // 并发安全
i = num % len(addressList)

address = addressList[i] 
num++

// num 不断递增,假设 addressList len=3 那么 i 就会在 0,1,2 之间循环
// 从而找到 address

上方的方案,在 API 网关中还能一用。在定时调度平台中就不太合适了。 假设有两个任务 A 和 B,addressList 一共有个 2 个。 A 先调度,address := addressList[0] B 跟着被调度, address := addressList[1] A 在调度,A 还在索引为 0 的执行器上。

正确情况应该是 A 先在 addressList[0] 执行,然后在 addressList[1] 执行。依次循环。

xxl-job 的解决方案就是使用一个并发安全的 map[jobId]num,这样每个任务都会绑定一个唯一的 num,每个任务都有自己的 num 从而更公平的轮询。

其中还有一个小细节,就是假设有 100 个任务都是第一次调度,如果 num 全部从 0 开始话,那么这个 100 个任务都会调度到 addressList[0] 上,对第一个执行器服务压力太大了。所以 num 初始化是个随机值。

// RouteRound 轮询
type RouterRound struct {
	cache sync.Map
	cache_valid_time int64
}

func (r *RouterRound) Router(jobId int, addressList []string) (address string, err error) {
	return addressList[r.count(jobId)%len(addressList)], nil
}

func (r *RouterRound) count(jobId int) int {

	if time.Now().UnixMilli() > r.cache_valid_time {
		// 清空
		r.cache.Range(func(key, value any) bool {
			r.cache.Delete(key)
			return true
		})
		// 缓存一天
		r.cache_valid_time = time.Now().UnixMilli() + 1000*60*60*24
	}

  

	// 使用一个并发安全的 map 缓存,每个 job 的路由次数。
	count, ok := r.cache.Load(jobId)

	if ok {
		if v, ok := count.(*atomic.Uint64); ok {
			// 100000 大于这个次数,xxl-job 里面这么写的。不太理解
			// 猜测,数太大取余数性能下降?
			if v.Load() > 1000000 {
				i := rand.Int63n(100)
				v.Store(uint64(i))
			} else {
				v.Add(1)
			}
			return int(v.Load())
		}
	}

  

	// 赋值随机数
	// 假设有 100 个任务,其中有一些比较耗时。
	// 第一次执行任务时,100 个任务都会在第一个执行器执行。如果耗时任务太多对一台执行器服务压力大。
	// 设置随机数,保证一定的随机性。
	c := &atomic.Uint64{}
	i := rand.Int63n(100)
	c.Store(uint64(i))
	r.cache.Load(c)
	return int(c.Load())

}

一致性HASH

这个算法主要是为了解决,即使增加执行器,调度依然是平均的。

一致性hash.jpg

如图所示,一致Hash算法是一个圆环,将执行器地址经过 hash 之后投射到圆环上。

一致性 Hash 算法执行流程:

  1. 将执行器地址通过 Hash 算法后,映射到圆环中。如图:address 0,1,2 分别映射到圆环 0 ,1,2 元素。
  2. 将任务 id 进行 Hash,job_0 经过 Hash 之后,得到的结果在 address_0 与 address_1 中间位置,那么 job_0 将会在执行器 address_1 中执行。同理 job_1 在 address_2 中执行,job_2 在 address_0 中执行。
  3. 假设 address_n 为新增加执行器,经过 Hash 后,映射到 4 元素,此时 job_2 就会在 address_n 中执行了。
  4. job_n 经过 Hash 在 address_n 之后那么,job_n 则会在 address_0 中执行。

看完上面的流程后,我们发现如果增加新的执行器,需要改变的只有 job_2 。job_0 和 job_1 并不会改变。

优点:无论是增加执行器还是下线执行器,影响的只有改变的执行器逆时针,遇到的第一个执行器这个区间的内容。(增加 address_n ,影响的就是 address_n 到 address_2 区间,job_2本来在address_0 中执行,增加了 n 之后则在 address_0 中执行。) 缺点:当节点较少时某一个执行器可能会压力很大。当执行器只有 address 0,1,2 时,address_0 逆时针到 address_2 这个区间的所有 job 都会在 address_0 中执行。数据发生了倾斜。

数据倾斜发生可以通过增加虚拟节点,来解决。 如图所示:有执行器 address 0,1,2 。此时为这三个执行器,分别增加一个虚拟节点,address_0_0,address_1_0,address_2_0,分别映射到圆环 3,4,5 中,此时圆环的分布就会均匀。

// RouterHash 一致Hash

type RouterHash struct{}

func (r *RouterHash) Router(jobId int, addressList []string) (address string, err error) {
	// 环形链表
	rring := ring.New(int(len(addressList)))
	
	for _, v := range addressList {
		key := r.hash(v)
		m := map[int64]string{key: v}
		rring.Value = m
		rring = rring.Next()
	}

  

	jobHash := r.hash(strconv.Itoa(jobId))
	
	rring.Do(func(a any) {
		if v, ok := a.(map[int64]string); ok {
			for hashId, a := range v {
				if hashId > jobHash {
					address = a // 返回第一个大于 jobId 的 address
					return
				}
			}
		}
	})

	// address 为空,说明 ring 中没有比 jobHash 更大的。
	// 此时则取第一个即可
	if address == "" {
		address = addressList[0]
	}
	return
}

  

func (r *RouterHash) hash(v string) int64 {

m := md5.New()

m.Write([]byte(v))

s := m.Sum(nil)

  

return int64(s[3]&0xFF<<24|s[2]&0xFF<<16|s[1]&0xFF<<8|s[0]&0xFF) & 0xFFFFFFFF

}

最不经常使用

使用并发安全 map 以及普通 map 实现。

sync.Map key 为 任务ID,value 为普通 map map key 为 执行器地址,value 为执行器地址被调度次数。

调度器每被调度一次次数增加 1 ,然后根据次数进行正序排序,那么第一个则是最不经常使用的执行器。

// RouteLFU Least Frequently Used 最不经常使用
type RouterLFU struct {
	cache sync.Map // jobId: map[address]num
	cache_valid_time int64
}

  

func (r *RouterLFU) Router(jobId int, addressList []string) (address string, err error) {
	if time.Now().UnixMilli() > r.cache_valid_time {
		r.cache.Range(func(key, value any) bool {
			r.cache.Delete(key)
			return true
		})

		// 缓存一天
		r.cache_valid_time = time.Now().UnixMilli() + 1000*60*60*24
	}

  

	v, ok := r.cache.Load(jobId)
	if !ok {
		v = make(map[string]int, len(addressList))
		r.cache.Store(jobId, v)
	}

	m, ok := v.(map[string]int)
	if !ok {
		m = make(map[string]int, len(addressList))
		r.cache.Store(jobId, m)
	}

	for _, address := range addressList {
		num, ok := m[address]
		if !ok || num > 1000000 {
			// 不存在次数以及超过 1000000 则初始化次数
			// 随机一个数。减轻服务器压力。
			// 与 RouterRound 一个道理,如果 num 都为 1,那么多个任务可能全部都调度到一台执行器。
			m[address] = rand.Intn(len(addressList))
		}
	}

  

	// 删除过期执行器
	for k, _ := range m {
		if !contains(addressList, k) {
			delete(m, k)
		}
	}

	// map[address]num 根据 num 排序,取出 num 最小的数据
	type kv struct {
		key string
		value int
	}

	var temp []kv
	for k, v := range m {
		temp = append(temp, kv{k, v})
	}

	// 排序
	sort.Slice(temp, func(i, j int) bool {
		return temp[i].value < temp[j].value
	})

	// 第一个就是使用次数最少的
	fist := temp[0]
	m[fist.key]++
	return fist.key, nil
}

最近最久未使用

调度时取最近最久未使用的执行器。

使用并发安全的map以及双向链表实现。

map key为 jobId,value 为 双向链表元素为执行器地址。

每次调度直接获取双向链表的头部地址,然后将该地址放入尾部。依次轮询。

假设有三个执行器分别是 0,1,2。双向链表内的顺序也是0,1,2。 第一次调度到的执行器为 0 ,然后将 0 放入尾部,此时双向链表为 1,2,0。 此时执行器 0 刚被调度,肯定是不如 1,2 从来未被调度过的。等 1,2被调度后 0 就变成最久未被调度的执行器了。

// RouteLRU Least Recently Used 最近最少使用
type RouterLRU struct {
	cache sync.Map // jobId:list
	cache_valid_time int64
}

  

func (r *RouterLRU) Router(jobId int, addressList []string) (address string, err error) {
	if time.Now().UnixMilli() > r.cache_valid_time {
		r.cache.Range(func(key, value any) bool {
			r.cache.Delete(key)
			return true
		})

	// 缓存一天
	r.cache_valid_time = time.Now().UnixMilli() + 1000*60*60*24
	}

  

	// 所有的 address 放入缓存
	// 第一次触发 router 则添加所有
	// 第二次触发 router 则添加新增加的执行器服务器地址
	for _, address := range addressList {
		if !r.itemContainsVal(jobId, address) {
			r.put(jobId, address)
		}
	}

  

	// del 已经不存在的 address
	l := r.getCacheValue(jobId)
	if l == nil {
		return "", errors.New("lru err")
	}

  

	for e := l.Front(); e != nil; e = e.Next() {
		if s, ok := e.Value.(string); ok {
			if !contains(addressList, s) {
				l.Remove(e)
			}
		}
	}

	address = r.next(jobId)
	return
}

func contains(addressList []string, value string) bool {
	for _, v := range addressList {
		if v == value {
			return true
		}
	}
	return false
}

  

// put 放入
func (r *RouterLRU) put(key int, value string) {
	l, ok := r.cache.Load(key)
	if ok {
		if v, ok := l.(*list.List); ok {
			v.PushBack(value)
		}
	} else {
		ll := list.New()
		ll.PushBack(value)
		r.cache.Store(key, ll)
	}
}

// next 获取首个元素,并将该元素放入尾部
func (r *RouterLRU) next(key int) string {
	l := r.getCacheValue(key)
	if l == nil {
		return ""
	}

	// 获得首个元素
	f := l.Front()

	if s, ok := f.Value.(string); ok {
		if ok {
			// 将该元素移动到尾部
			l.MoveToBack(f)
		return s
		}
	}
	
	return ""
}

  

// cache list 是否包含 address
func (r *RouterLRU) itemContainsVal(key int, value string) bool {
	l := r.getCacheValue(key)
	if l == nil {
		return false
	}

	for e := l.Front(); e != nil; e = e.Next() {
		if s, ok := e.Value.(string); ok {
			if s == value {
				return true
			}
		}
	}

	return false
}

  

func (r *RouterLRU) getCacheValue(key int) *list.List {
	v, ok := r.cache.Load(key)
	if !ok {
		return nil
	}

	if l, ok := v.(*list.List); ok {
		return l
	}
	return nil
}

本文所有代码

xin-job: golang 实现 xin-job 垃圾版 (gitee.com) 下的 xin-job-03.tar.gz