1.从Dubbo应用层级较小的角度去理解软件负载均衡的几种通用的负载均衡算法与实现
2.简单了解大神的优化思路
强调:这是一篇笔记,可能存在纰漏,如发现,请勿喷,烦请指正,谢谢。强烈推荐官方文档:
Dubbo官方文档-负载均衡
关于负载均衡,这里简单聊聊对这个概念的理解。这个概念其实是对流量通过一定的手段进行重新分配,避免单一机器直接被较为集中的流量击穿,或者避免部分机器由于没有合理分配流量导致过于空闲。
一般来说,负载均衡分为硬件级的负载均衡或者软件负载均衡,对于开发人员来说,我们基本都是面向软件层的负载均衡。
硬件层级的负载均衡解决方案是在服务器和外网架设专门的负载均衡设备,由这些设备进行专门的流量分发。一些比较主流的产品比如:F5、思科等。这里并不打算展开来讨论。可能会在后续会专门开一篇文章来讨论。
软件层级的负载均衡解决方案比较流行的有LVS、Nginx、HAProxy等,对于这些方案本文也并不打算扩展开来,只是作为简单地了解。
对于Dubbo的负载均衡,我们可以先看下Dubbo本身的负载均衡的类图
在Dubbo的负载均衡相关的代码中,可以看到它主要提供了四种负载均衡算法:
除了了解这四种算法,我们应该还需要知道几个问题:
该算法是Dubbo负载均衡的缺省算法。比较简单易懂。
假设有三台服务器[A,B,C],我们设置权重分别是5,3,2,权重总和为10,对于这三台服务器,可以认为A服务器在[0,5),B服务器在[5,8),C服务器在[8,10],此时提供一个随机数生成器(此处认为该随机数生成器算法足够好,产生的数据足够随机),那么让其生成一个[0,10)的随机数,这个数据将会落在其中一个区段,此时就将请求分发到对应的服务器。
package org.apache.dubbo.rpc.cluster.loadbalance;
import org.apache.dubbo.common.URL;
import org.apache.dubbo.rpc.Invocation;
import org.apache.dubbo.rpc.Invoker;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* random load balance.
*/
public class RandomLoadBalance extends AbstractLoadBalance {
public static final String NAME = "random";
@Override
protected Invoker doSelect(List> invokers, URL url, Invocation invocation) {
// Dubbo多个节点都会包装成invoker模型的形式
int length = invokers.size();
boolean sameWeight = true;
// 每一个invoker的权重
int[] weights = new int[length];
// 尝试获取第一个invoker的权重
int firstWeight = getWeight(invokers.get(0), invocation);
weights[0] = firstWeight;
// The sum of weights
int totalWeight = firstWeight;
for (int i = 1; i < length; i++) {
int weight = getWeight(invokers.get(i), invocation);
// save for later use
weights[i] = weight;
// Sum
totalWeight += weight;
if (sameWeight && weight != firstWeight) {
sameWeight = false;
}
}
if (totalWeight > 0 && !sameWeight) {
// 基于总权重,随机一个数据出来
int offset = ThreadLocalRandom.current().nextInt(totalWeight);
// 求随机出来的数据落在哪个区间段
for (int i = 0; i < length; i++) {
offset -= weights[i];
if (offset < 0) {
return invokers.get(i);
}
}
}
//如果权重相等,则随机一个数出来
return invokers.get(ThreadLocalRandom.current().nextInt(length));
}
}
/**
*
* 获取invoker的权重,如果部分耗费在warm过程中时,这部分权重要适当地减少
*
* @param invoker the invoker
* @param invocation the invocation of this invoker
* @return weight
*/
protected int getWeight(Invoker> invoker, Invocation invocation) {
int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), WEIGHT_KEY, DEFAULT_WEIGHT);
if (weight > 0) {
long timestamp = invoker.getUrl().getParameter(REMOTE_TIMESTAMP_KEY, 0L);
if (timestamp > 0L) {
int uptime = (int) (System.currentTimeMillis() - timestamp);
int warmup = invoker.getUrl().getParameter(WARMUP_KEY, DEFAULT_WARMUP);
if (uptime > 0 && uptime < warmup) {
weight = calculateWarmupWeight(uptime, warmup, weight);
}
}
}
return weight >= 0 ? weight : 0;
}
/**
*
* 根据运行部分与warm up时间占比来计算权重
*
* @param uptime the uptime in milliseconds
* @param warmup the warmup time in milliseconds
* @param weight the weight of an invoker
* @return weight which takes warmup into account
*/
static int calculateWarmupWeight(int uptime, int warmup, int weight) {
int ww = (int) ((float) uptime / ((float) warmup / (float) weight));
return ww < 1 ? 1 : (ww > weight ? weight : ww);
}
这里值得一提的时关于WarmUp的理解。系统在刚开始启动的时候都会有一段WarmUp的过程,这过程主要对一些线程池、数据库连接池等等的初始化,这些连接的创建、建立连接是十分消耗性能。另外本身JVM也有自己的预热策略。
如果对于这部分机器在处于服务预热的过程中就加以较大的流量,很容易让机器崩掉(因为此时正处于各种初始化的过程),因此我们提出一种优化的小手段,就是针对这部分正处于warm up的机器进行降权,让流量暂时减少流到这部分机器。
基于最少活跃调用数,也是很朴素的一种调度思想,可以理想化的认为,当机器性能越好,处理的请求越快,那么在承接同样的请求数时,经过一段时间后,性能较好的机器将会剩下越少的请求。理所当然的,能者得多劳,这种算法会安排更多的请求到这一台机器。
在Dubbo的实现中,还引入了权重这一概念。因此该算法实际是基于加权的最少活跃调用数的
public class LeastActiveLoadBalance extends AbstractLoadBalance {
public static final String NAME = "leastactive";
@Override
protected Invoker doSelect(List> invokers, URL url, Invocation invocation){
int length = invokers.size();
//初始化最小活跃值,最小活跃数,最小活跃的invoker索引(index),权重数组
int leastActive = -1;
int leastCount = 0;
int[] leastIndexes = new int[length];
int[] weights = new int[length];
int totalWeight = 0;
int firstWeight = 0;
boolean sameWeight = true;
// 开始查询哪一个invoker是最小活跃数
for (int i = 0; i < length; i++) {
Invoker invoker = invokers.get(i);
// 获取每一个invoker的活跃数
int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive();
// 获取invoker的权重
int afterWarmup = getWeight(invoker, invocation);
weights[i] = afterWarmup;
// If it is the first invoker or the active number of the invoker is less than the current least active number
if (leastActive == -1 || active < leastActive) {
leastActive = active;
leastCount = 1;
leastIndexes[0] = i;
totalWeight = afterWarmup;
firstWeight = afterWarmup;
sameWeight = true;
} else if (active == leastActive) {
leastIndexes[leastCount++] = i;
totalWeight += afterWarmup;
if (sameWeight && i > 0
&& afterWarmup != firstWeight) {
sameWeight = false;
}
}
}
if (leastCount == 1) {
return invokers.get(leastIndexes[0]);
}
if (!sameWeight && totalWeight > 0) {
int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight);
for (int i = 0; i < leastCount; i++) {
int leastIndex = leastIndexes[i];
offsetWeight -= weights[leastIndex];
if (offsetWeight < 0) {
return invokers.get(leastIndex);
}
}
}
// If all invokers have the same weight value or totalWeight=0, return evenly.
return invokers.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]);
}
}
其实代码没有那么复杂,只有两个分支:
(1)如果获取到的活跃度少于记录下来的最小活跃数,那么理所当然的就应将请求分发到这个invoker去。
(2)但是如果很巧,此时如果获取到的活跃度恰巧就等于记录下的最少活跃数,此时dubbo本身并不是随机取一个,而是利用权重来进行分发请求。这个算法和第一个加权算法的思路基本是一致的
(3)如果算出来的权重和最小活跃度都一样,此时就随机选一个节点进行请求分发。
这里比较有意思的是官网文档记录的一些优化的点,当然我看的是最新版本的代码,这部分优化的点(其实也是bug)已经被修复了。但是把前人的思考漏洞拿来引以为戒也是蛮不错的一件事情。
public class LeastActiveLoadBalance extends AbstractLoadBalance {
public static final String NAME = "leastactive";
private final Random random = new Random();
@Override
protected Invoker doSelect(List> invokers, URL url, Invocation invocation) {
int length = invokers.size();
// 最小的活跃数
int leastActive = -1;
// 具有相同“最小活跃数”的服务者提供者(以下用 Invoker 代称)数量
int leastCount = 0;
// leastIndexs 用于记录具有相同“最小活跃数”的 Invoker 在 invokers 列表中的下标信息
int[] leastIndexs = new int[length];
int totalWeight = 0;
// 第一个最小活跃数的 Invoker 权重值,用于与其他具有相同最小活跃数的 Invoker 的权重进行对比,
// 以检测是否“所有具有相同最小活跃数的 Invoker 的权重”均相等
int firstWeight = 0;
boolean sameWeight = true;
// 遍历 invokers 列表
for (int i = 0; i < length; i++) {
Invoker invoker = invokers.get(i);
// 获取 Invoker 对应的活跃数
int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive();
// 获取权重 - ⭐️
int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), Constants.WEIGHT_KEY, Constants.DEFAULT_WEIGHT);
// 发现更小的活跃数,重新开始
if (leastActive == -1 || active < leastActive) {
// 使用当前活跃数 active 更新最小活跃数 leastActive
leastActive = active;
// 更新 leastCount 为 1
leastCount = 1;
// 记录当前下标值到 leastIndexs 中
leastIndexs[0] = i;
totalWeight = weight;
firstWeight = weight;
sameWeight = true;
// 当前 Invoker 的活跃数 active 与最小活跃数 leastActive 相同
} else if (active == leastActive) {
// 在 leastIndexs 中记录下当前 Invoker 在 invokers 集合中的下标
leastIndexs[leastCount++] = i;
// 累加权重
totalWeight += weight;
// 检测当前 Invoker 的权重与 firstWeight 是否相等,
// 不相等则将 sameWeight 置为 false
if (sameWeight && i > 0
&& weight != firstWeight) {
sameWeight = false;
}
}
}
// 当只有一个 Invoker 具有最小活跃数,此时直接返回该 Invoker 即可
if (leastCount == 1) {
return invokers.get(leastIndexs[0]);
}
// 有多个 Invoker 具有相同的最小活跃数,但它们之间的权重不同
if (!sameWeight && totalWeight > 0) {
// 随机生成一个 [0, totalWeight) 之间的数字
int offsetWeight = random.nextInt(totalWeight);
// 循环让随机数减去具有最小活跃数的 Invoker 的权重值,
// 当 offset 小于等于0时,返回相应的 Invoker
for (int i = 0; i < leastCount; i++) {
int leastIndex = leastIndexs[i];
// 获取权重值,并让随机数减去权重值 - ⭐️
offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
if (offsetWeight <= 0)
return invokers.get(leastIndex);
}
}
// 如果权重相同或权重为0时,随机返回一个 Invoker
return invokers.get(leastIndexs[random.nextInt(leastCount)]);
}
}
主要看标星的两行代码,第一行代码取活跃数的时候由于没有考虑服务预热的情况,实际取得是未降权的权重,此时总权重会累计上这个未降权的权重,但是到了第二行带星号的代码,它会减去对应invoker的权重,此时则会减去已经降权的权重,那会出现什么问题呢?在经过leastCount次运算后,此时减去最后一次的invoker权重,此时仍然无法得到0,从加权算法那里可以得知,选中invoker的条件就是要让最终的结果等于0,此时如果无法等于0,自然而然的invoker则无法选中。
一致性哈希算法为了解决互联网的Hot Spot问题(热点问题)而产生的。
在一致性Hash算法提出了判断哈希算法好坏的四个定义,这里引用别人的文章来:
五分钟理解一致性哈希算法(consistent hashing)
1.平衡性:平衡性是指哈希的结果能够尽可能分布到所有的缓冲去,这样可以使得所有的缓冲空间都得到利用。
2.单调性。单调性是指如果已经有一些内容通过哈希分派到相应的缓冲中,又有新的缓冲加入到系统中。哈希的结果应该能够保证原有已分配的内容可以被映射到原有的或者新的缓冲去,而不会被映射到旧的缓冲集合中的其他缓冲区。
3.分散性。在分布式环境中,终端有可能看不到所有的缓冲,而是只能看到其中的一部分。当终端希望通过哈希过程将内容映射到缓冲上时,由于不同终端所见的缓冲范围有可能不同,从而导致哈希的结果不一致,最终的结果是相同的内容被不同的终端映射到不同的缓冲区中。这种情况显然是应该避免的,因为它导致相同内容被存储到不同缓冲中去,降低了系统存储的效率。分散性的定义就是上述情况发生的严重程度。好的哈希算法应能够尽量避免不一致的情况发生,也就是尽量降低分散性。
4.负载(Load):负载问题实际上是从另一个角度看待分散性问题。既然不同的终端可能将相同的内容映射到不同的缓冲区中,那么对于一个特定的缓冲区而言,也可能被不同的用户映射为不同 的内容。与分散性一样,这种情况也是应当避免的,因此好的哈希算法应能够尽量降低缓冲的负荷。
在分布式集群中,节点的添加或者删除,或者节点故障都是分布式集群管理的很正常的现象,如果采用普通hash算法,会导致一旦节点出现变更(增删或者故障转移),变更节点上的数据就会出现找不到或者需要全部重新计算hash迁移数据的问题。
那么一致性哈希算法相比于普通的哈希算法多了哪些改进呢?
一致性哈希算法采用的时环形哈希空间的方式,它首先根据ip或者其他信息为机器节点生成一个hash,它投射到一个[0,2^32-1]的圆环中。之后我们可以认为它是一个锚点。当请求进来后,它携带的数据,我们都统一生成一个hash值,这时候我们比对之前的机器节点信息产生的hash值,当遇到第一个大于或者等于该hash值得缓存节点,我们将这个数据便归属于这个机器节点。
如果某台机器挂了怎么办?
传统的hash算法需要重新计算所有数据的hash值,然后重新分配,涉及到的迁移数据将会是全部数据。一致性哈希算法优化的点也是在于这里,如果当前节点挂了,其实需要重新写入的也只有这个节点的数据,当这个节点的数据直接迁移到下一个大于其hash值得机器节点即可。
这里引用dubbo官网文档的图片:
当cache3节点坏掉了,此时原属于cache3的数据将转移到cache4,那么这里的数据影响的范围也只是cache-2到cache-3这一部分。
但是这里还有个问题,如果恰好计算所得hash值的数据过于集中,则会过于集中在某一个节点。
为了避免数据倾斜的问题,一致性哈希算法引入了虚拟节点的方式来解决这个问题。这里其实也是上面所说的四个定义决定了哈希算法的好坏,平衡性也是需要考虑的重要因素之一。
数据倾斜是指,由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到了少量请求的情况
怎么解决?
引入虚拟节点。那什么是虚拟节点呢?其实个人的理解如果说实际的节点是通过真实机器的真实信息的一个hash映射,那么虚拟节点无非是在真实机器中划分一个虚拟区域的信息,然后将真实机器的hash映射做一个细分。
举个例子,如果我们按照真实机器的ip进行hash化,从而在哈希环中做了一个节点的投射,那么虚拟节点我们可以采用ip加数据后缀的方式,投射出虚拟节点在哈希环的位置。
加入了虚拟节点,可以让数据的分布更加平衡。
在此通过dubbo官网的图来加深理解:
这里同一个颜色的则是指同一个实际节点。
package org.apache.dubbo.rpc.cluster.loadbalance;
import org.apache.dubbo.common.URL;
import org.apache.dubbo.rpc.Invocation;
import org.apache.dubbo.rpc.Invoker;
import org.apache.dubbo.rpc.support.RpcUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import static org.apache.dubbo.common.constants.CommonConstants.COMMA_SPLIT_PATTERN;
/**
* ConsistentHashLoadBalance
*/
public class ConsistentHashLoadBalance extends AbstractLoadBalance {
public static final String NAME = "consistenthash";
public static final String HASH_NODES = "hash.nodes";
public static final String HASH_ARGUMENTS = "hash.arguments";
private final ConcurrentMap> selectors = new ConcurrentHashMap>();
@SuppressWarnings("unchecked")
@Override
protected Invoker doSelect(List> invokers, URL url, Invocation invocation) {
String methodName = RpcUtils.getMethodName(invocation);
String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
int identityHashCode = System.identityHashCode(invokers);
ConsistentHashSelector selector = (ConsistentHashSelector) selectors.get(key);
if (selector == null || selector.identityHashCode != identityHashCode) {
selectors.put(key, new ConsistentHashSelector(invokers, methodName, identityHashCode));
selector = (ConsistentHashSelector) selectors.get(key);
}
return selector.select(invocation);
}
这里从ConsistentHashLoadBalance类的doSelect方法开始看。
doSelect方法做了以下几个事情:
检查invokers列表是不是变过,采用了直接比对Hash值得方式,如果invokers列表变了,自然而然Hash值也就产生变更。如果变更过,则需要为新的节点创建新的ConsistentHashSelector
private static final class ConsistentHashSelector {
private final TreeMap> virtualInvokers;
private final int replicaNumber;
private final int identityHashCode;
private final int[] argumentIndex;
ConsistentHashSelector(List> invokers, String methodName, int identityHashCode) {
this.virtualInvokers = new TreeMap>();
this.identityHashCode = identityHashCode;
URL url = invokers.get(0).getUrl();
//获取虚拟节点数,默认为160
this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
//获取参与Hash计算的参数下标值,默认对第一个参数进行hash运算
String[] index = COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, HASH_ARGUMENTS, "0"));
argumentIndex = new int[index.length];
for (int i = 0; i < index.length; i++) {
argumentIndex[i] = Integer.parseInt(index[i]);
}
//构建虚拟节点
for (Invoker invoker : invokers) {
String address = invoker.getUrl().getAddress();
for (int i = 0; i < replicaNumber / 4; i++) {
// 对 address + i 进行 md5 运算,得到一个长度为16的字节数组
byte[] digest = md5(address + i);
for (int h = 0; h < 4; h++) {
// h = 0 时,取 digest 中下标为 0 ~ 3 的4个字节进行位运算
// h = 1 时,取 digest 中下标为 4 ~ 7 的4个字节进行位运算
// h = 2, h = 3 时过程同上
long m = hash(digest, h);
virtualInvokers.put(m, invoker);
}
}
}
}
}
前面提到过,一致性哈希为了保证数据均衡化,加入虚拟节点这个概念。
在dubbo的实现中,采用了ip+序号的方式,再对其md5,得到的32位字符,每四位进行hash运算,之后就得到虚拟节点的hash映射了。
以上就是ConsistentHashSelector的初始化逻辑。ConsistentHashSelector的负载均衡逻辑只受到参数值得影响,具有相同参数值得请求会被分配到同一个服务提供者。
public Invoker select(Invocation invocation) {
String key = toKey(invocation.getArguments());
byte[] digest = md5(key);
return selectForKey(hash(digest, 0));
}
private String toKey(Object[] args) {
StringBuilder buf = new StringBuilder();
for (int i : argumentIndex) {
if (i >= 0 && i < args.length) {
buf.append(args[i]);
}
}
return buf.toString();
}
private Invoker selectForKey(long hash) {
// 到 TreeMap 中查找第一个节点值大于或等于当前 hash 的 Invoker
Map.Entry> entry = virtualInvokers.ceilingEntry(hash);
if (entry == null) {
entry = virtualInvokers.firstEntry();
}
return entry.getValue();
}
之后就比较简单了,计算地址携带参数的md5,然后对其哈希化,哈希得出来的值与virtualInvoker里的虚拟节点进行hash比对,拿到目标invoker即可。
加权轮询应该拆分成两部分来看,首先看轮询,这层理解比较简单,举例有五台机器,请求依次从第一台到第五台,这样就是一个轮询。
但是在实际生产中,我们不能假设每台机器处理性能都一致,如果请求按照众生平等的原则分发到每一台机器,个别机器可能性能瓶颈导致机器堆积请求,这种很容易造成某一个节点崩溃导致服务雪崩。
因此需要引入权重这一个概念。
理解这一层工作原理并不是太难。有意思的从官网看他们几个版本迭代过程中的对代码的优化思路。我们在接下来也可以看下过去的版本和当前版本的代码比对,以便我们在自己写代码的时候能规避同样的问题。
这是2.6.4的dubbo源码。
public class RoundRobinLoadBalance extends AbstractLoadBalance {
public static final String NAME = "roundrobin";
private final ConcurrentMap sequences =
new ConcurrentHashMap();
@Override
protected Invoker doSelect(List> invokers, URL url, Invocation invocation) {
// key = 全限定类名 + "." + 方法名,比如 com.xxx.DemoService.sayHello
String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
int length = invokers.size();
// 最大权重
int maxWeight = 0;
// 最小权重
int minWeight = Integer.MAX_VALUE;
final LinkedHashMap, IntegerWrapper> invokerToWeightMap = new LinkedHashMap, IntegerWrapper>();
// 权重总和
int weightSum = 0;
// 下面这个循环主要用于查找最大和最小权重,计算权重总和等
for (int i = 0; i < length; i++) {
int weight = getWeight(invokers.get(i), invocation);
// 获取最大和最小权重
maxWeight = Math.max(maxWeight, weight);
minWeight = Math.min(minWeight, weight);
if (weight > 0) {
// 将 weight 封装到 IntegerWrapper 中
invokerToWeightMap.put(invokers.get(i), new IntegerWrapper(weight));
// 累加权重
weightSum += weight;
}
}
// 查找 key 对应的对应 AtomicPositiveInteger 实例,为空则创建。
// 这里可以把 AtomicPositiveInteger 看成一个黑盒,大家只要知道
// AtomicPositiveInteger 用于记录服务的调用编号即可。至于细节,
// 大家如果感兴趣,可以自行分析
AtomicPositiveInteger sequence = sequences.get(key);
if (sequence == null) {
sequences.putIfAbsent(key, new AtomicPositiveInteger());
sequence = sequences.get(key);
}
// 获取当前的调用编号
int currentSequence = sequence.getAndIncrement();
// 如果最小权重小于最大权重,表明服务提供者之间的权重是不相等的
if (maxWeight > 0 && minWeight < maxWeight) {
// 使用调用编号对权重总和进行取余操作
int mod = currentSequence % weightSum;
// 进行 maxWeight 次遍历
for (int i = 0; i < maxWeight; i++) {
// 遍历 invokerToWeightMap
for (Map.Entry, IntegerWrapper> each : invokerToWeightMap.entrySet()) {
// 获取 Invoker
final Invoker k = each.getKey();
// 获取权重包装类 IntegerWrapper
final IntegerWrapper v = each.getValue();
// 如果 mod = 0,且权重大于0,此时返回相应的 Invoker
if (mod == 0 && v.getValue() > 0) {
return k;
}
// mod != 0,且权重大于0,此时对权重和 mod 分别进行自减操作
if (v.getValue() > 0) {
v.decrement();
mod--;
}
}
}
}
// 服务提供者之间的权重相等,此时通过轮询选择 Invoker
return invokers.get(currentSequence % length);
}
// IntegerWrapper 是一个 int 包装类,主要包含了一个自减方法。
private static final class IntegerWrapper {
private int value;
public void decrement() {
this.value--;
}
// 省略部分代码
}
}
待更新
五分钟理解一致性哈希算法(consistent hashing)