手撸RPC---负载均衡

常见的负载均衡策略由轮询、加权轮询、随机、一致性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();
    
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值