概述
上一篇把 user-service 注册进了 Eureka,但 order-service 里还写着 http://localhost:8081。服务注册了等于白注册------调用方根本没去查注册中心。这一篇就做这一件事:把硬编码的 IP 端口换成服务名,让 order-service 自己从 Eureka 拉实例列表,顺手把负载均衡也带上。
纲要
- 问题起点 :注册完成了,但调用方仍在写死
http://localhost:8081,注册中心形同虚设 - 两步改造
- 改 URL:
http://localhost:8081/user/{id}→http://userservice/user/{id} - 加注解:给创建
RestTemplate的@Bean加@LoadBalanced
- 改 URL:
- 原理拆解
@LoadBalanced做了什么(拦截器的注入过程)- 服务名是怎么被解析成真实地址的(
LoadBalancerInterceptor→ 实例列表 → 负载均衡选一台 → 改写 URI) - 为什么说这里已经悄悄引入了负载均衡,而不只是字符串替换
- 两种服务发现方式
@LoadBalanced+RestTemplate(推荐,代码最少)DiscoveryClient手动拿实例列表(理解原理、需要自己控制时用)
- 排错清单 :
UnknownHostException: userservice、注解加错位置、同一个RestTemplate想两用、服务列表缓存延迟 - 验证:两次请求打到两个不同端口,日志各出现一次
为什么注册好了还是没用
先把上一篇的结论摆出来。user-service 启动后向 Eureka Server 上报了 userservice 这个名字,Eureka 控制台里能看到 8081、8082 两个实例。这一步是服务注册。
但 order-service 侧的调用代码长这样:
java
String url = "http://localhost:8081/user/" + order.getUserId();
localhost:8081 是写死的字符串。它压根没问过 Eureka 一句话,注册中心存的那份地址列表它一份都没读。结果就是:
- 8081 那台挂了,
order-service照样往 8081 发请求,直接连接失败; - 加了 8083 实例,
order-service也完全不知道,压力全压在 8081 上。
所以「服务注册」只完成了一半,另一半是服务发现(也叫服务拉取):消费者按服务名去 Eureka 要一份实例列表,再自己挑一台调用。
| 阶段 | 谁在做 | 做什么 | 缺了会怎样 |
|---|---|---|---|
| 服务注册 | provider(user-service) | 上报「服务名 + IP + 端口」 | 控制台里什么都看不见 |
| 服务发现 | consumer(order-service) | 按服务名拉取实例列表 | 只能靠硬编码 IP 调用 |
| 负载均衡 | consumer 侧组件 | 从列表里挑一台 | 永远打第一台,无法分散流量 |
整个链路是这个样子。
#mermaid-svg-1EOjGIDYI8yEmObf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1EOjGIDYI8yEmObf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1EOjGIDYI8yEmObf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1EOjGIDYI8yEmObf .error-icon{fill:#552222;}#mermaid-svg-1EOjGIDYI8yEmObf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1EOjGIDYI8yEmObf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1EOjGIDYI8yEmObf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1EOjGIDYI8yEmObf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1EOjGIDYI8yEmObf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1EOjGIDYI8yEmObf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1EOjGIDYI8yEmObf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1EOjGIDYI8yEmObf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1EOjGIDYI8yEmObf .marker.cross{stroke:#333333;}#mermaid-svg-1EOjGIDYI8yEmObf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1EOjGIDYI8yEmObf p{margin:0;}#mermaid-svg-1EOjGIDYI8yEmObf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1EOjGIDYI8yEmObf .cluster-label text{fill:#333;}#mermaid-svg-1EOjGIDYI8yEmObf .cluster-label span{color:#333;}#mermaid-svg-1EOjGIDYI8yEmObf .cluster-label span p{background-color:transparent;}#mermaid-svg-1EOjGIDYI8yEmObf .label text,#mermaid-svg-1EOjGIDYI8yEmObf span{fill:#333;color:#333;}#mermaid-svg-1EOjGIDYI8yEmObf .node rect,#mermaid-svg-1EOjGIDYI8yEmObf .node circle,#mermaid-svg-1EOjGIDYI8yEmObf .node ellipse,#mermaid-svg-1EOjGIDYI8yEmObf .node polygon,#mermaid-svg-1EOjGIDYI8yEmObf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1EOjGIDYI8yEmObf .rough-node .label text,#mermaid-svg-1EOjGIDYI8yEmObf .node .label text,#mermaid-svg-1EOjGIDYI8yEmObf .image-shape .label,#mermaid-svg-1EOjGIDYI8yEmObf .icon-shape .label{text-anchor:middle;}#mermaid-svg-1EOjGIDYI8yEmObf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1EOjGIDYI8yEmObf .rough-node .label,#mermaid-svg-1EOjGIDYI8yEmObf .node .label,#mermaid-svg-1EOjGIDYI8yEmObf .image-shape .label,#mermaid-svg-1EOjGIDYI8yEmObf .icon-shape .label{text-align:center;}#mermaid-svg-1EOjGIDYI8yEmObf .node.clickable{cursor:pointer;}#mermaid-svg-1EOjGIDYI8yEmObf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1EOjGIDYI8yEmObf .arrowheadPath{fill:#333333;}#mermaid-svg-1EOjGIDYI8yEmObf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1EOjGIDYI8yEmObf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1EOjGIDYI8yEmObf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1EOjGIDYI8yEmObf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1EOjGIDYI8yEmObf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1EOjGIDYI8yEmObf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1EOjGIDYI8yEmObf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1EOjGIDYI8yEmObf .cluster text{fill:#333;}#mermaid-svg-1EOjGIDYI8yEmObf .cluster span{color:#333;}#mermaid-svg-1EOjGIDYI8yEmObf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1EOjGIDYI8yEmObf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1EOjGIDYI8yEmObf rect.text{fill:none;stroke-width:0;}#mermaid-svg-1EOjGIDYI8yEmObf .icon-shape,#mermaid-svg-1EOjGIDYI8yEmObf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1EOjGIDYI8yEmObf .icon-shape p,#mermaid-svg-1EOjGIDYI8yEmObf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1EOjGIDYI8yEmObf .icon-shape .label rect,#mermaid-svg-1EOjGIDYI8yEmObf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1EOjGIDYI8yEmObf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1EOjGIDYI8yEmObf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1EOjGIDYI8yEmObf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} order-service
user-service
Eureka Server (10086)
注册(心跳30s)
注册(心跳30s)
1.按服务名拉列表
2.返回 8081,8082
3.挑一台发请求
服务名 → 实例列表
userservice → 8081, 8082
实例1 :8081
实例2 :8082
服务发现 + 负载均衡
改造成服务名调用
改造动作就两步,第一步改 URL 是「看得见的改动」,第二步加注解才是「让改动生效的开关」。少了第二步,第一步写完必报错。
改造前:order-service 的调用代码
先把改造前的样子贴全,方便对照。这是 OrderService,用 RestTemplate 发起 HTTP 请求去查用户信息。
java
package cn.itcast.order.service;
import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RestTemplate restTemplate;
public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2.发起远程调用查询用户,这里是硬编码的 IP + 端口
String url = "http://localhost:8081/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);
// 3.把查询到的用户信息封装进订单
order.setUser(user);
// 4.返回
return order;
}
}
对应的 RestTemplate 是这么注册的。
java
package cn.itcast.order;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
此时 pom 里也还没有 eureka-client,application.yml 里也没有 Eureka 地址。这三处都要动。
第一步:引入 eureka-client 依赖并配置地址
服务发现用的依赖和服务注册是同一个------eureka-client 把注册和发现封装在一起了,一个微服务既可能是提供者也可能是消费者,没必要拆两套。
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
配置和 user-service 写法一致,区别只在服务名。
yaml
server:
port: 8080
spring:
application:
name: orderservice
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
注意 spring.application.name: orderservice 这一行是必须的。它有两个作用:把自己注册上去时用这个名字,同时也决定日志里怎么标识自己。
第二步:把 URL 里的 IP 端口换成服务名
真正的改造在这里。把 http://localhost:8081 换成 http://userservice。
java
// 改造前:写死地址,Eureka 里的实例列表完全用不上
String url = "http://localhost:8081/user/" + order.getUserId();
// 改造后:用服务名代替 IP 和端口
String url = "http://userservice/user/" + order.getUserId();
服务名必须和 user-service 的 spring.application.name 完全一致 。写错了不会在编译期报错,只会在运行时抛 UnknownHostException。写的时候别凭记忆手敲,从 user-service 的 application.yml 里复制过来最稳。
改造后的完整 OrderService 如下。
java
package cn.itcast.order.service;
import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RestTemplate restTemplate;
public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2.用服务名发起远程调用,具体打到哪个实例交给 Eureka 与负载均衡决定
String url = "http://userservice/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);
// 3.把查询到的用户信息封装进订单
order.setUser(user);
// 4.返回
return order;
}
}
第三步:给 RestTemplate 的 Bean 加 @LoadBalanced
这是整篇最关键的一行注解。少了它,上一步的 userservice 就是个普通的域名,RestTemplate 会老老实实拿去走 DNS 解析,然后报 UnknownHostException。
java
package cn.itcast.order;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
@Bean
@LoadBalanced // 关键开关:让这个 RestTemplate 具备按服务名调用的能力
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
注解加在创建 RestTemplate 的那个 @Bean 方法 上,不是加在 OrderApplication 类上,也不是加在 OrderService 的字段上。位置加错是最常见的翻车点,等下在排错部分细说。
目录结构
改造完成后 order-service 的关键文件位置。
tree
cloud-demo
├── eureka-server
├── order-service
│ ├── pom.xml # 新增 eureka-client 依赖
│ └── src/main
│ ├── java/cn/itcast/order
│ │ ├── OrderApplication.java # @Bean + @LoadBalanced 加在这里
│ │ ├── service/OrderService.java # URL 改成 http://userservice/...
│ │ ├── web/OrderController.java
│ │ ├── mapper/OrderMapper.java
│ │ └── pojo/{Order,User}.java
│ └── resources/application.yml # 配 orderservice 与 defaultZone
└── user-service
└── src/main/resources/application.yml # name: userservice
@LoadBalanced 到底做了什么
@LoadBalanced 本身不干活,它只是一个标记注解 。真正干活的是 Spring Cloud 的自动配置:启动时它扫描所有被 @LoadBalanced 标记的 RestTemplate,给这些 Bean 额外装一个拦截器 LoadBalancerInterceptor。
于是 restTemplate.getForObject("http://userservice/user/1", User.class) 的执行路径变成了这样。
user-service 实例 Eureka Server LoadBalancerClient LoadBalancerInterceptor RestTemplate OrderService user-service 实例 Eureka Server LoadBalancerClient LoadBalancerInterceptor RestTemplate OrderService #mermaid-svg-IexreiNcvkyGBxg3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IexreiNcvkyGBxg3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IexreiNcvkyGBxg3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IexreiNcvkyGBxg3 .error-icon{fill:#552222;}#mermaid-svg-IexreiNcvkyGBxg3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IexreiNcvkyGBxg3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IexreiNcvkyGBxg3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IexreiNcvkyGBxg3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IexreiNcvkyGBxg3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IexreiNcvkyGBxg3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IexreiNcvkyGBxg3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IexreiNcvkyGBxg3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IexreiNcvkyGBxg3 .marker.cross{stroke:#333333;}#mermaid-svg-IexreiNcvkyGBxg3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IexreiNcvkyGBxg3 p{margin:0;}#mermaid-svg-IexreiNcvkyGBxg3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IexreiNcvkyGBxg3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-IexreiNcvkyGBxg3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IexreiNcvkyGBxg3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-IexreiNcvkyGBxg3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-IexreiNcvkyGBxg3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-IexreiNcvkyGBxg3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-IexreiNcvkyGBxg3 .sequenceNumber{fill:white;}#mermaid-svg-IexreiNcvkyGBxg3 #sequencenumber{fill:#333;}#mermaid-svg-IexreiNcvkyGBxg3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-IexreiNcvkyGBxg3 .messageText{fill:#333;stroke:none;}#mermaid-svg-IexreiNcvkyGBxg3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IexreiNcvkyGBxg3 .labelText,#mermaid-svg-IexreiNcvkyGBxg3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-IexreiNcvkyGBxg3 .loopText,#mermaid-svg-IexreiNcvkyGBxg3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-IexreiNcvkyGBxg3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IexreiNcvkyGBxg3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-IexreiNcvkyGBxg3 .noteText,#mermaid-svg-IexreiNcvkyGBxg3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-IexreiNcvkyGBxg3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IexreiNcvkyGBxg3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IexreiNcvkyGBxg3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IexreiNcvkyGBxg3 .actorPopupMenu{position:absolute;}#mermaid-svg-IexreiNcvkyGBxg3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-IexreiNcvkyGBxg3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IexreiNcvkyGBxg3 .actor-man circle,#mermaid-svg-IexreiNcvkyGBxg3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-IexreiNcvkyGBxg3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 缓存未命中 getForObject("http://userservice/user/1") 请求被拦截器截获 从 URI 取出 host = "userservice" execute("userservice", request) 查本地服务列表缓存 按服务名拉取实例列表 8081, 8082 负载均衡算法挑一台(轮询) 返回 ServiceInstance(host, port) 把 http://userservice/... 改写成 http://localhost:8081/... 用真实地址发起 HTTP 请求 返回 User 对象
拆开看,关键动作只有三个:
request.getURI()拿到原始 URI,即http://userservice/user/1;originalUri.getHost()取出主机名,也就是服务名userservice------注意这一步没有做任何 DNS 解析,它把主机名当成服务 ID 用;loadBalancer.execute("userservice", request)拿着服务 ID 去要一个真实实例,拿到了就把 URI 里的主机字段换掉,再真正发出去。
这里有两点必须说清楚。
第一,服务名不是域名。 userservice 这个字符串不参与 DNS,它只是 Eureka 里的一个 key。所以没加 @LoadBalanced 时,Java 会认真地去域名服务器查 userservice 这个主机,查不到就抛 UnknownHostException。
第二,负载均衡已经生效了。 实例列表拿到之后,内部会用一个 ILoadBalancer 从列表里挑一台,默认是轮询(RoundRobin)策略。也就是说,@LoadBalanced 干的事情不是「把服务名翻译成固定的一个地址」,而是「每次调用都重新挑一台」。
这一点很值得留意:从 http://localhost:8081 改成 http://userservice,表面看是换了个字符串,实际上引入了一整套「拉列表 + 选实例 + 改写地址」的机制。这一篇其实已经悄悄把负载均衡用上了 ,只是背后的算法和 ILoadBalancer 的具体实现(Ribbon)还没展开,留到下一篇专门讲。
RestTemplate 换成 Feign 之后,这套拦截机制依然存在,只是调用方式更声明式。理解这里的拦截链,后面看 Feign 的负载均衡就不会觉得是黑魔法。
两种服务发现写法
@LoadBalanced 是上层封装,底层能力是 DiscoveryClient。两种都能实现服务发现,选哪个看场景。
方式一:@LoadBalanced + RestTemplate
就是上面改造完的写法,代码最少,URL 里直接写服务名,负载均衡自动做。
java
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
java
// 调用方只需要关心服务名和路径
User user = restTemplate.getForObject("http://userservice/user/" + userId, User.class);
方式二:DiscoveryClient 手动拿实例
如果需要在发请求之前先做点判断------比如打印一下有几个实例、自己按权重挑、或者只是想看看注册中心返回的数据长什么样------就用 DiscoveryClient。
java
package cn.itcast.order.service;
import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RestTemplate restTemplate;
@Autowired
private DiscoveryClient discoveryClient;
public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2.按服务名从注册中心拉取实例列表
List<ServiceInstance> instances = discoveryClient.getInstances("userservice");
if (instances == null || instances.isEmpty()) {
throw new IllegalStateException("没有可用的 userservice 实例");
}
// 3.自己挑一台,这里简单取第一台;真实场景应换成轮询/随机/权重算法
ServiceInstance instance = instances.get(0);
System.out.println("host=" + instance.getHost() + ", port=" + instance.getPort()
+ ", uri=" + instance.getUri() + ", serviceId=" + instance.getServiceId());
// 4.拼出真实地址再发起调用
String url = "http://" + instance.getHost() + ":" + instance.getPort()
+ "/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);
// 5.封装并返回
order.setUser(user);
return order;
}
}
ServiceInstance 提供的信息如下。
| 方法 | 含义 | 示例值 |
|---|---|---|
getServiceId() |
服务名(Eureka 里一般是大写) | USERSERVICE |
getHost() |
实例 IP 或主机名 | 192.168.1.10 或 localhost |
getPort() |
实例端口 | 8081 |
getUri() |
拼好的根地址 | http://192.168.1.10:8081 |
getMetadata() |
自定义元数据 | 可放机房、版本号等 |
注意方式二用的是裸 RestTemplate ,也就是 @Bean 上不能 加 @LoadBalanced。因为这里 URL 里已经是真实 IP 端口了,再套一层拦截器,拦截器会把它当服务名去查,反而出错。
两种方式对比
| 维度 | @LoadBalanced + RestTemplate |
DiscoveryClient |
|---|---|---|
| 代码量 | 改 URL 即可,几乎为零 | 每次调用都要手写拉列表、挑实例、拼地址 |
| 负载均衡 | 自动(默认轮询) | 需要自己实现 |
| 可观测性 | 看不到中间过程 | 能直接打印实例列表、端口 |
| 可控性 | 低,算法由框架定 | 高,可以自定义权重、灰度、就近路由 |
| 典型用途 | 日常业务调用 | 理解原理、排查注册问题、特殊路由需求 |
| 后续演进 | 换 Feign 后写法更声明式 | 一般不会直接用 |
生产代码基本都用方式一,或者直接上 Feign。方式二的价值在于搞懂注册中心到底吐出了什么数据------当你怀疑「实例是不是没注册上」「端口是不是不对」时,用它打一行日志比猜半天快。
没加 @LoadBalanced 会怎样
把 @LoadBalanced 删掉、URL 保持 http://userservice/user/1,启动后访问 http://localhost:8080/order/101,会看到这样的报错。
text
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://userservice/user/1": userservice;
nested exception is java.net.UnknownHostException: userservice
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:785)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:721)
at org.springframework.web.client.RestTemplate.getForObject(RestTemplate.java:342)
at cn.itcast.order.service.OrderService.queryOrderById(OrderService.java:26)
UnknownHostException: userservice 这句话的含义很直白:没有拦截器帮忙,RestTemplate 把 userservice 当成了一个真实域名,去 DNS 查,查不到。
这里有个容易看错的地方------报错在 UnknownHostException,有人第一反应是「Eureka 配错了」「网络不通」,然后去翻 defaultZone。其实问题不在注册中心,注册中心那边数据好好的。真正的因果关系是「注解没生效 → 无人改写 URI → 服务名被当域名」。
判断方法很简单:报错里出现的是服务名本身 (userservice),说明这个字符串根本没被拦截器处理过。如果报错里是真实 IP 加端口连不上(比如 Connection refused: localhost/127.0.0.1:8081),那又是另一回事了,属于实例本身不可用。
服务名大小写
在 Eureka 控制台里,注册上去的名字通常会显示成大写 USERSERVICE,因为 Eureka 会做一次规范化处理。但 spring.application.name 配置里写的是小写 userservice,调用时写的也是小写。
实测下来,@LoadBalanced 与 lb:// 场景下用小写是能正常匹配的,Eureka 客户端的服务 ID 匹配对大小写比较宽容,不会因为配置小写、注册大写就找不到实例。
不过这里得说清楚边界:这种宽容是 Eureka 实现的特性,不能当成通用规律。换到 ZooKeeper、Nacos 等其他注册中心实现,命名规范就不一定一样了,有的对大小写敏感。
所以结论是------统一小写。spring.application.name、调用 URL、lb:// 后面跟的服务名,全部保持一致的小写写法,别去依赖框架的大小写兼容。真正会导致找不到实例的,是拼写不一致 ,比如配置里是 userservice,调用写成了 userService 或者 user-service,这种错误不会自动纠正。
| 场景 | 写法 | 结果 |
|---|---|---|
| 配置与调用都小写 | userservice / http://userservice/... |
正常 |
| 配置小写、Eureka 显示大写 | 控制台 USERSERVICE,调用 userservice |
正常(Eureka 特性) |
| 大小写混淆 | userService vs userservice |
视实现而定,不保证 |
| 拼写不一致 | user-service vs userservice |
直接 UnknownHostException |
实战坑清单
这几个坑按出现频率排,基本都踩过。
忘了加 @LoadBalanced。 最常见的一个。症状是 UnknownHostException: userservice。改完 URL 一定要顺手确认注解在不在,可以看启动日志里有没有 LoadBalancerInterceptor 相关的装配信息。
注解加错位置。 @LoadBalanced 必须加在创建 RestTemplate 的 @Bean 方法上。
java
// 错误示范:加在启动类上,没有任何效果
package cn.itcast.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
@LoadBalanced
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
java
// 错误示范:加在注入的字段上,同样无效
package cn.itcast.order.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderService {
@Autowired
@LoadBalanced
private RestTemplate restTemplate;
}
java
// 正确:加在 RestTemplate 的 Bean 定义处
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
原因是这个注解由自动配置在创建 Bean 的阶段读取。加在类上或字段上,自动配置扫描不到,自然也就不会装拦截器,报错现象和「忘了加」完全一样。
服务名拼错。 同样报 UnknownHostException。排查办法是在 Eureka 控制台里看真实注册名,直接复制。别手敲。
想用一个 RestTemplate 两用。 有时候业务里既要按服务名调内部服务,又要按绝对地址调第三方接口(比如 http://api.xxx.com/...)。这时如果共用一个带 @LoadBalanced 的 Bean,调第三方会走拦截器,api.xxx.com 被当成服务名去 Eureka 里查,查不到直接报错。
正确做法是拆成两个 Bean,一个带注解、一个不带。
java
package cn.itcast.order.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 RestTemplateConfig {
/** 走注册中心,URL 里写服务名 */
@Bean
@LoadBalanced
public RestTemplate loadBalancedRestTemplate() {
return new RestTemplate();
}
/** 直连,URL 里写绝对地址 */
@Bean
public RestTemplate plainRestTemplate() {
return new RestTemplate();
}
}
注入时按字段名或 @Qualifier 区分。用 @Bean 方法名作为 Bean 名,loadBalancedRestTemplate 和 plainRestTemplate 各取所需。
服务列表缓存导致新实例没被发现。 消费者侧会把实例列表缓存在本地,默认 30 秒刷新一次(eureka.client.registry-fetch-interval-seconds)。刚启动的 user-service 实例可能要等一会儿才会被打到。验证负载均衡的时候,如果发现两次请求都打在同一台,先别怀疑代码,等 30 秒再试,或者重启 order-service 强制刷新。
两个实例端口没改。 启动第二个 user-service 时忘了配 -Dserver.port=8082,结果两个进程抢同一个端口,第二个直接启动失败。控制台里只有一个实例,自然也就看不出负载均衡效果。
验证负载均衡
改造完了,验证方式很朴素:清空两个 user-service 的日志,然后连续请求两次订单接口。
bash
curl http://localhost:8080/order/101
curl http://localhost:8080/order/102
两个 user-service 实例的日志里应该各出现一次查询记录,一次查用户 ID 为 1,一次查用户 ID 为 2,端口分别是 8081 和 8082。
而 order-service 的代码里,从头到尾没有任何 IP 和端口------只有 http://userservice/user/{id} 这么一个写着服务名的 URL。地址从哪来的?Eureka 给的。挑哪一台?负载均衡算法挑的。
#mermaid-svg-1oxXqlH314TmS36m{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1oxXqlH314TmS36m .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1oxXqlH314TmS36m .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1oxXqlH314TmS36m .error-icon{fill:#552222;}#mermaid-svg-1oxXqlH314TmS36m .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1oxXqlH314TmS36m .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1oxXqlH314TmS36m .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1oxXqlH314TmS36m .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1oxXqlH314TmS36m .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1oxXqlH314TmS36m .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1oxXqlH314TmS36m .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1oxXqlH314TmS36m .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1oxXqlH314TmS36m .marker.cross{stroke:#333333;}#mermaid-svg-1oxXqlH314TmS36m svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1oxXqlH314TmS36m p{margin:0;}#mermaid-svg-1oxXqlH314TmS36m .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1oxXqlH314TmS36m .cluster-label text{fill:#333;}#mermaid-svg-1oxXqlH314TmS36m .cluster-label span{color:#333;}#mermaid-svg-1oxXqlH314TmS36m .cluster-label span p{background-color:transparent;}#mermaid-svg-1oxXqlH314TmS36m .label text,#mermaid-svg-1oxXqlH314TmS36m span{fill:#333;color:#333;}#mermaid-svg-1oxXqlH314TmS36m .node rect,#mermaid-svg-1oxXqlH314TmS36m .node circle,#mermaid-svg-1oxXqlH314TmS36m .node ellipse,#mermaid-svg-1oxXqlH314TmS36m .node polygon,#mermaid-svg-1oxXqlH314TmS36m .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1oxXqlH314TmS36m .rough-node .label text,#mermaid-svg-1oxXqlH314TmS36m .node .label text,#mermaid-svg-1oxXqlH314TmS36m .image-shape .label,#mermaid-svg-1oxXqlH314TmS36m .icon-shape .label{text-anchor:middle;}#mermaid-svg-1oxXqlH314TmS36m .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1oxXqlH314TmS36m .rough-node .label,#mermaid-svg-1oxXqlH314TmS36m .node .label,#mermaid-svg-1oxXqlH314TmS36m .image-shape .label,#mermaid-svg-1oxXqlH314TmS36m .icon-shape .label{text-align:center;}#mermaid-svg-1oxXqlH314TmS36m .node.clickable{cursor:pointer;}#mermaid-svg-1oxXqlH314TmS36m .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1oxXqlH314TmS36m .arrowheadPath{fill:#333333;}#mermaid-svg-1oxXqlH314TmS36m .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1oxXqlH314TmS36m .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1oxXqlH314TmS36m .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1oxXqlH314TmS36m .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1oxXqlH314TmS36m .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1oxXqlH314TmS36m .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1oxXqlH314TmS36m .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1oxXqlH314TmS36m .cluster text{fill:#333;}#mermaid-svg-1oxXqlH314TmS36m .cluster span{color:#333;}#mermaid-svg-1oxXqlH314TmS36m div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1oxXqlH314TmS36m .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1oxXqlH314TmS36m rect.text{fill:none;stroke-width:0;}#mermaid-svg-1oxXqlH314TmS36m .icon-shape,#mermaid-svg-1oxXqlH314TmS36m .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1oxXqlH314TmS36m .icon-shape p,#mermaid-svg-1oxXqlH314TmS36m .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1oxXqlH314TmS36m .icon-shape .label rect,#mermaid-svg-1oxXqlH314TmS36m .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1oxXqlH314TmS36m .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1oxXqlH314TmS36m .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1oxXqlH314TmS36m :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} curl /order/101
OrderController
OrderService.queryOrderById
restTemplate.getForObject('http://userservice/user/1')
LoadBalancerInterceptor 拦截
查缓存 / 拉 Eureka 列表 8081,8082
轮询挑一台 → 8081
改写为 http://localhost:8081/user/1
HTTP 请求 user-service 8081
返回 User,封装进 Order
API 速览
| 类 / 注解 | 所在包 | 作用 |
|---|---|---|
@LoadBalanced |
org.springframework.cloud.client.loadbalancer |
标记 RestTemplate Bean,为其装配 LoadBalancerInterceptor |
LoadBalancerInterceptor |
org.springframework.cloud.client.loadbalancer |
拦截请求,解析服务名,改写 URI |
LoadBalancerClient |
org.springframework.cloud.client.loadbalancer |
执行「取实例 + 负载均衡」的核心接口 |
DiscoveryClient |
org.springframework.cloud.client.discovery |
服务发现客户端,getInstances(serviceId) 拉实例列表 |
ServiceInstance |
org.springframework.cloud.client |
单个实例的描述,含 host / port / serviceId / metadata |
RestTemplate |
org.springframework.web.client |
同步 HTTP 客户端,getForObject / postForObject 等 |
@EnableDiscoveryClient |
org.springframework.cloud.client.discovery |
开启服务发现,新版本(Spring Cloud 2020+)可省略 |
registry-fetch-interval-seconds |
配置项 | 本地实例列表刷新间隔,默认 30 秒 |
官方文档
- Spring Cloud Netflix - Eureka Client
- Spring Cloud Commons - LoadBalanced RestTemplate
- Spring Cloud Commons - DiscoveryClient
- Spring
RestTemplateAPI
总结
服务发现的落地动作只有两处:URL 用服务名替掉 IP 端口,RestTemplate 的 Bean 加上 @LoadBalanced。前者是显式改动,后者是生效开关。
几个值得记住的判断点:
@LoadBalanced只加在创建RestTemplate的@Bean方法上,加错位置和没加效果一样- 报
UnknownHostException: 服务名,先看注解,再看 Eureka 里注册名是否拼写一致,别急着怀疑网络 - 服务名不是域名,不参与 DNS,它只是 Eureka 里的一个 key
- 这一次改造已经引入了负载均衡,不是简单的字符串替换;背后的算法与
ILoadBalancer是下一篇的内容 DiscoveryClient是底层能力,@LoadBalanced是上层封装,两者别混用在一个裸RestTemplate上- 实例列表有缓存,验证效果时给 30 秒的缓冲