48.2 服务发现
一句话
服务发现解决「一个服务名,当前有哪些可用实例、地址是什么」;它由注册、健康检查、查询/订阅三件事组成,难点不在存表,而在「变更多快传播」与「注册中心挂了还能不能用」。
三个角色与两条路径
| 角色 | 做什么 | 典型实现 |
|---|---|---|
| 服务提供者 | 启动时注册地址/元数据,定期心跳;下线时注销 | 进程内 SDK、Sidecar、K8s Controller |
| 注册中心 | 存服务 → 实例列表,做健康检查,推送变更 | etcd、ZooKeeper、Consul、Nacos、Eureka |
| 服务消费者 | 查询/订阅实例列表,做负载均衡与重试 | SDK、Service Mesh、负载均衡器、DNS |
两条正交的选择:
| 维度 | 选项 | 特点 |
|---|---|---|
| 注册方式 | 自注册 / 第三方注册 | 自注册简单但耦合 SDK;第三方(K8s EndpointSlice、Consul agent)解耦但多一层 |
| 发现方式 | 客户端发现 / 服务端发现 | 客户端拿列表自己选;服务端由 LB/代理/DNS 选 |
客户端发现:Consumer ──查列表──▶ Registry
Consumer ──直连───▶ Provider(自己负载均衡)
服务端发现:Consumer ──▶ LB / Service VIP / Sidecar ──▶ Provider
▲
└── 从 Registry / Endpoints 拿列表
🔑 客户端发现把「选谁」交给调用方,延迟低、治理灵活,但每种语言都要实现一套 SDK;服务端发现把选择集中到基础设施,语言无关,但多一跳、需要数据面。
数据模型:一条实例记录里该有什么
| 字段 | 作用 | 缺了会怎样 |
|---|---|---|
service / namespace | 逻辑服务名与隔离域 | 环境/租户混在一起 |
addr(IP:port) | 连接目标 | —— |
zone / region | 就近路由、故障域 | 跨区流量、单区故障全挂 |
version / color | 灰度、金丝雀 | 无法按版本路由 |
weight | 加权负载均衡 | 异构机器分不均 |
metadata | 协议、机房、实例规格 | 路由策略缺依据 |
lease / health | 是否可服务 | 把死实例当活的用 |
revision | 列表版本,便于增量同步 | 每次全量推送 |
实例列表是短生命周期数据:秒级到分钟级变化,和配置中心的「版本化 KV」不是一类东西(边界见 48.1 配置中心)。用配置中心存实例列表,最常见的翻车是「全量推送 + 无 TTL」。
健康检查:三种手段,三种误判
| 手段 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 心跳 / TTL | 实例每 T 秒续租,超过 TTL 未续则摘除 | 实现简单、无需中心主动连接 | 检测慢(≈ TTL);GC/网络抖动会误摘 |
| 主动探测 | 注册中心周期性发 TCP/HTTP/gRPC 探针 | 能发现「进程活着但不服务」 | 探针本身有开销;网络分区时误判 |
| 被动摘除 | 调用方统计错误率/延迟,熔断摘除 | 反映真实可用性 | 滞后;需要足够的请求量 |
检测延迟与误摘的定量权衡
心跳 5s、TTL 15s 时,实例在心跳周期内随机时刻死亡,摘除延迟落在 (TTL−心跳, TTL]:
| TTL | 检测延迟 P50 | 检测延迟 P99 | 心跳 QPS(1000 实例) |
|---|---|---|---|
| 15s | 12.5s | 15.0s | 200 |
| 30s | 27.5s | 30.0s | 200 |
| 60s | 57.5s | 60.0s | 200 |
调大 TTL 不会降低心跳 QPS(心跳周期没变),只会让摘除更慢、误摘更少。误摘来自「暂停超过 TTL」——GC/STW、VM 迁移、网络分区。下面按「暂停超过 TTL」估算(忽略心跳相位,是保守下限;真实误摘会略高):
package main
import (
"fmt"
"math"
"math/rand"
"sort"
)
// 实例在心跳周期内随机时刻死亡;注册中心在 lastHB+TTL 摘除
func detectDelays(hb, ttl float64, n int) (p50, p99 float64) {
r := rand.New(rand.NewSource(42))
ds := make([]float64, n)
for i := range ds {
ds[i] = ttl - r.Float64()*hb
}
sort.Float64s(ds)
return ds[n/2], ds[int(float64(n)*0.99)]
}
// 误摘:存活实例因 GC/STW 暂停超过 TTL 没发心跳
// 暂停时长服从对数正态(中位数 0.5s),返回每天误摘次数
func falseRemovalsPerDay(ttl, instances, pausesPerDay, sigma float64) float64 {
r := rand.New(rand.NewSource(7))
const samples = 2000000
over := 0
for i := 0; i < samples; i++ {
if 0.5*math.Exp(sigma*r.NormFloat64()) > ttl {
over++
}
}
return instances * pausesPerDay * float64(over) / samples
}
func main() {
fmt.Println("心跳 5s,检测延迟(实例死亡到被摘除)")
for _, ttl := range []float64{15, 30, 60} {
p50, p99 := detectDelays(5, ttl, 100000)
fmt.Printf("TTL=%2.0fs P50=%.1fs P99=%.1fs\n", ttl, p50, p99)
}
fmt.Println("\n1000 实例、每实例每天 2 次 GC 暂停(对数正态,中位数 0.5s)")
fmt.Printf("%-6s %-10s %-14s %-14s\n", "sigma", "TTL", "误摘/天", "误摘/年")
for _, sigma := range []float64{1.0, 1.5} {
for _, ttl := range []float64{15, 30, 60} {
perDay := falseRemovalsPerDay(ttl, 1000, 2, sigma)
fmt.Printf("%-6.1f %-10.0f %-14.2f %-14.0f\n", sigma, ttl, perDay, perDay*365)
}
}
fmt.Printf("\n心跳写入 QPS(1000 实例 / 5s):%.0f\n", 1000/5.0)
}
输出:
TTL=15s P50=12.5s P99=15.0s
TTL=30s P50=27.5s P99=30.0s
TTL=60s P50=57.5s P99=60.0s
sigma TTL 误摘/天 误摘/年
1.0 15 0.67 246
1.0 30 0.05 19
1.0 60 0.00 1
1.5 15 23.46 8562
1.5 30 6.36 2322
1.5 60 1.47 535
读法:
- 延迟与误摘是一对矛盾:TTL 15s 检测快,但重型尾部(sigma=1.5,少数量级更长的暂停)每天误摘 23 次;TTL 60s 把误摘压到 1.5 次/天,代价是故障实例要 1 分钟才被摘掉。
- 误摘的代价不是「少一台」:实例被摘后会收到骤降的流量、可能触发自动扩容、恢复后又涌回流量;如果它正在处理长连接,还会造成一批断连。所以很多系统宁可慢一点摘,也要加「恢复宽限/抖动」。
- 结论不是选一个固定 TTL,而是按业务容忍的故障检测时间选 TTL,再用主动探测/被动摘除补速度。
健康检查的三个坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 把 liveness 和 readiness 混用 | 进程活着但没预热就接流量,或一次慢请求被重启 | K8s 里 liveness 管重启、readiness 管摘流量,分开配 |
| 探针只探端口 | 端口通但依赖挂了,仍然接流量 | 探针检查关键依赖/内部状态,但要防「依赖抖动导致全摘」 |
| 摘除没有宽限 | 网络抖动 3 秒,全集群实例互相摘除 | 连续 N 次失败才摘、恢复后渐进放量 |
一致性:CP 还是 AP,取决于「读到旧列表能不能忍」
| 类型 | 代表 | 分区时 | 风险 |
|---|---|---|---|
| CP | etcd、ZooKeeper、Consul(默认) | 少数派不能注册/查询 | 注册中心不可用,新实例上不了线 |
| AP | Eureka、Nacos(临时实例)、DNS | 各分区继续读写,可能读到旧列表 | 调用方拿到已死实例;需要客户端重试/熔断 |
| 混合 | Nacos 持久实例 CP、临时实例 AP | 按数据类型选 | 运维复杂,要理解两套语义 |
关键工程原则:客户端必须缓存实例列表,并在注册中心不可用时用旧列表降级(stale-while-error)。否则注册中心一挂,整个服务发现链路就停了。
| 缓存策略 | 行为 | 代价 |
|---|---|---|
| 短 TTL 轮询 | 过期就重新拉,拉不到用旧值 | 陈旧窗口 = TTL |
| watch + 本地缓存 | 变更推送,断线重连后全量同步 | 连接数、重连风暴 |
| 本地文件兜底 | 启动时读不到注册中心,用上次快照 | 可能启动到已下线实例 |
🔑 CP 注册中心保证「列表不会错」,但分区时可能「没列表」;AP 注册中心保证「总有列表」,但可能是旧的。服务发现通常选 AP + 客户端缓存 + 快速失败重试,因为「旧列表」比「没有列表」更容易降级。
推送风暴:全量列表是奢侈品
算一笔账:1000 个实例、2000 个消费者,每次变更推送全量列表(约 1000 × 48B ≈ 48KB):
每次变更:48KB × 2000 = 96,000KB ≈ 94 MiB
每秒 1 次变更:≈ 94 MiB/s ≈ 750 Mbps 的出向流量
如果改成增量推送(只发变更的那一条,约 100B):
每次变更:100B × 2000 = 200KB
差 480 倍。这就是为什么大规模注册中心必须支持:
- 增量事件(ADDED/MODIFIED/DELETED)而不是全量快照;
- 按服务/命名空间/区域分片推送;
- 客户端本地合并 + 版本号/Revision 校验,断线后从 revision 续传;
- 重连抖动(0–30s 随机),避免注册中心重启时被 2000 个客户端同时打满。
注册一个实例:Consul 的 service JSON
本机未部署 Consul,以下 JSON、命令与输出未实测(标:未验证)。
{
"ID": "order-api-10-0-1-11",
"Name": "order-api",
"Address": "10.0.1.11",
"Port": 8080,
"Tags": ["v2", "zone-a"],
"Check": {
"HTTP": "http://10.0.1.11:8080/healthz",
"Interval": "5s",
"Timeout": "2s",
"DeregisterCriticalServiceAfter": "1m"
}
}
consul services register order-api.json # 无输出,exit 0
consul catalog services # 输出里应出现 order-api
consul health service order-api # JSON 数组,Check.Status 为 "passing"
consul services deregister -id=order-api-10-0-1-11 # 下线/回滚
三个参数别拍脑袋:Interval 5s + Timeout 2s 意味着最坏 7s 才发现实例失联;DeregisterCriticalServiceAfter 太长会留下死实例被调用方选中,太短则网络抖动就把实例摘掉。健康检查的周期要与调用方重试预算对齐,否则“摘除”永远慢于“重试放大”。
替代品:Nacos(AP 注册 + 配置一体)、Eureka(纯 AP)、K8s Service/EndpointSlice(集群内首选,不需要额外注册中心)。
客户端发现 vs 服务端发现
| 维度 | 客户端发现 | 服务端发现 |
|---|---|---|
| 典型 | gRPC + 注册中心 SDK、Ribbon、Consul DNS | K8s Service、Service Mesh、云 LB |
| 延迟 | 低(直连) | 多一跳(LB/代理) |
| 语言支持 | 每种语言要 SDK | 语言无关 |
| 治理能力 | SDK 内做重试/熔断/灰度 | 数据面统一做 |
| 故障域 | 客户端要处理列表过期 | 数据面处理,但数据面本身是单点 |
| 适用 | 多语言、需要精细路由 | 标准化、平台化 |
K8s 自带的服务发现
| 机制 | 作用 | 说明 |
|---|---|---|
Service(ClusterIP) | 虚拟 IP + kube-proxy/iptables/IPVS 转发 | 服务端发现,客户端只连 VIP |
Endpoints / EndpointSlice | 实际 Pod 地址列表 | EndpointSlice 分片,避免大列表 |
Headless Service | 返回 Pod IP 列表(DNS) | 客户端自己负载均衡 |
| CoreDNS | 集群内 DNS 解析 | 解析 svc.namespace.svc |
K8s 已经解决了「集群内发现」,还需要 Consul/Nacos 的场景:跨集群/跨云、非 K8s 工作负载、需要配置+注册一体、需要更细的元数据与灰度路由。
DNS 做服务发现的边界
| 优点 | 限制 |
|---|---|
| 零 SDK、语言无关 | TTL 决定陈旧窗口(常见 30–60s,改 TTL 受解析器缓存影响) |
| 基础设施通用 | 只返回地址,不做健康检查(要靠注册中心改记录) |
| SRV 记录能带端口/权重 | 权重支持有限,客户端不一定实现 |
| 天然跨语言 | JVM 默认 DNS 缓存可能很久,要显式配 networkaddress.cache.ttl |
DNS 适合「变更不频繁、能容忍分钟级陈旧」的服务;不适合「秒级扩容、频繁上下线」的场景。
多地域与故障域
| 需求 | 做法 | 风险 |
|---|---|---|
| 就近访问 | 优先返回同 zone/region 实例,不足再跨区 | 跨区延迟、出网费 |
| 单区故障 | 其他区实例接管;注册中心分区部署 | 全局注册中心成为单点 |
| 跨区一致性 | 每区本地注册中心 + 异步同步 | 同步延迟内拿到旧列表 |
| 单元化 | 每个单元独立注册中心 | 跨单元调用需网关 |
服务发现的「多活」比数据多活简单:实例列表可以最终一致,只要客户端能重试到其他区;但注册中心的控制面(选主、写入)仍然要按故障域设计。
安全
| 风险 | 防护 |
|---|---|
| 恶意注册(把流量引到攻击者) | 注册鉴权(mTLS/Token)、命名空间隔离 |
| 信息泄露(实例列表暴露内网) | 注册中心不对外、查询鉴权 |
| 中间人篡改实例列表 | 传输 TLS、列表签名(高安全场景) |
| 越权注销他人实例 | 实例级 owner 校验 |
反模式
| 反模式 | 后果 |
|---|---|
| 没有客户端缓存,注册中心挂了就全挂 | 控制面故障直接变成数据面故障 |
| 全量推送 + 高频变更 | 带宽/CPU 打满,客户端 GC 抖动 |
| TTL 设得极短(如 3s)追求「实时」 | GC 抖动就误摘,实例频繁上下线 |
| 只探端口不探依赖 | 端口通但服务不可用,流量继续打 |
| liveness/readiness 混用 | 慢请求触发重启,雪崩 |
| DNS TTL 很长却要求秒级生效 | 发布/扩容后长时间打不到新实例 |
| 重连不做抖动 | 注册中心重启后被重连风暴二次打挂 |
| 注册中心跨地域单点 | 专线抖动导致异地实例全部失联 |
自检
Q1 心跳 5s、TTL 15s,实例死亡后平均多久被摘除?为什么不是 5s?
▸ 答案:平均约 12.5s。摘除发生在「最后一次心跳 + TTL」,而死亡时刻距离上一次心跳平均 2.5s,所以延迟是 15 − 2.5 = 12.5s,范围 10–15s。心跳周期只决定「多久续一次」,TTL 才决定「容忍多久没续」;把心跳调到 1s 只能让延迟分布更靠近 15s,不会让摘除更快——要更快就调小 TTL,代价是误摘增加。
Q2 为什么服务发现通常选 AP 而不是 CP?
▸ 答案:因为「旧列表」可降级,「没有列表」不可降级。AP 注册中心在分区时仍返回列表(可能含已死实例),客户端可以靠重试、熔断、负载均衡把失败请求转到其他实例;CP 注册中心在少数派直接拒绝查询,新实例也无法注册,整个服务发现停摆。除非业务要求「列表绝对准确」(例如强一致的选主/锁),否则服务发现选 AP + 客户端缓存。
Q3 1000 实例、2000 消费者,全量推送每次约 94 MiB。怎么把它降下来?
▸ 答案:① 增量推送(只发变更实例,约 200KB/次);② 按服务/区域分片,消费者只订阅自己关心的服务;③ 客户端用 revision 做增量同步,断线后从上次 revision 续传;④ 合并短时间内的多次变更(debounce);⑤ 大列表用 EndpointSlice 分片。核心是不要把「成员变更」当「全量配置下发」。
Q4 K8s 已经有 Service/EndpointSlice,为什么还需要 Consul/Nacos?
▸ 答案:K8s 的服务发现是集群内的:Service/EndpointSlice 由控制面维护,CoreDNS 解析集群域名。跨集群、跨云、非 K8s 工作负载(虚拟机、物理机、边缘)不在同一个 K8s API 里;Consul/Nacos 提供跨环境统一注册、更丰富的元数据/健康检查、配置一体、以及非 K8s 客户端 SDK。若全部工作负载都在一个 K8s 集群内,额外引入注册中心通常是重复建设。
Q5 注册中心故障 5 分钟,10% 的实例在这期间死亡,客户端 QPS 50k,会发生什么?怎么把损失压到最小?
▸ 答案:客户端如果只靠注册中心刷新列表,会继续把 10% 的流量打到死实例上——约 5,000 错误/秒,5 分钟约 150 万次失败。压损失:① 客户端本地缓存 + 失败重试到其他实例(重试要预算化,避免放大);② 被动健康检查/熔断把死实例快速摘掉;③ 注册中心多副本/多 AZ,控制面故障时读走本地缓存;④ 关键服务用 DNS/静态种子做兜底。注意:缓存越久,越能扛控制面故障,但越容易在实例真实下线后继续打旧地址。