22.SpringCloud服务调用与负载均衡

Spring Cloud 服务调用与负载均衡:RestTemplate、Nacos 与实例选择
provider 启动时把自己的地址登记到 Nacos,consumer 可以通过 DiscoveryClient 查询到 provider 的实例。现在还差一步:consumer 拿到地址后,怎样真正发送 HTTP 请求?当 provider 有多个实例时,又由谁决定请求发给哪一个实例?
这篇文章沿着一个简单的用户接口来回答这两个问题。先单独学习 RestTemplate 的基本请求方式,再把它和 DiscoveryClient 组合起来调用 provider,最后介绍 @LoadBalanced、Ribbon 的历史配置和现在使用的 Spring Cloud LoadBalancer。
本文使用 provider 和 consumer 两个 Spring Boot 项目:provider 在 Nacos 中的服务名为 provider,consumer 运行在 8282 端口,Nacos 地址为 127.0.0.1:8848。两端均已引入 Web MVC 与 Nacos Discovery 依赖,使用 Java 17 或以上版本。示例验证使用 Spring Boot 4.1.1、Spring Cloud 2025.1.2、Spring Cloud Alibaba 2025.1.0.0,依赖版本由父工程和各自的 BOM 管理。
provider 先提供下面两个用户接口:
GET /user/1:查询用户。POST /user:创建用户。
consumer 接收浏览器请求,调用 provider 后把结果返回。这样能把“发现地址”和“发送请求”两步分开看清楚。到了多实例部分,再用返回端口号的 /index 接口观察请求究竟落在哪个实例上。
1. RestTemplate 是什么
RestTemplate 是 Spring 提供的同步 HTTP 客户端。它可以在 Java 代码中发送 GET、POST、PUT、DELETE 等请求,并把响应正文转换成字符串或 Java 对象。
例如,浏览器访问 consumer 的 /user/1,consumer 再访问 provider 的 /user/1,这次从一个服务到另一个服务的 HTTP 请求,就可以由 RestTemplate 发出。
它负责的是“发送请求和接收响应”,不负责服务注册、服务发现或负载均衡。服务发现由 DiscoveryClient 或其他注册中心客户端完成;实例选择由负载均衡组件完成。把职责分开,遇到问题时更容易判断到底是哪一步出了问题。
RestTemplate 在很多已有项目中仍能见到,本篇先把这套调用方式学清楚。Spring Framework 7 已将它标记为弃用,新项目也可以选择同步客户端 RestClient;这里的服务发现和实例选择思路仍然适用。
1.1 为什么不直接复制 provider 的代码
用户服务的查询逻辑应该由 provider 负责。如果 consumer 把这段逻辑也复制一份,两个服务中的代码很快就会不一致。consumer 只需要知道接口地址、请求方法和数据格式,provider 负责真正处理业务。
这也是微服务调用的基本边界:调用方依赖对方公开的 HTTP 接口,不依赖对方的 Controller、Service 或数据库表。
1.2 把 RestTemplate 交给 Spring 管理
可以直接使用 new RestTemplate(),但在 Spring Boot 项目中,更常见的方式是定义一个 Bean。这样 consumer 的 Controller 或 Service 可以通过构造器注入同一个客户端。
java
package com.yigalaxy.consumer.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestClientConfig {
@Bean
public RestTemplate restTemplate() {
// 交给 Spring 管理,业务类通过构造器注入使用。
return new RestTemplate();
}
}
@Configuration 表示这是配置类,@Bean 方法返回的对象会放进 Spring 容器。后面如果需要统一设置超时、拦截器或错误处理,也可以集中修改这个 Bean。
2. 先用固定地址调用 provider
为了先理解 HTTP 请求本身,暂时把 provider 地址写成 http://localhost:8082。这一步不涉及 Nacos,适合先验证请求方法、参数和响应转换。
2.1 provider 提供一个查询接口
下面的 Controller 只返回一个简单的用户对象。真实项目中这里通常会调用 Service 和数据库,本文先把通信过程讲清楚。
java
package com.yigalaxy.provider.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@GetMapping("/user/{id}")
public UserVO findById(@PathVariable Long id) {
// 示例只返回固定数据,id 用来说明路径参数可以传给 provider。
return new UserVO(id, "张三");
}
}
UserVO 只保存这次要返回的编号和姓名,放在同一个包中:
java
package com.yigalaxy.provider.controller;
// record 会生成构造器和 id()、name() 等访问方法,适合这个只传数据的小例子。
public record UserVO(Long id, String name) {
}
访问 http://localhost:8082/user/1,响应正文类似:
json
{
"id": 1,
"name": "张三"
}
2.2 getForObject:只关心响应正文
consumer 注入前面定义的 RestTemplate,调用 provider 的查询接口:
java
package com.yigalaxy.consumer.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
@RestController
public class ConsumerController {
private final RestTemplate restTemplate;
public ConsumerController(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@GetMapping("/user/{id}")
public UserVO findUser(@PathVariable Long id) {
return restTemplate.getForObject(
"http://localhost:8082/user/{id}",
UserVO.class,
id);
}
}
在 consumer 的 com.yigalaxy.consumer.controller 包中也定义一个同样字段的 UserVO。两个项目分别持有自己的类型定义,真正通过 HTTP 传递的是 JSON,不是 provider 中的 Java 类本身。
这里的 URL 使用 {id} 占位符,最后一个参数 id 会替换它。provider 返回 JSON 后,Spring 的消息转换器会把 JSON 转换成 consumer 中的 UserVO。
为了先看清通信过程,这里直接在 Controller 中调用。项目变大后,可以把远程调用提取到 Service 或独立客户端类,Controller 负责接收请求和返回结果:
java
public UserVO findUser(Long id) {
// getForObject 只返回响应正文,不包含响应头和状态码。
return restTemplate.getForObject(
"http://localhost:8082/user/{id}",
UserVO.class,
id);
}
2.3 getForEntity:同时查看状态码和响应头
如果只需要正文,getForObject 就够了。如果还要判断状态码、读取响应头,可以使用 getForEntity:
java
ResponseEntity<UserVO> response = restTemplate.getForEntity(
"http://localhost:8082/user/{id}",
UserVO.class,
1L);
System.out.println(response.getStatusCode());
System.out.println(response.getHeaders().getContentType());
System.out.println(response.getBody());
ResponseEntity<UserVO> 是完整的 HTTP 响应对象:
getStatusCode():读取状态码,例如200 OK。getHeaders():读取响应头。getBody():读取转换后的响应正文。
例如,创建接口返回 201 Created,查询接口返回 200 OK,ResponseEntity 能让调用方读到这些区别。上面的代码需要导入 org.springframework.http.ResponseEntity,其余调用片段沿用前面注入的 restTemplate 和已定义的 UserVO。
不过,默认错误处理器遇到 4xx 或 5xx 会先抛出异常,不会把这些响应直接交给 getBody()。例如,需要把 provider 的 404 转成“用户不存在”时,可以在调用处捕获对应异常:
java
try {
return restTemplate.getForObject(
"http://localhost:8082/user/{id}", UserVO.class, id);
} catch (HttpClientErrorException.NotFound exception) {
// 将远程接口的 404 转成当前接口清楚的错误提示。
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
}
这里使用 org.springframework.web.client.HttpClientErrorException、org.springframework.web.server.ResponseStatusException 和 org.springframework.http.HttpStatus。连接失败或读取超时属于另一类通信异常,不能当作“用户不存在”处理。
3. POST、PUT 和 DELETE
查询使用 GET,创建、修改和删除通常使用其他 HTTP 方法。下面沿用用户例子,只展示请求数据怎样从 consumer 发到 provider。
3.1 POST 创建用户
provider 增加创建接口:
java
@PostMapping("/user")
public UserVO create(@RequestBody UserForm form) {
// 实际项目中这里会校验参数并保存数据库。
return new UserVO(2L, form.name());
}
这段方法添加到前面的 UserController 中,需要导入 PostMapping 和 RequestBody。再在 provider 同一个包中定义 UserForm:
java
package com.yigalaxy.provider.controller;
// 接收调用方提交的姓名,新增编号由 provider 决定。
public record UserForm(String name) {
}
consumer 中也定义 public record UserForm(String name) {},供下面的调用片段使用。
consumer 使用 postForObject 发送对象:
java
UserForm form = new UserForm("李四");
UserVO created = restTemplate.postForObject(
"http://localhost:8082/user",
form,
UserVO.class);
如果配置了 JSON 消息转换器,form 会被转换成下面的请求正文:
json
{
"name": "李四"
}
provider 的 @RequestBody 再把 JSON 转成 UserForm。因此,双方的字段名和数据类型需要提前约定。
postForObject(URL, request, responseType) 的三个参数分别是请求地址、请求对象和响应类型。若创建接口通过响应头返回新资源地址,可用 postForLocation 读取 Location;它要求服务端实际提供该响应头。需要状态码、请求头或其他响应信息时,可以使用后面的 exchange。
3.2 PUT 修改用户
java
UserForm form = new UserForm("李四(已修改)");
restTemplate.put(
"http://localhost:8082/user/{id}",
form,
2L);
put 会等待响应,默认遇到 4xx 或 5xx 也会抛出异常,但方法本身没有返回值。若修改接口需要返回修改后的对象,使用后面的 exchange 更直观。
为了接收这次修改,在 provider 的 UserController 中增加下面的方法,同时导入 PutMapping 和 RequestBody:
java
@PutMapping("/user/{id}")
public UserVO update(@PathVariable Long id, @RequestBody UserForm form) {
// 这里回传修改结果,实际业务还需要更新数据库。
return new UserVO(id, form.name());
}
UserForm 是 3.1 中的请求数据类型;它和 UserVO 一样,在 consumer 中也需要有可用的定义。请求类型组织提交数据,VO 组织返回给调用方的数据,双方不需要依赖对方 Controller 中的内部类。
3.3 DELETE 删除用户
java
restTemplate.delete(
"http://localhost:8082/user/{id}",
2L);
DELETE 常见的成功状态码是 204 No Content,所以调用方不应假设一定有响应正文。真正的业务删除是否成功,仍应根据 HTTP 状态码或统一响应体判断。
provider 中对应的方法如下,导入 DeleteMapping、ResponseStatus 和 HttpStatus 后,添加到同一个 UserController 中:
java
@DeleteMapping("/user/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
// 示例只演示请求能到达这里;实际业务在这里执行删除。
System.out.println("收到删除用户请求:" + id);
}
这些接口没有保存数据。POST 返回编号 2,并不意味着之后 GET 就能查到新用户;GET 仍返回固定的“张三”。当前要观察的是请求方法、参数与响应,真正保存用户信息需要接入数据库。
4. exchange:需要请求头时使用
前面的快捷方法适合简单请求。如果请求需要携带 Token、指定请求方法、设置请求头或读取完整响应,可以使用 exchange。
例如,consumer 调用需要登录凭证的接口:
java
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth("demo-token");
// GET 没有请求正文,所以这里只封装请求头。
HttpEntity<Void> request = new HttpEntity<>(headers);
ResponseEntity<UserVO> response = restTemplate.exchange(
"http://localhost:8082/user/{id}",
HttpMethod.GET,
request,
UserVO.class,
1L);
发出的请求会带有:
text
Authorization: Bearer demo-token
HttpHeaders 保存请求头,HttpEntity 把请求头和请求正文放在一起,HttpMethod.GET 指定请求方法。POST 或 PUT 时,可以把 HttpEntity<UserForm> 作为第三个参数,同时携带请求头和 JSON 正文。
java
HttpEntity<UserForm> request = new HttpEntity<>(form, headers);
ResponseEntity<UserVO> response = restTemplate.exchange(
"http://localhost:8082/user",
HttpMethod.POST,
request,
UserVO.class);
5. 结合 Nacos 服务发现调用 provider
固定地址可以帮助我们理解 HTTP 请求,但它有一个明显问题:provider 换机器、改端口或启动多个实例后,consumer 里的 URL 就要跟着修改。既然 Nacos 中已经有实例地址,就可以先查询地址,再发送请求。
5.1 用端口号观察调用结果
为了看清多个实例的区别,先在 provider 中增加一个 /index 接口。每个实例执行相同的代码,只是端口不同:
java
package com.yigalaxy.provider.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProviderController {
@Value("${server.port}")
private String port;
@GetMapping("/index")
public String index() {
// 把端口放进响应,方便判断请求落到了哪个实例。
return "当前的服务端口是:" + port + "provider";
}
}
启动两个服务名同为 provider 的实例,分别使用 8082、8083 端口。它们向 Nacos 注册后,consumer 就能查到两个可用地址。
5.2 先查询实例,再随机选择一个
consumer 同时注入 DiscoveryClient 和 RestTemplate:
java
package com.yigalaxy.consumer.controller;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.client.RestTemplate;
@RestController
public class ConsumerController {
private final DiscoveryClient discoveryClient;
private final RestTemplate restTemplate;
public ConsumerController(
DiscoveryClient discoveryClient,
RestTemplate restTemplate) {
this.discoveryClient = discoveryClient;
this.restTemplate = restTemplate;
}
@GetMapping("/index")
public String index() {
List<ServiceInstance> instances =
discoveryClient.getInstances("provider");
if (instances.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"没有可用的 provider 实例");
}
// 在 [0, size) 中随机取下标,不固定使用第一台机器。
int index = ThreadLocalRandom.current().nextInt(instances.size());
ServiceInstance instance = instances.get(index);
String url = instance.getUri().toString() + "/index";
String result = restTemplate.getForObject(url, String.class);
return "consumer 服务调用 provider 服务,结果是:" + result;
}
}
调用过程可以拆成四步:
DiscoveryClient按服务名provider查询实例。ServiceInstance保存一个实例的信息,getHost()可以读主机地址,getPort()可以读端口,getUri()可以直接取得包含协议、主机和端口的地址。- 用 JDK 的
ThreadLocalRandom随机选择一项,在实例地址后拼接/index。 - 普通
RestTemplate请求这个具体地址,取得 provider 返回的字符串。
访问 http://localhost:8282/index,可能看到:
text
consumer 服务调用 provider 服务,结果是:当前的服务端口是:8082provider
再次访问可能仍然是 8082,也可能变成 8083。随机选择不保证轮流出现,也不保证少量请求均分。如果写成 instances.get(0),就只是固定取列表第一项,不能达到分摊请求的目的。
注意导入的是 java.util.concurrent.ThreadLocalRandom,不使用 Nacos 依赖包里重打包的同名内部类。JDK 已经提供这个工具,业务代码无需依赖 Nacos 的内部实现。
这一小节替换前面的 ConsumerController,单独演示手动选择实例。它注入的是 1.2 中的普通 RestTemplate,URL 已经是具体 IP 和端口,不要再让带 @LoadBalanced 的客户端把 IP 当成服务名查找。
5.3 失败情况要有清楚的结果
服务发现是动态的。provider 尚未启动、刚刚下线或分组配置不一致时,getInstances 可能返回空集合。直接写 instances.get(0) 会抛出下标异常,调用方只能看到一个难以理解的 500。
示例中把空集合转换成 503 Service Unavailable,意思是“当前没有可用的依赖服务”。如果已经查到实例但网络连接失败,还需要配置连接超时、读取超时,并结合重试、熔断或降级策略。服务发现成功不等于业务请求一定成功。
6. @LoadBalanced:按服务名调用
每个 Controller 都重复“查询实例、选择实例、拼接地址”的代码并不方便。Spring Cloud LoadBalancer 可以把这部分选择工作交给客户端完成。
6.1 当前项目使用 Spring Cloud LoadBalancer
在 consumer 的 Maven 依赖中增加:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
把 1.2 中的普通客户端配置替换为下面的配置。学习服务名调用时只保留这个 Bean;若还有固定地址请求,再按下文说明分别配置两个客户端:
java
package com.yigalaxy.consumer.config;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class LoadBalancedClientConfig {
@Bean
@LoadBalanced
public RestTemplate loadBalancedRestTemplate() {
// 这个客户端可以识别 URL 中的服务名 provider。
return new RestTemplate();
}
}
接着替换 5.2 中的 ConsumerController,由框架接手查找和选择实例:
java
package com.yigalaxy.consumer.controller;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
@RestController
public class ConsumerController {
private final RestTemplate restTemplate;
public ConsumerController(
@Qualifier("loadBalancedRestTemplate") RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@GetMapping("/index")
public String index() {
// provider 是 Nacos 中的服务名,不再手动拼接具体 IP 和端口。
String result = restTemplate.getForObject(
"http://provider/index", String.class);
return "consumer 服务调用 provider 服务,结果是:" + result;
}
}
调用 URL 中的 provider 是服务名。@LoadBalanced 让 Spring Cloud 在真正发送请求前,从服务发现客户端取得 provider 的实例,再选择其中一个实例。因此,业务代码不再拼接具体 IP 和端口。
如果项目中同时存在普通 RestTemplate 和带 @LoadBalanced 的 RestTemplate,容器里会有多个同类型 Bean,需要使用 @Qualifier 明确注入哪个 Bean。不要把普通客户端直接交给 http://provider/...,普通客户端会把 provider 当作普通主机名解析。
反过来也一样:已经手动取得 http://某个IP:8082/index 时,要用普通客户端。带 @LoadBalanced 的客户端会把 URL 主机部分当成服务名,通常会因为查不到以 IP 命名的服务而失败。
查询用户也只需要把请求地址换成服务名,放在前面用户查询方法中的调用语句可以写成:
java
// UserVO 沿用 consumer 中的用户展示类型。
return restTemplate.getForObject(
"http://provider/user/{id}", UserVO.class, id);
6.2 多实例调用结果怎样观察
启动两个服务名都为 provider 的实例,例如 8082 和 8083,然后连续访问 consumer 的 /index。当前没有自定义策略时,Spring Cloud LoadBalancer 默认采用轮询,通常可以看到返回的端口轮流变化。第一项从哪个实例开始不固定,实例增减也会改变后续顺序。
例如,四次请求可能得到下面的结果:
text
consumer 服务调用 provider 服务,结果是:当前的服务端口是:8082provider
consumer 服务调用 provider 服务,结果是:当前的服务端口是:8083provider
consumer 服务调用 provider 服务,结果是:当前的服务端口是:8082provider
consumer 服务调用 provider 服务,结果是:当前的服务端口是:8083provider
负载均衡解决的是“从多个实例中选择一个”,不负责业务异常、数据一致性或重试成功。provider 全部下线时,consumer 仍然会失败,调用方需要有对应的错误处理。
7. Ribbon 与 Spring Cloud LoadBalancer 的版本区别
Ribbon 曾经是 Spring Cloud 常用的客户端负载均衡组件,但从 Spring Cloud 2020.0 开始,Spring Cloud 不再集成 Ribbon。当前使用的 Spring Cloud 2025.1.x 应使用 Spring Cloud LoadBalancer。了解旧配置是为了能看懂已有项目,不能把 Ribbon 规则直接复制到新版本中。
7.1 旧版 Ribbon 的写法
如果你维护的是仍然使用 Netflix Ribbon 的旧项目,可以看到这样的配置:
yaml
provider:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
这里的 provider 是服务名,RandomRule 表示从可用实例中随机选择一个。旧版 Ribbon 默认常见的是轮询规则,也可以配置基于响应时间的 WeightedResponseTimeRule:
yaml
provider:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.WeightedResponseTimeRule
这类规则会根据响应时间动态调整选择概率,不能简单理解成“实例 A 固定 5 份、实例 B 固定 1 份”。这些配置只适用于仍然引入 Ribbon 的旧项目。
7.2 Nacos Ribbon 固定权重的历史配置
旧项目如果使用 Spring Cloud Alibaba 的 Nacos Ribbon 集成,可能会看到:
yaml
provider:
ribbon:
NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule
然后在 Nacos 控制台给不同实例设置权重。权重必须按实例分别设置;如果所有实例注册的权重相同,请求自然不会产生不同的比例。
这段配置是历史方案。新项目使用 Spring Cloud LoadBalancer 时,不应继续添加 Ribbon 的 NFLoadBalancerRuleClassName。
如果旧项目使用的不是 Nacos,Ribbon 也没有一套通用 YAML 能直接为每个实例指定固定比例,通常需要实现自己的 IRule。响应时间权重、Nacos 实例权重和自己编写规则是不同的做法,应先确认项目采用哪一种。
8. 在 Nacos 控制台调整实例权重
前面已经能让两个 provider 轮流处理请求了。不过,实际部署的两台机器不一定性能相同:如果 8082 所在机器的资源更多,就希望它多接一些请求,8083 少接一些。这时可以给两个实例设置不同的权重。
权重表示实例承担请求的相对份额。这里主要在 Nacos 控制台修改每个实例的“权重”字段,consumer 负责按照这些权重选择实例。 代码接入一次以后,日常调整比例就在控制台完成,不需要每次改 Controller 或重新发布程序。
8.1 先让 consumer 使用 Nacos 的权重规则
只在控制台修改数字还不够:第 6 节的默认轮询不会自动变成按 Nacos 权重分配。因此,需要先让 consumer 使用 Spring Cloud Alibaba 提供的 Nacos 负载均衡实现。
沿用第 6 节的 spring-cloud-starter-loadbalancer 依赖、带 @LoadBalanced 的 RestTemplate 和 http://provider/index 调用。在 consumer 已有的 YAML 中合并下面的配置:
yaml
spring:
cloud:
loadbalancer:
nacos:
# 使用 Nacos 的负载均衡实现,按实例原生权重进行加权随机选择。
enabled: true
这段配置适用于这里使用的 Spring Cloud Alibaba 2025.1.0.0。修改客户端配置后启动或重启 consumer,使它完成接入;之后仅在控制台调整实例权重,就不需要重启 consumer 了。
启用后,默认选择过程由 NacosLoadBalancer 完成。它取得 Nacos 中可用的实例,再按实例权重做加权随机选择。每次 HTTP 请求仍只调用一个 provider,Nacos 负责保存、通知实例信息,实际业务请求由 consumer 直接发给选中的 provider。
这条主线不需要自己编写 ProviderLoadBalancerConfig,也不需要配置 metadata.weight。如果项目已经添加了自定义负载均衡器或加权供应器,先移除这类自定义配置,避免覆盖或干扰 Nacos 默认规则。补充方案会在 8.6 单独说明。
8.2 打开服务详情,分别修改两个实例的权重
先启动两个 provider,让它们以同一个服务名 provider 注册到 Nacos,端口分别为 8082、8083:
powershell
java -jar target/provider-0.0.1-SNAPSHOT.jar --server.port=8082
在另一个终端启动第二个实例:
powershell
java -jar target/provider-0.0.1-SNAPSHOT.jar --server.port=8083
接下来进入 Nacos 控制台。Nacos 3.x 的控制台默认使用独立的 8080 端口,本地示例访问 http://localhost:8080;旧版 2.x 常见地址是 http://localhost:8848/nacos。实际地址以部署时的端口和路径为准。
在控制台按下面的步骤操作,不同版本的菜单名称可能略有不同:
- 进入“服务管理 → 服务列表”,选择与 provider 注册配置一致的命名空间。
- 按分组和服务名找到
provider。默认分组通常为DEFAULT_GROUP,不要选错环境中的同名服务。 - 点击“详情”,找到实例列表,确认 8082 和 8083 两个实例都健康、已启用。
- 找到端口为 8082 的实例,点击这一行的“编辑”,把 “权重”改为 5,保存或确认。
- 找到端口为 8083 的实例,按同样的方式把 “权重”改为 1,保存或确认。
- 返回实例列表,确认两个实例显示的权重分别为 5 和 1。
这里改的是实例编辑窗口中的原生“权重”字段。不要把 weight=5 填进“元数据”,也不要去“配置管理”中另建一个权重配置文件。控制台展示的两个实例各有自己的权重,因此要分别修改。
设置完成后,实例信息应类似下面这样,IP 以实际注册结果为准:
| provider 实例 | 权重 | 预期分配趋势 |
|---|---|---|
| IP:8082 | 5 | 大约承担 5/6 的请求 |
| IP:8083 | 1 | 大约承担 1/6 的请求 |
两个实例的默认权重通常都为 1。5 和 1 是相对比例;本例的分配趋势约为 5∶1。
8.3 从 consumer 发请求,观察比例变化
现在连续访问 http://localhost:8282/index,通过返回的 provider 端口判断请求交给了谁。必须从 consumer 访问,直接请求 http://localhost:8082/index 会固定访问 8082,无法观察负载均衡。
少量请求不容易看出比例,可以用下面的 PowerShell 连续请求 120 次并统计:
powershell
$counts = @{ '8082' = 0; '8083' = 0 }
1..120 | ForEach-Object {
# consumer 内部通过 http://provider/index 选择一个 provider。
$result = Invoke-RestMethod 'http://localhost:8282/index'
if ($result -match '8082') {
$counts['8082']++
} elseif ($result -match '8083') {
$counts['8083']++
} else {
throw "返回结果中没有预期的 provider 端口:$result"
}
}
$counts
例如,可能观察到下面的结果:
text
Name Value
---- -----
8082 98
8083 22
这只是说明结果形式,并不是每次都应出现的固定次数。Nacos 默认采用加权随机选择,权重 5∶1 表示每次选择时更倾向于 8082,不表示“严格先给 8082 五次,再给 8083 一次”。请求越多,通常越容易看出分配趋势。
然后回到控制台,把 8082 改为 1,把 8083 改为 5。保存后等待实例变更同步到 consumer,再重新统计。此时应看到 8083 获得更多请求,不需要重启 consumer,也不需要改调用代码。
权重变化需要经过 Nacos 通知和客户端实例信息更新,不保证点击保存的那一瞬间所有请求都使用新权重。已经发出去的请求也不会因为修改权重而重新分配。
8.4 权重改成 0,会有什么效果
假设 8082 刚更新完程序,希望先让它保持注册状态,暂时不接新流量。可以在控制台把它的权重设为 0,同时让 8083 保持正权重。
在本节的 Nacos 加权选择方式下,8082 不再参与新的实例选择。等待变更同步后,再运行上面的统计,新的请求应全部交给 8083;之后把 8082 恢复为正权重,它就能重新参与分配。
权重为 0 不会关闭 provider 进程,也不会中断已经处理中的请求。直接访问 8082 仍然可以调用它的接口;采用其他选择方式的调用方也未必遵守这个权重。需要把实例正式移出服务时,还应结合禁用、下线和应用的优雅停机处理。
不要把所有实例都设为 0 来期待服务继续工作,至少应保留一个健康、已启用且权重为正的候选实例。不同 cluster 或其他筛选规则也可能先缩小候选范围,本例先让两个 provider 使用相同的 cluster,再比较权重。
控制台调整针对当前注册实例;应用重新注册、重新部署后的有效值要重新核对。若需要设置 provider 注册时的初始原生权重,可以使用 spring.cloud.nacos.discovery.weight,它与控制台的原生权重属于同一种字段;不要与下面的自定义元数据混淆。
8.5 开发接入与日常调整分别做什么
开发时先完成三件事:引入 LoadBalancer 依赖,给调用服务名的 RestTemplate 添加 @LoadBalanced,再启用 spring.cloud.loadbalancer.nacos.enabled。同时保留空实例、超时和远程错误的处理。
运行后,由负责部署和维护的人员在 Nacos 控制台选择正确的 namespace、group、服务和实例,修改权重并观察请求分配。机器扩容或性能不同时,可以提高容量较大实例的权重;新实例刚上线时,也可以先用较小的正权重观察,再逐步提高。
这样,调用代码始终写 http://provider/index。哪些实例参与服务、每个实例承担多少流量,主要通过控制台调整。
8.6 补充:metadata.weight 是另一种权重来源
除了读取 Nacos 原生权重,Spring Cloud LoadBalancer 还提供通用的 withWeighted() 供应器。供应器负责提供候选实例;这个通用方法默认读取 ServiceInstance 元数据中名为 weight 的值。
两种方案读取的字段不同:
| 方案 | 权重来源 | 本文的主要调整位置 |
|---|---|---|
| Nacos 负载均衡实现 | Nacos 原生权重,发现后映射为 nacos.weight |
控制台实例编辑窗口的“权重” |
通用 withWeighted() 默认用法 |
自定义元数据 weight |
provider 注册的 metadata.weight |
通过控制台原生权重调整流量,使用 8.1—8.5 即可。 下面介绍通用供应器的独立写法,帮助区分权重入口。尝试此方案时,关闭 spring.cloud.loadbalancer.nacos.enabled,使用下列配置替代 Nacos 规则,不要把两套加权方式叠在一起。
先在 consumer 中指定 provider 使用哪一份配置:
java
package com.yigalaxy.consumer.config;
import com.yigalaxy.loadbalance.ProviderLoadBalancerConfig;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.context.annotation.Configuration;
@Configuration
@LoadBalancerClient(
name = "provider",
configuration = ProviderLoadBalancerConfig.class)
public class LoadBalancerClientConfig {
}
然后定义供应器配置。下面放在 com.yigalaxy.loadbalance 包中,它在 consumer 启动类默认扫描的 com.yigalaxy.consumer 包之外,由 @LoadBalancerClient 单独引入,避免把针对 provider 的规则意外注册到所有服务:
java
package com.yigalaxy.loadbalance;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
public class ProviderLoadBalancerConfig {
@Bean
public ServiceInstanceListSupplier serviceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
// 使用当前 Nacos 集成提供的同步 DiscoveryClient 查询实例。
.withBlockingDiscoveryClient()
// 默认读取实例元数据中的 weight,缺省为 1。
.withWeighted()
.build(context);
}
}
这里的 withBlockingDiscoveryClient() 使用同步的 DiscoveryClient。不要将它直接替换为名字很像的 withDiscoveryClient(),后者需要 ReactiveDiscoveryClient;当前 Nacos 项目没有提供这个 Bean,会在创建实例列表供应器时出错。
withWeighted() 默认读取 ServiceInstance.getMetadata() 中的 weight。没有提供时按 1 处理,本例使用正整数 5 和 1,分别表示期望承担的相对份额。供应器选择规则写在 consumer 中,每个实例的元数据则由 provider 注册到 Nacos,两边配置要一起生效。
给 provider 注册自定义元数据
可以把权重放在 Nacos Discovery 的元数据中:
yaml
spring:
cloud:
nacos:
discovery:
metadata:
weight: ${INSTANCE_WEIGHT:1}
启动实例时,通过环境变量给出不同值:
powershell
$env:INSTANCE_WEIGHT = "5"
java -jar target/provider-0.0.1-SNAPSHOT.jar --server.port=8082
在另一个终端启动第二个实例:
powershell
$env:INSTANCE_WEIGHT = "1"
java -jar target/provider-0.0.1-SNAPSHOT.jar --server.port=8083
这表示实例 A 的权重为 5、实例 B 的权重为 1,选择比例会倾向于 A。两个实例稳定、连续请求时,可以观察到大约 5∶1 的比例;一次请求不会同时发到两台机器。不要把它当作业务配额,实例列表变化、并发请求和失败重试都可能影响短期计数。
可以在 consumer 中读取元数据确认注册结果,下面的片段使用已注入的 discoveryClient:
java
for (ServiceInstance instance : discoveryClient.getInstances("provider")) {
System.out.println(instance.getPort() + " -> "
+ instance.getMetadata().get("weight"));
}
两个实例的输出顺序可能不同,但应该能看到 8082 -> 5 和 8083 -> 1。这是补充方案中的自定义元数据;调整它时,要让对应元数据重新注册或更新。前面在控制台修改的原生权重对应 nacos.weight,不会自动替你修改这里的 weight。
9. 常见问题
9.1 http://provider/... 无法访问
先检查注入的 RestTemplate 是否确实带有 @LoadBalanced,以及 consumer 是否引入了 spring-cloud-starter-loadbalancer。没有这两项时,普通 HTTP 客户端会把 provider 当成 DNS 主机名。
9.2 instances.get(0) 抛异常
调用前先判断集合是否为空:
java
List<ServiceInstance> instances =
discoveryClient.getInstances("provider");
if (instances.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"provider 暂时没有可用实例");
}
同时检查 provider 是否启动、服务名是否完全一致、group 和 namespace 是否一致。
9.3 为什么改了权重但比例没变化
先确认请求经过 consumer 的 http://provider/index。如果直接访问某个 provider 的固定地址,或仍在使用第 5 节手动等概率随机的代码,都不会按照本节的 Nacos 权重选择。
然后检查 consumer 是否引入 LoadBalancer 依赖、是否使用带 @LoadBalanced 的客户端,以及是否启用了 spring.cloud.loadbalancer.nacos.enabled。只有 @LoadBalanced 而没有启用 Nacos 规则时,默认轮询不会使用控制台权重;旧版 Ribbon 的配置也不能让新版自动生效。
再去控制台核对 namespace、group、服务名、实例 IP 和端口,确认修改的是原生“权重”字段,两个实例健康且已启用,并位于本例的同一 cluster。保存后等实例信息同步,再用足够多的请求观察趋势,不要只刷新两三次就判断无效。
如果项目有自定义负载均衡器或供应器,也要检查它是否覆盖了默认配置。尤其要区分 8.6 的 metadata.weight 和控制台的原生权重:读错字段时,控制台虽然改了数字,实际选择策略仍可能使用另一个值。
9.4 provider 返回 4xx 或 5xx
如果响应确实来自 provider,通常需要检查接口路径、参数、权限和业务逻辑,并处理默认客户端抛出的 HTTP 异常。不过,consumer 自己生成的 503 也可能表示没有可用实例,所以还要结合异常日志确认响应来自哪里。
10. 一次完整验证
可以按下面的顺序学习和验证:
- 先单独启动 provider,确认
/user/1能返回 JSON。 - 用固定地址的普通
RestTemplate调用 provider,确认 GET、POST、PUT 和 DELETE 的参数传递。 - 启动 Nacos 和 consumer,用
DiscoveryClient查询provider的实例。 - 使用普通客户端,手动随机选择一个
ServiceInstance,拼接地址后调用/index。 - 引入 Spring Cloud LoadBalancer,把
RestTemplate标记为@LoadBalanced。 - 改用
http://provider/index,确认不再依赖固定 IP 和端口。 - 启动第二个 provider,连续请求 consumer,观察实例选择结果。
- 在 consumer 启用 Nacos 负载均衡实现,重启使客户端配置生效。
- 在 Nacos 控制台把两个 provider 的原生权重分别改为 5 和 1,统计 consumer 的返回端口。
- 不重启 consumer,在控制台把比例调为 1∶5,等待同步后重新统计;再将一个实例设为 0,观察新请求避开它,最后恢复正常权重。
这条顺序先验证 HTTP 客户端,再验证服务发现,最后才加入负载均衡和权重。某一步失败时,可以根据它所在的层次缩小排查范围。

评论