基于Dubbo实现一致哈希法与最少活跃优先法结合

Posted gcx18478

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了基于Dubbo实现一致哈希法与最少活跃优先法结合相关的知识,希望对你有一定的参考价值。

针对分布式视频流转码场景

目标:基于dubbo和zookeeper完成一致性哈希法和最少活跃优先法结合,场景是分布式视频流转码,有五个转码节点,首先用一致性哈希对请求映射到哈希环,哈希环上有实际节点生成的n个虚拟节点,找到离哈希值最近的三个虚拟节点,对这个虚拟节点对应的实际节点组成集合,然后用最少活跃法对集合里的点选择一个服务,如果集合内的点不可用或者负载过高,则顺延选择最近的下一个集合执行最少活跃优先法。

方案:

  1. 使用Dubbo提供的扩展机制,实现自定义的负载均衡策略,同时使用Zookeeper作为服务注册中心。
  2. 在Dubbo的负载均衡器中,实现一致性哈希算法和最少活跃法的结合,具体实现步骤如下:
    • 当有新的请求到达时,先使用一致性哈希算法将请求映射到哈希环上的某一个位置,找到离请求哈希值最近的三个虚拟节点。
    • 对这三个虚拟节点对应的实际节点组成一个集合,然后使用最少活跃法对集合内的节点选择一个服务。
    • 如果选中的节点不可用或负载过高,则顺延选择下一个集合,执行最少活跃法。
  3. 在Zookeeper中注册视频流转码节点,每个节点在注册时需要创建对应的虚拟节点,并将虚拟节点信息保存到Zookeeper中。同时,在节点下线时需要删除对应的虚拟节点。

代码实现:

自定义负载均衡器:

public class ConsistentHashLeastActiveLoadBalance extends AbstractLoadBalance 

    // 一致性哈希对象
    private final ConsistentHash<Invoker> consistentHash;

    public ConsistentHashLeastActiveLoadBalance() 
        // 初始化一致性哈希对象
        consistentHash = new ConsistentHash<>();
    

    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException 
        // 获取服务名称和参数列表
        String key = url.getServiceKey();
        Object[] args = invocation.getArguments();
        // 计算哈希值
        int hash = HashUtils.hash(key + Arrays.hashCode(args));
        // 获取哈希环上离该哈希值最近的三个虚拟节点所对应的实际节点
        List<Invoker<T>> sortedInvokers = consistentHash.getSortedInvokers(hash);
        for (int i = 0; i < sortedInvokers.size(); i += 3) 
            // 将三个实际节点组成一个集合
            List<Invoker<T>> group = sortedInvokers.subList(i, Math.min(i + 3, sortedInvokers.size()));
            // 在该集合中选择一个最少活跃的实际节点
            Invoker<T> invoker = selectByLeastActive(group, invocation);
            if (invoker != null) 
                // 如果找到可用的实际节点,则返回该节点
                return invoker;
            
        
        // 如果没有找到可用的实际节点,则返回null
        return null;
    

这段代码实现了一致性哈希和最少活跃法的结合,用于在分布式视频流转码场景中进行节点的负载均衡。其中,ConsistentHashLeastActiveLoadBalance 类继承了 Dubbo 的 AbstractLoadBalance 类,实现了其中的 doSelect 方法,用于从一组实际节点中选择一个节点。

具体来说,该类使用了一致性哈希算法,将每个实际节点映射到一个哈希环上,哈希值相近的实际节点会被映射到哈希环的相邻位置。在进行节点选择时,该类首先根据服务名称和参数列表计算出一个哈希值,然后找到离该哈希值最近的三个虚拟节点所对应的实际节点,将它们组成一个集合。接着,该类使用最少活跃法在该集合中选择一个最少活跃的实际节点,并返回该节点。如果在该集合中没有找到可用的实际节点,则继续寻找下一个集合,直到找到可用的实际节点或者遍历完所有集合为止。

一致性哈希算法实现:

public class ConsistentHash<T> 

    private final TreeMap<Integer, T> hashCircle = new TreeMap<>(); // 哈希环,使用TreeMap实现,key为哈希值,value为实际节点
    private final int virtualNodeCount = 100; // 每个实际节点生成的虚拟节点数

    /**
     * 添加一个实际节点,将其对应的虚拟节点添加到哈希环上
     *
     * @param node 实际节点
     */
    public void add(T node) 
        for (int i = 0; i < virtualNodeCount; i++) 
            String virtualNodeId = node.toString() + "#" + i; // 构造虚拟节点id,格式为"实际节点#虚拟节点编号"
            int hash = HashUtils.hash(virtualNodeId); // 计算虚拟节点的哈希值
            hashCircle.put(hash, node); // 将虚拟节点的哈希值和对应的实际节点放入哈希环中
        
    

    /**
     * 移除一个实际节点,将其对应的所有虚拟节点从哈希环中移除
     *
     * @param node 实际节点
     */
    public void remove(T node) 
        for (int i = 0; i < virtualNodeCount; i++) 
            String virtualNodeId = node.toString() + "#" + i; // 构造虚拟节点id
            int hash = HashUtils.hash(virtualNodeId); // 计算虚拟节点的哈希值
            hashCircle.remove(hash); // 从哈希环中移除虚拟节点
        
    

    /**
     * 根据请求的哈希值获取哈希环上大于等于该哈希值的节点集合
     *
     * @param hash 请求的哈希值
     * @return 节点集合,按照哈希值递增排序
     */
    public List<T> getSortedInvokers(int hash) 
        SortedMap<Integer, T> tailMap = hashCircle.tailMap(hash); // 获取大于等于该哈希值的部分哈希环
        List<T> invokers = new ArrayList<>(tailMap.values()); // 将哈希环上的节点按照哈希值递增排序
        if (tailMap.isEmpty())  // 如果哈希环上不存在大于等于该哈希值的节点
            invokers.addAll(hashCircle.values()); // 将所有节点按照哈希值递增排序
        
        return invokers; // 返回排序后的节点集合
    

这段代码实现了一致性哈希算法的核心功能。一致性哈希算法是一种分布式哈希算法,用于解决分布式系统中的负载均衡问题。

该类中的哈希环采用了 TreeMap 实现,key 为哈希值,value 为实际节点。virtualNodeCount 表示每个实际节点生成的虚拟节点数量。

add() 方法将一个实际节点添加到哈希环中,同时生成对应数量的虚拟节点并放入哈希环中。remove() 方法将一个实际节点从哈希环中移除,同时将其对应的所有虚拟节点也从哈希环中移除。

getSortedInvokers() 方法根据请求的哈希值获取哈希环上大于等于该哈希值的节点集合,并将节点集合按照哈希值递增排序。如果哈希环上不存在大于等于该哈希值的节点,则将所有节点按照哈希值递增排序并返回。

这段代码的实现思路与前面所述的一致性哈希算法原理一致,可以应用于分布式系统中的负载均衡、分布式缓存、分布式存储等场景。

Zookeeper注册中心:

public class ZookeeperRegistry implements Registry 

    private final CuratorFramework client; // Curator客户端
    private final String basePath = "/dubbo"; // ZooKeeper上的基础路径

    /**
     * 构造函数,创建Curator客户端并启动
     *
     * @param url 注册中心地址
     */
    public ZookeeperRegistry(URL url) 
        client = CuratorFrameworkFactory.newClient(url.getAddress(), new RetryNTimes(10, 1000));
        client.start();
    

    /**
     * 将URL注册到ZooKeeper
     *
     * @param url 待注册的URL
     */
    @Override
    public void register(URL url) 
        String path = toPath(url); // 将URL转换为ZooKeeper上的路径
        try 
            client.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(path); // 创建节点,并设置为临时节点
         catch (Exception e) 
            throw new RuntimeException(e);
        
    

    /**
     * 将URL从ZooKeeper注销
     *
     * @param url 待注销的URL
     */
    @Override
    public void unregister(URL url) 
        String path = toPath(url); // 将URL转换为ZooKeeper上的路径
        try 
            client.delete().forPath(path); // 删除节点
         catch (Exception e) 
            throw new RuntimeException(e);
        
    

    /**
     * 将URL转换为ZooKeeper上的路径
     *
     * @param url 待转换的URL
     * @return ZooKeeper上的路径
     */
    private String toPath(URL url) 
        return basePath + "/" + url.getServiceKey() + "/" + url.toFullString(); // 将URL的serviceKey和toFullString拼接为ZooKeeper上的路径
    

该类主要实现了Registry接口中的register和unregister方法,分别用于将URL注册到ZooKeeper和将URL从ZooKeeper注销。

在构造函数中,ZooKeeper连接的地址通过传入的URL的getAddress方法获取。Curator客户端在创建之后会自动启动。

在register方法中,将URL转换为ZooKeeper上的路径之后,使用Curator客户端创建一个节点,并设置为临时节点。节点的路径为创建时传入的path参数,节点的数据为null。

在unregister方法中,同样将URL转换为ZooKeeper上的路径,然后使用Curator客户端删除对应的节点。

toPath方法用于将URL转换为ZooKeeper上的路径。其中,URL的serviceKey作为一级目录,URL的toFullString方法返回的字符串作为二级目录。

Dubbo服务提供者:

public class TranscodeServiceImpl implements TranscodeService 
    private final String nodeName;
    private final Registry registry;

    public TranscodeServiceImpl(String nodeName, URL registryUrl) 
        // 构造方法,接受节点名和注册中心的 URL
        this.nodeName = nodeName;
        registry = new ZookeeperRegistry(registryUrl); // 创建 ZookeeperRegistry 对象,用于注册和取消注册服务
        // 创建当前服务的 URL 对象,使用 nodeName 作为主机名,0 表示随机选择端口号
        URL serviceUrl = URL.valueOf("dubbo://" + nodeName + ":0/" + TranscodeService.class.getName());
        registry.register(serviceUrl); // 将服务 URL 注册到注册中心
    

    @Override
    public void transcode(String videoId, String format) 
        // 视频转码的实现
    

    public void shutdown() 
        // 创建当前服务的 URL 对象,使用 nodeName 作为主机名,0 表示随机选择端口号
        URL serviceUrl = URL.valueOf("dubbo://" + nodeName + ":0/" + TranscodeService.class.getName());
        registry.unregister(serviceUrl); // 将服务 URL 从注册中心取消注册
    

该服务提供者实现类在初始化时会通过构造方法接受节点名和注册中心的 URL,然后创建 ZookeeperRegistry 对象用于服务注册和取消注册。在创建完 ZookeeperRegistry 对象后,会使用 nodeName 创建当前服务的 URL 对象,然后将服务 URL 注册到注册中心。在服务关闭时,会将当前服务的 URL 从注册中心取消注册。

transcode 方法是服务提供者提供的转码方法,这里只是一个空方法,需要根据实际情况实现具体的转码逻辑。

Dubbo服务消费者:

public class TranscodeClient 
    private final TranscodeService transcodeService;
    public TranscodeClient(URL registryUrl) 
        // 创建一个 ReferenceConfig 实例
        ReferenceConfig<TranscodeService> referenceConfig = new ReferenceConfig<>();
        // 指定接口类型
        referenceConfig.setInterface(TranscodeService.class);
        // 指定注册中心 URL
        referenceConfig.setRegistryUrl(registryUrl);
        // 指定负载均衡算法
        referenceConfig.setLoadbalance(new ConsistentHashLeastActiveLoadBalance());
        // 通过 ReferenceConfig 实例获取 TranscodeService 代理对象
        transcodeService = referenceConfig.get();
    

    public void transcode(String videoId, String format) 
        // 调用代理对象的方法
        transcodeService.transcode(videoId, format);
    

这段代码在构造函数中创建了一个 Dubbo 客户端的代理对象,并在调用 transcode 方法时调用该代理对象的方法。

ReferenceConfig 类是 Dubbo 的配置类,它包含了创建 Dubbo 服务代理的相关配置。在这里,我们使用 setInterface 方法指定服务接口类型,setRegistryUrl 方法指定注册中心的 URL,setLoadbalance 方法指定负载均衡算法。

get() 方法返回服务代理对象,该对象封装了客户端与服务器的连接,并负责将客户端的请求发送到服务器,并返回响应。

使用示例:

public class Demo 

    public static void main(String[] args) 
        List<TranscodeServiceImpl> nodes = Arrays.asList(
                new TranscodeServiceImpl("node1", URL.valueOf("zookeeper://localhost:2181")),
                new TranscodeServiceImpl("node2", URL.valueOf("zookeeper://localhost:2181")),
                new TranscodeServiceImpl("node3", URL.valueOf("zookeeper://localhost:2181")),
                new TranscodeServiceImpl("node4", URL.valueOf("zookeeper://localhost:2181")),
                new TranscodeServiceImpl("node5", URL.valueOf("zookeeper://localhost:2181"))
        );

        TranscodeClient client = new TranscodeClient(URL.valueOf("zookeeper://localhost:2181"));

        // 调用视频转码服务
        client.transcode("video1", "mp4");

        // 关闭服务提供者节点
        nodes.get(2).shutdown();

        // 再次调用视频转码服务
        client.transcode("video2", "mp4");

        // 关闭所有服务提供者节点
        nodes.forEach(TranscodeServiceImpl::shutdown);
    

在主函数中,首先创建了五个 TranscodeServiceImpl 实例,它们分别用于模拟五个不同的服务提供者节点,每个节点都向 Zookeeper 注册自己的服务,并等待消费者节点发起调用。

然后,创建了一个 TranscodeClient 实例,用于消费服务。

接着,调用了 client.transcode("video1", "mp4") 方法,该方法会随机选择一个服务提供者节点,调用其 transcode 方法,完成视频转码操作。

之后,模拟了一个服务提供者节点关闭的情况,通过调用 nodes.get(2).shutdown() 方法,关闭了第三个服务提供者节点。Dubbo 会自动将该节点从可用节点列表中移除,并重新计算节点权重,确保下一次调用时能够避开该节点。

最后,通过调用 nodes.forEach(TranscodeServiceImpl::shutdown) 方法,关闭了所有的服务提供者节点。

总结:

一致性哈希和最少活跃法结合的好处在于,它们可以互相弥补彼此的不足,使负载均衡更加精细和可靠。

一致性哈希可以避免节点的加入和退出对系统的影响,保证了负载均衡的稳定性,但是由于节点的虚拟节点数量是有限的,所以在某些情况下可能会导致节点负载不均衡的问题。而最少活跃法可以根据节点的实时负载情况进行动态的调整,保证了节点的负载均衡,但是它对节点的加入和退出比较敏感,容易受到节点状态的影响。

因此,将一致性哈希和最少活跃法结合使用,可以克服它们各自的缺点,使得负载均衡更加平稳和可靠,同时也能够更加适应节点的动态变化。

一文讲透Dubbo负载均衡之最小活跃数算法

技术图片

本文是对于Dubbo负载均衡策略之一的最小活跃数算法的详细分析。文中所示源码,没有特别标注的地方均为2.6.0版本。

为什么没有用截止目前的最新的版本号2.7.4.1呢?因为2.6.0这个版本里面有两个bug。从bug讲起来,印象更加深刻。

最后会对2.6.0/2.6.5/2.7.4.1版本进行对比,通过对比学习,加深印象。

本文目录

第一节:Demo准备。

本小节主要是为了演示方便,搭建了一个Demo服务。Demo中启动三个服务端,负载均衡策略均是最小活跃数,权重各不相同。

第二节:断点打在哪?

本小节主要是分享我看源码的方式。以及我们看源码时断点如何设置,怎么避免在源码里面"瞎逛"。

第三节:模拟环境。

本小节主要是基于Demo的改造,模拟真实环境。在此过程中发现了问题,引申出下一小节。

第四节:active为什么是0?

本小节主要介绍了RpcStatus类中的active字段在最小活跃数算法中所承担的作用,以及其什么时候发生变化。让读者明白为什么需要在customer端配置ActiveLimitFilter拦截器。

第五节:剖析源码

本小节对于最小活跃数算法的实现类进行了逐行代码的解读,基本上在每一行代码上加入了注释。属于全文重点部分。

第六节:Bug在哪里?

逐行解读完源码后,引出了2.6.0版本最小活跃数算法的两个Bug。并通过2.6.0/2.6.5/2.7.4.1三个版本的异同点进行交叉对比,加深读者印象。

第七节:意外收获

看官方文档的时候发现了一处小小的笔误,我对其进行了修改并被merged。主要是介绍给开源项目贡献代码的流程。

PS:前一到三节主要是分享我看源码的一点思路和技巧,如果你不感兴趣可以直接从第四节开始看起。本文的重点是第四到第六节。

另:阅读本文需要对Dubbo有一定的了解。

一.Demo准备

我看源码的习惯是先搞个Demo把调试环境搭起来。然后带着疑问去抽丝剥茧的Debug,不放过在这个过程中在脑海里面一闪而过的任何疑问。

这篇文章分享的是Dubbo负载均衡策略之一最小活跃数(LeastActiveLoadBalance)。所以我先搭建一个Dubbo的项目,并启动三个provider供consumer调用。

三个provider的loadbalance均配置的是leastactive。权重分别是默认权重、200、300。

技术图片

默认权重是多少?后面看源码的时候,源码会告诉你。

三个不同的服务提供者会给调用方返回自己是什么权重的服务。

技术图片

启动三个实例。(注:上面的provider.xml和DemoServiceImpl其实只有一个,每次启动的时候手动修改端口、权重即可。)

技术图片

到zookeeper上检查一下,服务提供者是否正常:

技术图片

可以看到三个服务提供者分别在20880、20881、20882端口。(每个红框的最后5个数字就是端口号)。

最后,我们再看服务消费者。消费者很简单,配置consumer.xml

技术图片

直接调用接口并打印返回值即可。

技术图片

二.断点打在哪?

相信很多朋友也很想看源码,但是不知道从何处下手。处于一种在源码里面"乱逛"的状态,一圈逛下来,收获并不大。

这一小节我想分享一下我是怎么去看源码。首先我会带着问题去源码里面寻找答案,即有针对性的看源码。

如果是这种框架类的,正如上面写的,我会先搭建一个简单的Demo项目,然后Debug跟进去看。Debug的时候当然需要是设置断点的,那么这个断点如何设置呢?

第一个断点,当然毋庸置疑,是打在调用方法的地方,比如本文中,第一个断点是在这个地方:

技术图片

接下里怎么办?

你当然可以从第一个断点处,一步一步的跟进去。但是在这个过程中,你发现了吗?大多数情况你都是被源码牵着鼻子走的。本来你就只带着一个问题去看源码的,有可能你Debug了十分钟,还没找到关键的代码。也有可能你Debug了十分钟,问题从一个变成了无数个。

那么我们怎么避免被源码牵着四处乱逛呢?我们得找到一个突破口,还记得我在《很开心,在使用mybatis的过程中我踩到一个坑》这篇文章中提到的逆向排查的方法吗?这次的文章,我再次展示一下该方法。

看源码之前,我们得冷静的分析。目标要十分明确,就是想要找到Dubbo最小活跃数算法的具体实现类以及实现类的具体逻辑是什么。根据我们的provider.xml里面的:

技术图片

很明显,我们知道loadbalance是关键字。所以我们拿着loadbalance全局搜索,可以看到dubbo包下面的LoadBalance。

技术图片

这是一个SPI接口com.alibaba.dubbo.rpc.cluster.LoadBalance:

技术图片

其实现类为:

com.alibaba.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance

AbstractLoadBalance是一个抽象类,该类里面有一个抽象方法doSelect。这个抽象方法其中的一个实现类就是我们要分析的最少活跃次数负载均衡的源码。

技术图片

同时,到这里。我们知道了LoadBalance是一个SPI接口,说明我们可以扩展自己的负载均衡策略。抽象方法doSelect有四个实现类。这个四个实现类,就是Dubbo官方提供的负载均衡策略,他们分别是:

ConsistentHashLoadBalance 一致性哈希算法

LeastActiveLoadBalance 最小活跃数算法

RandomLoadBalance 加权随机算法

RoundRobinLoadBalance 加权轮询算法

我们已经找到了LeastActiveLoadBalance这个类了,那么我们的第二个断点打在哪里已经很明确了。

技术图片

目前看来,两个断点就可以支撑我们的分析了。

有的朋友可能想问,那我想知道Dubbo是怎么识别出我们想要的是最少活跃次数算法,而不是其他的算法呢?其他的算法是怎么实现的呢?从第一个断点到第二个断点直接有着怎样的调用链呢?

在没有彻底搞清楚最少活跃数算法之前,这些统统先记录在案但不予理睬。一定要明确目标,带着一个问题进来,就先把带来的问题解决了。之后再去解决在这个过程中碰到的其他问题。在这样环环相扣解决问题的过程中,你就慢慢的把握了源码的精髓。这是我个人的一点看源码的心得。供诸君参考。

三.模拟环境

既然叫做最小活跃数策略。那我们得让现有的三个消费者都有一些调用次数。所以我们得改造一下服务提供者和消费者。

服务提供者端的改造如下:

技术图片
技术图片

PS:这里以权重为300的服务端为例。另外的两个服务端改造点相同。

客户端的改造点如下:

技术图片

一共发送21个请求:其中前20个先发到服务端让其hold住(因为服务端有sleep),最后一个请求就是我们需要Debug跟踪的请求。

运行一下,让程序停在断点的地方,然后看看控制台的输出:

技术图片
技术图片
技术图片

权重为300的服务端共计收到9个请求

权重为200的服务端共计收到6个请求

默认权重的服务端共计收到5个请求

我们还有一个请求在Debug。直接进入到我们的第二个断点的位置,并Debug到下图所示的一行代码(可以点看查看大图):

技术图片

正如上面这图所说的:weight=100回答了一个问题,active=0提出的一个问题。

weight=100回答了什么问题呢?

默认权重是多少?是100。

我们服务端的活跃数分别应该是下面这样的

权重为300的服务端,active=9

权重为200的服务端,active=6

默认权重(100)的服务端,active=5

但是这里为什么active会等于0呢?这是一个问题。

继续往下Debug你会发现,每一个服务端的active都是0。所以相比之下没有一个invoker有最小active。于是程序走到了根据权重选择invoker的逻辑中。

技术图片

四.active为什么是0?

active为0说明在dubbo调用的过程中active并没有发生变化。那active为什么是0,其实就是在问active什么时候发生变化?

要回答这个问题我们得知道active是在哪里定义的,因为在其定义的地方,必有其修改的方法。

下面这图说明了active是定义在RpcStatus类里面的一个类型为AtomicInteger的成员变量。

技术图片

在RpcStatus类中,有三处()调用active值的方法,一个增加、一个减少、一个获取:

技术图片

很明显,我们需要看的是第一个,在哪里增加。

所以我们找到了beginCount(URL,String)方法,该方法只有两个Filter调用。ActiveLimitFilter,见名知意,这就是我们要找的东西。

技术图片

com.alibaba.dubbo.rpc.filter.ActiveLimitFilter具体如下:

技术图片

看到这里,我们就知道怎么去回答这个问题了:为什么active是0呢?因为在客户端没有配置ActiveLimitFilter。所以,ActiveLimitFilter没有生效,导致active没有发生变化。

怎么让其生效呢?已经呼之欲出了。

技术图片

好了,再来试验一次:

技术图片
技术图片
技术图片

加上Filter之后,我们通过Debug可以看到,对应权重的活跃数就和我们预期的是一致的了。

权重为300的活跃数为6

权重为200的活跃数为11

默认权重(100)的活跃数为3

技术图片

根据活跃数我们可以分析出来,最后我们Debug住的这个请求,一定会选择默认权重的invoker去执行,因为他是当前活跃数最小的invoker。如下所示:

技术图片

虽然到这里我们还没开始进行源码的分析,只是把流程梳理清楚了。但是把Demo完整的搭建了起来,而且知道了最少活跃数负载均衡算法必须配合ActiveLimitFilter使用,位于RpcStatus类的active字段才会起作用,否则,它就是一个基于权重的算法。

比起其他地方直接告诉你,要配置ActiveLimitFilter才行哦,我们自己实验得出的结论,能让我们的印象更加深刻。

我们再仔细看一下加上ActiveLimitFilter之后的各个服务的活跃数情况:

权重为300的活跃数为6

权重为200的活跃数为11

默认权重(100)的活跃数为3

你不觉得奇怪吗,为什么权重为200的活跃数是最高的?

其在业务上的含义是:我们有三台性能各异的服务器,A服务器性能最好,所以权重为300,B服务器性能中等,所以权重为200,C服务器性能最差,所以权重为100。

当我们选择最小活跃次数的负载均衡算法时,我们期望的是性能最好的A服务器承担更多的请求,而真实的情况是性能中等的B服务器承担的请求更多。这与我们的设定相悖。

如果你说20个请求数据量太少,可能是巧合,不足以说明问题。说明你还没被我带偏,我们不能基于巧合编程。

所以为了验证这个地方确实有问题,我把请求扩大到一万个。

技术图片

同时,记得扩大provider端的Dubbo线程池:

技术图片

由于每个服务端运行的代码都是一样的,所以我们期望的结果应该是权重最高的承担更多的请求。但是最终的结果如图所示:

技术图片

各个服务器均摊了请求。这就是我文章最开始的时候说的Dubbo 2.6.0版本中最小活跃数负载均衡算法的Bug之一

接下来,我们带着这个问题,去分析源码。

五.剖析源码

com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance的源码如下,我逐行进行了解读。可以点开查看大图,细细品读,非常爽:

技术图片

下图中红框框起来的部分就是一个基于权重选择invoker的逻辑:

技术图片

我给大家画图分析一下:

技术图片

请仔细分析图中给出的举例说明。同时,上面这图也是按照比例画的,可以直观的看到,对于某一个请求,区间(权重)越大的服务器,就越可能会承担这个请求。所以,当请求足够多的时候,各个服务器承担的请求数,应该就是区间,即权重的比值。

其中第81行有调用getWeight方法,位于抽象类AbstractLoadBalance中,也需要进行重点解读的代码。

com.alibaba.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance的源码如下,我也进行了大量的备注:

技术图片

在AbstractLoadBalance类中提到了一个预热的概念。官网中是这样的介绍该功能的:

权重的计算过程主要用于保证当服务运行时长小于服务预热时间时,对服务进行降权,避免让服务在启动之初就处于高负载状态。服务预热是一个优化手段,与此类似的还有 JVM 预热。主要目的是让服务启动后“低功率”运行一段时间,使其效率慢慢提升至最佳状态。

从上图代码里面的公式(演变后):计算后的权重=(uptime/warmup)*weight可以看出:随着服务启动时间的增加(uptime),计算后的权重会越来越接近weight。从实际场景的角度来看,随着服务启动时间的增加,服务承担的流量会慢慢上升,没有一个陡升的过程。所以这是一个优化手段。同时Dubbo接口还支持延迟暴露。

在仔细的看完上面的源码解析图后,配合官网的总结加上我的灵魂画作,相信你可以对最小活跃数负载均衡算法有一个比较深入的理解:

1.遍历 invokers 列表,寻找活跃数最小的 Invoker

2.如果有多个 Invoker 具有相同的最小活跃数,此时记录下这些 Invoker 在 invokers 集合中的下标,并累加它们的权重,比较它们的权重值是否相等

3.如果只有一个 Invoker 具有最小的活跃数,此时直接返回该 Invoker 即可

4.如果有多个 Invoker 具有最小活跃数,且它们的权重不相等,此时处理方式和 RandomLoadBalance 一致

5.如果有多个 Invoker 具有最小活跃数,但它们的权重相等,此时随机返回一个即可

技术图片

所以我觉得最小活跃数负载均衡的全称应该叫做:有最小活跃数用最小活跃数,没有最小活跃数根据权重选择,权重一样则随机返回的负载均衡算法。

六.BUG在哪里

Dubbo2.6.0最小活跃数算法Bug一

技术图片

问题出在标号为①和②这两行代码中:

标号为①的代码在url中取出的是没有经过getWeight方法降权处理的权重值,这个值会被累加到权重总和(totalWeight)中。

标号为②的代码取的是经过getWeight方法处理后的权重值。

取值的差异会导致一个问题,标号为②的代码的左边,offsetWeight是一个在[0,totalWeight)范围内的随机数,右边是经过getWeight方法降权后的权重。所以在经过leastCount次的循环减法后,offsetWeight在服务启动时间还没到热启动设置(默认10分钟)的这段时间内,极大可能仍然大于0。导致不会进入到标号为④的代码中。直接到标号为⑤的代码处,变成了随机调用策略。这与设计不符,所以是个bug。

前面章节说的情况就是这个Bug导致的。

这个Bug对应的issues地址和pull request分为:

https://github.com/apache/dubbo/issues/904

https://github.com/apache/dubbo/pull/2172

那怎么修复的呢?我们直接对比Dubbo 2.7.4.1(目前最新版本)的代码:

技术图片

可以看到获取weight的方法变了:从url中直接获取变成了通过getWeight方法获取。获取到的变量名称也变了:从weight变成了afterWarmup,更加的见名知意。

还有一处变化是获取随机值的方法的变化,从Randmo变成了ThreadLoaclRandom,性能得到了提升。这处变化就不展开讲了,有兴趣的朋友可以去了解一下。

技术图片

Dubbo2.6.0最小活跃数算法Bug二

这个Bug我没有遇到,但是我在官方文档上看了其描述(官方文档中的版本是2.6.4),引用如下:

技术图片

官网上说这个问题在2.6.5版本进行修复。我对比了2.6.0/2.6.5/2.7.4.1三个版本,发现每个版本都略有不同。如下所示:

技术图片

图中标记为①的三处代码:

2.6.0版本的是有Bug的代码,原因在上面说过了。

2.6.5版本的修复方式是获取随机数的时候加一,所以取值范围就从[0,totalWeight)变成了[0,totalWeight],这样就可以避免这个问题。

2.7.4.1版本的取值范围还是[0,totalWeight),但是它的修复方法体现在了标记为②的代码处。2.6.0/2.6.5版本标记为②的地方都是if(offsetWeight<=0),而2.7.4.1版本变成了if(offsetWeight<0)

你品一品,是不是效果是一样的,但是更加优雅了。

技术图片

朋友们,魔鬼,都在细节里啊!

七.意外收获

在看官网文档负载均衡介绍的时候。发现了一处笔误。所以我对其进行了修改并被merged。

技术图片

可以看到,改动点也是一个非常小的地方。但是,我也为Dubbo社区贡献了一份自己的力量。我是Dubbo文档的committer,简称"Dubbo committer"。

技术图片

本小节主要是简单的介绍一下给开源项目提pr的流程。

首先,fork项目到自己的仓库中。然后执行以下命令,拉取项目并设置源:

git clone https://github.com/thisiswanghy/dubbo-website.git

cd dubbo-website

git remote add upstream https://github.com/apache/dubbo-website.git

git remote set-url --push upstream no_push

创建本地分支:

git checkout -b xxxx

开发完成后提交代码:

git fetch upstream

git checkout master

git merge upstream/master

git checkout -b xxxx

git rebase master

git push origin xxxx:xxxx

然后到git上创建pull request后,静候通知。

技术图片

最后说一句

之前也写过Dubbo的文章《Dubbo 2.7新特性之异步化改造》,通过对比Dubbo2.6.0/2.7.0/2.7.3版本的源码,分析Dubbo2.7 异步化的改造的细节,可以看看哦。

才疏学浅,难免会有纰漏,如果你发现了错误的地方,还请你留言给我指出来,我对其加以修改。

如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。

感谢您的阅读十分欢迎并感谢您的关注。

以上。

 

以上是关于基于Dubbo实现一致哈希法与最少活跃优先法结合的主要内容,如果未能解决你的问题,请参考以下文章

负载均衡算法--源地址哈希法(Hash)

Dubbo的负载均衡算法

图片对比 平均哈希法(aHash)

代码手记笔录——哈希法

第十五周 项目2--用哈希法组织关键字

图片对比 感知哈希法(pHash)