前言
在服务器部署中,为了避免单点故障,通常将服务采用集群化部署,那么消费者应该调用那个服务提供者实例呢?这就涉及到服务的负载均衡(load balance)。一般情况下,负载均衡策略需要将请求均匀分配到各个服务节点,避免出现请求集中在某一点上的情况。有时会考虑节点权重,会话粘连等需求
按照执行负载均衡策略的角色可分为客户端或服务端负载均衡,但无论哪种方式,其适用的负载均衡策略是相通的,常见的几种负载均衡策略如下:
- 随机
- 轮询
- 最小活跃数
- 一致性哈希
每种策略都有其适用的场景,下面依次介绍
1. 随机
顾名思义,随机策略会从服务列表中随机选择一个节点进行服务调用,简单的实现如下所示
private Node select(List<Node> servers){
Random r = new Random();
int serverSize = servers.size();
return servers.get(r.nextInt(serverSize));
}
若每个节点的权重不一样(例如某个节点配置较好,则权重较大,预期承担更多的请求),则需要按照概率进行随机,使得权重更高的节点有更高的概率被随机到
权重既可以是预先静态配置,也可以根据系统运行动态变化,例如节点刚启动时有预热阶段,一开始权重可以很小,随着时间慢慢增加到预定义权重
下面是一段按权重随机的实现代码:
private Node select(List<Node> servers){
// 计算所有节点权重和
int totalWeight = calTotalWeight(servers);
// 随机数,范围:[0,totalWeight)
int offset = new Random().nextInt(totalWeight);
for (int i = 0;i<servers.size();i++){
totalWeight -= servers.get(i).weight;
if(totalWeight < 0){
return servers.get(i);
}
}
return null;
}
这段代码能正确分配请求吗?我们一个例子分析,假设有权重分别为1,2,3的3节点集群,totalWeight为6,因此随机数offset范围为[0:5]
生成的每个数字和策略选择的节点编号有如下对于关系:
随机数字 | 节点编号 |
---|---|
0 | 1 |
1 | 2 |
2 | 2 |
3 | 3 |
4 | 3 |
5 | 3 |
可以看到节点编号出现的次数确实符合每个节点的权重
由于是随机数,生成[0:5]中每个数字的概率相同,因此选择到每个节点的总概率就等于其出现的次数占总次数的比例,也就等于其权重
可能有人认为,随机策略实现简单,会不会负载均衡效果不好。这种算法确实不能完全均匀地分配请求,特别是请求数小的时候,但随着请求数增大,最终负载结果是按照权重均匀分配的
2. 轮询
轮询策略为依次遍历节点列表,选择请求调用的服务节点。不同于随机策略,轮询策略会使得就算在请求量小的情况下,每个节点也能均匀地获得请求
同样,若节点间权重不同,某些节点理应承担更多的请求,因此在轮询时需要考虑权重因素。带权重的轮询一般分为普通权重轮询,以及平滑权重轮询
2.1 普通权重轮询
该算法比较简单,会在短时间内请求某个节点符合其权重占比的次数,再转移到下个节点继续请求
例如权重分别为1,2,3的3该节点A,B,C,会先请求A一次,再请求B两次,再请求C三次
以上操作确实将请求按照节点的权重进行了分配,但会在短时间内只请求一个节点,导致该节点压力过大,增大宕机风险,而其他节点闲置,资源没有有效利用。下面的平滑权重轮询能有效缓解该问题
2.2 平滑权重轮询
所谓平滑, 即在一定的时间内, 不仅服务器被选择的次数分布和权重一致,满足权重要求,且调度算法还能比较均匀的选择节点分配请求
先介绍该算法的实现,可能有点绕,后面会举例子详细说明,并简单证明其正确性
// 所有节点原始权重总和
private int totalWeight;
// 所有节点原始权重
private int[] nodeOriginWeight;
// 所有节点当前权重
private int[] nodeCurWeights;
totalWeight:保存所有节点的权重和,该值在后续流程中保持不变
nodeOriginWeight:保持每个节点的原始权重,在后续流程中也保持不变
nodeCurWeights:保存每个节点的当前权重,该数组在后续每次计算请求应该分配到哪个节点时都会发生变化,初始化为每个节点的权重
private Node select(List<Node> servers){
int maxIndex = 0;
int maxCurWeight = servers.get(0).weight;
// 找出当前权重最大的节点
for (int i = 1;i<nodeCurWeights.length;i++){
if(maxCurWeight < nodeCurWeights[i]){
maxCurWeight = i;
maxCurWeight = nodeCurWeights[i];
}
}
// 将当前权重最大的节点的的值减去totalWeight
nodeCurWeights[maxIndex] -= totalWeight;
// 将每个当前权重加上每个节点的原始权重
for (int i = 0;i<servers.size();i++){
nodeCurWeights[i] += nodeOriginWeight[i];
}
// 返回选中的节点
return servers.get(maxIndex);
}
每次选择节点,都会执行以下3步
- 选出当前权重中,值最大节点a
- 将a的当前权重值减去totalWeight
- 将每个当前权重加上每个节点的原始权重
下面举例说说明:
还是举例权重分别为1,2,3的3该节点A,B,C,用平滑轮询算法请求6次
请求次数 | 请求前nodeCurWeights | 请求后nodeCurWeights | 选择节点 |
---|---|---|---|
1 | [1,2,3] | [1,2,-3] | C |
2 | [2,4,0] | [2,-2,0] | B |
3 | [3,0,3] | [-3,0,3] | A(这里也可选C) |
4 | [-2,2,6] | [-2,2,0] | C |
5 | [-1,4,3] | [-1,-2,3] | B |
6 | [0,0,6] | [0,0,0] | C |
第7次请求前nodeCurWeights又会回到[1,2,3],开始新一轮循环
可以发现,前6次请求中有3次请求到C节点,2次请求到B节点,1次请求到A节点,每个节点请求次数占总次数的比例恰好符合其权重,且在轮询时会穿插其他节点轮询,避免的请求过于集中的问题
该算法比较抽象,为什么它能做到既满足权重要求,又可以穿插其他节点呢?
可以这么理解,该算法每次会选出最大当前权重的节点,并减去所有节点权重总和
因此,若某个节点增长越快,则越有概率被选中,而增长的速度和权重大小成正比,因此节点权重越大,越有概率被选中。相反或节点权重越小,增长成为最大当前权重节点的速度越慢,被选中的概率较低,从而达到按权重分配请求的效果
当每个节点被选中后,减去的值都相等,由于减去了一个较大的值(所有节点原始权重总和),使得该节点在下几次请求中,被选中的概率较低,因为恢复成为最大值需要时间。从而达到平滑的效果
3. 最小活跃数
活跃数指一个节点正在处理的请求个数
最小活跃数策略为,监控每个节点当前的活跃请求数,每次将请求分配到活跃数最小的节点
其基于的原理是:活跃请求数小的节点,其负载也可能小。一方面避免给活跃数已经很大的节点增大负担,一方面让活跃数最小的节点增加负载
那怎么知道每个节点的活跃数呢?可以在每个请求开始前给该节点的计数加1,请求结束将该节点的计数减一。若为客户端负载均衡,则这里只会记录当前客户端发起的请求。若为服务端负载均衡,可以记录到所有请求数,使得最小活跃数更为精确
该策略可配合随机及轮询使用,比如当出现多个节点的最小活跃数相同时,可以从这些相同的节点随机取一个,总体实现如下:
// 记录每个节点的活跃数
private Map<Node,Integer> activeCount;
private Node select(List<Node> servers){
// 记录拥有最小活跃数节点的下标,因为可能多个最小值相同,这里使用数组
int[] minIndexs = new int[servers.size()];
// 活跃数等于最小活跃数的节点个数
int nodeCount = 0;
int minActiveCount = Integer.MAX_VALUE;
for (int i = 0;i<servers.size();i++){
int curCount = activeCount.get(servers.get(i));
// 发现新的最小值
if(curCount < minActiveCount){
minActiveCount = curCount;
nodeCount = 0;
minIndexs[nodeCount++] = i;
// 和之前的最小值相同,加入minIndexes
} else if(curCount == minActiveCount){
minIndexs[nodeCount++] = i;
}
}
// 最小值只有一个,就是他!
if(nodeCount == 1){
return servers.get(minIndexs[0]);
}
// 多个节点活跃数相同,且都等于最小值,那么随机选一个
return servers.get(minIndexes[new Random().nextInt(minIndexs.length)]);
}
和其类似的还有最小响应时间策略,监控每个节点中对每个请求的响应时间,每次将请求分配到平均响应时间最小的节点,这里的隐含逻辑是:平均响应时间小的节点,负载也可能小
4. 一致性哈希
一致性哈希算法更多用于数据,缓存存储时的路由策略。因为其需要考虑增加删除节点后的数据的失效范围,及数据迁移成本。但一般的服务器集群请求调用都是无状态,每次是否请求到同一节点关系不大,都能正常执行业务并返回响应。在某些情况下可能需要,例如会话保持需求
一般情况下哈希是不一致的,这里的不一致指当节点数量发生变化后,请求很可能不会分配到原先的节点,这在某些场景下是难以接受的,例如文件存储系统,相当于每次增加节点,都需要将所有节点的所有文件重新分配,以免出现文件找不到的情况
一致性哈希算法需要保持以下特性:
- 单调性:若已经有一些请求已经分配到一些节点上,此时新增加了一个节点,需要保证这些请求需要被分配到原有节点或新增节点上。该条性质使得一致性哈希区别于其他分配策略
- 平衡性:尽量保证每个节点处理请求个数均匀。体现在实现上则为哈希环中每个节点距离尽量相等。若计算哈希值比较集中,考虑换均匀性更好的哈希函数
这里主要介绍一致性哈希能带来的收益,具体原理可参考聊聊一致性哈希
总结
本文介绍了几种常见的负载均衡策略,这些策略实现方式不同,但从不同的角度思考如何分配请求,使得负载能够均衡的问题。大家也可以针对具体业务构想自己的负载均衡策略