常见的负载均衡策略由轮询、加权轮询、随机、一致性hash、最短响应时间、最少连接数等,我们选取轮询、一致性hash、最短响应时间这三个比较具有代表性的算法一起来实现一下:
一、一致性hash
在rpc中可以使用一致性hash算法来解决负载均衡的问题,当然在学习一致性hash之前我们需要了解普通的hash算法有什么问题,按照传统的hash算法思路,我们需要构建一张hash表,将服务器挂载在hash表中,如下图:
但是,这样的方式会存在很多问题,如动态扩容的问题。比如,随着业务量增长,将原有的六个服务扩容至八个,此时,我们不仅要修改路由表,还要修改hash的路由策略。
一致性hash借鉴了hash算法的部分能力做了如下的设计,
1、将hash值均匀的分布在一个区间,我们一般将区间设置为整形的取值范围(-231 ~ 231-1)当然这个范围也可以是(0 ~ 232-1),只要是一个合理的容易计算的足够大的范围即可。
2、将这个区间构建成一个环,构建成环不一定必须要链表,其实很多的有序的数据结构都可以,比如数组,比如红黑树,只要加上一点点逻辑,就是数完最后一个回到第一个节点就可以了。
3、将服务器按照自身的特点,计算hash值,并将其挂载在hash表中。
那形成了这样的hash环后我们我们应该如何进行操作呢?
当请求进来以后,根据请求的部分特征,如url、请求id,请求来源等信息进行hash运算,看请求落在哪个范围,然后顺时针找到第一个服务器即可,这样最大的好处就是当有新的服务加入集群只需要将服务挂载在hash环即可,但是后自然会有流量进入该服务器,而不需要修改任何的逻辑,因为我们的hash环足够大,所以可以容纳的机器也很多。
**问题:**但是此时会出现一个问题,如果节点过少,hash分布不均匀会产生严重的流量倾斜:
为了解决这个问题,我们就需要引入虚拟节点的概念,我们可以将一个真实节点化身为n个(比如128)虚拟节点,每个虚拟节点都指向同一个服务,分别对虚拟节点进行hash,可以让一个服务的虚拟节点大致均匀的分布在hash环上,不要觉得他很神奇,代码实现其实很简单。当然虚拟节点数也可以乘以每个服务单位权重。
还是只有两个节点:
以下是代码实现:
@Slf4j
public class ConsistentHashBalancer extends AbstractLoadBalancer {
@Override
protected Selector getSelector(List<InetSocketAddress> serviceList) {
return new ConsistentHashSelector(serviceList,128);
}
/**
* 一致性hash的具体算法实现
*/
private static class ConsistentHashSelector implements Selector{
// hash环用来存储服务器节点
private SortedMap<Integer,InetSocketAddress> circle= new TreeMap<>();
// 虚拟节点的个数
private int virtualNodes;
public ConsistentHashSelector(List<InetSocketAddress> serviceList,int virtualNodes) {
// 我们应该尝试将节点转化为虚拟节点,进行挂载
this.virtualNodes = virtualNodes;
for (InetSocketAddress inetSocketAddress : serviceList) {
// 需要把每一个节点加入到hash环中
addNodeToCircle(inetSocketAddress);
}
}
@Override
public InetSocketAddress getNext() {
// 1、hash环已经建立好了,接下来需要对请求的要素做处理我们应该选择什么要素来进行hash运算
// 有没有办法可以获取,到具体的请求内容 --> threadLocal
YrpcRequest yrpcRequest = YrpcBootstrap.REQUEST_THREAD_LOCAL.get();
// 我们想根据请求的一些特征来选择服务器 id
String requestId = Long.toString(yrpcRequest.getRequestId());
// 请求的id做hash,字符串默认的hash不太好
int hash = hash(requestId);
// 判断该hash值是否能直接落在一个服务器上,和服务器的hash一样
if( !circle.containsKey(hash)){
// 寻找离我最近的一个节点
SortedMap<Integer, InetSocketAddress> tailMap = circle.tailMap(hash);
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}
return circle.get(hash);
}
/**
* 将每个节点挂载到hash环上
* @param inetSocketAddress 节点的地址
*/
private void addNodeToCircle(InetSocketAddress inetSocketAddress) {
// 为每一个节点生成匹配的虚拟节点进行挂载
for (int i = 0; i < virtualNodes; i++) {
int hash = hash(inetSocketAddress.toString() + "-" + i);
// 关在到hash环上
circle.put(hash,inetSocketAddress);
if(log.isDebugEnabled()){
log.debug("hash为[{}]的节点已经挂载到了哈希环上.",hash);
}
}
}
private void removeNodeFromCircle(InetSocketAddress inetSocketAddress) {
// 为每一个节点生成匹配的虚拟节点进行挂载
for (int i = 0; i < virtualNodes; i++) {
int hash = hash(inetSocketAddress.toString() + "-" + i);
// 挂载到hash环上
circle.remove(hash);
}
}
/**
* 具体的hash算法, todo 小小的遗憾,这样也是不均匀的
* @param s
* @return
*/
private int hash(String s) {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
byte[] digest = md.digest(s.getBytes());
// md5得到的结果是一个字节数组,但是我们想要int 4个字节
int res = 0;
for (int i = 0; i < 4; i++) {
res = res << 8;
if( digest[i] < 0 ){
res = res | (digest[i] & 255);
} else {
res = res | digest[i];
}
}
return res;
}
private String toBinary(int i){
String s = Integer.toBinaryString(i);
int index = 32 -s.length();
StringBuilder sb = new StringBuilder();
for (int j = 0; j < index; j++) {
sb.append(0);
}
sb.append(s);
return sb.toString();
}
}
}
二、心跳检测
事实上netty框架本身可以实现探活,可以自动感知channel的连接状态。但是我们为了和大家一起体验一把(说实话就是写的时候不知道),特意将相关的内容手动实现了一下。
其核心原理十分简单,就是定期向所有的channel发送一个简单的请求即可,如果能得到回应说明连接是正常的。
其中我们要在心跳探测的过程中完成以下几项工作:
1、如果可以正常访问,记录响应时间,以备后用。
2、如果不能正常访问,则进行重试,重试三次依旧不能访问,则从健康服务列表中剔除,以后的访问不会使用该连接。
注意:重试的等待时间我们选取一个合适范围内的随机时间,这样可以避免局域网络问题导致的大面积同时重试,产生重试风暴。
代码如下:
@Override
public void run() {
// 将响应时长的map清空
YrpcBootstrap.ANSWER_TIME_CHANNEL_CACHE.clear();
// 遍历所有的channel
Map<InetSocketAddress, Channel> cache = YrpcBootstrap.CHANNEL_CACHE;
for (Map.Entry<InetSocketAddress, Channel> entry : cache.entrySet()) {
// 定义一个重试的次数
int tryTimes = 3;
while (tryTimes > 0) {
// 通过心跳检测处理每一个channel
Channel channel = entry.getValue();
long start = System.currentTimeMillis();
// 构建一个心跳请求
YrpcRequest yrpcRequest =
... // 省略
.build();
// 4、写出报文
CompletableFuture<Object> completableFuture = new CompletableFuture<>();
// 将 completableFuture 暴露出去
YrpcBootstrap.PENDING_REQUEST.put(yrpcRequest.getRequestId(), completableFuture);
channel.writeAndFlush(yrpcRequest).addListener((ChannelFutureListener) promise -> {
if (!promise.isSuccess()) {
completableFuture.completeExceptionally(promise.cause());
}
});
Long endTime = 0L;
try {
// 阻塞方法,get()方法如果得不到结果,就会一直阻塞
// 我们想不一直阻塞可以添加参数
completableFuture.get(1, TimeUnit.SECONDS);
endTime = System.currentTimeMillis();
} catch (InterruptedException | ExecutionException | TimeoutException e) {
// 一旦发生问题,需要优先重试
tryTimes --;
log.error("和地址为【{}】的主机连接发生异常.正在进行第【{}】次重试......",
channel.remoteAddress(), 3 - tryTimes);
// 将重试的机会用尽,将失效的地址移出服务列表
if(tryTimes == 0){
YrpcBootstrap.CHANNEL_CACHE.remove(entry.getKey());
}
// 尝试等到一段时间后重试
try {
Thread.sleep(10*(new Random().nextInt(5)));
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
continue;
}
Long time = endTime - start;
// 使用treemap进行缓存
YrpcBootstrap.ANSWER_TIME_CHANNEL_CACHE.put(time, channel);
log.debug("和[{}]服务器的响应时间是[{}].", entry.getKey(), time);
break;
}
}
log.info("-----------------------响应时间的treemap----------------------");
for (Map.Entry<Long, Channel> entry : YrpcBootstrap.ANSWER_TIME_CHANNEL_CACHE.entrySet()) {
if (log.isDebugEnabled()) {
log.debug("[{}]--->channelId:[{}]", entry.getKey(), entry.getValue().id());
}
}
}
}
三、最短响应时间
事实上,不同的人可能实现的方式也不同,我们手写了心跳检测,心跳检测正好可以帮助我们收集一些元数据(比如响应时间),我们利用treeMap将响应时间和对应的channel进行排序,取出响应时间最短的即可。
四、模板方法进行改造
为了支持多种负载均衡策略,我们同样抽象出了负载均衡器的抽象概念,形成一个接口,如下:
public interface LoadBalancer {
/**
* 根据服务名获取一个可用的服务
* @param serviceName 服务名称
* @return 服务地址
*/
InetSocketAddress selectServiceAddress(String serviceName,String group);
/**
* 当感知节点发生了动态上下线,我们需要重新进行负载均衡
* @param serviceName 服务的名称
*/
void reLoadBalance(String serviceName, List<InetSocketAddress> addresses);
}
同时,我们也发现不同的负载均衡器只是实现的算法不同,在执行一些操作时骨架代理逻辑是一样的,所以我们想起了模板方法设计模式,将相同的骨干逻辑封装在抽象类中:
public abstract class AbstractLoadBalancer implements LoadBalancer {
// 一个服务会匹配一个selector
private Map<String, Selector> cache = new ConcurrentHashMap<>(8);
// 骨架逻辑
@Override
public InetSocketAddress selectServiceAddress(String serviceName,String group) {
// 1、优先从cache中获取一个选择器
Selector selector = cache.get(serviceName);
// 2、如果没有,就需要为这个service创建一个selector
if (selector == null) {
// 对于这个负载均衡器,内部应该维护服务列表作为缓存
List<InetSocketAddress> serviceList = YrpcBootstrap.getInstance()
.getConfiguration().getRegistryConfig().getRegistry().lookup(serviceName,group);
// 提供一些算法负责选取合适的节点
selector = getSelector(serviceList);
// 将select放入缓存当中
cache.put(serviceName, selector);
}
// 获取可用节点
return selector.getNext();
}
@Override
public synchronized void reLoadBalance(String serviceName,List<InetSocketAddress> addresses) {
// 我们可以根据新的服务列表生成新的selector
cache.put(serviceName,getSelector(addresses));
}
/**
* 由子类进行扩展
* @param serviceList 服务列表
* @return 负载均衡算法选择器
*/
protected abstract Selector getSelector(List<InetSocketAddress> serviceList);
}
而算法独立进行抽象:
public interface Selector {
/**
* 根据服务列表执行一种算法获取一个服务节点
* @return 具体的服务节点
*/
InetSocketAddress getNext();
}