先放架构图
假设 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算法是一个圆环,将执行器地址经过 hash 之后投射到圆环上。
一致性 Hash 算法执行流程:
- 将执行器地址通过 Hash 算法后,映射到圆环中。如图:address 0,1,2 分别映射到圆环 0 ,1,2 元素。
- 将任务 id 进行 Hash,job_0 经过 Hash 之后,得到的结果在 address_0 与 address_1 中间位置,那么 job_0 将会在执行器 address_1 中执行。同理 job_1 在 address_2 中执行,job_2 在 address_0 中执行。
- 假设 address_n 为新增加执行器,经过 Hash 后,映射到 4 元素,此时 job_2 就会在 address_n 中执行了。
- 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