服务发现的本质,是把"我知道一个服务叫什么"翻译成"这一刻应该往哪个 IP:PORT 发请求"。前者在配置里写死就够了,后者每隔几十秒就可能变一次。所有麻烦都来自这个变化:注册中心的缓存延迟、健康检查的探测间隔、摘除与停机之间的空档,任何一环没对齐,都会变成 502 或者打到一个已经不存在的实例上。
1. 要解决的两个问题
实例列表从哪来:进程启动时把 ip:port 报到注册中心(或由平台自动注入),调用方从注册中心拉取。
变化怎么通知:主动推送(watch/long-poll)、定时拉取、或依赖调用方的本地缓存 TTL。三者延迟不同,决定了"实例下线后多久流量才停止打过去"。
这两件事决定了后面所有设计:注册中心选谁、健康检查怎么做、LB 放客户端还是服务端。
2. 客户端发现 vs 服务端发现
| 维度 | 客户端发现 | 服务端发现 |
|---|---|---|
| 谁持有实例列表 | 调用方进程内缓存 | 中间的 LB / 网关 / Service |
| 典型实现 | Nacos + Spring Cloud LoadBalancer、Consul + gRPC | kube-proxy、nginx upstream、云 LB |
| 复杂度归属 | 落在每个业务应用(SDK、版本升级) | 落在基础设施(运维统一升级) |
| 多语言支持 | 每种语言都要有 SDK | 任何语言都只是 HTTP 调用 |
| 故障域 | 某个应用缓存脏 → 只有它出问题 | LB 挂了 → 全站出问题 |
选型没有绝对优劣:基础设施团队强、语言栈统一,客户端发现更省一次网络跳转;语言栈杂、或者跑在 K8s 上,服务端发现更省心。
3. 注册中心的能力对照
| 能力 | Consul | Nacos | Eureka | K8s Service/DNS |
|---|---|---|---|---|
| 一致性取向 | CP(Raft,可配 AP 读) | AP 为主,可切 CP(2.x 的 Raft 模式) |
AP | 依赖 etcd,AP 读 |
| 健康检查 | 主动探测(TCP/HTTP/脚本)+ TTL 心跳 | 心跳 + 主动探测 | 客户端心跳 | 就绪探针 + Endpoints 控制器 |
| 配置中心 | 有 KV | 有,能力更全 | 无 | ConfigMap/Secret |
| 变更通知 | watch(阻塞查询) | 长轮询推送 | 客户端定时拉取 | watch Endpoints |
| 跨数据中心 | 原生支持 | 支持命名空间/集群 | 不支持 | 多集群方案 |
CP/AP 的选择要看后果:注册中心脑裂时,CP 取向会拒绝写入新实例(新 Pod 注册不上,滚动发布卡住),AP 取向会返回陈旧列表(流量打到已下线实例)。多数业务选 AP + 短 TTL + 客户端重试,比选 CP 更实用。
4. 健康检查的三种形态
# 应用侧:把"活着"和"能干活"拆成两个信号
management:
endpoint.health.probes.enabled: true
health.readiness-state.enabled: true
| 形态 | 谁发起 | 能发现什么 | 盲区 |
|---|---|---|---|
| 心跳(客户端上报) | 实例自己 | 进程活着 | 依赖全挂仍报健康 |
| 主动探测(TCP/HTTP) | 注册中心 | 端口可连、接口可响应 | 只探一个端点,可能探到缓存健康的假象 |
| 就绪探针(readiness) | 平台(K8s) | 是否可以接流量 | 只影响 Service 转发,不通知外部注册中心 |
"注册了但不可用"的窗口就是这三者的时间差:进程已死,心跳还没超时(典型是 3 个周期);或进程活着但数据库连不上,心跳仍然正常。窗口内的流量只能靠调用方超时 + 重试 + 熔断兜底,不能指望注册中心秒级摘除。
5. 负载均衡算法与适用面
| 算法 | 原理 | 适用 | 短板 |
|---|---|---|---|
| 轮询 | 依次分配 | 实例同质、耗时接近 | 慢实例被持续压 |
| 加权轮询 | 按权重分配 | 机型不同、灰度按比例放量 | 权重靠人工拍 |
| 最少连接 | 选连接数最少的 | 请求耗时差异大 | 需共享计数,客户端难做准 |
| 一致性哈希 | 按 key 落到固定节点 | 有本地缓存、需要命中率 | 热点 key 压单实例 |
| 随机二选一 | 随机挑两个,取较优 | 大规模、低开销 | 只能近似 |
最少连接在客户端发现模式下很难做准——每个调用方只知道自己发了多少请求,不知道别人的。所以客户端 LB 常用轮询/加权/一致性哈希,最少连接留给服务端 LB。
6. 一致性哈希与扩容迁移量
朴素取模 hash(key) % N 的问题:N 从 4 变 5,几乎所有 key 都要换节点。一致性哈希把节点和 key 都映射到一个环上,key 顺时针找到第一个节点——扩容时只有新节点负责的那一段需要迁移。
虚拟节点是为了解决"节点少时环上分布不均":
// 每个物理节点放 160 个虚拟节点,分布才够均匀
for (int i = 0; i < 160; i++) ring.put(hash(nodeId + "#VN" + i), nodeId);
// 查找用 ceilingEntry,找不到则回绕到 firstEntry
迁移量取决于虚拟节点数量与新增节点的份额:加第 N+1 个节点时,理想情况只迁移总量的 1/(N+1)。但这是理想值——真实迁移量还受 key 分布倾斜影响,热点 key 所在的那一段可能一次搬走大半。
7. 客户端负载均衡的两个坑
# Spring Cloud LoadBalancer:缓存过期时间是第一个坑的根源
spring.cloud.loadbalancer.cache.ttl=2s
spring.cloud.loadbalancer.retry.enabled=true
spring.cloud.loadbalancer.retry.retry-on-all-operations=false
坑一:本地缓存过期导致打到已下线实例。默认 TTL 偏长(按所用版本核对),实例下线后调用方仍拿旧列表。对策是压到秒级,并配合重试——重试必须换实例。
坑二:重试打到同一实例。若重试只是"再选一次"而缓存没刷新,两次都选中同一个死实例,白做还放大延迟。要确认重试走的是 ServiceInstanceListSupplier 的实时列表。
// 自定义 LB 时把 supplier 注入进来,才能拿到实时列表
@Bean
ReactorLoadBalancer<ServiceInstance> lb(LoadBalancerClientFactory f) {
return new RoundRobinLoadBalancer(f.getLazyProvider("orders", ServiceInstanceListSupplier.class), "orders");
}
这只是骨架,实际要配合重试过滤器实现"排除上次实例",别指望默认行为自动避开。
8. 服务端负载均衡与会话保持
upstream order_api {
least_conn;
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=10s backup;
keepalive 64;
}
max_fails/fail_timeout 是最被低估的两个参数:max_fails=3 fail_timeout=10s 意味着 10 秒内失败 3 次就摘除 10 秒(不是永久摘除)。backup 只在主节点全挂时启用,适合兜底不适合分摊。
会话保持有三条路:粘性 Cookie(ip_hash / sticky)、把 session 外置到 Redis、或干脆无状态用 JWT。网关层做粘性会让扩容失效——新实例分不到流量,因为老会话都粘在旧实例上。
9. 优雅下线:三步不能省
# 1) 先摘除:从注册中心下线,等待调用方缓存刷新
curl -X PUT "http://127.0.0.1:8848/nacos/v1/ns/instance?serviceName=order&ip=${IP}&port=8080&enabled=false"
# 2) 等在途请求结束(等待时长 ≥ 客户端缓存 TTL + 最长请求耗时)
sleep 30
# 3) 再停进程
kill -TERM ${PID}
K8s 里对应的是 preStop + terminationGracePeriodSeconds:
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10 && curl -sf -X POST http://localhost:8080/actuator/shutdown || true"]
terminationGracePeriodSeconds: 40
顺序错了就是 502 制造机:先杀进程再摘除,注册中心还有心跳、调用方还有缓存,几十秒内全是连接拒绝。
10. DNS 作为服务发现的限制
dig +short order.svc.cluster.local A
dig +short order.internal CNAME
DNS 能用,但有四个硬伤:
- TTL 缓存不受你控制:中间解析器会缓存,设 5 秒也可能几分钟才生效。
- 没有权重与健康检查:DNS 只会返回 A 记录列表,客户端随机或按顺序取,不问死活。
- 没有端口语义:SRV 记录能带端口,但绝大多数客户端不查 SRV。
- 连接复用掩盖变化:长连接池不会因为 DNS 变了就重建,摘除后旧连接还在用。
所以 DNS 适合数据库、缓存这类不常变的基础设施,不适合频繁扩缩容的业务实例。
11. 常见坑
- 实例注册了但端口没起:注册动作在端口监听之前执行,形成几秒的空窗。注册必须放在
ApplicationReadyEvent之后。 - 摘除与停止之间没有等待:少了 sleep,等于把在途请求直接掐断。
- 区域感知缺失导致跨机房流量:同机房实例没优先,一半请求跨机房,延迟与带宽双输。
- 缓存刷新间隔设得太长:扩缩容看起来"没生效",实际是调用方列表没刷。
- 健康检查探到的是缓存:
/health返回 200 但数据库已断,探针永远不报警。
12. 排错:五种"打不到/打到死实例"
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 502 集中出现在发布后几分钟 | 先停进程后摘除 | 加 preStop 与 sleep,先摘后停 |
| 新实例没流量 | 本地缓存 TTL 长、粘性会话 | 缩短 TTL,关粘性,检查权重 |
| 请求偶发打到已下线 IP | 注册中心 AP 模式下的陈旧列表 | 调用方重试换实例 + 熔断 |
| 跨机房延迟高 | 无区域感知或权重相同 | 配 region/zone 优先,权重按机房 |
| 探针一直健康但接口全 500 | 探针只查进程存活 | 就绪探针检查关键依赖,或分离 liveness/readiness |
13. 边界:这些场景别用服务发现
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 单体应用、固定几台机器 | 静态 upstream | 变化频率低,注册中心多余 |
| 数据库、缓存等有状态服务 | 配置中心 + 人工变更 | 自动摘除会引发更大故障 |
| 跨公网调用第三方 | 固定域名 + 客户端重试 | 你控制不了对方的注册中心 |
| 极低频调用的内部工具 | 服务端 LB 或 DNS | 客户端 SDK 维护成本更高 |
| 需要严格全局限流的入口 | 网关统一入口 | 分散在客户端的限流阈值无法全局一致 |
一句话收束:注册中心负责"列表正确",负载均衡负责"选得合理",优雅下线负责"退出时不伤人",三者缺一环就等着 502。
14. 自测
- 客户端发现与服务端发现,复杂度分别落在谁身上?
- 注册中心选 CP 时,滚动发布会遇到什么问题?
- "注册了但不可用"的窗口由哪几个时间差构成?
- 一致性哈希扩容时,迁移量由什么决定,为什么真实值常大于理论值?
- 为什么重试逻辑必须"换实例",否则会放大故障?
- 优雅下线的三步顺序是什么,颠倒会怎样?