负载均衡设计与实现

1,322 阅读4分钟

前言

在服务器部署中,为了避免单点故障,通常将服务采用集群化部署,那么消费者应该调用那个服务提供者实例呢?这就涉及到服务的负载均衡(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]

生成的每个数字和策略选择的节点编号有如下对于关系:

随机数字节点编号
01
12
22
33
43
53

可以看到节点编号出现的次数确实符合每个节点的权重

由于是随机数,生成[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. 一致性哈希

一致性哈希算法更多用于数据,缓存存储时的路由策略。因为其需要考虑增加删除节点后的数据的失效范围,及数据迁移成本。但一般的服务器集群请求调用都是无状态,每次是否请求到同一节点关系不大,都能正常执行业务并返回响应。在某些情况下可能需要,例如会话保持需求

一般情况下哈希是不一致的,这里的不一致指当节点数量发生变化后,请求很可能不会分配到原先的节点,这在某些场景下是难以接受的,例如文件存储系统,相当于每次增加节点,都需要将所有节点的所有文件重新分配,以免出现文件找不到的情况

一致性哈希算法需要保持以下特性:

  • 单调性:若已经有一些请求已经分配到一些节点上,此时新增加了一个节点,需要保证这些请求需要被分配到原有节点或新增节点上。该条性质使得一致性哈希区别于其他分配策略
  • 平衡性:尽量保证每个节点处理请求个数均匀。体现在实现上则为哈希环中每个节点距离尽量相等。若计算哈希值比较集中,考虑换均匀性更好的哈希函数

这里主要介绍一致性哈希能带来的收益,具体原理可参考聊聊一致性哈希

总结

本文介绍了几种常见的负载均衡策略,这些策略实现方式不同,但从不同的角度思考如何分配请求,使得负载能够均衡的问题。大家也可以针对具体业务构想自己的负载均衡策略

参考文档

平滑加权轮询怎么理解?

聊聊一致性哈希