深入理解Dubbo-分布式系统原理与实现


1. 为什么项目中要将系统进行拆分?如何进行系统拆分?拆分后不用Dubbo可以吗?

这是为了实现了系统解耦,比如说在虚拟实验系统中,一般来说会分为两个业务部分

  • 业务实现部分,这部分要实现的主要是用户的基本使用接口,这个接口主要要完成的是用户注册,登录,班级等基本业务的管理
  • Docker容器实现部分,这部分要实现的主要是Dokcer服务,比如说在多个物理节点上,需要发布多个Docker Daemon,使用这个Dockerd来实现Docker服务,然后通过中控节点分发请求去调用Docker节点的上的服务,这个还要要求服务端主动去收集Docker宿主机上的负载情况以及容器的负载情况

如果不拆分的话,那么一个中控节点就需要完成上述的所有操作,包括与远程的Dockerd后台线程建立UNIX/TCP/HTTPS远程通信连接,保持这条连接,还要和宿主机维持SSH的通信,同时还需要完成用户传递过来的业务请求,这样的话就会导致中控节点的任务量过大,而且如何来维护中控节点和这些物理节点的关系也是一个问题,要使用cache吗?要使用MySQL吗?

(1)服务监控:其中一个最重要的问题就是,中控节点如果要调用Docker服务,首先要求Docker服务存活,如果不做服务拆分,那么中控节点还需要动态地去维护/轮询节点的健康状态

(2)开发效率:可以让一部分擅长业务开发的同学去做中控节点上的任务,让一部分擅长Docker服务的通信去做计算节点的开发,两边互不干扰

拆分后不用dubbo可以吗?

完全可以,在项目的初期也是这样做的,它是通过HTTP远程调用+Redis假注册中心实现的,通过中控节点查询Redis中的相关IP地址,就可以组装一个HTTP请求

但是这样的缺点很明显:

(1)项目开发过程对于bug的排查非常不友好,如果计算节点上的服务出问题了,需要到Docker容器中去查看,具体步骤是:登录到宿主机=>通过读取日志文件/或者进入docker容器进行日志的查询

(2)超时重试,需要自己编写

(3)负载均衡,也需要完全由自己来代理

2. Dubbo的SPI机制

API和SPI有什么区别?

  • API:是应用程序接口(Application Programming Interface),它的实现一般是框架开发者完成的,也就是说,无论有多少个API,这些接口的实现都是在内部完成的
  • SPI:是服务提供接口(Service Provider Interface),用来提供远程支持,比如说JDBC,它本身并没有绑定过任何的数据库底层驱动,那么在JDK中的JDBC,需要调用者显式的调用Class.forname()加载本地驱动,只提供接口,不提供实现,实现由使用者使用

JDK的SPI是如何使用的?

第一步:向外暴露接口,自己并不实现接口

第二步:在不同的工程下实现不同的接口

第三步:在resource下新建META-INF/service

第四步:基于下面的代码进行扫描

ServiceLoader<SPIInterface> load = ServiceLoader.load(SPIInterface.class);
for (SPIInterface spiInterface : load) {
    spiInterface.send("haha");
}

SPI的目的就是为了增强它的扩展性,将固定的配置提取出来,通过SPI机制来配置,一般都会有一个默认的配置,然后通过SPI文件配置不同的实现,这样就会存在一个接口存在多个实现的问题,如果找到了多个实现,那么哪个实现作为最后的实例呢

关于JDK的SPI机制的缺点:在启动项目的时候,这个ServiceLoader的顺序是基于运行时的ClassPath的配置,在前面加载的jar包自然在前面,最后的jar里的自然也在后面,所以用户的包在ClassPath中的顺序比super-log的包更加靠后, 才会处于最后一个位置

因此:JDK的SPI机制无法确认具体加载哪一个的实现,也无加载指定的实现,它仅依靠ClassPath的顺序

DubboSPI机制

  • Dubbo自主实现了SPI机制
  • SPI提高了加载速度和降低了资源消耗
  • Apache Dubbo为SPI提供的IOC和AOP的支持?**

DubboSPI就是通过SPI机制来加载所有的组件,不过Dubbo并未使用JDK原生的SPI机制,而是对其进行了增强,使其能够更好的满足需求,基于SPI,可以很容易地对Dubbo进行扩张

它的核心关键类是ExtensionLoader类,通过ExtensionLoader,可以加载指定的实现类,DubboSPI所需的配置文档需要放在META-INF/dubbo的路径下

JavaSPI实现类配置不同,DubboSPI是通过键值对的方式进行配置,这样的话就可以按照需求加载指定的实现类,另外在使用的时候还需要在接口上标注@SPI

@SPI
public interface Robot {
    void sayHello();
}

public class OptimusPrime implements Robot {
    
    @Override
    public void sayHello() {
        System.out.println("Hello, I am Optimus Prime.");
    }
}

public class Bumblebee implements Robot {

    @Override
    public void sayHello() {
        System.out.println("Hello, I am Bumblebee.");
    }
}


public class DubboSPITest {
    @Test
    public void sayHello() throws Exception {
        ExtensionLoader<Robot> extensionLoader = 
            ExtensionLoader.getExtensionLoader(Robot.class);
        Robot optimusPrime = extensionLoader.getExtension("optimusPrime");
        optimusPrime.sayHello();
        Robot bumblebee = extensionLoader.getExtension("bumblebee");
        bumblebee.sayHello();
    }
}
optimusPrime = org.apache.spi.OptimusPrime
bumblebee = org.apache.spi.Bumblebee

DubboSPI和JDKde SPI的最大区别就在于支持别名,可以通过某个扩展点的别名来获取固定的扩展点,比如说上面的Robot的实现,我们可以在元信息文件中配置相关的实现类,最终就可以在代码中,通过getExtension()来获取具体的实现了

3. Spring的SPI机制

Spring的SPI配置文件是一个固定的文件META-INF/spring.factories,功能上和JDK是类似的,每个接口可以有多个扩展实现,使用方式

//获取所有factories文件中配置的LoggingSystemFactory
List<LoggingSystemFactory>> factories = 
    SpringFactoriesLoader.loadFactories(LoggingSystemFactory.class, classLoader);

Spring也是支持ClassPath中存在多个配置文件的,加载的时候会按照classpath的顺序去依次加载这个spring.factories文件,添加到一个ArrayList中,由于没有别名,所以没有去重的概念,有多少就会添加多少,但是由于Spring的SPI主要用在SpringBoot中,而SpringBoot中的ClassLoader会优先加载项目中的文件,而不是依赖包的文件,所以如果你在项目中定义了Spring.factories文件,那么项目中的文件就会被第一个加载,得到的Factories中,项目所定义的spring.factories中配置的那个实现类也会排在第一个。

JDKSPI DUBBO SPI Spring SPI
文件方式 每个扩展点单独一个文件 每个扩展点单独一个文件 所有的扩展点在一个文件
获取某个固定的实现 不支持,只能按照顺序获取所有的实现 有别名的概念,可以通过名称获取扩展点的某个固定实现,可以配合DubboSPI的注解 不支持,只能按照顺序获取所有实现,但是由于SpringClassLoader会保证优先加载用户代码中的文件,因此可以保障用户自定义的文件在第一个
其他 支持Dubbo内部的依赖注入,通过目录来区分Dubbo 内置SPI和外部SPI,优先加载内部,保证内部的优先级最高

4. DubboSPI详解

5. Dubbo负载均衡算法

服务负载均衡:负载均衡的本质就是通过算法选择节点,避免过热的节点

实际上就是我们的consumer去用一定的策略去调用不同的provider,核心目的:防止过热节点,防止流量一直打到同一个节点上,可以通过loadbalance配置

JVM的稳启动:就是说因为JVM中的一些加载设定都是懒加载的,因此初始化的时候尽量不要让JVM满负荷运载,这样不但影响QPS,还容易使得服务的宕机

RandomLoadBalance:加权随机算法,随机访问,黑盒访问

RoundRobinLoadBalance:加权轮询算法

LeastActiveLoadBalance:最少活跃优先+加权随机

ShortestReponseLoadBalance:最短响应优先+加权随机

ConsistentHashLoadBalance:确定的入参,确定的提供者,适用于有状态的请求,访问同一台机器

有状态的请求,就是consumer去调用provider的两次请求之间是有关系的,必须要使得这两次请求都打到同一台实例上

如何自定义LoadBalance?

public class RandomLoadBalance extends AbstractLoadBalanc{//继承这个方法
    
    <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException;
}

首先先来看是如何定义权重值的

protected int getWeight(Invoker<?> invoker, Invocation invocation) {
	//获取权重值,默认为100
	int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), "weight",100);
	if (weight > 0) {
		//服务提供者启动时间戳
		long timestamp = invoker.getUrl().getParameter("remote.timestamp", 0L);
		if (timestamp > 0L) {
			//当前时间-启动时间=运行时长
			int uptime = (int) (System.currentTimeMillis() - timestamp);
			//获取服务预热时间 默认10分钟 
			int warmup = invoker.getUrl().getParameter("warmup", 600000 );
			//如果服务运行时间小于预热时间,即服务启动未到达10分钟
			if (uptime > 0 && uptime < warmup) {
				//重新计算服务权重
				weight = calculateWarmupWeight(uptime, warmup, weight);
			}
		}
	}
	return weight;
}

大致的流程是这样的:

  • 首先获取配置的权重值,它默认是100
  • 然后获取服务启动的时间戳
  • 当前时间-服务启动时间=服务运行时长
  • 获取服务温启动时间,默认是10分钟
  • 判断服务运行的时长是否小于预热的时间,条件的成立就会重新计算权重

这个所谓的重新计算权重其实就是降权,也就是说不要让在还处于一个温启动的服务承载太高的流量,如果都处于一个温启动的状态,那么就让启动时间最长的来承载流量

服务预热是一个优化手段,与之类似的还有JVM预热,主要目的是服务启动后低功率运行,使其效率慢慢提升到最佳的状态,比如说在线程池中创建线程,加载常见的类

权重随机算法

权重随机算法是加权随机算法的具体实现,是Dubbo默认的,首先需要将服务器按照权重进行分区

假设有三台服务器A、B、C,对应的权重为1 3 6

那么分区就有三个,权重总和为10

那么通过分区[0,1),就将服务分发到A

通过分区[1,4),将服务分发到B上

通过分区[4,10),将服务分发到C上

protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, 
				URL url, Invocation invocation) {
	
	//服务提供者列表数量
	int length = invokers.size(); 
	//总权重
	int totalWeight = 0; 
	//是否具有相同的权重
	boolean sameWeight = true; 
	
	//循环服务列表,计算总权重和检测每个服务权重是否相同
	for (int i = 0; i < length; i++) {
	
		//获取单个服务的权重值
		int weight = getWeight(invokers.get(i), invocation);
		//累加 计算总权重
		totalWeight += weight; 
		//校验服务权重是否相同
		if (sameWeight && i > 0
				&& weight != getWeight(invokers.get(i - 1), invocation)) {
			sameWeight = false;
		}
	}
	if (totalWeight > 0 && !sameWeight) {
		//获取[0-totalWeight]之间的随机数
		int offset = random.nextInt(totalWeight);
		//计算随机数处于哪个区间,返回对应invoker
		for (int i = 0; i < length; i++) {
			offset -= getWeight(invokers.get(i), invocation);
			if (offset < 0) {
				return invokers.get(i);
			}
		}
	}
	//如果权重相同,随机返回
	return invokers.get(random.nextInt(length));
}

简述流程:

获取服务提供者的数量,累加,计算权重和,检验服务权重是否相等

如果服务权重都是相等,那么直接随机返回

如果服务权重都是不相等的,获取0~10的随机数,然后判断随机数在哪个区间,根据区间来返回具体的调用者

最小活跃数算法

大体思路,通过统计各个调用者的调用次数,如果有一个实例的调用次数是最少的,那么优先选择这个实例

protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {

	//服务提供者列表数量
	int length = invokers.size(); 
	//默认的最小活跃数值
	int leastActive = -1;
	//最小活跃数invoker的数量
	int leastCount = 0; 
	
	//最小活跃数invoker索引
	int[] leastIndexs = new int[length];
	//总权重
	int totalWeight = 0; 
	//第一个Invoker权重值 用于比较invoker直接的权重是否相同
	int firstWeight = 0;
	boolean sameWeight = true;
	//循环比对Invoker的活跃数大小
	for (int i = 0; i < length; i++) {
		//获取当前Invoker对象
		Invoker<T> invoker = invokers.get(i);
		//获取活跃数大小
		int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive(); 
		//获取权重值
		int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), "weight", 100); 
		
		//对比发现更小的活跃数,重置(打擂算法)
		if (leastActive == -1 || active < leastActive) {
			//更新最小活跃数
			leastActive = active; 
			//更新最小活跃数 数量为1
			leastCount = 1;
			//记录坐标
			leastIndexs[0] = i; 
			totalWeight = weight; 
			firstWeight = weight; 
			sameWeight = true;
			
		//如果当前Invoker的活跃数 与 最小活跃数相等
		} else if (active == leastActive) { 
			leastIndexs[leastCount++] = i;//调用次数是最小的
			totalWeight += weight;
			if (sameWeight && i > 0
					&& weight != firstWeight) {
				sameWeight = false;
			}
		}
	}
	//如果只有一个Invoker具有最小活跃数,直接返回即可 
	if (leastCount == 1) {
		return invokers.get(leastIndexs[0]);
	}
	//多个Invoker具体相同的最小活跃数,但权重不同,就走权重的逻辑
	if (!sameWeight && totalWeight > 0) {
		int offsetWeight = random.nextInt(totalWeight);
		for (int i = 0; i < leastCount; i++) {
			int leastIndex = leastIndexs[i];
			offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
			if (offsetWeight <= 0)
				return invokers.get(leastIndex);
		}
	}
	//从leastIndexs中随机获取一个返回
	return invokers.get(leastIndexs[random.nextInt(leastCount)]);
}

简述流程:第一个通过比较,确定最小活跃数的Invoker,第二是根据权重来确定Invoker

第一步:定义变量-最小活跃数大小等基础变量

第二步:循环invokers数组,获取当前invoker的活跃数和权重

第三步:打擂算法,比较出最小的活跃次数所对应的invoker

第四步:比对完成,如果只有一个最小活跃数,那么直接返回这个invoker

第五步:如果有多个invoker,那么就走权重的逻辑

第六步:如果权重都相同,或者是活跃数都相同,那么就随机返回

问题:活跃数是在哪里改变的?

它是通过ActiveLimitFilter这个类实现的

//触发active自增操作
RpcStatus.beginCount(url, methodName);
Result result = invoker.invoke(invocation);
//触发active自减操作
RpcStatus.endCount(url, methodName, System.currentTimeMillis() - begin, true);
return result;

一致性哈希算法

原理是这样的:

首先先构造一个长度为2^32的整数环(一致性哈希环),然后根据节点名称的Hash值(分布在0-2^32-1)将服务器节点放置在这个Hash环上,最后根据数据的Key值来计算得到它的Hash值,在Hash环上顺时针查找距离这个Key值最近的Hash值最近的服务节点,完成Key到服务器的映射查找

一致性哈希算法也是使用取模的方式,但是取模算法是对服务器的数量进行取模,而一致性哈希算法则是对2^32进行取模,具体的步骤是这样的:

  • 一致性哈希算法将整个哈希值空间按照顺时针方向组织成一个虚拟的圆环,称为是Hash环
  • 接着将各个服务器使用Hash函数进行哈希,具体可以选择服务器的IP或者是主机的关键字进行哈希,从而确定每台及其在哈希环上的位置
  • 最后使用定位数据访问到相应服务器,将数据key使用相同的函数Hash计算出哈希值,并且确定此数据在环上的位置,从此位置沿环顺时针寻找,第一台遇到的服务器就是这个算法定位到的服务器

如果简单对服务器的数量进行取模,那么服务器数量发生变化的时候,就会发生缓存的雪崩,从而可能导致系统的崩溃,而使用一致性哈希可以很好的解决这个问题

虽然一致性哈希算法对于节点的增减都只需要重定位环空间中的一小部分数据,只有部分缓存会失效,不至于将所有的压力都在同一个时间集中到后台服务器上,具有很好的容错性和可扩张性

关于一致性Hash算法,在Dubbo中引入了一个虚拟节点用来解决数据倾斜的问题

一致性哈希算法在服务节点太少的情况下,容易因为节点分布不均匀而导致一个数据的倾斜的问题,也就是被缓存的对象大部分集中在某一台服务器上,从而出现数据分布不均匀的情况,这种情况就被称作是hash环的倾斜

hash环的倾斜在极端情况下,依然有可能引起系统的崩溃,为了解决这种数据倾斜的问题,一致性哈希算法引入了虚拟节点的机制,也就是对每一个服务节点计算多个哈希,每个计算结果位置都防止一个服务节点,称为是虚拟节点,一个物理节点可能对应了有多个虚拟节点,虚拟节点越多,hash环上的节点就越多,缓存被均匀分布的概率就越大,hash环倾斜所带来的影响就会越小,同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,具体做法可以在服务器ip或者主机名后面添加编号来实现

加权轮询算法

加权轮询简单来说就是通过动态改变各个机器在请求后的权重,从而实现动态调度的效果

三个服务提供者的固定权重分别是10,20,50,假设其当前权重依次为0,0,0

计算总权重 10+20+50 = 80
当请求来了,更新三个服务提供者的当前权重,当前权重 = 当前权重+固定权重,依次结果为10,20,50.
从中选择最大的一个,也就是第三个用于处理请求,同时将其当前权重更新:当前权重 = 50 - 80 = -30
所以此时三个服务提供者的当前权重依次为10,20,-30
当第二个请求来的时候,更新三个服务提供者的当前权重,当前权重 = 当前权重+固定权重,依次结果为20,40,20
从重选择最大的一个,也就是第二个用于处理请求,同时将其当前权重更新:当前权重 = 40 - 80 = -40.
依次类推。

6. 热插拔问题


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录