引言

Dubbo架构图

Dubbo负载均衡是在Dubbo框架的第5层(自上而下)Cluster层,客户端根据注册中心提供的服务端列表,根据配置的负载均衡算法选择一个最佳的调用者。Dubbo提供的负载均衡算法列表如下:

  • RandomLoadBalance,加权随机负载均衡
  • RoundRobinLoadBalance,加权轮询负载均衡
  • LeastActiveLoadBalance,最少连接数
  • ShortestResponseLoadBalance,最短响应时间
  • ConsistentHashLoadBalance,一致性 Hash

由于篇幅原因,本文只介绍随机负载均衡和轮询负载均衡的原理,然后结合Dubbo中代码分析具体实现。

随机负载均衡

负载均衡算法简介

随机负载均衡是从服务器列表中随机选择一个服务器提供服务,当请求量足够大时,各服务器分配到的流量近似相同。这样完全随机有一个问题是,不同服务器的处理能力不同,完全随机不能将处理能力强的服务器的能力全部发挥出来,另外也会对处理能力弱的服务器造成一定压力。所以我们需要根据服务器的处理能力给服务器添加权重,让高权重的服务器处理更多的请求。

在有了各服务器的权重后,如何进行分配呢?这里介绍一种非常巧妙的算法:

假设我们现在有A,B,C三个Provider,权重分别为2,8,1,权重和为11,将invoker根据权重放在坐标轴上有:

然后生成一个大于0小于权重和的随机数,假设生成的随机数为s,若:

  • $0\leq s < 2$,则选择服务A;
  • $2\leq s < 10$,则选择服务B;
  • $10\leq s < 11$,则选择服务C。

这样,权重越大的服务器,生成的随机数命中该服务器权重区域的概率就越大。

Dubbo中的加权随机负载均衡实现

加权随机负载均衡是Dubbo使用的默认负载均衡算法,用户可指定不同服务器的权重,使用相同的默认权重。RandomLoadBalance的核心代码如下:

 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
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
	
	// 获得所有invoker的数量
	int length = invokers.size();

	if (!needWeightLoadBalance(invokers,invocation)){
		return invokers.get(ThreadLocalRandom.current().nextInt(length));
	}

	// 用于判断是否所有的invoker都有相同的权重
	boolean sameWeight = true;
	// 存储每个invoker的最大权重,也即累积和
	int[] weights = new int[length];
	// 计算权重和
	int totalWeight = 0;
	for (int i = 0; i < length; i++) {
		// 计算每个invoker的权重
		int weight = getWeight(invokers.get(i), invocation);
		// 求和
		totalWeight += weight;
		// 存储每个invoker当前的累积和
		weights[i] = totalWeight;
		// 判断权重是否不同
		if (sameWeight && totalWeight != weight * (i + 1)) {
			sameWeight = false;
		}
	}
	
	if (totalWeight > 0 && !sameWeight) {
		// 如果各个invoker的权重不相同,则根据我们上一节介绍的算法随机选出一个invoker
		int offset = ThreadLocalRandom.current().nextInt(totalWeight);
		for (int i = 0; i < length; i++) {
			if (offset < weights[i]) {
				return invokers.get(i);
			}
		}
	}
	// 如果权重和为0或权重均相同,则随机返回一个
	return invokers.get(ThreadLocalRandom.current().nextInt(length));
}

轮询负载均衡

完全轮询负载均衡

完全轮询负载均衡算法比较简单,不断遍历服务器列表即可。与随机算法相同,不能根据服务器的能力去分配流量。所以也需要通过权重控制流量分配。

1
2
3
4
5
6
servers = ['A', 'B', 'C']
index = 0
def select():
	global index
	index = (index+1) % len(servers)
	return servers[index]

加权轮询负载均衡

加权轮询负载均衡与加权随机算法实现方式类似,但是不再生成随机数,而是累计一个值,该值在哪个区间内就请求哪个服务器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
servers = [(2, 'A'), (8, 'B'), (1, 'C')]
index = 0

def select():
	global index
	total_weight = 0
	weights = [0] * len(servers)
	target_server = servers[0]
	for i in range(len(servers)):
		total_weight += servers[i][0]
		weights[i] = total_weight
	for i in range(len(servers)):
		if index < weights[i]:
			target_server = servers[i]
			break
	index = (index + 1) % total_weight
	return target_server

这种负载均衡算法也有其缺点,考虑当某个服务器的权重特别大时,那么所有的请求都会发送给该服务器,而其他的服务器则没有流量。

平滑加权轮询负载均衡

为了防止出现某个服务器压力过大的情况,Nginx在一次更新中提出一种平滑负载均衡算法,算法原文描述为:

Algorithm is as follows: on each peer selection we increase current_weight of each eligible peer by its weight, select peer with greatest current_weight and reduce its current_weight by total number of weight points distributed among peers.

假设共有n个节点,每个节点的权重分别为$[x_1, \dots, x_n]$,和为$S$,初始化时将所有节点的当前权重设置为0,在每次负载均衡选择执行两个步骤:

  1. 将每个节点的当前权重加上每个节点的权重,选出当前权重最大的节点。
  2. 将权重最大的节点的当前权重减去权重和S。

算法的步骤很简单,但是背后的原理却很反直觉,为什么这样平滑了呢?这样就能均衡的访问每个服务器吗?下面给出一个数学证明,对此不感兴趣的读者可直接跳到Dubbo实现

我们先来证明均衡性,也即在选择$S$次后,每个节点选择的次数均为$x_i$。

假设在第$t$轮的时候,第$j$个节点已经选了$x_j$次,其中$x_j \leq t < S$,此时第$j$个节点的权重为:

$$ \begin{aligned} w_j(t) & = t\times x_j - x_j\times S \\ &= (t-S)\times x_j \\ &< 0 \end{aligned} $$

而我们在每个loadbalance的过程中,先将每个节点的当前权重加上每个节点的权重,然后最大的减去权重和,总的来看,所有节点的权重和仍然为0.

  1. $x_1 + \dots + x_n$,假设最大的为第$j$个
  2. $x_1 + \dots + (x_j - S) + \dots + x_n = x_1 + \dots + x_n - S = 0$

所以每次进行选择时当前权重和均为0,又有$w_j(t) < 0$,那么必定存在一个节点的权重满足$w_p(t) > 0$。所以当一个节点被选择$x_j$次之后一定不会再选择该节点。在之后的$S-t$次选择中,随着t的增大$w_j(t)= (t-S)\times x_j$会不断趋向于0,直至$t=S$时,$w_j(t)=0$。

所以,每个节点在被选择$x_i$次之后,都不会再被选择,而我们一共选择S次,所以每个节点都会被恰好选择$x_i$次,满足均衡性。

我们再来证明平滑性。平滑性是指,当$x_i > 1$时,在选择$x_i-1$次某个节点之后,下一次一定不会再选择第i个节点了。

假设在$t$时刻,我们连续选择了$x_i-1$次$i$节点。在这之前,我们选择了$n$次$j$节点且$j$节点仍未被轮询完,也即$0\leq n\leq x_j-1$,则有:

$$ \begin{aligned} w_i(t) &= t\times x_i - (x_i-1)\times S \\ w_j(t) &= t\times x_j - n\times S \\ &\geq t\times x_j - (x_j-1) \times S \end{aligned} \tag {1} $$

此时分两种情况讨:

若当前恰好是前$t$个时刻,则有:

$$ \begin{aligned} t &= x_i-1 \\ n &= 0 \\ w_i(t) &= t\times x_i - (x_i-1)\times S \\ w_j(t) &= t\times x_j - n\times S \\ &\geq t\times x_j - (x_j-1) \times S \end{aligned}\tag {2} $$

在$t+1$时刻有:

$$ \begin{aligned} w_i(t) &= (x_i-1)\times x_i - (x_i-1)\times S + x_i \\ &= (x_i-1)\times(x_i-S) + x_i \\ &\leq x_i - x_i + 1 \\ &\leq 1 \end{aligned}\tag {3} $$

其中第3步中是因为$x_i-S < 0 \Rightarrow x_i - S \leq -1$。其他节点的权重为:

$$ \begin{aligned} w_j(t) &= (x_i-1)\times x_j + x_j \\ &= x_i\times x_j \end{aligned}\tag {4} $$

所以$w_i(t) < w_j(t)$,在$t+1$时刻一定不会选i节点。

再来看t不是前t个时刻:

因为我们在选择i节点之前已经选择过j节点,说明$x_j>x_i$,所以带入到公式$(1)$中就有$w_j(t) > w_i(t)$

Dubbo中的平滑加权轮询负载均衡实现

先看下Dubbo中的数据结构:

1
2
3
4
5
6
7
8
protected static class WeightedRoundRobin {
	// 节点的权重
	private int weight;
	// 节点的当前权重
	private AtomicLong current = new AtomicLong(0);
	// 节点权重的上次更新时间
	private long lastUpdate;
}

基于这个数据结构实现的负载均衡算法如下:

 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
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
    // 负载均衡实现的粒度是方法
    String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
    // 根据方法key获取invoker的负载均衡map
    ConcurrentMap<String, WeightedRoundRobin> map = methodWeightMap.computeIfAbsent(key, k -> new ConcurrentHashMap<>());
	int totalWeight = 0;
	long maxCurrent = Long.MIN_VALUE;
	long now = System.currentTimeMillis();
	Invoker<T> selectedInvoker = null;
	WeightedRoundRobin selectedWRR = null;
	for (Invoker<T> invoker : invokers) {
		// 获取每个invoker的唯一标识
		String identifyString = invoker.getUrl().toIdentityString();
		int weight = getWeight(invoker, invocation);
		// 根据唯一标志获取该inoker的当前权重,如果没有就新建
		WeightedRoundRobin weightedRoundRobin = map.computeIfAbsent(identifyString, k -> {
			WeightedRoundRobin wrr = new WeightedRoundRobin();
			wrr.setWeight(weight);
			return wrr;
		});
		// 如果权重发生变化,那么就重新设置权重。这里会将当前权重置0,同时设置节点权重
		if (weight != weightedRoundRobin.getWeight()) {
			//weight changed
			weightedRoundRobin.setWeight(weight);
		}
		// 更新节点的当前权重,current = current + weight
		long cur = weightedRoundRobin.increaseCurrent();
		weightedRoundRobin.setLastUpdate(now);
		if (cur > maxCurrent) {
			maxCurrent = cur;
			selectedInvoker = invoker;
			selectedWRR = weightedRoundRobin;
		}
		totalWeight += weight;
	}
	// 移除已经不活跃的invker的权重,将在外下一次负载均衡生效
	if (invokers.size() != map.size()) {
		map.entrySet().removeIf(item -> now - item.getValue().getLastUpdate() > RECYCLE_PERIOD);
	}
	// 选出invoker后,将该invoker的当前权重减去权重和
	if (selectedInvoker != null) {
		selectedWRR.sel(totalWeight);
		return selectedInvoker;
	}
	// should not happen here
	return invokers.get(0);
}

附录

Dubbo中计算权重的方式

Dubbo的权重计算考虑了服务器的预热时间。防止在系统启动之初,在缓存等组件还未初始化完毕时就分配了大量请求,出现将服务器压垮的情况。

Dubbo通过服务器权重进行服务器预热,服务器的权重会随着运行时间的延长逐渐增大,逐渐分配更多的流量。

 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
int getWeight(Invoker<?> invoker, Invocation invocation) {
	int weight;
	URL url = invoker.getUrl();
	// Multiple registry scenario, load balance among multiple registries.
	if (REGISTRY_SERVICE_REFERENCE_PATH.equals(url.getServiceInterface())) {
		weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, DEFAULT_WEIGHT);
	} else {
		// 从Method中获取权重参数
		weight = url.getMethodParameter(invocation.getMethodName(), WEIGHT_KEY, DEFAULT_WEIGHT);
		if (weight > 0) {
			// 获取服务器启动时间
			long timestamp = invoker.getUrl().getParameter(TIMESTAMP_KEY, 0L);
			if (timestamp > 0L) {
				long uptime = System.currentTimeMillis() - timestamp;
				if (uptime < 0) {
					return 1;
				}
				// 获取warmup时间
				int warmup = invoker.getUrl().getParameter(WARMUP_KEY, DEFAULT_WARMUP);
				if (uptime > 0 && uptime < warmup) {
					// 计算预热期权重
					weight = calculateWarmupWeight((int)uptime, warmup, weight);
				}
			}
		}
	}
	return Math.max(weight, 0);
}

static int calculateWarmupWeight(int uptime, int warmup, int weight) {
	// 随着uptime增大,uptime/warmup逐渐变大
	int ww = (int) ( uptime / ((float) warmup / weight));
	// 当uptime/warmup > 1时为weight
	return ww < 1 ? 1 : (Math.min(ww, weight));
}

参考文献

  1. http://aibenlin.com/algorithm/2019/07/22/algorithm-weight.html
  2. https://tenfy.cn/2018/11/12/smooth-weighted-round-robin/
  3. https://hedzr.com/golang/algorithm/go-load-balancer-1/#%E5%B8%A6%E6%9D%83%E9%87%8D%E7%9A%84%E8%BD%AE%E8%AF%A2%E7%AE%97%E6%B3%95-weighted-round-robin
  4. https://colobu.com/2016/12/04/smooth-weighted-round-robin-algorithm/