文章目录
DUBBO负载均衡算法及源码分析
前言
下面是dubbo官方集群容错的布局图,集群容错有以下组件,Cluster、Cluster Invoker、Directory、Router 和 LoadBalance 等。这边分析下LoadBalance (负载均衡模块) ,LoadBalance 仍然是在消费者端实现的功能。
后文源码 dubbo版本2.6.1
类结构
由下图可知,Dubbo共提供了四种负载均衡策略,分别是基于权重随机算法的 RandomLoadBalance(默认实现)、基于最少活跃调用数算法的 LeastActiveLoadBalance、基于 hash 一致性的 ConsistentHashLoadBalance,以及基于加权轮询算法的 RoundRobinLoadBalance。
其中所有的实现类继承与AbstractLoadBalance,该类提供模板方法
/**
* select one invoker in list.
*
* 从 Invoker 集合中,选择一个
*
* @param invokers invokers. Directory模块查到的列表
* @param url refer url
* @param invocation invocation.
* @return selected invoker.
*/
@Override
public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
if (invokers == null || invokers.isEmpty()) {
return null;
}
//如果长度为1 直接返回
if (invokers.size() == 1) {
return invokers.get(0);
}
//留给具体子类实现
return doSelect(invokers, url, invocation);
}
随机权重-RandomLoadBalance
算法思想
下面讲解的时权重不相等的情况,如果权重都相等,采用随机算法从0到n-1中随机获取一个整数,取对应下标的invoker。
第一步:对所有的invoker列表权重求和,和为totalWeight。
第二步:在0-totalWeight中取一个随机整数offset。
第三步:offset依次减去每一个的invoker的权重,offset小于0时终止条件。
offset每次减去一个权重,其实主要为了看offset落在上图的对应哪一个区间,每个区间的大小是该机器的权重,所以达到了随机权重的作用。
源码分析
主要代码-doSelect
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
int length = invokers.size(); // Number of invokers
int totalWeight = 0; // The sum of weights
boolean sameWeight = true; // Every invoker has the same weight?
// 计算总权限
for (int i = 0; i < length; i++) {
int weight = getWeight(invokers.get(i), invocation); // 获得权重
totalWeight += weight; // Sum
if (sameWeight && i > 0 && weight != getWeight(invokers.get(i - 1), invocation)) {
sameWeight = false;
}
}
// 权重不相等,随机后,判断在哪个 Invoker 的权重区间中
if (totalWeight > 0 && !sameWeight) {
// 随机
// If (not every invoker has the same weight & at least one invoker's weight>0), select randomly based on totalWeight.
int offset = random.nextInt(totalWeight);
// Return a invoker based on the random value.
// 区间判断
for (Invoker<T> invoker : invokers) {
offset -= getWeight(invoker, invocation);
if (offset < 0) {
return invoker;
}
}
}
// 权重相等,平均随机
// If all invokers have the same weight value or totalWeight=0, return evenly.
return invokers.get(random.nextInt(length));
}
getweight分析
如果远程URL配置了remote.timestamp参数 ,权重采用动态获取。为了防止在做服务扩容的时候,扩容的机器,如果是固定的权重,可能一下子接收了大量的请求,导致服务波动严重。这边通过预热时长,和启动时长动态的调整了扩容服务的权重,规避的大量请求打入刚加入集群的新机器。
protected int getWeight(Invoker<?> invoker, Invocation invocation) {
// 获得 weight 配置,即服务权重。默认为 100
int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), Constants.WEIGHT_KEY, Constants.DEFAULT_WEIGHT);
if (weight > 0) {
long timestamp = invoker.getUrl().getParameter(Constants.REMOTE_TIMESTAMP_KEY, 0L);
if (timestamp > 0L) {
// 获得启动总时长
int uptime = (int) (System.currentTimeMillis() - timestamp);
// 获得预热需要总时长。默认为 10 * 60 * 1000 = 10 分钟
int warmup = invoker.getUrl().getParameter(Constants.WARMUP_KEY, Constants.DEFAULT_WARMUP);
// 处于预热中,计算当前的权重
if (uptime > 0 && uptime < warmup) {
weight = calculateWarmupWeight(uptime, warmup, weight);
}
}
}
return weight;
}
static int calculateWarmupWeight(int uptime, int warmup, int weight) {
// 计算权重
int ww = (int) ((float) uptime / ((float) warmup / (float) weight));
// 权重范围为 [0, weight] 之间
return ww < 1 ? 1 : (ww > weight ? weight : ww);
}
加权轮询-RoundRobinLoadBalance
算法思想
在讲加权轮询时,我们先看一下轮询如何实现,现有服务提供者A,B,C。我们将第一个请求分配给服务器 A,第二个请求分配给服务器 B,第三个请求分配给服务器 C,第四个请求再次分配给服务器 A。这个过程就叫做轮询。
加权轮询:由于每一台机器的性能是有差异的,或者我们生产有多机房,机房之间请求也不是平均的,所以对不同机器配置不同权重是很常见的。现在A,B,C的权重为5,2,1 我们要达到在8次请求中,服务器 A 将收到其中的5次请求,服务器 B 会收到其中的2次请求,服务器 C 则收到其中的1次请求。
步骤分析
第一步:采用一个全局map保存每个服务和服务的请求次数
第二步:计算总的列表权重weightSum,并提取每个invoker和他的权重放进局部invokerToWeightMap中
第三步:用当前轮询序号与服务提供者总权重取模,余数为mod-从0取到weightSum
第四步:外循环,从0-maxweight,由于maxweight*length >= weightSum 保证了对应mod可以递减到0
第五步:内循环,对invokerToWeightMap进行遍历,当取到mod为0,且该invoker的权重还大于0的时候,返回,否则mod–,invoker.weight–;
下面是官方文档的一个案例
假设我们有三台服务器 servers = [A, B, C],对应的权重为 weights = [2, 5, 1]。接下来对上面的逻辑进行简单的模拟。
mod = 0:满足条件,此时直接返回服务器 A
mod = 1:需要进行一次递减操作才能满足条件,此时返回服务器 B
mod = 2:需要进行两次递减操作才能满足条件,此时返回服务器 C
mod = 3:需要进行三次递减操作才能满足条件,经过递减后,服务器权重为 [1, 4, 0],此时返回服务器 A
mod = 4:需要进行四次递减操作才能满足条件,经过递减后,服务器权重为 [0, 4, 0],此时返回服务器 B
mod = 5:需要进行五次递减操作才能满足条件,经过递减后,服务器权重为 [0, 3, 0],此时返回服务器 B
mod = 6:需要进行六次递减操作才能满足条件,经过递减后,服务器权重为 [0, 2, 0],此时返回服务器 B
mod = 7:需要进行七次递减操作才能满足条件,经过递减后,服务器权重为 [0, 1, 0],此时返回服务器 B
源码分析
/**
* 服务方法与计数器的映射
*
* KEY:serviceKey + "." + methodName
*/
private final ConcurrentMap<String, AtomicPositiveInteger> sequences = new ConcurrentHashMap<String, AtomicPositiveInteger>();
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
int length = invokers.size(); // Number of invokers
int maxWeight = 0; // The maximum weight
int minWeight = Integer.MAX_VALUE; // The minimum weight
final LinkedHashMap<Invoker<T>, IntegerWrapper> invokerToWeightMap = new LinkedHashMap<Invoker<T>, IntegerWrapper>();
int weightSum = 0;
// 计算最小、最大权重,总的权重和。
for (int i = 0; i < length; i++) {
int weight = getWeight(invokers.get(i), invocation);
maxWeight = Math.max(maxWeight, weight); // Choose the maximum weight
minWeight = Math.min(minWeight, weight); // Choose the minimum weight
if (weight > 0) {
invokerToWeightMap.put(invokers.get(i), new IntegerWrapper(weight));
weightSum += weight;
}
}
// 获得 AtomicPositiveInteger 对象
AtomicPositiveInteger sequence = sequences.get(key);
if (sequence == null) {
// 不存在的话 初始当前服务请求计数器
sequences.putIfAbsent(key, new AtomicPositiveInteger());
sequence = sequences.get(key);
}
// 返回当前顺序号,并递增 + 1
int currentSequence = sequence.getAndIncrement();
// 权重不相等,顺序根据权重分配
if (maxWeight > 0 && minWeight < maxWeight) {
int mod = currentSequence % weightSum; // 剩余权重
for (int i = 0; i < maxWeight; i++) { // 循环最大权重
for (Map.Entry<Invoker<T>, IntegerWrapper> each : invokerToWeightMap.entrySet()) { // 循环 Invoker 集合
final Invoker<T> k = each.getKey();
final IntegerWrapper v = each.getValue();
// 剩余权重归 0 ,当前 Invoker 还有剩余权重,返回该 Invoker 对象
if (mod == 0 && v.getValue() > 0) {
return k;
}
// 若 Invoker 还有权重值,扣除它( value )和剩余权重( mod )。
if (v.getValue() > 0) {
v.decrement();
mod--;
}
}
}
}
// 权重相等,平均顺序获得
// Round robin
return invokers.get(currentSequence % length);
}
通过源码分析该算法采用了两重循环,如果机器权重配置差异过大,实际上性能会有点慢,所以高版本做了后续的几次优化。
最少活跃连接数-LeastActiveLoadBalance
算法思想
在分析源码前,我们先来了解一个概念—活跃连接数(active),active表示该服务提供者的当前服务活跃程度,初始情况下,活跃度为0,每收到(发出)一个请求,活跃数加1,完成请求时减1。在服务运行中,服务提供者处理的请求越快,则其活跃度下降的越快,所以最少活跃连接数的服务提供者,相对应的其性能最好,所以此负载均衡算法是基于性能为参考依据,进行负载。
活跃连接数也作用于并发限流,ActiveLimitFilter(服务消费者端),ExecuteLimitFilter(服务提供者端)
补充:如果存在多个最小活跃连接数的服务提供者,则对该集合采用随机权重的获取方法得到对应的invoker。
源码分析
/**
* 最少活跃调用数,相同活跃数的随机,活跃数指调用前后计数差。
* 使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大。
*
**/
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
int length = invokers.size(); // invokers列表总个数
int leastActive = -1; // 最小的活跃数
int leastCount = 0; // 相同最小活跃数的个数
int[] leastIndexes = new int[length]; // 相同最小活跃数的下标
int totalWeight = 0; // 总权重
int firstWeight = 0; // 第一个权重,用于于计算是否相同
boolean sameWeight = true; // 是否所有权重相同
// 计算获得相同最小活跃数的数组和个数
for (int i = 0; i < length; i++) {
Invoker<T> invoker = invokers.get(i);
//通过RpcStatus获取该invokers的活跃数
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) {
leastActive = active; // 记录最小活跃数
leastCount = 1; // 重新统计相同最小活跃数的个数
leastIndexes[0] = i; // 重新记录最小活跃数下标
totalWeight = weight; // 重新累计总权重
firstWeight = weight; // 记录第一个权重
sameWeight = true; // 还原权重相同标识
} else if (active == leastActive) { // 累计相同最小的活跃数
leastIndexes[leastCount++] = i; // 累计相同最小活跃数下标
totalWeight += weight; // 累计总权重
// 判断所有权重是否一样
if (sameWeight && weight != firstWeight) {
sameWeight = false;
}
}
}
// 如果只有一个最小则直接返回
if (leastCount == 1) {
return invokers.get(leastIndexes[0]);
}
// 如果权重不相同且权重大于0则按总权重数随机
if (!sameWeight && totalWeight > 0) {
int offsetWeight = random.nextInt(totalWeight);
// 并确定随机值落在哪个片断上
for (int i = 0; i < leastCount; i++) {
int leastIndex = leastIndexes[i];
offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
if (offsetWeight <= 0) {
return invokers.get(leastIndex);
}
}
}
// 如果权重相同或权重为0则均等随机
return invokers.get(leastIndexes[random.nextInt(leastCount)]);
}
一致性hash-ConsistentHashLoadBalance
一致性哈希算法保证相同的请求参数,每次请求到相同的服务器上面,
算法思想
首先存在一个hash环,原算法包含(232次方 - 1)个节点,dubbo采用了一个treeMap在虚拟构造该环。
然后,所有节点分布在环上面,如果一次采用一个节点 可能有多种情况的分布,如下图。
可以看到,如果一个服务提供者值在环上只有一个节点的话,分布是各种各样的,极有可能存在数据倾斜,为了避免这个问题,提出了虚拟节点的概念。dubbo默认为每一个提供者设置160个虚拟节点,每四个节点为一组,然后所有虚拟节点均匀分布在环上面。
在做服务选择时,先从请求参数中构造keyString,并采用同样MD5摘要算法,然后做hash求具体key,在哈希换上取距离key最近的服务提供者如果不存在,选择第一个。
源码分析
public class ConsistentHashLoadBalance extends AbstractLoadBalance {
public static final String NAME = "consistenthash";
/**
* Hash nodes name 哈希node键
*/
public static final String HASH_NODES = "hash.nodes";
/**
* Hash arguments name
*/
public static final String HASH_ARGUMENTS = "hash.arguments";
/**
* 服务方法和一致性哈希选择器的映射 key为serviceKey + "."methodName
*/
private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors = new ConcurrentHashMap<String, ConsistentHashSelector<?>>();
@SuppressWarnings("unchecked")
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
String methodName = RpcUtils.getMethodName(invocation);
// 构造key
String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
// 基于invokers 根据对象内存地址获取系统的hashCode
int identityHashCode = System.identityHashCode(invokers);
// 获取当前服务的选择器
ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
// 如果选择器为空 或者hash值改变了 重新创建选择器
if (selector == null || selector.identityHashCode != identityHashCode) {
selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, identityHashCode));
selector = (ConsistentHashSelector<T>) selectors.get(key);
}
return selector.select(invocation);
}
private static final class ConsistentHashSelector<T> {
// 虚拟节点和invoker的对应映射
private final TreeMap<Long, Invoker<T>> virtualInvokers;
// 虚拟节点的个数
private final int replicaNumber;
// 定义的哈希值
private final int identityHashCode;
//
private final int[] argumentIndex;
ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
this.identityHashCode = identityHashCode;
URL url = invokers.get(0).getUrl();
// 初始化虚拟节点个数 默认160个
this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
// 初始化argumentIndex
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]);
}
// 构造virtualInvokers
for (Invoker<T> invoker : invokers) {
String address = invoker.getUrl().getAddress();
// 每四个虚拟节点一组 保证digest的充分利用 提高效率
for (int i = 0; i < replicaNumber / 4; i++) {
// 获取一个唯一的摘要
byte[] digest = md5(address + i);
for (int h = 0; h < 4; h++) {
// 对四个中每一个虚拟节点取hash,作为key 并均匀分布在hash环上面
long m = hash(digest, h);
virtualInvokers.put(m, invoker);
}
}
}
}
public Invoker<T> select(Invocation invocation) {
//基于方法参数 作为key
String key = toKey(invocation.getArguments());
// 对key计算md5 同样采用MD5 便于计算long型的key
byte[] digest = md5(key);
// 先计算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<T> selectForKey(long hash) {
//返回与大于或等于给定键的最小键关联的键值映射,如果没有这样的键,则返回null
Map.Entry<Long, Invoker<T>> entry = virtualInvokers.ceilingEntry(hash);
if (entry == null) {
// 不存在,则取 virtualInvokers 第一个
entry = virtualInvokers.firstEntry();
}
return entry.getValue();
}
/**
* hash算法 digest每四位进行拼接位一个32的long数据
*/
private long hash(byte[] digest, int number) {
return (((long) (digest[3 + number * 4] & 0xFF) << 24)
| ((long) (digest[2 + number * 4] & 0xFF) << 16)
| ((long) (digest[1 + number * 4] & 0xFF) << 8)
| (digest[number * 4] & 0xFF))
& 0xFFFFFFFFL;
}
/**
* md5摘要算法 返回一个128位的byte数组,数组长度位16
*/
private byte[] md5(String value) {
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e.getMessage(), e);
}
md5.reset();
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
md5.update(bytes);
return md5.digest();
}
}
}
补充
- 所谓数据倾斜是指,由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到了少量请求的情况。
- 如果服务提供者有变动(新增或者下线),则需要重新构造一致性选择器,此时会有额外的性能问题。
- 一致性哈希算法中每个节点都是均匀分配的,如果性能不同的机器获取到了相同的请求,则其请求时间会有不同。
- 大多数请求都是不带状态的,只请求一次就结束了,所以一般不采用一致性哈希路由算法,该算法常用于数据库,缓存集群的方案配置
总结
这次分析了dubbo的负载均衡源码,我们常用的是随机权重,加权轮询,最少活跃连接数这三种算法,其中加权轮询在不同版本分别做了优化,后续有多余时间再进行研究。我们应该根据机器性能合理的配置权重,一开始如果是相同的机器的话,可以采用加权轮询,但我们后续难免会对机器扩容,如果存在新老机器都存在的情况,就需要配置不同的权重了。
另外我们学习了一致性hash算法,一致性hash算法主要解决了常规hash算法在分布式环境下存在的 问题,如果服务节点增加或者减少,其对hash值进行取模时,由于余数变化,所有的路由关系或者数据映射都会混乱,从而请求不能请求到原先的机器。
即使坠落谷底,也要绝地反击。