在微服务架构中,服务间调用必然涉及负载均衡 ------ 核心目的是避免所有请求集中到单个服务实例,实现请求的均匀分发。而负载均衡的实现中,如何获取服务实例列表是最关键的设计点,这背后藏着 "性能" 与 "实时性" 的核心矛盾。
一、基础负载均衡逻辑:轮询(Round Robin)
我们手写实现负载均衡时,核心公式是:
java
// 计数器自增(CAS 保证线程安全)
int index = count.getAndIncrement() % instances.size();
// 根据下标选取服务实例
String uri = instances.get(index).getUri().toString();
对应请求与实例的映射关系如下:
| 请求次数 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 实例下标 | 0 | 1 | 2 | 0 | 1 | 2 | 0 |
这种轮询模式能保证请求均匀分发,避免单个实例过载,是负载均衡的基础逻辑。
二、关键设计:实例列表的获取时机
负载均衡的核心前提是持有服务实例列表 (如商品服务的 product-service 实例地址),而列表的获取方式,直接决定了负载均衡的稳定性与可用性。
1. 错误写法:每次请求都从注册中心获取
如果在业务方法中每次调用都执行:
java
instances = discoveryClient.getInstances("product-service");
会存在两个致命问题:
- 性能损耗:频繁网络请求,增加注册中心压力,降低接口响应效率;
- 负载均衡乱套 :Eureka/ZK 返回的实例顺序不保证固定,第一次可能是 [实例 1, 实例 2, 实例 0],第二次可能变成 [实例 2, 实例 0, 实例 1]。取模后的下标对应实例会频繁变化,请求无法均匀落到不同实例上。
2. 初步优化:@PostConstruct 启动时只取一次
这是很多新手的常用写法,在项目启动时获取实例列表:
java
@PostConstruct
public void init() {
// 项目启动时,从 Eureka 获取服务实例列表
instances = discoveryClient.getInstances("product-service");
}
优势非常明显:
- ✅ 负载均衡稳定:实例列表只获取一次,取模下标与实例的对应关系固定,轮询逻辑不乱;
- ✅ 性能高效:避免重复网络请求,仅在启动时查询一次注册中心。
但致命缺陷同样无法忽视:
- ❌ 无法感知服务上下线:如果商品服务新增实例、原有实例重启或宕机,当前服务完全无法感知,仍会调用失效实例,导致 500 报错;
- ❌ 缓存失效:实例列表变成 "死数据",无法同步注册中心的最新状态。
java
@Override
public OrderInfo selectById(Long orderId){
OrderInfo orderInfo = orderMapper.selectById(orderId);
// String url = "https://127.0.0.1:8080/product" + orderInfo.getProductId();
List<ServiceInstance> instances = discoveryClient.getInstances("product-service");
//找出他的ip和端口号
String uri = instances.get(0).getUri().toString();
//
String url = uri + "/product/" + orderInfo.getProductId();
ProductInfo proInfo = restTemplate.getForObject(url , ProductInfo.class);
orderInfo.setProductInfo(proInfo);
return orderInfo;
}
三、核心矛盾:性能与实时性的平衡
这正是负载均衡设计的核心痛点:
| 设计方案 | 核心优势 | 核心缺陷 |
|---|---|---|
| 每次请求获取 | 实例列表实时最新 | 负载均衡乱套、性能差 |
| 启动时只取一次 | 负载均衡稳定、性能高 | 无法感知服务上下线 |
简单来说:不能每次都查(性能乱),也不能只查一次(实时性乱)。
四、生产级解决方案:定时拉取 + 本地缓存
Spring Cloud 官方负载均衡(LoadBalancer / Ribbon)的底层逻辑,完美解决了这个矛盾,核心思路是:
- 本地缓存实例列表:启动时获取实例列表并缓存,后续请求直接使用缓存数据,保证性能与负载均衡稳定;
- 定时拉取更新缓存:每隔 3~5 秒(可配置)从注册中心拉取最新实例列表,更新本地缓存,保证服务上下线的实时性。
这种设计兼顾了性能 与实时性:
- 定时拉取频率可调整,平衡注册中心压力与服务感知时效;
- 本地缓存保证负载均衡逻辑稳定,请求均匀分发;
- 服务新增 / 宕机时,缓存会及时更新,避免调用失效实例。
自定义的负载均衡(loadbalancer)
定义beanconfig
java
package com.ytvc.order.Config;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
@LoadBalancerClient(name = "product-server",configuration = CustomLoadBalanancerConfiguration.class)
public class BeanConfig {
@LoadBalanced
@Bean
public RestTemplate restTemplet() {
return new RestTemplate();
}
}
定义自定义的负载均衡
java
package com.ytvc.order.Config;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.loadbalancer.core.RandomLoadBalancer;
import org.springframework.cloud.loadbalancer.core.ReactorLoadBalancer;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
public class CustomLoadBalanancerConfiguration {
@Bean
public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(Environment environment,
LoadBalancerClientFactory loadBalancerClientFactory) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new RandomLoadBalancer(
loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class),
name
);
}
}
创建模块的时候,通常在抽出一个模块放公共的东西
你的项目父工程(只放pom.xml,不放代码)
├── common <-- 这里才是放公共类的地方(普通Maven项目)
├── order-service └── product-service
父工程 pom.xml 这样写
<modules>
<module>common</module>
<module>order-service</module>
<module>product-service</module>
</modules>
微服务(order /product)只引 common
<dependency>
<groupId>com.ytvc</groupId>
<artifactId>common</artifactId>
</dependency>
要不然就是使用:
java
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<classifier>exec</classifier>
</configuration>
</plugin>
在 Maven 里,classifier 是用来区分同一个 groupId:artifactId:version 下,不同类型的附属构件的标签,比如:
- 带源码的包:
xxx-1.0-sources.jar - 带 Javadoc 的包:
xxx-1.0-javadoc.jar - 你这里的
exec,就是给 Spring Boot 插件生成的包打个 "标记"
加了 <classifier>exec</classifier> 之后,会发生什么?
1. 生成的包名会变
不加 classifier 时,spring-boot-maven-plugin 默认会生成两个包:
xxx.jar:可执行的、带依赖的胖包 (能直接java -jar运行)xxx.original.jar:原始的普通 Jar 包(不含依赖)
加了 <classifier>exec</classifier> 之后,生成的包名会变成:
xxx-exec.jar:可执行胖包(classifier是exec)xxx.jar:原始普通 Jar 包
也就是说,把 "可执行胖包" 加上了 -exec 后缀,和普通 Jar 包区分开了。
2. 为什么要这么做?
常见的使用场景有两个:
场景 1:父子工程中,只把普通 Jar 包给其他模块依赖
- 父工程下有多个子模块,比如:
eureka-server、order-service - 如果
eureka-server模块不加classifier,它生成的eureka-server.jar是可执行胖包 - 其他模块如果依赖
eureka-server,会把这个胖包一起带进来,导致依赖臃肿、冲突
加上 <classifier>exec</classifier> 之后:
- 模块的主构件是
eureka-server.jar(普通 Jar,不含启动类和依赖) - 可执行胖包是
eureka-server-exec.jar,只用来运行 - 其他模块依赖
eureka-server时,只会引入普通 Jar,不会把胖包带进来,完美解决依赖冲突
场景 2:同一个模块,生成多个不同用途的 Jar 包
比如一个模块既要作为依赖被其他项目引用,又要自己作为独立服务运行:
- 主构件
xxx.jar:作为依赖,供其他项目引用 xxx-exec.jar:可执行胖包,用来直接部署运行
XML
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 给可执行胖包加上exec后缀 -->
<classifier>exec</classifier>
<!-- 如果你需要指定主类,可以加上 -->
<mainClass>com.example.EurekaServerApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
不适用的话就是这个
XML
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<classifier>exec</classifier>
</configuration>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>**/**</include>
</includes>
</resource>
</resources>
</build>