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/静态种子做兜底。注意:缓存越久,越能扛控制面故障,但越容易在实例真实下线后继续打旧地址。

关联