Skip to content

微服务服务发现:注册、订阅、本地缓存、健康检查与优雅上下线

服务发现解决的是“调用方怎样把一个稳定服务名变成当前可用的实例地址”。它不是简单的 Map<String, List<IP>>,也不是每次请求都去注册中心实时查询。真实生产链路里,服务注册、健康检测、订阅推送、本地缓存、负载均衡、连接池、Readiness、优雅下线和灰度规则共同决定一次请求会打到哪台机器。

先记住一句话:

注册中心是控制面,保存和传播实例事实;业务请求走数据面,由调用方、网关或代理基于本地快照选择实例并直连目标。

这也解释了为什么“注册中心已经没有旧实例,但调用方还打到了旧服务”并不矛盾:控制面变更、调用方缓存刷新和连接池排空之间天然存在传播窗口。

一、学习目标

学完本页应能回答:

  1. 服务注册、续约、发现、订阅、本地缓存分别解决什么问题。
  2. 为什么调用方通常不会每次请求都实时访问注册中心。
  3. Nacos、Eureka、Consul、Kubernetes、Service Mesh 服务发现链路有什么差异。
  4. 本地实例快照怎样更新,为什么不能保证任意瞬间绝对最新。
  5. 健康检查、Readiness、Liveness、心跳、租约和业务健康有什么区别。
  6. 服务上线、下线、滚动发布时请求为什么会打到旧实例。
  7. 调到旧服务后怎样排查,写请求怎样避免重复副作用。
  8. 怎样设计优雅下线、连接排空、慢启动、版本路由和观测指标。

二、服务发现完整心智模型

mermaid
flowchart TD
    A["服务提供者启动"] --> B["注册服务名、IP、端口、元数据"]
    B --> C["注册中心维护实例表"]
    C --> D["消费者订阅或拉取实例表"]
    D --> E["消费者保存本地实例快照"]
    F["业务请求到达"] --> G["LoadBalancer读取本地快照"]
    G --> H["过滤健康、版本、区域、权重"]
    H --> I["选择一个实例"]
    I --> J["HTTP、RPC或代理发出真实请求"]

核心对象:

对象是什么容易误解的点
Service逻辑服务名,例如 order-service不是一台机器
Instance一个运行中的实例,例如 10.1.2.11:8080注册存在不代表业务一定健康
Metadata实例标签,例如 version=v2zone=shanghai灰度、同机房和权重依赖它
Registry注册中心维护的服务表不是每次请求都同步查询
Local Snapshot调用方本地缓存的实例快照可能短暂陈旧
LoadBalancer从候选实例里选一个不负责保存权威注册表
HTTP/RPC Client真正发网络请求连接池可能复用旧连接

如果面试官问“Feign 是不是从 Nacos 拿地址后发请求”,高质量回答应拆开:

text
Feign负责把接口方法变成HTTP请求模板;Nacos负责服务注册发现;DiscoveryClient或
Nacos Client把实例数据提供给Spring Cloud LoadBalancer;LoadBalancer从本地实例
快照里选一个ServiceInstance;最后由Apache HttpClient、OkHttp、JDK Client或
Reactor Netty真正建立连接并发送请求。

三、控制面和数据面

服务发现一定要区分控制面和数据面。

负责什么典型组件
控制面注册、续约、健康状态、配置、路由规则、实例快照传播Nacos、Eureka、Consul、Kubernetes API Server、Istiod
数据面真实业务请求转发、连接、重试、熔断、负载均衡Gateway、Feign底层HTTP Client、Dubbo Client、Envoy、kube-proxy/eBPF

控制面故障不一定让正在运行的业务请求立刻失败,因为调用方可能已有本地快照;但控制面故障会影响新实例注册、坏实例摘除、规则变更和故障恢复。数据面故障则直接影响真实请求。

错误理解:

text
Consumer -> Nacos -> Provider

正确理解:

text
Consumer -> 本地实例快照和LoadBalancer -> Provider

Nacos、Eureka、Consul 这类注册中心一般不转发订单、库存、支付这样的业务请求。

四、服务注册全过程

服务提供者启动时通常会做这些事:

mermaid
flowchart TD
    A["应用启动"] --> B["读取服务名、端口和注册配置"]
    B --> C["确定可被其他服务访问的IP"]
    C --> D["组装Instance和Metadata"]
    D --> E["向注册中心注册"]
    E --> F["注册中心保存或更新实例"]
    F --> G["开启心跳、连接保持或健康检查"]

注册信息至少包含:

字段示例作用
服务名inventory-service调用方按这个名字发现实例
IP10.20.1.8真实调用地址
端口8080真实调用端口
实例ID10.20.1.8:inventory-service:8080区分同服务多实例
健康状态UPDOWNhealthy=true决定是否进入候选列表
权重1.0调整流量比例
区域zone=shanghai同机房优先和容灾
版本version=v1灰度和金丝雀发布
租户标签tenant=medical-aB端租户灰度或隔离

生产常见坑:

问题后果排查点
注册成 127.0.0.1其他服务调用自己本机失败注册IP来源、容器网络、NAT
注册容器内不可达IP控制台有实例但调用超时Pod IP、Node网络、跨网段路由
服务名大小写或分组不一致调用方发现不到spring.application.name、namespace、group
metadata遗漏版本灰度过滤异常注册配置、启动日志、控制台实例详情
还没Ready就注册冷启动实例接流量失败Readiness、注册时机、预热

五、心跳、租约、健康检查和 Readiness

这些词经常混在一起,但含义不同:

概念判断什么不代表什么
心跳 Heartbeat客户端进程或连接还活着业务线程池、DB、Redis一定可用
租约 Lease一段时间内认为实例有效过期前可能已经不可服务
注册中心健康实例是否应进入发现列表业务每个接口都正常
Readiness当前是否应该接新流量失败不一定要重启进程
Liveness进程是否需要被重启不适合承载所有依赖健康判断
Startup Probe启动阶段是否完成启动后不再持续判断业务健康
业务健康核心交易、查询、依赖是否达标需要指标和探针组合判断

不能把“心跳还在”当成“业务健康”。例如 JVM 还能发送注册中心心跳,但 Tomcat 业务线程池已经满了,数据库连接池也耗尽了。注册中心看到实例存活,LoadBalancer 仍可能继续选择它,最终表现为某台实例 P99 很高。

商业项目中建议分层:

  1. Liveness 只判断进程是否卡死到需要重启。
  2. Readiness 判断是否应接新流量,包括启动预热、下线摘流、核心依赖状态。
  3. 注册中心健康用于服务发现候选过滤。
  4. 业务指标用于慢实例、坏实例、错误率和尾延迟治理。

六、服务发现和本地快照更新

调用方为什么要本地缓存?

原因解释
性能每次请求都查注册中心,会多一次远程调用
可用性注册中心短暂不可用时,已有快照还能支撑调用
降低压力注册中心不是业务请求级数据库
本地治理LoadBalancer、灰度、熔断需要在本地快速决策

典型更新链路:

mermaid
flowchart TD
    A["Provider上线、下线或状态变化"] --> B["注册中心服务表变化"]
    B --> C["通知或等待Consumer拉取"]
    C --> D["Consumer构造新实例快照"]
    D --> E["原子替换本地引用"]
    E --> F["新请求使用新快照"]
    F --> G["旧请求和旧连接自然结束"]

这里有两个关键词:快照原子替换

快照表示调用方在某一刻看到的完整实例列表。不要在多个线程遍历列表时原地修改同一个 ArrayList,否则会出现并发异常或半新半旧状态。更好的方式是构造不可变新列表,然后一次性替换引用。

七、JDK 8 Demo:本地实例快照原子替换

这个 Demo 不依赖任何框架,只演示“注册中心推送新列表后,调用方怎样安全替换本地快照”。

java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public final class LocalInstanceSnapshotDemo {

    static final class Instance {
        final String host;
        final int port;
        final String version;

        Instance(String host, int port, String version) {
            this.host = host;
            this.port = port;
            this.version = version;
        }

        String address() {
            return host + ":" + port + "(" + version + ")";
        }
    }

    static final class InstanceRepository {
        private final AtomicReference<List<Instance>> snapshot =
                new AtomicReference<List<Instance>>(Collections.<Instance>emptyList());
        private final AtomicInteger roundRobin = new AtomicInteger();

        void updateFromRegistry(List<Instance> newInstances) {
            List<Instance> copy = new ArrayList<Instance>(newInstances);
            snapshot.set(Collections.unmodifiableList(copy));
        }

        Instance choose(String requiredVersion) {
            List<Instance> current = snapshot.get();
            List<Instance> candidates = new ArrayList<Instance>();
            for (Instance instance : current) {
                if (requiredVersion == null || requiredVersion.equals(instance.version)) {
                    candidates.add(instance);
                }
            }
            if (candidates.isEmpty()) {
                throw new IllegalStateException("没有可用实例,version=" + requiredVersion);
            }
            int index = Math.abs(roundRobin.getAndIncrement() % candidates.size());
            return candidates.get(index);
        }
    }

    public static void main(String[] args) {
        InstanceRepository repository = new InstanceRepository();
        List<Instance> v1 = new ArrayList<Instance>();
        v1.add(new Instance("10.0.0.1", 8080, "v1"));
        v1.add(new Instance("10.0.0.2", 8080, "v1"));
        repository.updateFromRegistry(v1);
        System.out.println(repository.choose("v1").address());

        List<Instance> mixed = new ArrayList<Instance>(v1);
        mixed.add(new Instance("10.0.0.3", 8080, "v2"));
        repository.updateFromRegistry(mixed);
        System.out.println(repository.choose("v2").address());
    }
}

这个 Demo 说明三件事:

  1. 调用方读的是本地内存快照,不是每次实时查注册中心。
  2. 新快照构造完成后再整体替换,避免读到半更新状态。
  3. 灰度过滤发生在候选集阶段,过滤后再负载均衡。

生产实现还要补充健康状态、权重、zone、慢启动、熔断、连接池和统计指标。

八、为什么不能保证本地列表绝对最新

本地列表“不绝对最新”不是实现偷懒,而是分布式系统的必然结果。

mermaid
flowchart TD
    A["旧实例准备下线"] --> B["Readiness变为不可用"]
    B --> C["注册中心或Endpoint更新"]
    C --> D["调用方收到变更"]
    D --> E["本地快照替换"]
    E --> F["新请求不再选择旧实例"]
    F --> G["旧连接和在途请求排空"]

可能产生窗口的地方:

窗口说明
探测窗口健康检查不是连续的,下一次检查前状态可能已变
注册中心处理窗口服务表更新、复制、推送需要时间
客户端接收窗口网络抖动、线程调度、SDK队列会延迟处理
本地快照窗口调用线程可能已经拿到旧快照
负载均衡窗口一次选择完成后,目标实例可能立刻下线
连接池窗口旧连接可继续复用,尤其 HTTP/2/gRPC
在途请求窗口请求已经进入旧实例,不能凭空取消业务副作用

因此正确目标不是“零旧实例调用”,而是:

  1. 短窗口。
  2. 可观测。
  3. 旧实例仍兼容。
  4. 写请求幂等。
  5. 可回滚和可对账。

8.1 工程上怎样让本地列表尽量最新

先给结论:本地服务列表不能保证“任意时刻强一致最新”,但可以通过多层机制保证“变化尽快传播、旧列表窗口足够短、旧实例被调用也不会造成不可控后果”。

如果每次 Feign 调用都实时问注册中心“现在有哪些实例”,注册中心会进入每一次业务请求链路:调用延迟变高,注册中心压力暴涨,注册中心抖动会直接拖垮业务。所以商业项目通常选择“注册中心异步通知 + 调用方本地快照 + 负载均衡本地选址”的模式。

mermaid
flowchart TD
    A["Provider状态变化"] --> B["注册中心更新事实"]
    B --> C["推送或拉取传播"]
    C --> D["Consumer原子替换本地快照"]
    D --> E["LoadBalancer读取候选实例"]
    E --> F["HTTP或RPC客户端发请求"]
    F --> G["连接池和在途请求逐步排空"]

这条链路里,“最新”由下面几道防线共同保证:

防线具体做法解决的问题如果不做会怎样
注册中心感知Provider 心跳、连接断开、主动注销、健康状态上报让注册中心尽快知道实例是否还活着进程死了但注册表仍认为它可用
订阅或拉取Nacos 订阅推送、Eureka 周期拉取、Consul Blocking Query、K8s EndpointSlice Watch让 Consumer 尽快拿到新实例表调用方长期拿旧列表
本地快照替换用不可变列表或原子引用整体替换,不在原列表上边读边改避免业务线程读到半更新数据候选列表可能空、重复或并发异常
LoadBalancer过滤过滤 unhealthy、权重 0、版本不匹配、zone 不匹配、灰度标签不匹配实例避免“注册了但不该被调用”的实例进入候选灰度串流、跨机房调用、不健康实例被选中
摘流优先下线前先 Readiness 失败、权重置 0 或主动注销,再等待传播给调用方缓存刷新留时间Pod 一停,旧缓存里的请求直接打失败
连接回收设置连接最大存活时间、空闲连接回收、HTTP/2/gRPC drain避免快照已更新但旧长连接继续承载请求下线后仍有请求从旧连接进入
调用兜底短超时、有限重试、熔断、隔离、幂等、对账控制旧实例不可达或状态未知的损失重试风暴、重复扣款、重复发消息
观测验证trace 记录目标 IP、实例版本、routeId、attempt、本地快照年龄出问题时知道到底选了谁、为什么选它只能靠猜,无法证明是缓存、连接还是灰度规则问题

注意这里有一个很重要的认知:注册中心控制的是“候选实例列表”,不是已经发出去的请求,也不是 HTTP 连接池里已经建立的连接。调用线程一旦拿到了旧快照,或者连接池已经拿到了旧连接,即使下一毫秒注册中心完成了更新,这次请求也可能继续打到旧实例。

8.2 常见组件的新鲜度边界

组件怎样更新本地列表为什么仍可能旧排查重点
Nacos订阅服务,实例变化后服务端推送,客户端更新 ServiceInfo 本地缓存推送延迟、客户端处理延迟、连接重建 Redo 窗口、LoadBalancer 二级缓存Nacos 实例健康、namespace/group/cluster、客户端事件、缓存年龄
Eureka客户端定期拉取注册表,本地缓存拉取周期、Server 剔除周期、自我保护、客户端刷新周期Provider 续约、Server 注册表、Consumer 本地注册表
Spring Cloud LoadBalancer通过 ServiceInstanceListSupplier 获取实例,可叠加缓存和过滤器注册客户端缓存之外又多一层 LB 缓存Supplier 链顺序、缓存 TTL、过滤后候选数
KubernetesEndpointSlice Watch 更新,Service 数据面转发,客户端还可能 DNS 缓存Readiness 传播、EndpointSlice 更新、DNS/JVM 缓存、连接复用Pod Ready、EndpointSlice、kube-proxy/eBPF、连接池
Service Mesh控制面推送 xDS,Envoy 本地按配置转发xDS ACK/NACK、配置 warming、旧连接 drainEnvoy cluster/endpoint 状态、xDS版本、drain时间

所以面试里不要回答“开启 Nacos 推送就能保证最新”。更准确的说法是:Nacos 推送能缩短注册中心到客户端的传播时间,但不能消除客户端快照、LoadBalancer 缓存、线程已选址、连接池复用和在途请求这些窗口。

8.3 配置与代码层面的防误调建议

下面不是固定模板,而是商业项目常用检查项。不同 Spring Cloud、Spring Cloud Alibaba 和注册中心版本的配置项名称可能有差异,落地时要以项目实际依赖版本为准。

yaml
spring:
  cloud:
    nacos:
      discovery:
        namespace: prod
        group: DEFAULT_GROUP
        cluster-name: shanghai
        metadata:
          version: v2
          zone: shanghai
    loadbalancer:
      cache:
        enabled: true
        ttl: 5s

这段配置表达的是三件事:

  1. namespace/group/cluster 隔离环境、分组和机房,避免测试服务被生产调用。
  2. metadata.versionzone 支持灰度和同机房优先。
  3. 控制 LoadBalancer 缓存 TTL,避免二级缓存把旧实例保留太久。

更重要的是调用链里要把“最终选到谁”打出来。生产建议在 Gateway、Feign 拦截器或 RPC Filter 中记录:

字段作用
traceId串起一次调用经过的网关、调用方和提供方
serviceId逻辑服务名,例如 asset-service
peer.address实际目标 ip:port
instance.version实例版本或镜像版本
routeIdGateway 路由或灰度规则
lb.candidate.count负载均衡过滤前后候选数量
lb.filtered.reason实例被过滤原因
attempt第几次重试
snapshot.age.ms本地实例快照距离更新时间

没有这些字段时,“为什么调到旧服务”很难定位。你只能看到 Feign 报错,却不知道它是拿到了旧缓存、灰度标签丢了、连接池复用了旧连接,还是注册中心本身没有更新。

8.4 调用方每次请求都会更新本地实例快照吗

不会。调用方不会在每一次业务请求到来时都去注册中心更新本地实例快照。正常链路是:实例快照由服务发现客户端在后台维护,业务请求只读取当前已经存在的快照,再交给 LoadBalancer 选择实例。

mermaid
flowchart TD
    A["业务请求到达"] --> B["读取当前本地快照"]
    B --> C["LoadBalancer选择实例"]
    C --> D["HTTP或RPC发请求"]
    E["后台订阅或定时刷新线程"] --> F["发现实例列表变化"]
    F --> G["构造新快照"]
    G --> H["原子替换旧快照"]
    H --> B

可以把它理解成两条并行链路:

链路谁负责发生频率做什么
业务调用链路Feign、Gateway、Dubbo Consumer、HTTP Client每次请求都会发生读取本地快照、过滤、负载均衡、发请求
服务发现刷新链路Nacos/Eureka/Consul/K8s/Mesh 客户端实例变化推送、定期拉取或连接重建时发生拉取或接收新实例表,更新本地缓存

所以“每次请求都更新快照”这个理解是错的。每次请求最多是“读取快照”,不是“刷新快照”。原因很简单:

  1. 如果每次请求都刷新,注册中心会被业务 QPS 打爆。
  2. 每次远程查询都会增加调用延迟,服务调用会变慢。
  3. 注册中心抖动会直接影响所有业务请求,控制面变成数据面瓶颈。
  4. 多个调用线程同时刷新还会造成锁竞争和重复请求。

不同组件触发刷新快照的方式不同:

组件主要刷新方式说明
Nacos订阅推送为主,客户端也有缓存和重做机制服务端发现实例变化后推送给订阅客户端,客户端更新 ServiceInfo
Eureka周期拉取注册表为主客户端定时从 Eureka Server 拉取注册表,所以天然有拉取周期窗口
ConsulBlocking Query 或健康接口查询客户端通过索引等待变化,仍可能有客户端缓存
KubernetesEndpointSlice Watch、DNS、Service 数据面Pod Ready 变化先更新 Endpoint,再传播到数据面和客户端连接
Service MeshxDS 推送给 Envoy业务进程不一定直接感知实例,Envoy 用本地已确认配置转发

这里还要区分两层缓存:

缓存层位置作用风险
注册客户端缓存Nacos/Eureka/Consul Client 内部保存注册中心下发的实例列表推送或拉取延迟导致旧
LoadBalancer 缓存Spring Cloud LoadBalancer 等组件内部减少频繁访问 DiscoveryClient,提高选址性能TTL 太长会把旧实例再保留一段时间

因此排查“为什么还调旧实例”时,要看清楚是哪一层没更新:注册中心事实没变、注册客户端本地缓存没刷新、LoadBalancer 二级缓存没过期、过滤规则把新实例过滤掉,还是 HTTP 连接池继续复用旧连接。它们现象相似,但处理方式完全不同。

8.5 怎么保证每次请求都是最新可用的

严格说,分布式系统里不能保证“每一次请求都一定拿到全局最新且绝对可用的实例”。原因有两个:

  1. “最新”需要跨 Provider、注册中心、Consumer、本地缓存、LoadBalancer、连接池同步,而网络传播一定有延迟。
  2. “可用”不是选中时刻的永久属性。实例在 LoadBalancer 选中之后、HTTP 请求发出之前,也可能立刻 Full GC、重启、网络断开或数据库连接池打满。

所以生产目标不是承诺“每次请求都最新”,而是实现“每次请求尽量只选当前已知健康实例,失败后快速换路,并且写请求不会重复产生副作用”。

mermaid
flowchart TD
    A["请求到达"] --> B["读取本地最新快照"]
    B --> C["过滤不健康、权重0、版本不匹配实例"]
    C --> D["LoadBalancer选择候选实例"]
    D --> E["连接池获取或新建连接"]
    E --> F["发送请求"]
    F --> G{"调用结果"}
    G --> H["成功返回"]
    G --> I["连接失败或超时"]
    I --> J["按错误语义判断是否可重试"]
    J --> K["幂等保护下换实例有限重试"]

要把“每次请求尽量可用”做好,通常要同时做下面几件事:

机制作用注意点
Readiness 摘流实例不具备接流量条件时先从候选里移除Readiness 要检查关键依赖,不能只返回进程存活
健康实例过滤LoadBalancer 只从健康、权重正常、metadata匹配的实例里选注册中心健康状态有延迟,不能当绝对事实
本地快照尽快刷新订阅推送、定期拉取、缓存TTL让实例变化尽快到调用方TTL过短会压注册中心,过长会延长旧实例窗口
连接失败快速切换connect timeout 短,连接失败可换实例重试读请求相对安全,写请求必须先有幂等
连接池回收设置最大连接存活时间和空闲回收防止快照已更新但旧连接继续打旧实例
慢实例治理基于实例P99、错误率、并发、熔断状态降权或摘除普通注册中心只知道存活,不一定知道业务慢
重试预算限制最大Attempt、总Deadline、退避抖动多层重试会把故障放大成雪崩
幂等和事实查询写请求超时后按业务号查询事实不允许盲目换新实例再执行一次

更落地一点,一次请求真正想做到“最新可用”,不是靠一个开关,而是靠下面这条请求级闭环:

mermaid
flowchart TD
    A["Provider准备下线或异常"] --> B["Readiness先变为不可接流量"]
    B --> C["注册中心更新健康或权重"]
    C --> D["Consumer后台刷新本地快照"]
    D --> E["新请求读取当前快照"]
    E --> F["LB过滤不可用实例"]
    F --> G["HTTP Client发送请求"]
    G --> H{"是否成功"}
    H --> I["成功返回"]
    H --> J["连接失败、超时或5xx"]
    J --> K["按错误语义判断能否换路"]
    K --> L["读请求有限重试"]
    K --> M["写请求按幂等号查事实"]

每一环都要理解清楚:

环节怎么保证尽量新如果不做会怎样
Provider Readiness进程关闭、发布、预热、依赖异常时先拒绝新流量应用还没准备好就接流量,或者下线中仍被打进来
注册中心传播Nacos推送、Eureka拉取、K8s EndpointSlice Watch 等机制让变化进入消费者调用方长时间拿旧实例表
Consumer快照替换后台线程构造新列表后原子替换,业务线程只读快照业务请求阻塞在注册中心,或者读到半新半旧列表
LB候选过滤过滤 DOWN、权重0、版本不匹配、zone不匹配、灰度标签不匹配实例注册了但不该接流量的实例仍被选中
连接池治理控制连接最大存活、空闲回收、下线前 drain快照已经新了,但旧长连接还在打旧实例
超时和重试短连接超时、总Deadline、只在可重试错误上换实例慢实例拖垮线程池,多层重试放大流量
幂等和事实查询写请求带业务幂等号,超时后查订单、库存、支付事实超时后换实例再写一次,造成重复扣款、重复扣库存
可观测性日志记录服务名、候选数、最终IP、版本、快照年龄、重试次数出问题只看到“Feign超时”,不知道选了谁、为什么选

8.6 为什么不每次请求都实时查注册中心

直觉上,“每次请求都去Nacos查一次最新实例,然后再发请求”好像最准确,但生产上通常不这样做,原因很硬:

问题解释
性能成本每个业务调用都多一次注册中心远程读,链路RT变长,P99会被放大
注册中心压力业务QPS会直接打到注册中心,注册中心从控制面变成了业务同步依赖
可用性下降注册中心抖动时,原本可用的业务调用也会跟着失败
仍不能绝对可用刚查到健康实例后,它也可能立刻重启、Full GC、网络断开
治理能力变差灰度、熔断、权重、慢实例降权都需要本地快速判断,不适合每次阻塞查权威表

所以成熟系统通常采用“控制面异步收敛 + 数据面本地快速决策”的架构。它承认分布式系统不可能瞬时全局一致,然后用健康过滤、连接排空、失败换路、幂等补偿和监控证据把风险控制住。

8.7 Spring Cloud里可以落地哪些配置和设计

下面不是让你死记参数名,而是理解应该控制哪些东西。不同 Spring Cloud 版本、Nacos 版本、HTTP Client 实现的配置项会有差异,落地时要以项目依赖版本为准。

yaml
spring:
  cloud:
    loadbalancer:
      cache:
        enabled: true
        ttl: 5s
    openfeign:
      client:
        config:
          default:
            connectTimeout: 1000
            readTimeout: 3000

配置背后的原则:

配置或设计原理建议
LB缓存TTLTTL越长越省DiscoveryClient调用,但旧实例窗口越长高频变更、灰度发布期适当缩短;平稳期不要过短
连接超时连接都建不上,通常说明实例不可达或网络异常比读超时短,允许读请求在预算内换实例
读超时请求已发出但迟迟没有响应,结果可能未知查询类可谨慎重试;写类必须先查事实
最大重试次数重试能绕开瞬时故障,也会放大流量设置总Deadline和重试预算,避免Feign、LB、网关多层同时重试
HTTP连接池生命周期长连接会复用旧地址设置空闲回收和最大存活时间,发布下线时先drain
Readiness探针控制实例是否接新流量预热完成、依赖可用、线程池未打满时才Ready
请求日志字段让排查有证据至少记录目标服务、实例IP、版本、traceId、attempt、snapshotAge

如果业务真的要求“每次请求都必须确认最新状态”,只能把注册中心实时查询、强一致读或控制面确认放进调用链。但这通常不适合高 QPS 微服务调用,因为会牺牲性能和可用性,让注册中心成为同步瓶颈。更常见的做法是按业务风险分层:

业务类型推荐策略
普通读请求本地快照 + 健康过滤 + 短超时 + 可重试
核心写请求本地快照 + 短超时 + 幂等键 + 状态查询 + 补偿
灰度或版本强约束请求metadata过滤,找不到目标版本就明确失败或走业务允许的回退
资金、权限、库存最终确认不能只依赖服务发现“最新”,还要数据库事实源、状态机和一致性校验

面试中可以这样回答:

微服务里不能保证每次请求都拿到全局最新实例,因为注册中心更新、客户端缓存替换、LoadBalancer选址和连接池排空都是异步的,而且实例被选中后也可能瞬间故障。工程上保证的是“当前视角下尽量可用”:调用方读取本地最新快照,按健康状态、权重、版本、机房和灰度标签过滤,再负载均衡选址;连接失败或读超时可以在总Deadline和重试预算内换实例重试;写请求必须有幂等键和事实查询,不能盲目重放。下线时用Readiness摘流、等待缓存传播和连接排空,缩短旧实例窗口。

8.8 如果业务强要求“每次请求必须最新”怎么办

这类问题要先反问一句:你要的“最新”到底是哪一种。

目标能否由服务发现保证正确做法
每次都看到注册中心最新服务表不能靠普通本地缓存保证降低缓存TTL、订阅推送、必要时请求前强制刷新,但只适合低QPS管理接口
每次都调用当前健康实例不能绝对保证Readiness、健康过滤、短超时、失败换路、熔断和慢实例摘除
每次都调用指定版本可以通过规则尽量保证metadata严格过滤,目标版本为空时明确失败,不要静默回退
每次写操作都不重复、不丢失不能靠LB保证幂等号、状态机、事务表、Outbox、补偿和对账
每次都读到最新业务数据服务发现无能为力读主库、强一致读、版本号校验或业务一致性方案

如果真的在低QPS、强治理场景下要求“请求前确认最新实例”,可以设计成受控强刷新,而不是让所有业务请求都打注册中心:

mermaid
flowchart TD
    A["低QPS强治理请求"] --> B["检查本地快照年龄"]
    B --> C{"是否超过强制刷新阈值"}
    C -- "否" --> D["直接读取本地快照"]
    C -- "是" --> E["带超时刷新注册中心"]
    E --> F{"刷新是否成功"}
    F -- "成功" --> G["原子替换快照"]
    F -- "失败" --> H["按业务策略失败或使用旧快照"]
    D --> I["LoadBalancer过滤并选址"]
    G --> I
    H --> I

这个方案一定要加边界:

边界原因
只给低QPS接口使用高频接口强刷新会打爆注册中心
刷新必须有很短超时不能让注册中心抖动拖住业务线程
失败策略要明确核心写接口可能宁可失败,普通读接口可以用旧快照
仍要做超时和幂等刷新成功后实例也可能马上故障
记录快照版本和年龄出问题时能证明请求用的是哪份实例表

也就是说,服务发现解决的是“地址最终收敛”,不是“每次请求强一致读地址”。大多数商业系统选择本地快照,是因为它把注册中心放在控制面,把业务请求放在数据面;真正高可靠来自多层防线,而不是每次请求都同步问一次注册中心。

九、不同服务发现组件的机制差异

组件更新方式一致性倾向典型场景常见旧实例原因
Eureka客户端周期拉取注册表,本地缓存偏AP,允许短暂陈旧Spring Cloud Netflix老系统拉取周期、自我保护、剔除延迟
Nacos订阅推送加本地缓存,临时实例有心跳或连接语义兼顾可用与及时感知Spring Cloud Alibaba推送延迟、客户端缓存、namespace/group错误
ConsulHealth API、Blocking Query、TTL Check、CatalogCatalog底层Raft,健康结果仍有窗口多语言服务发现、KV、ConnectCheck状态延迟、Index等待、客户端缓存
Kubernetes ServiceEndpointSlice、DNS、kube-proxy或eBPF数据面控制面最终传播容器平台内服务发现Readiness传播、DNS缓存、连接复用
Service Mesh控制面下发 xDS,Envoy本地转发代理按已ACK配置执行多语言治理、mTLS、灰度xDS推送、Warming、NACK、旧连接
Dubbo RegistryProvider注册URL或应用,Consumer订阅Directory取决于注册中心和本地DirectoryJava RPCDirectory未刷新、Router过滤、Invoker缓存

不要用某一个组件的细节解释所有系统。Eureka 的定期拉取、Nacos 的订阅推送、Kubernetes 的 EndpointSlice 和 Mesh 的 xDS 都能实现服务发现,但传播路径和排查证据不同。

十、调到旧实例的生产排查

先判断旧实例是哪一种“旧”:

类型含义例子
旧地址实例已经下线或IP已变,但调用方仍访问10.0.0.1:8080 进程已退出
旧版本v1和v2并存,请求本应去v2却去了v1灰度用户串到稳定版本
旧配置实例版本对,但仍使用旧配置配置中心刷新失败
旧连接快照已更新,但连接池还在复用旧连接HTTP/2流继续到旧Pod
旧规则LoadBalancer或Gateway规则未更新灰度权重回滚不生效

排查流程:

mermaid
flowchart TD
    A["发现请求打到旧实例"] --> B["用Trace确认目标IP、版本和时间"]
    B --> C["查注册中心当时实例事实"]
    C --> D["查调用方本地快照和过滤结果"]
    D --> E["查连接池是否复用旧连接"]
    E --> F["查Readiness、preStop和优雅停机"]
    F --> G["按读写风险决定容忍、补偿或止血"]

具体证据:

证据看什么
Trace Span目标 peer.address、服务名、版本、routeId、attempt
调用方日志LoadBalancer候选数量、被过滤原因、最终实例
注册中心接口旧实例当时是否存在、健康、权重、metadata
Gateway日志路由ID、灰度Header、认证用户、租户
HTTP连接池指标每目标连接数、连接年龄、等待连接耗时
Kubernetes事件Pod Ready时间、EndpointSlice变化、preStop执行
业务表写请求是否已产生事实、副作用是否重复

排查时不要只问“注册中心现在还有没有旧实例”。你看到的是当前状态,事故发生在过去某个时间点。正确做法是按时间线对齐证据:

时间点要确认什么典型证据
T0 发布或下线开始旧实例是否先摘流,而不是直接杀进程发布平台记录、Pod 事件、应用日志
T1 注册中心更新旧实例是否已经不健康、权重 0 或被注销Nacos/Eureka/Consul 审计、Server 日志
T2 调用方收到变化调用方本地快照是否已经更新客户端实例变更日志、缓存版本、快照年龄
T3 LoadBalancer 选择候选列表里为什么还有旧实例候选数量、过滤链日志、metadata 匹配结果
T4 HTTP/RPC 发出是否复用旧连接或旧 Stream连接池指标、目标地址、连接年龄
T5 业务执行是否产生写入、副作用或消息业务表、幂等表、消息表、审计日志

如果 T1 没完成,问题在注册或摘流;如果 T1 完成但 T2 没完成,问题在推送、拉取或客户端缓存;如果 T2 完成但 T3 仍选到旧实例,问题在 LoadBalancer 缓存、过滤规则或灰度标签;如果 T3 没选旧实例但 T4 仍打旧地址,重点查连接池、HTTP/2/gRPC 长连接和代理层。

十一、如果真的调了旧服务怎么办

处理方式取决于请求类型。

11.1 只读请求

只读请求通常可以容忍短窗口,前提是 API 响应兼容、缓存结构兼容、用户体验可接受。处理重点是缩短窗口:

  1. 降低旧实例权重或摘流。
  2. 确认调用方缓存刷新。
  3. 调整连接最大存活时间。
  4. 检查灰度标签是否丢失。
  5. 按版本观察 P95/P99 和错误率。

11.2 写请求

写请求不能简单“再调用一次新服务”。因为旧服务可能已经扣库存、创建订单、发短信或写数据库,只是调用方不满意版本结果。

正确方式:

  1. 使用同一个业务幂等号查询事实。
  2. 如果旧服务已成功,按兼容结果返回或做后续迁移。
  3. 如果旧服务失败且未产生副作用,再决定是否由新服务处理。
  4. 如果状态未知,进入待确认、补偿或人工对账。
  5. 不要用新的请求号再次执行同一业务意图。

示例:

java
public OrderResult createOrder(CreateOrderCommand command) {
    OrderResult existing = orderQuery.findByRequestId(command.getRequestId());
    if (existing != null) {
        return existing;
    }
    return orderService.create(command);
}

这个 Demo 的关键不是查一次就万事大吉,而是 requestId 必须有唯一约束或幂等表兜底,防止并发重复创建。

11.3 立刻止血怎么做

当线上确认旧实例仍被调用时,先控制影响面,再查根因:

动作目的注意点
暂停继续放量防止更多流量进入错误版本灰度发布先把灰度比例降回 0
旧实例权重置 0 或摘除让新的负载均衡选择不再命中旧实例仍要等待调用方缓存和连接池传播
缩短超时并限制重试避免旧实例不可达引发线程堆积和重试风暴写请求没有幂等不能随便重试
强制关闭旧连接让已经建立的 HTTP/2/gRPC/Keep-Alive 连接尽快断开可能影响在途请求,要配合 drain
对写请求查事实判断旧服务是否已经成功执行以数据库、幂等表、消息表为准
补偿或对账修复状态未知、消息漏发、缓存/ES未同步要有补偿任务和人工兜底入口

11.4 为什么“再调一次新服务”很危险

旧服务被调用后,调用方看到的可能只是超时或版本不符合预期,但服务端实际可能已经成功写入。比如订单创建接口:

mermaid
flowchart TD
    A["调用方请求旧实例"] --> B["旧实例创建订单成功"]
    B --> C["网络超时或调用方拿不到结果"]
    C --> D["调用方误以为失败"]
    D --> E["再次请求新实例"]
    E --> F["如果没有幂等约束就重复创建"]

因此写请求必须把“业务意图”设计成可查询、可去重、可补偿:

  1. 请求进入系统前生成全局唯一 requestIdorderNo 或业务幂等号。
  2. 数据库对幂等号加唯一索引。
  3. 调用超时后先查幂等表或业务事实。
  4. 查到成功就返回成功或兼容响应。
  5. 查到失败且确认无副作用才重试。
  6. 查不到或状态不确定就进入待确认、补偿、对账,而不是无限重试。

十二、优雅下线全过程

可靠下线不是 kill -9,而是一个有顺序的流程。

mermaid
flowchart TD
    A["收到下线指令"] --> B["Readiness置为不可用"]
    B --> C["注册中心摘流或权重置0"]
    C --> D["等待调用方缓存和Endpoint传播"]
    D --> E["停止接收新请求"]
    E --> F["等待在途请求完成"]
    F --> G["关闭MQ消费者、定时任务和连接池"]
    G --> H["关闭Web容器和进程"]

生产建议:

步骤目的常见配置或动作
先摘流新请求不要进来Readiness失败、权重0、下线注册
等传播给注册中心和调用方缓存时间preStop sleep、发布平台等待
等在途避免请求执行一半断开graceful shutdown timeout
停消费者防止实例退出前继续拉新消息pause consumer、停止定时任务
关连接池释放下游资源HTTP、DB、Redis、MQ client close
记录版本便于事故回溯发布单、实例ID、镜像Digest

Kubernetes 场景尤其要注意:Pod 进入 Terminating 后,EndpointSlice 摘除、Ingress/Gateway 感知、应用收到 SIGTERM、preStop 执行和容器退出有先后关系。terminationGracePeriodSeconds 太短会导致还没排空就被杀。

十三、上线、慢启动和预热

新实例 Ready 不代表已经适合满流量:

  1. JIT 还没编译热点代码。
  2. 本地缓存是冷的。
  3. DB、Redis、HTTP 连接池还没建立。
  4. 类加载和序列化缓存还没稳定。
  5. 新版本可能有慢 SQL 或依赖错误。

上线流程:

mermaid
flowchart TD
    A["新实例启动"] --> B["加载配置并建立基础连接"]
    B --> C["执行Startup和Readiness检查"]
    C --> D["以低权重注册或接少量流量"]
    D --> E["观察错误率、P99和资源池"]
    E --> F{"是否达标"}
    F -- "是" --> G["逐步增加权重"]
    F -- "否" --> H["暂停放量并摘流排查"]

预热不能做有副作用的真实操作。支付、扣库存、发短信、写正式订单都不能作为预热请求。

十四、服务发现和灰度 Metadata

服务发现不仅保存 IP,还保存治理标签。常见标签:

标签用途
version=v1/v2版本灰度
zone=shanghai同可用区优先
weight=10权重流量
env=prod环境隔离
region=east地域路由
tenant=medical-a租户隔离或试点

灰度调用链:

mermaid
flowchart TD
    A["Gateway生成可信灰度标签"] --> B["服务间调用透传标签"]
    B --> C["LoadBalancer读取实例metadata"]
    C --> D["过滤符合版本和区域的实例"]
    D --> E["按权重或算法选择实例"]
    E --> F["记录版本维度指标和Trace"]

灰度规则必须有统一 Owner。不要 Gateway 一套规则、Feign 一套规则、Mesh 又一套规则,三层都在改版本,否则排查时很难判断哪一层真正生效。

十五、服务发现必须观测哪些指标

指标说明
注册实例数每个服务、版本、zone有多少实例
健康实例数实际可被选择的实例数量
本地快照年龄调用方多久没更新实例列表
候选过滤结果被健康、版本、zone、权重过滤掉多少
No instance次数是否出现无可用实例
每实例QPS是否负载倾斜
每实例P95/P99是否有慢实例
连接数和连接年龄是否复用旧连接
重试Attempt是否旧实例失败后重试放大
注册中心推送延迟控制面传播是否慢
Endpoint或xDS版本K8s/Mesh环境判断配置是否收敛

没有这些指标时,线上只能靠猜。例如“服务下线后还被调用”可能是本地缓存没刷新,也可能是连接池复用,也可能是灰度标签过滤错误,还可能是你看错了 namespace。

十六、商业场景

16.1 医疗采集平台:医院接口服务上线

医院接口服务按租户部署,新增 hospital-adapter-v2

  1. v2 以低权重注册,metadata 带 version=v2tenant=hospital-a
  2. Gateway 根据租户生成可信灰度标签。
  3. 采集服务 Feign 透传标签。
  4. LoadBalancer 只给医院A选择 v2 实例。
  5. 观察采集成功率、字段解析差异、P99、重试和最老批次等待时间。
  6. 异常时先切回 v1,不删除 v2 生成的审计和差异数据。

16.2 订单系统:库存服务滚动发布

库存服务 v2 修改扣减逻辑:

  1. 数据库先完成兼容字段新增。
  2. v1/v2 共享幂等表和库存流水。
  3. 新实例先低权重接只读查询,再接少量写流量。
  4. 写请求超时后按订单号和请求号查询库存事实。
  5. 如果旧实例被调到,不能再用新请求号重新扣减。

16.3 搜索系统:ES查询服务扩容

扩容后新 Pod 没流量:

  1. EndpointSlice 已包含新 Pod,说明发现层完成。
  2. Gateway 到旧 Pod 的 HTTP/2 连接仍承载大量请求。
  3. 配置连接最大存活时间和连接排空。
  4. 新 Pod 慢启动,避免冷缓存被打满。
  5. 按 Pod 观察连接数、QPS、Active Stream、P99。

十七、面试标准回答

text
服务发现的核心是把稳定服务名解析成当前可用实例列表。注册中心负责控制面,保存服务名、
实例地址、健康状态和metadata;调用方通常订阅或定期拉取实例列表,在本地维护快照,
LoadBalancer基于本地快照过滤健康、版本、区域和权重后选择实例,底层HTTP/RPC客户端
才真正发请求。

本地服务列表不能保证任意瞬间绝对最新,因为实例状态变化、注册中心更新、客户端推送或
拉取、本地快照替换、负载均衡选择和连接池排空都是异步过程。所以生产要通过Readiness
摘流、优雅停机、缓存刷新、健康过滤、连接最大存活时间、短超时、有限重试、实例级指标
和幂等设计来降低旧实例窗口。

如果调到旧服务,先用Trace确认目标IP、版本和时间,再查注册中心事实、本地快照、过滤
规则、连接池和灰度标签。只读请求通常依赖兼容协议容忍短窗口;写请求不能盲目重放到
新版本,必须按幂等号查询业务事实后补偿或对账。

十八、关联知识点

知识点入口
Spring Cloud服务调用链调用链路
Nacos注册发现Nacos
Nacos内部原理Nacos内部原理
Eureka注册中心Eureka
Kubernetes服务发现EndpointSlice与传播窗口
负载均衡微服务负载均衡
故障检测心跳、Phi与SWIM
发布策略灰度、金丝雀、蓝绿与回滚
服务治理选型微服务组件选型