动机
五一临近,四月也接近尾声,五一节乃小长假的最后一天。今天是最后一天工作日,竟然感冒了,半夜里翻来覆去无法安睡,加上窗外大飞机屋里小飞机(也就是蚊子)的骚扰,实在是必须起来做点有意义的事了!
忆起与人交流一个负载均衡问题时,偶然聊到了WRR算法,就必然要记下些什么,以表示曾经聊过这个话题,作此文以记之!
简介
在负载均衡场景中,我们经常需要对一组服务器做加权轮询均衡调用,即适配一个叫做WRR(Weighted Round-Robin Scheduling)的算法。本文的主要内容就是分析常见的两种WRR算法,即Linux IPVS的WRR算法和Nginx的WRR算法,并试图做出二者的比较。
当然了,负载均衡的算法非常多,但很难在一篇技术随笔中盖以全貌,与其说不透,不如干脆不说,因此本文的内容仅仅包含两种常见的WRR算法。
Linux内核IPVS使用的WRR算法
这里接不介绍IPVS了,直接切入算法本身,详见net/netfilter/ipvs/ip_vs_wrr.c中的结构体:
static struct ip_vs_scheduler ip_vs_wrr_scheduler = { .name = "wrr", .refcnt = ATOMIC_INIT(0), .module = THIS_MODULE, .n_list = LIST_HEAD_INIT(ip_vs_wrr_scheduler.n_list), .init_service = ip_vs_wrr_init_svc, .done_service = ip_vs_wrr_done_svc, .add_dest = ip_vs_wrr_dest_changed, .del_dest = ip_vs_wrr_dest_changed, .upd_dest = ip_vs_wrr_dest_changed, .schedule = ip_vs_wrr_schedule, };
这里重点关注schedule 回调函数ip_vs_wrr_schedule。
为了让事情更加直观,不至于陷入到Linux内核源码IPVS复杂业务逻辑的深渊,这里给出其Wiki上上的写法,摘自:http://kb.linuxvirtualserver.org/wiki/Weighted_Round-Robin_Scheduling:
Supposing that there is a server set S = {S0, S1, …, Sn-1}; W(Si) indicates the weight of Si; i indicates the server selected last time, and i is initialized with -1; cw is the current weight in scheduling, and cw is initialized with zero; max(S) is the maximum weight of all the servers in S; gcd(S) is the greatest common divisor of all server weights in S; while (true) { i = (i + 1) mod n; if (i == 0) { cw = cw - gcd(S); if (cw <= 0) { cw = max(S); if (cw == 0) return NULL; } } if (W(Si) >= cw) return Si; }
如果你还是没有一个直观上的感受,下面是我写的一个简单的能run的程序,直接编译运行即可:
#include <stdio.h> #include <stdlib.h> struct entry { int weight; }; struct entry *g_entry = NULL; int max_weight = 0; int curr_weight = 0; int divisor = 0; int iter = -1; int gcd(int a, int b) { if (a == 0) { return b; } return gcd(b%a, a); } struct entry *next(int size) { struct entry *ent; while (1) { iter = (iter + 1) % size; if (iter == 0) { curr_weight = curr_weight - divisor; if (curr_weight <= 0) { curr_weight = max_weight; } } ent = &g_entry[iter]; if (ent->weight >= curr_weight) { return ent; } } } int main(int argc, char **argv) { int size = atoi(argv[1]); int i = 0; int total = 0; g_entry = (struct entry *)calloc(size, sizeof(struct entry)); for (i = 0; i < size; i++) { struct entry *ent = &g_entry[i]; ent->weight = atoi(argv[i+2]); total += ent->weight; if (ent->weight > max_weight) { max_weight = ent->weight; } divisor = gcd(divisor, ent->weight); } for (i = 0; i < total; i++) { struct entry *ent = next(size); printf("[LAST]: %d\n", ent->weight); } }
你可以这样使用这个程序:
# 这里生成一个3(第一个参数)个元素的集合,其权值分别为5,1,1(后面的参数) ./a.out 3 5 1 1
简单的证明和分析
这个算法给出的结果总是正确的吗?回答这个问题我觉得非常简单直观,请看下图:
按照上图中的规则,取元素的顺序则是:
在数学上证明算法的正确性似乎也不难,设一个元素的权值为
问题
观察上面的图,如果一个集合中最大权值的元素和次大权值的元素相隔太远,那么这个算法在选元素的时候是不会把权值大的元素打散的,比如:
root@debian:/home/zhaoya# ./a.out 2 5 1 [LAST]: 5 [LAST]: 5 [LAST]: 5 [LAST]: 5 [LAST]: 5 [LAST]: 1
映射回负载均衡的真实场景,这显然会对某些大权值的服务器造成很大的压力,因此对这个算法的改进或者说换另外一个算法是一件必须要做的事。接下来我们就开始分析一个结果序列更加平均的WRR算法,即Nginx服务器中使用的WRR算法。
Nginx使用的WRR算法
关于这个算法,具体的描述详见:
https://github.com/phusion/nginx/commit/27e94984486058d73157038f7950a0a36ecc6e35
和分析IPVS之WRR算法的做法一样,我依然给出一个能run的代码,其运行方法与上述IPVS的算法完全一致:
#include <stdio.h> #include <stdlib.h> struct entry { int weight; int curr_weight; }; struct entry *curr_entry = NULL; struct entry *g_entry = NULL; struct entry *next(struct entry *entrys, int size) { struct entry *ent; int i = 0, total = 0; for (i = 0; i < size; i++) { ent = &entrys[i]; ent->curr_weight += ent->weight; total += ent->weight; if (curr_entry == NULL || ent->curr_weight > curr_entry->curr_weight) { curr_entry = ent; } } curr_entry->curr_weight -= total; for (i = 0; i < size; i++) { ent = &entrys[i]; } return curr_entry; } int main(int argc, char **argv) { int size = atoi(argv[1]); int i = 0; int total = 0; g_entry = (struct entry *)calloc(size, sizeof(struct entry)); for (i = 0; i < size; i++) { struct entry *ent = &g_entry[i]; ent->weight = atoi(argv[i+2]); total += ent->weight; } for (i = 0; i < total; i++) { struct entry *ent = next(g_entry, size); printf("[LAST]: %d\n", ent->weight); } }
以上就是Nginx所采用的WRR算法的代码描述,在大多数情况下,采用这种算法是一个不错的选择。即满足了固定的加权平均,又使得元素的选择尽可能地分散开来,非常精妙!
该算法与IPVS的WRR按照某种规则和组织静态遍历完全不同,它完全是一个动态的过程,因此除非用动画,否则一张图无法展示全貌。我用简单的3个元素加权来描述一下这个算法。假设有3个具有不同权值的
元素
元素
元素
每选中一个元素,将会从其递增量中减去
元素
元素
元素
然而,这个解是唯一的解吗?如何证明这是唯一解?这便是一个数学问题。理解并使用该算法是完全没有问题的,coders深谙此道,然而想要彻底理解它,则必须要证明在算法的操作下,最终得到的解是唯一解,接下来我就简单用反证法来证明一下。
算法的描述
这个算法很有意思,所有集合一开始各就各位初始化自己的
而选择了
这像不像古代军队弓箭手放乱箭的过程,简直太像了!同时,这是一种非积累即时消费的模型,即获胜者一次性消费掉其它选手在本轮中获取的配额。这种非积累特性抹掉了很多潜在的记忆阻止了幂律产生作用,让结果散列地更均匀。
算法正确性证明
假设在集合
由算法基本逻辑,我们知道上面
化简得到:
现在假设,在取到第
又因为取到权值为
化简上面式子:
根据条件
最终,我们得到两个不等式:
由于我们是在第
现在谜底要揭开了!由于我们假设在集合
因此,假设是错误的!即:
权值为
那么,能不能少于
好了,本节我们证明了Nginx里面的这个WRR算法是正确的,即通过算法指示的操作,算法轮询结束后,会严格按照元素的权值比例分配被轮询的次数。
为什么比IPVS的WRR要好?
这个问题其实很难回答,因为很难有一个确定的标准。我咨询过很多大师大神,得到的答案几乎都是从概率,global state的变更频率以及最大熵的角度来分析,然而这些对于回到这个问题有点复杂了。因为我知道Nginx的WRR算法也远不是最好的,其序列分布也不满足最大熵…
所以,我把问题化简,我只要能求出一个权值最大的元素在序列一开始时连续分布的最大上界就基本OK了,如果我求出的这个上界小于其权值
现在让我们开始。
假设元素
上面式子的含义是,选最后一次
这就是上界!
好吧,我现在用一个更加极端的例子来展示一下:
root@debian:/home/zhaoya# ./a.out 2 18 1 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 1 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18 [LAST]: 18
很显然,
按照算法描述,每选中一个最大值
依然以3个元素的集合为例,假设元素
化简为:
在下面的例子中代入上式:
root@debian:/home/zhaoya# ./a.out 2 18 1
我们得到
当然,在这里我有意把问题简化了,因此这不是一个普通的相遇问题,因此上面式子中的等号
算法的O(n)问题
很多人对本文中所描述的两种WRR算法并不是很满意,因为在寻找next元素的时候,其时间复杂度是
但是实际上,这并无所谓,虽然是
在不考虑权值动态更新的前提下,事实上,给定一个集合,按照权值的WRR分布是一个固定的序列,我们不妨在第一次获取到这个序列的时候就将其保存起来,随便用什么基于定位而非查找的数据结构都可以,比如数组,bitmap这种,总之就是在后续的操作中,用
这类似将WRR做了一个预处理,事先生成了序列。
CFS/FQ/PQ调度与WRR负载均衡
最后来比较一下WRR和FQ队列。
FQ队列以及PQ队列以及队列领域的WRR算法注重于在时间上调度上的公平性,即完全按照其优先级权值来进行调度,谁的权值大,谁优先。
而负载均衡中的WRR更多的是在空间上考虑公平性,在空间维度,打散结果是最好的方案。
其实,我们也可以按照队列调度的思想来重新实现负载均衡的WRR算法,以下是一个简单的代码,参照Linux CFS调度器的原理:
#include <stdio.h> #include <stdlib.h> struct entry { int weight; int curr_cfs; }; struct entry *curr_entry = NULL; struct entry *g_entry = NULL; struct entry *next_cfs(struct entry *entrys, int size) { struct entry *ent; int i = 0, total = 0; for (i = 0; i < size; i++) { ent = &entrys[i]; // 选择最小的curr_cfs if (curr_entry == NULL || ent->curr_cfs < curr_entry->curr_cfs) { curr_entry = ent; } } // 满足“单位1”中有weight个元素,算法的结果才是正确的 curr_entry->curr_cfs += 100000000/(curr_entry->weight); return curr_entry; } int main(int argc, char **argv) { int size = atoi(argv[1]); int i = 0; int total = 0; g_entry = (struct entry *)calloc(size, sizeof(struct entry)); for (i = 0; i < size; i++) { struct entry *ent = &g_entry[i]; ent->weight = atoi(argv[i+2]); ent->curr_cfs = 100000000/ent->weight; total += ent->weight; } for (i = 0; i < total; i++) { struct entry *ent = next_cfs(g_entry, size); printf("[LAST_CFS]: %d\n", ent->weight); } }
你可以试一下结果。你会发现,所有权值一样的元素完全挤在一起了,这非常符合在时间序列上的公平性表现(大体上,进程调度和数据包调度都是如此表现),但是在空间维度上却非常差劲。
后记
生日过后,待续!
共同学习,写下你的评论
评论加载中...
作者其他优质文章