本文讲述的是”将外部发送来的请求均匀分配到对称结构中的某一台服务器上”的各种算法,并以Java代码演示每种算法的具体实现,OK,下面进入正题,在进入正题前,先写一个类来模拟Ip列表:

import java.util.HashMap;
public class IpMap   {
// 待路由的Ip列表,Key代表Ip,Value代表该Ip的权重
public static HashMap serverWeightMap =
new HashMap();
static
{
serverWeightMap.put("192.168.1.100", 1);
serverWeightMap.put("192.168.1.101", 1);
// 权重为4
serverWeightMap.put("192.168.1.102", 4);
serverWeightMap.put("192.168.1.103", 1);
serverWeightMap.put("192.168.1.104", 1);
// 权重为3
serverWeightMap.put("192.168.1.105", 3);
serverWeightMap.put("192.168.1.106", 1);
// 权重为2
serverWeightMap.put("192.168.1.107", 2);
serverWeightMap.put("192.168.1.108", 1);
serverWeightMap.put("192.168.1.109", 1);
serverWeightMap.put("192.168.1.110", 1);
}
}

一、轮询(Round Robin)

轮询调度算法的原理是每一次把来自用户的请求轮流分配给内部中的服务器,从1开始,直到N(内部服务器个数),然后重新开始循环。算法的优点是其简洁性,它无需记录当前所有连接的状态,所以它是一种无状态调度。

其代码实现大致如下:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class RoundRobin   {
private static Integer pos = 0;
public static String getServer()
{
// 重建一个Map,避免服务器的上下线导致的并发问题
Map serverMap =
new HashMap();
serverMap.putAll(IpMap.serverWeightMap);
// 取得Ip地址List
Set keySet = serverMap.keySet();
ArrayList keyList = new ArrayList();
keyList.addAll(keySet);
String server = null;
synchronized (pos)
{
if (pos > keySet.size())
pos = 0;
server = keyList.get(pos);
pos ++;
}
return server;
}
}

由于serverWeightMap中的地址列表是动态的,随时可能有机器上线、下线或者宕机,因此为了避免可能出现的并发问题,方法内部要新建局部变量serverMap,现将serverMap中的内容复制到线程本地,以避免被多个线程修改。这样可能会引入新的问题,复制以后serverWeightMap的修改无法反映给serverMap,也就是说这一轮选择服务器的过程中,新增服务器或者下线服务器,负载均衡算法将无法获知。新增无所谓,如果有服务器下线或者宕机,那么可能会访问到不存在的地址。因此,服务调用端需要有相应的容错处理,比如重新发起一次server选择并调用。

对于当前轮询的位置变量pos,为了保证服务器选择的顺序性,需要在操作时对其加锁,使得同一时刻只能有一个线程可以修改pos的值,否则当pos变量被并发修改,则无法保证服务器选择的顺序性,甚至有可能导致keyList数组越界。

轮询法的优点在于:试图做到请求转移的绝对均衡。

轮询法的缺点在于:为了做到请求转移的绝对均衡,必须付出相当大的代价,因为为了保证pos变量修改的互斥性,需要引入重量级的悲观锁synchronized,这将会导致该段轮询代码的并发吞吐量发生明显的下降。

二、随机(Random)

通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的一台服务器进行访问。由概率统计理论可以得知,随着客户端调用服务端的次数增多,

其实际效果越来越接近于平均分配调用量到后端的每一台服务器,也就是轮询的结果。

随机法的代码实现大致如下:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
class Random   {
public static String getServer()
{
// 重建一个Map,避免服务器的上下线导致的并发问题
Map serverMap =
new HashMap();
serverMap.putAll(IpMap.serverWeightMap);
// 取得Ip地址List
Set keySet = serverMap.keySet();
ArrayList keyList = new ArrayList();
keyList.addAll(keySet);
java.util.Random random = new java.util.Random();
int randomPos = random.nextInt(keyList.size());
return keyList.get(randomPos);
}
}

整体代码思路和轮询法一致,先重建serverMap,再获取到server列表。在选取server的时候,通过Random的nextInt方法取0~keyList.size()区间的一个随机值,从而从服务器列表中随机获取到一台服务器地址进行返回。基于概率统计的理论,吞吐量越大,随机算法的效果越接近于轮询算法的效果。

三、源地址哈希(Hash)

源地址哈希的思想是根据获取客户端的IP地址,通过哈希函数计算得到的一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同一IP地址的客户端,当后端服务器列表不变时,它每次都会映射到同一台后端服务器进行访问。

源地址哈希算法的代码实现大致如下:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
class Hash      {
public static String getServer()
{
// 重建一个Map,避免服务器的上下线导致的并发问题
Map serverMap =
new HashMap();
serverMap.putAll(IpMap.serverWeightMap);
// 取得Ip地址List
Set keySet = serverMap.keySet();
ArrayList keyList = new ArrayList();
keyList.addAll(keySet);
// 在Web应用中可通过HttpServlet的getRemoteIp方法获取
String remoteIp = "127.0.0.1";
int hashCode = remoteIp.hashCode();
int serverListSize = keyList.size();
int serverPos = hashCode % serverListSize;
return keyList.get(serverPos);
}
}

前两部分和轮询法、随机法一样就不说了,差别在于路由选择部分。通过客户端的ip也就是remoteIp,取得它的Hash值,对服务器列表的大小取模,结果便是选用的服务器在服务器列表中的索引值。

源地址哈希法的优点在于:保证了相同客户端IP地址将会被哈希到同一台后端服务器,直到后端服务器列表变更。根据此特性可以在服务消费者与服务提供者之间建立有状态的session会话。

源地址哈希算法的缺点在于:除非集群中服务器的非常稳定,基本不会上下线,否则一旦有服务器上线、下线,那么通过源地址哈希算法路由到的服务器是服务器上线、下线前路由到的服务器的概率非常低,如果是session则取不到session,如果是缓存则可能引发”雪崩”。

四、加权轮询(Weight Round Robin)

不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更高的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。

加权轮询法的代码实现大致如下:

import java.util.*;
class WeightRoundRobin   {
private static Integer pos;
public static String getServer()
{
// 重建一个Map,避免服务器的上下线导致的并发问题
Map serverMap =
new HashMap();
serverMap.putAll(IpMap.serverWeightMap);
// 取得Ip地址List
Set keySet = serverMap.keySet();
Iterator iterator = keySet.iterator();
List serverList = new ArrayList();
while (iterator.hasNext())
{
String server = iterator.next();
int weight = serverMap.get(server);
for (int i = 0; i < weight; i++)
serverList.add(server);
}
String server = null;
synchronized (pos)
{
if (pos > keySet.size())
pos = 0;
server = serverList.get(pos);
pos ++;
}
return server;
}
}

与轮询法类似,只是在获取服务器地址之前增加了一段权重计算的代码,根据权重的大小,将地址重复地增加到服务器地址列表中,权重越大,该服务器每轮所获得的请求数量越多。

五、加权随机(Weight Random)

与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。

import java.util.*;
class WeightRandom   {
public static String getServer()
{
// 重建一个Map,避免服务器的上下线导致的并发问题
Map serverMap =
new HashMap();
serverMap.putAll(IpMap.serverWeightMap);
// 取得Ip地址List
Set keySet = serverMap.keySet();
Iterator iterator = keySet.iterator();
List serverList = new ArrayList();
while (iterator.hasNext())
{
String server = iterator.next();
int weight = serverMap.get(server);
for (int i = 0; i < weight; i++)
serverList.add(server);
}
java.util.Random random = new java.util.Random();
int randomPos = random.nextInt(serverList.size());
return serverList.get(randomPos);
}
}

六、最小连接数法

前面我们费尽心思来实现服务消费者请求次数分配的均衡,我们知道这样做是没错的,可以为后端的多台服务器平均分配工作量,最大程度地提高服务器的利用率,但是,实际上,请求次数的均衡并不代表负载的均衡。因此我们需要介绍最小连接数法,最小连接数法比较灵活和智能,由于后台服务器的配置不尽相同,对请求的处理有快有慢,它正是根据后端服务器当前的连接情况,动态的选取其中当前积压连接数最少的一台服务器来处理当前请求,尽可能的提高后台服务器利用率,将负载合理的分流到每一台服务器。

import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;
public class LeastBalance {
public static String getServer() {
Map serverMap = new TreeMap<>(ConnectionsServerManager.serverMap);
Iterator iterator = serverMap.keySet().iterator();
ConnectionsServer minConnectionsServer = null;
while (iterator.hasNext()){
ConnectionsServer server = serverMap.get(iterator.next());
if(minConnectionsServer == null){
minConnectionsServer = server;
}
if(minConnectionsServer.getConnnections() > server.getConnnections()){
minConnectionsServer = server;
}
}
minConnectionsServer.setConnnections(minConnectionsServer.getConnnections() + 1);
ConnectionsServerManager.serverMap.put(minConnectionsServer.getServer(), minConnectionsServer);
System.out.println(String.format("ip=%s, connections=%s",minConnectionsServer.getServer(), minConnectionsServer.getConnnections()));
return minConnectionsServer.getServer();
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
String server = getServer();
}
}
}

七、最小延迟(Latency-Aware)

与最小连接数类似,该方法也是为了让性能强的机器处理更多的请求,只不过最小连接数使用的指标是连接数,而该方法用的请求服务器的往返延迟(RTT),动态地选择延迟最低的节点处理当前请求。该方法的计算延迟的具体实现可以用EWMA算法来实现,它使用滑动窗口来计算移动平均耗时。具体代码如下:

public class EWMA {
private static final long serialVersionUID = 2979391326784043002L;
//时间类型枚举
public static enum Time {
MICROSECONDS(1),
MILLISECONDS(1000),
SECONDS(MILLISECONDS.getTime() * 1000),
MINUTES(SECONDS.getTime() * 60),
HOURS(MINUTES.getTime() * 60),
DAYS(HOURS.getTime() * 24),
WEEKS(DAYS.getTime() * 7);
private long micros;
private Time(long micros) {
this.micros = micros;
}
public long getTime() {
return this.micros;
}
}
//三个alpha常量,这些值和Unix系统计算负载时使用的标准alpha值相同
public static final double ONE_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 1d);
public static final double FIVE_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 5d);
public static final double FIFTEEN_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 15d);
private long window;
private long alphaWindow;
private long last;
private double average;
private double alpha = -1D;
private boolean sliding = false;
private long requests;//请求量
private double weight;//权重
public EWMA() {
}
public EWMA sliding(double count, Time time) {
return this.sliding((long) (time.getTime() * count));
}
public EWMA sliding(long window) {
this.sliding = true;
this.window = window;
return this;
}
public EWMA withAlpha(double alpha) {
if (!(alpha > 0.0D && alpha <= 1.0D)) {
throw new IllegalArgumentException("Alpha must be between 0.0 and 1.0");
}
this.alpha = alpha;
return this;
}
public EWMA withAlphaWindow(long alphaWindow) {
this.alpha = -1;
this.alphaWindow = alphaWindow;
return this;
}
public EWMA withAlphaWindow(double count, Time time) {
return this.withAlphaWindow((long) (time.getTime() * count));
}
/**
* 默认使用当前时间更新移动平均值
*/
public void mark(){
mark(System.currentTimeMillis());
}
/**
* 更新移动平均值
* @param time
*/
public void mark(long time){
if(this.sliding){
//如果发生时间间隔大于窗口,则重置滑动窗口
if(time-this.last > this.window){
this.last = 0;
}
}
if(this.last == 0){
this.average = 0;
this.requests = 0;
this.last = time;
}
// 计算上一次和本次的时间差
long diff = time - this.last;
// 计算alpha
double alpha = this.alpha != -1.0 ? this.alpha : Math.exp(-1.0*((double)diff/this.alphaWindow));
// 计算当前平均值
this.average = (1.0-alpha)*diff + alpha*this.average;
this.last = time;
// 请求量加1
this.requests++;
// 计算权重值
// this.weight = this.average != 0 ? this.requests/this.average : -1;
}
//返回mark()方法多次调用的平均值
public double getAverage() {
return this.average;
}
//按照特定的时间单位来返回平均值,单位详见Time枚举
public double getAverageIn(Time time) {
return this.average == 0.0 ? this.average : this.average / time.getTime();
}
//返回特定时间度量内调用mark()的频率
public double getAverageRatePer(Time time) {
return this.average == 0.0 ? this.average : time.getTime() / this.average;
}
//返回mark()方法多次调用的权重值
public double getWeight() {return this.weight;}
public long getRequests() {return this.requests;}
public static   void main(String[] args) {
//建立1分钟滑动窗口EWMA实例
EWMA ewma = new EWMA().
sliding(1.0, Time.MINUTES).
withAlpha(EWMA.ONE_MINUTE_ALPHA).
withAlphaWindow(1.0, EWMA.Time.MINUTES);
int randomSleep = 0;
long markVal = System.currentTimeMillis() * 1000;//单位为微秒
try {
ewma.mark(markVal);
for (int i = 1; i <= 10000000; i++) {
randomSleep = util.get_rand_32() % 1500;
markVal += randomSleep;
ewma.mark(markVal);
if (i % 1000 == 0) {
System.out.println("Round: " + i + ", Time: " + randomSleep
+ ", Requests: " + ewma.getRequests()
+ ", Average: " + ewma.getAverage()
+ ", Weight:" + ewma.getWeight());
}
}
}
catch (Exception e) {
e.printStackTrace();
}
}
}