负载均衡-WRR算法

一、工作流程

1.1:实现加权轮询的方式

WRR叫做加权轮询算法,相比普通的轮询算法,它支持给每个节点配置权重,权重越大,越容易被访问,且符合轮询的特点。

一般情况下,我们会按照下方逻辑设计算法的实现:

图1

图1中流程执行完毕,接下来就跟普通RR算法一样做轮询即可,保证每个虚拟节点都被均匀访问到,而被访问概率也与权重值成正比。

但正如上图所说,乱序那一步是比较容易出问题的,可能会出现下面这样的情况:

图2

1.2:SWRR算法

如果不足够散列,意味着轮询窗口内访问不足够均匀,基于此,便有了SWRR算法,它是一种可以平滑访问的、nginx默认的加权轮询算法,相比普通加权轮询,它在轮询访问节点时表现的更加散列。

在该算法中,每个节点有如下基本属性:

图3

结合上面几个属性,来看下SWRR算法下,选举流程是怎样的:

首先是Node信息的初始化,按照图3的描述,每个Node在初始化的那一刻,effective_weight跟它的weight值相等,current_weight都为0:

图4

然后开始pick,来看下pick的流程:

图5

每次pick,都会经历上面的流程,这样来模拟一下这个轮询过程,假设现在有三个节点,节点名为a、b、c,对应权重值为:4:2:1,结合上面的pick流程,它的轮询流程如下:

表1

上表为SWRR两次轮询的执行效果,可以看到pick出来的节点非常散列,而且每一次轮询得到的顺序是一致的,且每次轮询完成后,各项指标会还原为初始状态,以此类推,它的数学推导&证明请参考:Nginx SWRR 算法解读

1.3:SWRR存在的问题

上面的按照SWRR模拟的pick过程很完美,但该算法依然有一些漏洞,请参考:QPS 比 Nginx 提升 60%,阿里 Tengine 负载均衡算法揭秘

简单来理解,SWRR有以下缺陷:

  1. 算法复杂度为O(N),而且在pick节点时为了保证准确性,需要加
  2. 如果刷新某节点的权重值,会导致该节点流量值瞬间暴增

第一点很好理解,因为每次都要选出current_weight最大的那个节点,必然要循环一次所有的节点。第二点存疑,参考上方文章评论区:

图6

我在自测过程中,在算法启动后动态调整weighteffective_weight的值是不存在这个问题的,访问依旧均匀,并不会出现大规模pick到加权节点上的情况,个人猜测他们可能是在探听到weight变化后,把对应节点的current_weight也给改掉了才可能出现这个问题。

1.4:优化SWRR

按照上方文章里描述,我们现在需要将O(N)的算法变成O(1),这样不仅仅性能迅速提升,pick时也无需加锁,对于使用SWRR算法来说是个不错的选择,那如何实现O(1)呢?表1告诉我们,按照算法pick出的节点是有规律性的,以权重和为模来界定一次轮询,而每个轮询窗口内的节点散列顺序完全一致,那么这样优化起来就简单多了:

图7

经过上图里展示的方法,做一层转换,即可达成O(1)成就,这样在高QPS时的效率会显著提升,后续如果有节点信息变更,只需要以同样的方式,刷新虚拟节点有序集合即可。

二、实现

这里采用JAVA语言来实现:

代码块1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
public class WrrLoadBalancer {

private int effectiveWeightSum; //总权重值

private Node[] virtualNodes; //虚拟节点有序集

private AtomicLong pointer; //pick次数

public WrrLoadBalancer(Node... nodes) {
refreshVirtual(nodes);
}

//刷新虚拟节点
public void refreshVirtual(Node... nodes) {
int total = 0;
for (Node node : nodes) {
total += node.getEffectiveWeight(); //累加total
}
int newEffectiveWeightSum = total;
Node[] newVirtualNodes = new Node[newEffectiveWeightSum];
for (int i = 0; i < newEffectiveWeightSum; i++) {
newVirtualNodes[i] = pickInit(newEffectiveWeightSum, nodes);
}
this.virtualNodes = newVirtualNodes;
this.effectiveWeightSum = newEffectiveWeightSum;
pointer = new AtomicLong((long) (Math.random() * effectiveWeightSum)); //随机开始位置
}

public Node pickInit(int effectiveWeightSum, Node... nodes) {
Node picked = null;
for (Node node : nodes) {
node.refreshCurrentWeight();
if (picked == null) {
picked = node;
} else {
if (picked.getCurrentWeight() < node.getCurrentWeight()) {
picked = node;
}
}
}
if (picked == null) {
throw new IllegalArgumentException("wrr pick error!");
}
return picked.pick(effectiveWeightSum);

}

public Node pick() { //注:更新节点时,可能下标溢出
return virtualNodes[(int) (pointer.incrementAndGet() % effectiveWeightSum)];
}

public static class Node {

private final String host;

// 用户所配的权重
private final AtomicInteger weight = new AtomicInteger();

// 有效权重,正常情况下,该值等于weight,但是当node本身发生错误时,
// 会适当降低该值,后面被选中一次,若不报错,则累加该值,顺利的话最后会再次恢复到weight
private final AtomicInteger effectiveWeight = new AtomicInteger();

//后端目前的权重,一开始为0,后期动态调整,选节点的依据,谁这个值最大就选谁
//计算方式,每次被选中,
private final AtomicInteger currentWeight = new AtomicInteger();

public Node(String host, int weight) {
this.host = host;
this.weight.set(weight);
this.effectiveWeight.set(weight);
}

public Node pick(int total) {
try {
return this;
} finally {
//节点被选中后,需要"降权",即减去Sum(effective_weight)
this.currentWeight.addAndGet(total * -1);
}
}

//刷新currentWeight值,使其累加当前的effectiveWeight值
public void refreshCurrentWeight() {
this.currentWeight.getAndAdd(this.effectiveWeight.get());
}

//刷新effectiveWeight值
public int getEffectiveWeight() {
return this.effectiveWeight.get();
}

public int getCurrentWeight() {
return this.currentWeight.get();
}

public String getHost() {
return host;
}
}
}

四、算法验证

算法的验证会以实际压测的方式来进行,请前往:WRR算法验证实验