微服务服务发现:注册、订阅、本地缓存、健康检查与优雅上下线
服务发现解决的是“调用方怎样把一个稳定服务名变成当前可用的实例地址”。它不是简单的 Map<String, List<IP>>,也不是每次请求都去注册中心实时查询。真实生产链路里,服务注册、健康检测、订阅推送、本地缓存、负载均衡、连接池、Readiness、优雅下线和灰度规则共同决定一次请求会打到哪台机器。
先记住一句话:
注册中心是控制面,保存和传播实例事实;业务请求走数据面,由调用方、网关或代理基于本地快照选择实例并直连目标。
这也解释了为什么“注册中心已经没有旧实例,但调用方还打到了旧服务”并不矛盾:控制面变更、调用方缓存刷新和连接池排空之间天然存在传播窗口。
一、学习目标
学完本页应能回答:
- 服务注册、续约、发现、订阅、本地缓存分别解决什么问题。
- 为什么调用方通常不会每次请求都实时访问注册中心。
- Nacos、Eureka、Consul、Kubernetes、Service Mesh 服务发现链路有什么差异。
- 本地实例快照怎样更新,为什么不能保证任意瞬间绝对最新。
- 健康检查、Readiness、Liveness、心跳、租约和业务健康有什么区别。
- 服务上线、下线、滚动发布时请求为什么会打到旧实例。
- 调到旧服务后怎样排查,写请求怎样避免重复副作用。
- 怎样设计优雅下线、连接排空、慢启动、版本路由和观测指标。
二、服务发现完整心智模型
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=v2、zone=shanghai | 灰度、同机房和权重依赖它 |
| Registry | 注册中心维护的服务表 | 不是每次请求都同步查询 |
| Local Snapshot | 调用方本地缓存的实例快照 | 可能短暂陈旧 |
| LoadBalancer | 从候选实例里选一个 | 不负责保存权威注册表 |
| HTTP/RPC Client | 真正发网络请求 | 连接池可能复用旧连接 |
如果面试官问“Feign 是不是从 Nacos 拿地址后发请求”,高质量回答应拆开:
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 |
控制面故障不一定让正在运行的业务请求立刻失败,因为调用方可能已有本地快照;但控制面故障会影响新实例注册、坏实例摘除、规则变更和故障恢复。数据面故障则直接影响真实请求。
错误理解:
Consumer -> Nacos -> Provider正确理解:
Consumer -> 本地实例快照和LoadBalancer -> ProviderNacos、Eureka、Consul 这类注册中心一般不转发订单、库存、支付这样的业务请求。
四、服务注册全过程
服务提供者启动时通常会做这些事:
flowchart TD
A["应用启动"] --> B["读取服务名、端口和注册配置"]
B --> C["确定可被其他服务访问的IP"]
C --> D["组装Instance和Metadata"]
D --> E["向注册中心注册"]
E --> F["注册中心保存或更新实例"]
F --> G["开启心跳、连接保持或健康检查"]注册信息至少包含:
| 字段 | 示例 | 作用 |
|---|---|---|
| 服务名 | inventory-service | 调用方按这个名字发现实例 |
| IP | 10.20.1.8 | 真实调用地址 |
| 端口 | 8080 | 真实调用端口 |
| 实例ID | 10.20.1.8:inventory-service:8080 | 区分同服务多实例 |
| 健康状态 | UP、DOWN、healthy=true | 决定是否进入候选列表 |
| 权重 | 1.0 | 调整流量比例 |
| 区域 | zone=shanghai | 同机房优先和容灾 |
| 版本 | version=v1 | 灰度和金丝雀发布 |
| 租户标签 | tenant=medical-a | B端租户灰度或隔离 |
生产常见坑:
| 问题 | 后果 | 排查点 |
|---|---|---|
注册成 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 很高。
商业项目中建议分层:
- Liveness 只判断进程是否卡死到需要重启。
- Readiness 判断是否应接新流量,包括启动预热、下线摘流、核心依赖状态。
- 注册中心健康用于服务发现候选过滤。
- 业务指标用于慢实例、坏实例、错误率和尾延迟治理。
六、服务发现和本地快照更新
调用方为什么要本地缓存?
| 原因 | 解释 |
|---|---|
| 性能 | 每次请求都查注册中心,会多一次远程调用 |
| 可用性 | 注册中心短暂不可用时,已有快照还能支撑调用 |
| 降低压力 | 注册中心不是业务请求级数据库 |
| 本地治理 | LoadBalancer、灰度、熔断需要在本地快速决策 |
典型更新链路:
flowchart TD
A["Provider上线、下线或状态变化"] --> B["注册中心服务表变化"]
B --> C["通知或等待Consumer拉取"]
C --> D["Consumer构造新实例快照"]
D --> E["原子替换本地引用"]
E --> F["新请求使用新快照"]
F --> G["旧请求和旧连接自然结束"]这里有两个关键词:快照和原子替换。
快照表示调用方在某一刻看到的完整实例列表。不要在多个线程遍历列表时原地修改同一个 ArrayList,否则会出现并发异常或半新半旧状态。更好的方式是构造不可变新列表,然后一次性替换引用。
七、JDK 8 Demo:本地实例快照原子替换
这个 Demo 不依赖任何框架,只演示“注册中心推送新列表后,调用方怎样安全替换本地快照”。
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 说明三件事:
- 调用方读的是本地内存快照,不是每次实时查注册中心。
- 新快照构造完成后再整体替换,避免读到半更新状态。
- 灰度过滤发生在候选集阶段,过滤后再负载均衡。
生产实现还要补充健康状态、权重、zone、慢启动、熔断、连接池和统计指标。
八、为什么不能保证本地列表绝对最新
本地列表“不绝对最新”不是实现偷懒,而是分布式系统的必然结果。
flowchart TD
A["旧实例准备下线"] --> B["Readiness变为不可用"]
B --> C["注册中心或Endpoint更新"]
C --> D["调用方收到变更"]
D --> E["本地快照替换"]
E --> F["新请求不再选择旧实例"]
F --> G["旧连接和在途请求排空"]可能产生窗口的地方:
| 窗口 | 说明 |
|---|---|
| 探测窗口 | 健康检查不是连续的,下一次检查前状态可能已变 |
| 注册中心处理窗口 | 服务表更新、复制、推送需要时间 |
| 客户端接收窗口 | 网络抖动、线程调度、SDK队列会延迟处理 |
| 本地快照窗口 | 调用线程可能已经拿到旧快照 |
| 负载均衡窗口 | 一次选择完成后,目标实例可能立刻下线 |
| 连接池窗口 | 旧连接可继续复用,尤其 HTTP/2/gRPC |
| 在途请求窗口 | 请求已经进入旧实例,不能凭空取消业务副作用 |
因此正确目标不是“零旧实例调用”,而是:
- 短窗口。
- 可观测。
- 旧实例仍兼容。
- 写请求幂等。
- 可回滚和可对账。
8.1 工程上怎样让本地列表尽量最新
先给结论:本地服务列表不能保证“任意时刻强一致最新”,但可以通过多层机制保证“变化尽快传播、旧列表窗口足够短、旧实例被调用也不会造成不可控后果”。
如果每次 Feign 调用都实时问注册中心“现在有哪些实例”,注册中心会进入每一次业务请求链路:调用延迟变高,注册中心压力暴涨,注册中心抖动会直接拖垮业务。所以商业项目通常选择“注册中心异步通知 + 调用方本地快照 + 负载均衡本地选址”的模式。
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、过滤后候选数 |
| Kubernetes | EndpointSlice Watch 更新,Service 数据面转发,客户端还可能 DNS 缓存 | Readiness 传播、EndpointSlice 更新、DNS/JVM 缓存、连接复用 | Pod Ready、EndpointSlice、kube-proxy/eBPF、连接池 |
| Service Mesh | 控制面推送 xDS,Envoy 本地按配置转发 | xDS ACK/NACK、配置 warming、旧连接 drain | Envoy cluster/endpoint 状态、xDS版本、drain时间 |
所以面试里不要回答“开启 Nacos 推送就能保证最新”。更准确的说法是:Nacos 推送能缩短注册中心到客户端的传播时间,但不能消除客户端快照、LoadBalancer 缓存、线程已选址、连接池复用和在途请求这些窗口。
8.3 配置与代码层面的防误调建议
下面不是固定模板,而是商业项目常用检查项。不同 Spring Cloud、Spring Cloud Alibaba 和注册中心版本的配置项名称可能有差异,落地时要以项目实际依赖版本为准。
spring:
cloud:
nacos:
discovery:
namespace: prod
group: DEFAULT_GROUP
cluster-name: shanghai
metadata:
version: v2
zone: shanghai
loadbalancer:
cache:
enabled: true
ttl: 5s这段配置表达的是三件事:
- 用
namespace/group/cluster隔离环境、分组和机房,避免测试服务被生产调用。 - 用
metadata.version、zone支持灰度和同机房优先。 - 控制 LoadBalancer 缓存 TTL,避免二级缓存把旧实例保留太久。
更重要的是调用链里要把“最终选到谁”打出来。生产建议在 Gateway、Feign 拦截器或 RPC Filter 中记录:
| 字段 | 作用 |
|---|---|
traceId | 串起一次调用经过的网关、调用方和提供方 |
serviceId | 逻辑服务名,例如 asset-service |
peer.address | 实际目标 ip:port |
instance.version | 实例版本或镜像版本 |
routeId | Gateway 路由或灰度规则 |
lb.candidate.count | 负载均衡过滤前后候选数量 |
lb.filtered.reason | 实例被过滤原因 |
attempt | 第几次重试 |
snapshot.age.ms | 本地实例快照距离更新时间 |
没有这些字段时,“为什么调到旧服务”很难定位。你只能看到 Feign 报错,却不知道它是拿到了旧缓存、灰度标签丢了、连接池复用了旧连接,还是注册中心本身没有更新。
8.4 调用方每次请求都会更新本地实例快照吗
不会。调用方不会在每一次业务请求到来时都去注册中心更新本地实例快照。正常链路是:实例快照由服务发现客户端在后台维护,业务请求只读取当前已经存在的快照,再交给 LoadBalancer 选择实例。
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 客户端 | 实例变化推送、定期拉取或连接重建时发生 | 拉取或接收新实例表,更新本地缓存 |
所以“每次请求都更新快照”这个理解是错的。每次请求最多是“读取快照”,不是“刷新快照”。原因很简单:
- 如果每次请求都刷新,注册中心会被业务 QPS 打爆。
- 每次远程查询都会增加调用延迟,服务调用会变慢。
- 注册中心抖动会直接影响所有业务请求,控制面变成数据面瓶颈。
- 多个调用线程同时刷新还会造成锁竞争和重复请求。
不同组件触发刷新快照的方式不同:
| 组件 | 主要刷新方式 | 说明 |
|---|---|---|
| Nacos | 订阅推送为主,客户端也有缓存和重做机制 | 服务端发现实例变化后推送给订阅客户端,客户端更新 ServiceInfo |
| Eureka | 周期拉取注册表为主 | 客户端定时从 Eureka Server 拉取注册表,所以天然有拉取周期窗口 |
| Consul | Blocking Query 或健康接口查询 | 客户端通过索引等待变化,仍可能有客户端缓存 |
| Kubernetes | EndpointSlice Watch、DNS、Service 数据面 | Pod Ready 变化先更新 Endpoint,再传播到数据面和客户端连接 |
| Service Mesh | xDS 推送给 Envoy | 业务进程不一定直接感知实例,Envoy 用本地已确认配置转发 |
这里还要区分两层缓存:
| 缓存层 | 位置 | 作用 | 风险 |
|---|---|---|---|
| 注册客户端缓存 | Nacos/Eureka/Consul Client 内部 | 保存注册中心下发的实例列表 | 推送或拉取延迟导致旧 |
| LoadBalancer 缓存 | Spring Cloud LoadBalancer 等组件内部 | 减少频繁访问 DiscoveryClient,提高选址性能 | TTL 太长会把旧实例再保留一段时间 |
因此排查“为什么还调旧实例”时,要看清楚是哪一层没更新:注册中心事实没变、注册客户端本地缓存没刷新、LoadBalancer 二级缓存没过期、过滤规则把新实例过滤掉,还是 HTTP 连接池继续复用旧连接。它们现象相似,但处理方式完全不同。
8.5 怎么保证每次请求都是最新可用的
严格说,分布式系统里不能保证“每一次请求都一定拿到全局最新且绝对可用的实例”。原因有两个:
- “最新”需要跨 Provider、注册中心、Consumer、本地缓存、LoadBalancer、连接池同步,而网络传播一定有延迟。
- “可用”不是选中时刻的永久属性。实例在 LoadBalancer 选中之后、HTTP 请求发出之前,也可能立刻 Full GC、重启、网络断开或数据库连接池打满。
所以生产目标不是承诺“每次请求都最新”,而是实现“每次请求尽量只选当前已知健康实例,失败后快速换路,并且写请求不会重复产生副作用”。
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、退避抖动 | 多层重试会把故障放大成雪崩 |
| 幂等和事实查询 | 写请求超时后按业务号查询事实 | 不允许盲目换新实例再执行一次 |
更落地一点,一次请求真正想做到“最新可用”,不是靠一个开关,而是靠下面这条请求级闭环:
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 实现的配置项会有差异,落地时要以项目依赖版本为准。
spring:
cloud:
loadbalancer:
cache:
enabled: true
ttl: 5s
openfeign:
client:
config:
default:
connectTimeout: 1000
readTimeout: 3000配置背后的原则:
| 配置或设计 | 原理 | 建议 |
|---|---|---|
| LB缓存TTL | TTL越长越省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、强治理场景下要求“请求前确认最新实例”,可以设计成受控强刷新,而不是让所有业务请求都打注册中心:
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错误 |
| Consul | Health API、Blocking Query、TTL Check、Catalog | Catalog底层Raft,健康结果仍有窗口 | 多语言服务发现、KV、Connect | Check状态延迟、Index等待、客户端缓存 |
| Kubernetes Service | EndpointSlice、DNS、kube-proxy或eBPF数据面 | 控制面最终传播 | 容器平台内服务发现 | Readiness传播、DNS缓存、连接复用 |
| Service Mesh | 控制面下发 xDS,Envoy本地转发 | 代理按已ACK配置执行 | 多语言治理、mTLS、灰度 | xDS推送、Warming、NACK、旧连接 |
| Dubbo Registry | Provider注册URL或应用,Consumer订阅Directory | 取决于注册中心和本地Directory | Java RPC | Directory未刷新、Router过滤、Invoker缓存 |
不要用某一个组件的细节解释所有系统。Eureka 的定期拉取、Nacos 的订阅推送、Kubernetes 的 EndpointSlice 和 Mesh 的 xDS 都能实现服务发现,但传播路径和排查证据不同。
十、调到旧实例的生产排查
先判断旧实例是哪一种“旧”:
| 类型 | 含义 | 例子 |
|---|---|---|
| 旧地址 | 实例已经下线或IP已变,但调用方仍访问 | 10.0.0.1:8080 进程已退出 |
| 旧版本 | v1和v2并存,请求本应去v2却去了v1 | 灰度用户串到稳定版本 |
| 旧配置 | 实例版本对,但仍使用旧配置 | 配置中心刷新失败 |
| 旧连接 | 快照已更新,但连接池还在复用旧连接 | HTTP/2流继续到旧Pod |
| 旧规则 | LoadBalancer或Gateway规则未更新 | 灰度权重回滚不生效 |
排查流程:
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 响应兼容、缓存结构兼容、用户体验可接受。处理重点是缩短窗口:
- 降低旧实例权重或摘流。
- 确认调用方缓存刷新。
- 调整连接最大存活时间。
- 检查灰度标签是否丢失。
- 按版本观察 P95/P99 和错误率。
11.2 写请求
写请求不能简单“再调用一次新服务”。因为旧服务可能已经扣库存、创建订单、发短信或写数据库,只是调用方不满意版本结果。
正确方式:
- 使用同一个业务幂等号查询事实。
- 如果旧服务已成功,按兼容结果返回或做后续迁移。
- 如果旧服务失败且未产生副作用,再决定是否由新服务处理。
- 如果状态未知,进入待确认、补偿或人工对账。
- 不要用新的请求号再次执行同一业务意图。
示例:
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 为什么“再调一次新服务”很危险
旧服务被调用后,调用方看到的可能只是超时或版本不符合预期,但服务端实际可能已经成功写入。比如订单创建接口:
flowchart TD
A["调用方请求旧实例"] --> B["旧实例创建订单成功"]
B --> C["网络超时或调用方拿不到结果"]
C --> D["调用方误以为失败"]
D --> E["再次请求新实例"]
E --> F["如果没有幂等约束就重复创建"]因此写请求必须把“业务意图”设计成可查询、可去重、可补偿:
- 请求进入系统前生成全局唯一
requestId、orderNo或业务幂等号。 - 数据库对幂等号加唯一索引。
- 调用超时后先查幂等表或业务事实。
- 查到成功就返回成功或兼容响应。
- 查到失败且确认无副作用才重试。
- 查不到或状态不确定就进入待确认、补偿、对账,而不是无限重试。
十二、优雅下线全过程
可靠下线不是 kill -9,而是一个有顺序的流程。
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 不代表已经适合满流量:
- JIT 还没编译热点代码。
- 本地缓存是冷的。
- DB、Redis、HTTP 连接池还没建立。
- 类加载和序列化缓存还没稳定。
- 新版本可能有慢 SQL 或依赖错误。
上线流程:
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 | 租户隔离或试点 |
灰度调用链:
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:
- v2 以低权重注册,metadata 带
version=v2、tenant=hospital-a。 - Gateway 根据租户生成可信灰度标签。
- 采集服务 Feign 透传标签。
- LoadBalancer 只给医院A选择 v2 实例。
- 观察采集成功率、字段解析差异、P99、重试和最老批次等待时间。
- 异常时先切回 v1,不删除 v2 生成的审计和差异数据。
16.2 订单系统:库存服务滚动发布
库存服务 v2 修改扣减逻辑:
- 数据库先完成兼容字段新增。
- v1/v2 共享幂等表和库存流水。
- 新实例先低权重接只读查询,再接少量写流量。
- 写请求超时后按订单号和请求号查询库存事实。
- 如果旧实例被调到,不能再用新请求号重新扣减。
16.3 搜索系统:ES查询服务扩容
扩容后新 Pod 没流量:
- EndpointSlice 已包含新 Pod,说明发现层完成。
- Gateway 到旧 Pod 的 HTTP/2 连接仍承载大量请求。
- 配置连接最大存活时间和连接排空。
- 新 Pod 慢启动,避免冷缓存被打满。
- 按 Pod 观察连接数、QPS、Active Stream、P99。
十七、面试标准回答
服务发现的核心是把稳定服务名解析成当前可用实例列表。注册中心负责控制面,保存服务名、
实例地址、健康状态和metadata;调用方通常订阅或定期拉取实例列表,在本地维护快照,
LoadBalancer基于本地快照过滤健康、版本、区域和权重后选择实例,底层HTTP/RPC客户端
才真正发请求。
本地服务列表不能保证任意瞬间绝对最新,因为实例状态变化、注册中心更新、客户端推送或
拉取、本地快照替换、负载均衡选择和连接池排空都是异步过程。所以生产要通过Readiness
摘流、优雅停机、缓存刷新、健康过滤、连接最大存活时间、短超时、有限重试、实例级指标
和幂等设计来降低旧实例窗口。
如果调到旧服务,先用Trace确认目标IP、版本和时间,再查注册中心事实、本地快照、过滤
规则、连接池和灰度标签。只读请求通常依赖兼容协议容忍短窗口;写请求不能盲目重放到
新版本,必须按幂等号查询业务事实后补偿或对账。十八、关联知识点
| 知识点 | 入口 |
|---|---|
| Spring Cloud服务调用链 | 调用链路 |
| Nacos注册发现 | Nacos |
| Nacos内部原理 | Nacos内部原理 |
| Eureka注册中心 | Eureka |
| Kubernetes服务发现 | EndpointSlice与传播窗口 |
| 负载均衡 | 微服务负载均衡 |
| 故障检测 | 心跳、Phi与SWIM |
| 发布策略 | 灰度、金丝雀、蓝绿与回滚 |
| 服务治理选型 | 微服务组件选型 |
