Java框架 SpringCloud 快速入门: Eureka 服务发现与服务名调用改造

概述

上一篇把 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
  • 原理拆解
    • @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 对象

拆开看,关键动作只有三个:

  1. request.getURI() 拿到原始 URI,即 http://userservice/user/1;
  2. originalUri.getHost() 取出主机名,也就是服务名 userservice------注意这一步没有做任何 DNS 解析,它把主机名当成服务 ID 用;
  3. 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 秒

官方文档

总结

服务发现的落地动作只有两处:URL 用服务名替掉 IP 端口,RestTemplate 的 Bean 加上 @LoadBalanced。前者是显式改动,后者是生效开关。

几个值得记住的判断点:

  • @LoadBalanced 只加在创建 RestTemplate 的 @Bean 方法上,加错位置和没加效果一样
  • 报 UnknownHostException: 服务名,先看注解,再看 Eureka 里注册名是否拼写一致,别急着怀疑网络
  • 服务名不是域名,不参与 DNS,它只是 Eureka 里的一个 key
  • 这一次改造已经引入了负载均衡,不是简单的字符串替换;背后的算法与 ILoadBalancer 是下一篇的内容
  • DiscoveryClient 是底层能力,@LoadBalanced 是上层封装,两者别混用在一个裸 RestTemplate 上
  • 实例列表有缓存,验证效果时给 30 秒的缓冲
相关推荐
夕除1 小时前
redis--OpenResty
java·redis
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Eureka 注册中心原理分析
java·spring cloud·eureka
她说彩礼65万1 小时前
C语言 assert
java·linux·c语言
vx-程序开发1 小时前
【计算机毕设】52744基于微信小程序的民宿智能预订系统
java·spring boot·毕业设计·源码·课程设计·毕设·大作业
Wang's Blog2 小时前
Java框架 SpringCloud 快速入门: Eureka 服务注册
java·spring cloud·eureka
liangshanbo12152 小时前
请求拦截器相关面试题
java·前端·javascript
她说彩礼65万2 小时前
C语言 const的使用
java·服务器·c语言
开开心心就好2 小时前
视频压缩工具推荐,绿色版免安装双击即用
java·前端·人工智能·spring·智能手机·intellij-idea·excel
Wang's Blog2 小时前
Java框架 SpringCloud 快速入门: Eureka 注册中心的搭建
java·spring cloud·eureka