通过前面的学习,大家已经了解到如何搭建服务注册中心,如何将一个 provider 注册到服务注册中心, consumer 又如何从服务注册中心获取到 provider 的地址,在 consumer 获取 provider 地址时,我们一直采用了 DiscoveryClient 来手动获取,这样出现了大量冗余代码,而且负载均衡功能也没能实现。因此,本文我将和大家分享在微服务中如何实现负载均衡,以及负载均衡的实现原理、常见的负载均衡策略等。

准备工作

参考前面我写的例子,我们还是先创建一个名子叫 Loadbalancer的普通maven工程作为父工程,然后在父工程中创建一个eureka的SpringBoot项目 作为注册中心,在创建一个provider的SpringBoot工程作为服务提供者 ,在provider中提供一个hello接口。
hello接口如下:

@RestController
public class HelloController {
    @Value("${server.port}")
    Integer port;

    @GetMapping("/hello")
    public String hello(String name) {
        return "hello" + name + ":" + port;
    }
}

这里的hello接口中的port是从application.yml中读取的使用的是spring提供的value注解,当我们从consumer 消费者 去调用 服务提供商 provider 的时候 这里就会返回 这个端口,方便我们查看调用的情况。
provider的application.yml配置如下:

spring:
  application:
    name: provider
server:
  port: 4001
eureka:
  client:
    service-url:
      defaultZone: http://localhost:1111/eureka

配置好了后 我们需要打包provider 这里选择package
在这里插入图片描述
打包好后,我们在我们provider 文件夹中的 target中找到了我们打包好的jar
在这里插入图片描述
我首先启动eureka 服务注册中心 然后运行 jar

nohub java -jar provider-0.0.1-SNAPSHOT.jar --server.port= 4001 &
nohub java -jar provider-0.0.1-SNAPSHOT.jar --server.port= 4002 &

注意这里我们启动了两个provider服务提供商
我们在浏览器输入http://localhost:1111去访问注册中心结果如下:
在这里插入图片描述
这样就完成我们的准备工作了,下面就开始实现如何负载均衡。

手动实现负载均衡

首先我们去创建一个consumer 用来去调用provider 的服务,创建consumer和provider是一样的不明白的,可以去看我前面的文章。我们在consumer提供hello接口

@RestController
public class UserHelloController {
    @Autowired
    DiscoveryClient discoveryClient;
    @Autowired
    RestTemplate restTemplate;
    int count = 0;

    @GetMapping("/hello")
    public String hello(String name) {
        List<ServiceInstance> list = discoveryClient.getInstances("provider");
        ServiceInstance instance = list.get(count % list.size());
        count ++;
        String host = instance.getHost();
        int port = instance.getPort();
        String s = restTemplate.getForObject("http://" + host + ":" + port + "/hello?name={1}", String.class, name);
        return s;
    }

这个简单的轮询负债均衡的思路如下:

  1. 由DiscoveryClient 从 eureka 上获取所有的provider实例,这些获取到的实例保存到一个list集合中,所以这个轮询就是从list中取出ServiceInstance 实例,组装成相关的实例去运行
  2. 我们创建了一个count变量,每次将集合的大小size和 count进行取模运算,然后拼接地址,这就就实现了手动的轮询,达到了负债均衡,但是这样的负载均衡其实是由问题。并不是最好的,我们还有更好的方式。

然后我们启动eureka 然后 分别 启动两个provider 然后去访问我们的http://localhost:4003/hello?name=技术无止境 访问是如下的结果:
当点击一次的时候
在这里插入图片描述
我们再点击一次
在这里插入图片描述
可以看到一直再 4001 和 4002 端口之间来回的轮询,这样就完成了手动的负载均衡

使用 @Loadbalancer 实现负载均衡

上面是一个自己手动实现的负载均衡,代码量虽然不大,但是如果每次请求都这么写,实际上还是很头大,因为充斥着大量的代码冗余,此时,有人可能会想到上篇文章我们在讲解 RestTemplate 时提到的拦截器,如果能够在拦截器中将请求地址拦截下来,然后自动进行负载均衡计算,进而使用一个合适的地址去发送请求的话,就会方便很多。没错,实际上在 Spring Cloud 中也是这么做的!

在SpringCloud中实现负债均衡非常的简单,只需要在RestTemplate上加入一个@LoadBalanced注解就可以了如下

@Bean
@LoadBalanced
RestTemplate loadBalancer() {
    return new RestTemplate();
}

这个时候RestTemplate就有了负载均衡的能力了,我们继续在consumer 中的UserHelloController中加入hello2接口如下:

@Autowired
@Qualifier("loadBalancer")
RestTemplate loadBalancer;
@GetMapping("/hello2")
public String hello2(String name) {
    String s = loadBalancer.getForObject("http://provider/hello?name={1}", String.class, name);
    return s;
}

点击第一次
在这里插入图片描述
点击第二次
在这里插入图片描述
这样写代码比手动实现更加的简洁,十分的清爽。
关于这段代码的解释

  1. 由于现在项目中存在两个 RestTemplate 的实例,因此这里我加了 @Qualifier(“loadBalancer”) 注解,表示通过名称来查找 RestTemplate 的实例(如果继续根据类型来查找,系统会不知道到底注入哪个实例);
  2. 此时注入进来的 RestTemplate 实例自动就具备了负载均衡功能,但需要注意的是这里的请求地址,原本的 Host+":"+Port 被微服务名称所替代,实际上这个很好理解,因为这里如果还继续明确直接指定了服务地址的话,那还怎么负载均衡呀?
  3. 这里使用了 provider 来代替 Host+":"+Port ,在真正的请求发起时,会通过拦截器将请求拦截下来,然后将 provider 换成一个具体的服务地址。

使用了具备负载均衡功能的 RestTemplate 之后,当我们再次启动 consumer ,发起一个网络访问时,可以在 IntelliJ IDEA 的控制台看到如下日志:
在这里插入图片描述
可以看到, consumer 会自动从 eureka 上根据 provider 获取 provider 所有实例的信息,不仅仅包括实例的地址信息,也包括实例的历史请求数据等信息,根据这些辅助数据,可以实现不同的负载均衡策略。

请求失败重试

具备了负载均衡功能的 RestTemplate 也可以开启请求重试功能。有的时候,在微服务调用的过程中,由于网络抖动等原因造成了访问失败,这个时候如果直接就认定访问失败显然是不划算的,可以多尝试几次,多次尝试之后,如果还是请求失败,再判定访问失败。默认情况下,重试功能是没有开启的,开启重试功能很简单,开发者只需要在 consumer 中添加 Spring Retry 依赖即可,如下:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

只要添加了该依赖,我们的 RestTemplate 此时就自动具备了请求失败重试的功能(注意,加入依赖后请求失败重试功能就会自动开启了),如果开发者加入了该依赖,但是又不想开启请求失败重试功能,可以在application.properties中添加如下配置:

spring.cloud.loadbalancer.retry.enabled=false

表示关闭请求失败重试功能。至于请求失败重试的次数以及切换实例的次数,可以通过如下配置实现:

# 最大的重试次数,不包括第一次请求
ribbon.MaxAutoRetries=3
# 最大重试server的个数,不包括第一个 server
ribbon.MaxAutoRetriesNextServer=1

另外也可以指定是否开启任何异常都重试:

ribbon.OkToRetryOnAllOperations=true

客户端负载均衡

这里我们所说的负载均衡和大家平时所了解到的负载均衡不太一样,平时我们所说的负载均衡一般是指 Nginx 或者 F5 之类的工具,其中 Nginx也叫反向代理代理服务器,它的工作流程如下图:
在这里插入图片描述

所有的请求首先到达负载均衡服务器,再由负载均衡服务器根据提前配置好的负载均衡策略,将请求转发到不同的 Real Server 上,对于客户端来说,它并不知道究竟是哪一个 Real Server 提供的服务,这种负载均衡方式我们一般称之为服务端负载均衡。而上文我们提到的负载均衡则与这种方式不同,上文的负载均衡是先将 provider 的所有地址拿到,然后 consumer 根据本地配置的负载均衡策略,从 provider 地址列表中挑选一个地址去调用,调用过程如下图:
在这里插入图片描述

在这个调用过程中,客户端首先去 Eureka 中获取 provider 地址,获取到 provider 地址列表之后,再根据本地提前配置好的负载均衡策略从地址列表中挑选一个地址去调用,这个过程没有中间代理服务器,到底调用哪一个服务是由 consumer 自己决定的,因此这种负载均衡我们也称之为客户端负载均衡。

常见负载均衡策略

通过上面的学习,大家了解到,默认的负载均衡策略实际上是轮询,那么除了这种负载均衡策略之外,还有哪些负载均衡策略呢?又是如何配置的呢?我们继续来看。
要说负载均衡策略,就需要先向大家介绍一个接口叫做 ILoadBalancer ,它的源码如下:

public interface ILoadBalancer {
	public void addServers(List<Server> newServers);  
	public Server chooseServer(Object key);  
	public void markServerDown(Server server);  
	@Deprecated  
	public List<Server> getServerList(boolean availableOnly);  
    public List<Server> getReachableServers();  
	public List<Server> getAllServers();  
}

从这个方法就可以看出,这里有服务的添加 、选择、标记服务下线、获取服务列表等功能,这个接口的实现类如下:
在这里插入图片描述

在它的实现类 BaseLoadBalancer 中,去向 Eureka 获取服务列表,并不断地通过心跳消息去检查服务是否可用,有了服务列表之后,再根据具体的 IRule 进行负载均衡,所以,我们再来关注下 IRule ,这也是一个接口,它的实现类如下:

在这里插入图片描述
这里一个实现类就代表了一个负载均衡实现策略,例如:

  • RandomRule 表示随机策略
  • RoundRobinRule 表示轮询策略
  • WeightedResponseTimeRule 表示加权策略(即将过期,功能和 ResponseTimeWeightedRule 一致
  • ResponseTimeWeightedRule 也是加权,它是根据每一个 Server 的平均响应时间动态加权,响应时间越长,权重越小,处理请求的机会也越小
  • RetryRule 表示一个具备重试功能的负载均衡策略,内部默认使用了 RoundRobinRule 这个内部也可以自己传其他的负载均衡策略进去
  • BestAvailableRule 策略表示使用并发数最小的服务
  • 那么这些不同的负载均衡策略要如何去配置呢?很简单,需要哪种负载均衡,就提供哪种负载均衡的 IRule 实例就行了。例如,需要使用随机策略,那么只需要在 consumer 的配置类中提供一个 RandomRule 的实例即可,如下:

    @Bean
    IRule iRule() {
        return new RandomRule();
    }
    

    此时,多次访问 provider 接口,就会发现负载均衡策略已经变为随机策略了。

    负载均衡原理

    网上关于负载均衡策略源码分析的文章很多,但是大多数都看得初学者丈二和尚摸不着头脑,因为这里确实涉及到的类比较多,因此本文换一个思路来和大家讲这个原理。关于负载均衡,大家可能会比较感兴趣服务列表到底是什么时候通过什么方式加载的?为什么在 RestTemplate 访问中不需要写服务的具体地址,而只需要给一个服务名就行了?所有的配置又是如何切入到 RestTemplate 中的?接下来,带着这些问题,我来和大家分析负载均衡具体的实现逻辑。

    服务列表加载问题

    首先第一个问题就是服务列表的加载问题。在前面的文章中,我们都是自己通过 DiscoveryClient 手动获取服务地址列表的,自从用了 @LoadBalanced 注解之后,我们不再需要自己手动加载地址了,直接写服务名就可以了,不用看源码我们也知道,服务名最终肯定要被转为一个具体的地址才能使用,那么这个过程到底是在哪里呢?

    通过查看 @LoadBalanced 的源码我们发现凡是加了该注解的 RestTemplate 都会被自动配置一个 LoadBalancerClient ,LoadBalancerClient 只是一个接口,这个接口继承自 ServiceInstanceChooser 并且只有一个实现类 RibbonLoadBalancerClient ,如下图:

    在这里插入图片描述

    其中, ServiceInstanceChooser 里边只定义了一个方法,如下:

    public interface ServiceInstanceChooser {
    	ServiceInstance choose(String serviceId);
    }
    

    看名字就知道,这个方法是用来根据 serviceId 获取 ServiceInstance 的, ServiceInstance 中则保存了一个服务的详细信息, LoadBalancerClient 接口在此基础上又增加了三个方法,如下:

    public interface LoadBalancerClient extends ServiceInstanceChooser {
    
    	<T> T execute(String serviceId, LoadBalancerRequest<T> request) throws IOException;
    
    	<T> T execute(String serviceId, ServiceInstance serviceInstance,
    			LoadBalancerRequest<T> request) throws IOException;
    
    	URI reconstructURI(ServiceInstance instance, URI original);
    
    }
    

    这里方法有三个, execute 方法用来做执行操作, reconstructURI 方法则用来重构 Url,也就是把 http://provider/hello 这样的地址变为 http://localhost:4001/hello 这样的形式。

    这里只是一个接口,具体的实现在 RibbonLoadBalancerClient 类中。在该类中,首先来看服务的选择问题, choose 方法经过一系列的跳转最终来到了这里:

    public ServiceInstance choose(String serviceId, Object hint) {
    	Server server = getServer(getLoadBalancer(serviceId), hint);
    	if (server == null) {
    		return null;
    	}
    	return new RibbonServer(serviceId, server, isSecure(server, serviceId),
    			serverIntrospector(serviceId).getMetadata(server));
    }
    

    在这个方法中,首先使用 getServer 方法去获取服务,这个方法最终会来到 ILoadBalancer 接口的 chooseServer 方法中, ILoadBalancer 接口的继承关系如下图:

    在这里插入图片描述
    默认情况下,在 RibbonLoadBalancerClient 类中注入的 ILoadBalancer 的实例是 ZoneAwareLoadBalancer ,但是 ZoneAwareLoadBalancer 继承自 DynamicServerListLoadBalancer ,而在 DynamicServerListLoadBalancer 的构造方法中,调用了 restOfInit(clientConfig)
    方法,该方法源码如下:

    void restOfInit(IClientConfig clientConfig) {
        boolean primeConnection = this.isEnablePrimingConnections();
        // turn this off to avoid duplicated asynchronous priming done in BaseLoadBalancer.setServerList()
        this.setEnablePrimingConnections(false);
        enableAndInitLearnNewServersFeature();
        updateListOfServers();
        if (primeConnection && this.getPrimeConnections() != null) {
            this.getPrimeConnections()
                    .primeConnections(getReachableServers());
        }
        this.setEnablePrimingConnections(primeConnection);
        LOGGER.info("DynamicServerListLoadBalancer for client {} initialized: {}", clientConfig.getClientName(), this.toString
    }
    

    在这个方法中,通过 updateListOfServers(); 去加载服务列表,而该方法会调用到 ServerList 接口中的 getUpdatedListOfServers 方法, ServerList 是一个接口,这个接口中定义了获取所有注册微服务信息的方法,它的最终实现类是 DomainExtractingServerList 。在 DomainExtractingServerList 中,我们终于看到了通过 EurekaClient 去获取服务列表的代码了,如下:

    private List<DiscoveryEnabledServer> obtainServersViaDiscovery() {
            EurekaClient eurekaClient = eurekaClientProvider.get();
            if (vipAddresses!=null){
                for (String vipAddress : vipAddresses.split(",")) {
                    List<InstanceInfo> listOfInstanceInfo = eurekaClient.getInstancesByVipAddress(vipAddress, isSecure, targetRegion);
                    for (InstanceInfo ii : listOfInstanceInfo) {
                        //省略
                    }
                }
            }
            return serverList;
        }
    

    毫无疑问,这里最终会来到 DiscoveryClient 中获取服务地址列表。至此,大家应该知道了服务列表是在哪里加载了吧!

    请求地址替换问题

    那么请求地址又是怎么样从 http://provider/hello 变为 http://localhost:4001/hello 呢?上文我们提到了在 LoadBalancerClient 类中有一个方法叫做 reconstructURI ,这个方法就是用来重构 Url ,它的具体实现在 LoadBalancerContext 类的 reconstructURIWithServer 方法中,具体代码如下:

    public URI reconstructURIWithServer(Server server, URI original) {
        String host = server.getHost();
        int port = server.getPort();
        String scheme = server.getScheme();
        
        if (host.equals(original.getHost()) 
                && port == original.getPort()
                && scheme == original.getScheme()) {
            return original;
        }
        if (scheme == null) {
            scheme = original.getScheme();
        }
        if (scheme == null) {
            scheme = deriveSchemeAndPortFromPartialUri(original).first();
        }
        try {
            StringBuilder sb = new StringBuilder();
            sb.append(scheme).append("://");
            if (!Strings.isNullOrEmpty(original.getRawUserInfo())) {
                sb.append(original.getRawUserInfo()).append("@");
            }
            sb.append(host);
            if (port >= 0) {
                sb.append(":").append(port);
            }
            sb.append(original.getRawPath());
            if (!Strings.isNullOrEmpty(original.getRawQuery())) {
                sb.append("?").append(original.getRawQuery());
            }
            if (!Strings.isNullOrEmpty(original.getRawFragment())) {
                sb.append("#").append(original.getRawFragment());
            }
            URI newURI = new URI(sb.toString());
            return newURI;            
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
    }
    

    方法参数 Server 中包含了服务的基本信息,original 则是待转换的 Url ,这里的逻辑整体比较好理解,就是将 Server 中的数据拿出来重新拼接地址。

    如何切入到 RestTemplate 中

    那么上面这些东西又是如何切入到 RestTemplate 中的呢?这里需要大家回忆一下上篇文章我们在介绍 RestTemplate 时讲过的拦截器,实际上,这里的功能也都是通过拦截器嵌入到 RestTemplate 的执行中的。

    当我们的 classpath 下存在 RestTemplate ,并且项目中存在 LoadBalancerClient 的实例时,在 LoadBalancerAutoConfiguration 类中就会启动一个自动配置,部分源码如下:

    @Configuration
    @ConditionalOnClass(RestTemplate.class)
    @ConditionalOnBean(LoadBalancerClient.class)
    @EnableConfigurationProperties(LoadBalancerRetryProperties.class)
    public class LoadBalancerAutoConfiguration {
    	@Configuration
    	@ConditionalOnClass(RetryTemplate.class)
    	public static class RetryInterceptorAutoConfiguration {
    
    		@Bean
    		@ConditionalOnMissingBean
    		public RetryLoadBalancerInterceptor ribbonInterceptor(
    				LoadBalancerClient loadBalancerClient,
    				LoadBalancerRetryProperties properties,
    				LoadBalancerRequestFactory requestFactory,
    				LoadBalancedRetryFactory loadBalancedRetryFactory) {
    			return new RetryLoadBalancerInterceptor(loadBalancerClient, properties,
    					requestFactory, loadBalancedRetryFactory);
    		}
    
    		@Bean
    		@ConditionalOnMissingBean
    		public RestTemplateCustomizer restTemplateCustomizer(
    				final RetryLoadBalancerInterceptor loadBalancerInterceptor) {
    			return restTemplate -> {
    				List<ClientHttpRequestInterceptor> list = new ArrayList<>(
    						restTemplate.getInterceptors());
    				list.add(loadBalancerInterceptor);
    				restTemplate.setInterceptors(list);
    			};
    		}
    
    	}
    }
    

    可以看到,在这里,自动向 RestTemplate 中加了一个拦截器 RetryLoadBalancerInterceptor ,正是这个拦截器将上面提到的所有功能给集成进来了,有兴趣的读者可以了解下具体的拦截过程,在 RetryLoadBalancerInterceptor 类的 intercept 方法中,通过 LoadBalancerClient 的实例进行了服务获取、负载均衡以及请求 Url 重构,该方法的逻辑就比较简单了,这里就不再赘述。

    总结

    本文首先带领读者手动实现了一个简易的负载均衡功能,然后向读者介绍了 RestTemplate 如何在框架中实现负载均衡以及请求失败重试等配置,并带着读者大致过了一下源码,实际上这里的源码并不复杂,抓住主线,然后通过 Debug 的方式很容易将思路理清。由于集群化部署是微服务一个重要的特点,因此,负载均衡策略以及配置方式大家一定要掌握。

    源码地址

    github

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐