Theory · Microservice · Service Discovery
实例时刻在变(发布/扩缩/宕机),硬编码地址必然失效 —— 注册中心是微服务的"活体通讯录"
服务启动时注册自身地址,消费者订阅实例列表;健康检查持续剔除坏实例,watch 推送拓扑变化
注册与注销 · 健康检查(心跳/lease/主动探测)· 发现与变更推送(watch/长轮询/长连接)
CP(etcd/ZK:强一致,分区时拒绝服务)vs AP(Eureka/Nacos-Distro:可用优先,允许短暂不一致)
Why Service Discovery Matters
| 时刻 | 发生的事 |
|---|---|
| 配置里 | 订单服务调用库存服务,地址写在配置文件:inventory.host = 10.1.2.3:8080 |
| 23:40 | 大促,库存服务 CPU 90%,运维紧急扩容到 6 个实例。 |
| 23:45 | CPU 还是 90% —— 订单服务仍然只打那一个 IP,新加的 5 个实例一个请求都没接到。 |
| 00:10 | 偏偏 10.1.2.3 这台 OOM 宕机 → 订单服务100% 调用失败;另外 5 个实例活得好好的,但没人知道它们在。 |
| 00:30 | 改配置 → 滚动重启订单服务 8 个实例 → 20 分钟后才恢复。 |
① 新实例进不来:扩容/发布产生的新实例不在调用方的配置里 → 扩容无效,流量全压在老实例上。
② 坏实例摘不掉:宕机/假死的实例仍在配置里 → 单点故障直接放大成调用方整体失败。
③ 变更要重启:每加一台机器都要改配置、滚动重启所有调用方 → 恢复以十分钟计,且重启本身还在制造新的不确定性。
它换掉的不是"地址"本身,而是地址的来源:从"写在配置文件里的常量"变成一份动态数据——每个服务自己上报"我是谁、我在哪、我还活着",调用方订阅这份列表并在进程内缓存。于是扩容自动生效、宕机自动摘除、发布不用重启别人,恢复时间从 20 分钟压到秒级。
先把这套"动态通讯录"的问题模型说清(第 3 页)→ 再看两种发现模式谁去查这份列表(第 4 页)→ 拆开注册、续约、摘除、推送四个动作(第 5-6 页)→ 然后是最难的取舍:CP 还是 AP(第 7 页)→ 落到 etcd / Nacos / K8s 三套实现(第 8-11 页)→ 最后是优雅上下线与 Go 实践。读完你应该能回答:"实例变了,调用方怎么知道"。
Why
开场那场事故的根因,可以拆成三问:谁在变(发布/扩缩/宕机)、为什么 DNS 不够、注册中心的答案是什么。
发布滚动重启(实例 IP 换)、HPA 自动扩缩、宕机与自愈、Pod 重建 IP 必变——写死 IP/端口不可行
DNS 缓存延迟(TTL)、无法及时摘除坏实例、负载均衡策略弱、客户端看不到完整实例列表——只适合粗粒度入口
服务把"我活着+我的地址"上报为可查询数据;消费者拿到全量健康实例列表自己做决策,变更秒级推送
{服务名, IP, 端口, 版本, 权重, 元数据}注册中心的两个角色要分清:服务发现(谁在哪儿)与配置中心(行为参数是什么)是两类数据、两种访问模式——etcd 两样都能干,Nacos 也把两者打包,但工程上常分开部署(见配置中心 deck)。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 服务名 / 实例 | 服务名是逻辑名字(如 order),实例是真正干活的一个进程(IP:端口)。一个服务名背后通常有多个实例 |
| 注册 / 注销 Register / Deregister | 实例上线时把地址写进注册中心叫注册;正常下线时主动删除叫注销(宕机则靠租约过期自动删) |
| 租约 / 心跳 Lease / Heartbeat | 注册时带一个有效期(如 15 秒),实例必须不断续期;不再续期 = 租约过期 = 自动摘除——这是宕机也能被发现的原因 |
| 存活 vs 就绪 liveness / readiness | 存活=进程还在(不行就重启);就绪=能处理请求(不行就摘流量)。一个证明"活着",一个证明"能干活" |
| 拉 / 推 Pull / Watch | 拉=主动查一次全量列表;推=订阅后由注册中心在变更时通知。生产组合是"启动时拉全量 + 之后收推送 + 定时拉取兜底" |
| 客户端发现 服务端发现 | 客户端发现=调用方自己拿列表、自己挑实例直连;服务端发现=调用方只认一个固定地址(VIP/LB),由它转发 |
| CP / AP | 网络分区时的取舍:CP=宁可拒绝服务也要保证数据一致;AP=宁可数据暂时不一致也要继续可用 |
| 优雅下线 Drain | 下线不是"直接退出":先摘掉新流量 → 等存量请求处理完 → 再关连接退出,中间那段等待叫 drain |
| 命名空间 / 分组 namespace / group | 同一套注册中心内的隔离手段:namespace 分环境(dev/prod),group 分机房或单元——比部署多套更省事 |
微服务总览 → 为什么一个服务会有很多实例(本 deck 要解决的问题的前提)
负载均衡 → 拿到实例列表之后,怎么从里面挑一个
服务间通信与 RPC → 查到的地址最后由谁去连
统一直觉:注册中心就是一份"谁还活着、在哪"的表。服务方负责写(注册 + 续约),调用方负责读(拉全量 + 订阅变更),表里的数据靠租约过期自动删除保持新鲜——所以连宕机都不需要谁来通知。后面所有产品的差别,只在这份表怎么保持一致与变更怎么通知。
这份表是可缓存的数据:调用方把它缓存在自己的进程里,所以注册中心短暂不可用不影响已有调用(只是新实例上线、坏实例摘除会延迟)。理解这一点,后面"为什么发现场景偏 AP""注册中心挂了还能不能调"就都是同一个结论的不同说法。
Patterns
上一页说调用方要"读那份表"——那么问题来了:谁去读?读完之后谁来决定连哪个实例?
Model
Instance {
service: "order" // 服务名
id: "10.1.2.3:8080" // 实例唯一标识
addr: "10.1.2.3:8080" // host:port
weight: 100 // 负载均衡权重
version: "v2.3.1" // 用于灰度路由
metadata: {"region":"gz","az":"gz-1"}
healthy: true // 健康标记
lease: TTL=15s // 生命周期(续约)
}
version/metadata.region/weight 是灰度发布、同城双活、流量调权的挂载点——注册中心不只是"通讯录",还是路由决策的数据源(服务网格 deck 的 DestinationRule 同理)。
面试话术:"注册中心解决三件事——服务在哪(寻址)、谁还活着(健康)、拓扑变了怎么通知(推送);实例记录上的元数据支撑灰度与多活路由。"
Health Check
/healthz(存活)与 /readyz(就绪)分离优雅上下线三件套:上线先 readiness 通过再进 LB(预热:JIT/缓存/连接池就绪);下线先摘流量再退出(deregister → 等存量请求 drain → 关连接);滚动发布依赖就绪探针——这是发布不 5xx 的关键,比"发布后重启看看"靠谱得多。
CAP Trade-off
实例列表是可过期数据(eventually consistent 天然合理):每次调用本身就有超时与重试保护;而 CP 系统在分区期间"拒绝服务"会放大故障——注册中心挂了不能成为全站挂的理由。这就是 Eureka 论文式取舍、Nacos 临时实例用 Distro 协议的原因。
K8s 把拓扑数据放在 etcd(CP),但 endpoint 订阅走的是 AP 的 watch/informer 缓存(每节点 informer 本地缓存 + 失败重连),调用路径不直接依赖 etcd 强一致读。等价于"强一致存储 + 弱一致读取",兼取两家。
Deep Dive · etcd v3.7
Grant(TTL) 发租约,key 挂在租约上;KeepAlive 周期续约;进程崩溃 → 租约过期 → key 自动删除(实例摘除)// 注册(key 挂 lease,随续约存活)
lease := clientv3.NewLease(cli)
gresp, _ := lease.Grant(ctx, 10)
cli.Put(ctx, "services/order/10.1.2.3:8080",
`{"weight":100,"v":"2.3.1"}`,
clientv3.WithLease(gresp.ID))
ka, _ := lease.KeepAlive(ctx, gresp.ID)
for range ka {} // 阻塞续约
// 发现(前缀拉全量 + watch 增量)
resp, _ := cli.Get(ctx, "services/order/",
clientv3.WithPrefix())
wch := cli.Watch(ctx, "services/order/",
clientv3.WithPrefix(),
clientv3.WithRev(resp.Header.Revision+1))
for wresp := range wch { // PUT/DELETE 事件
updateLocalEndpoints(wresp)
}
注意:etcd 每次写都要多数派确认,QPS 有上限——不适合高频心跳(KeepAlive 是 O(实例数) 的低频续约,不是 1s 心跳)。海量临时实例场景选 Nacos/Eureka 更合适。
Deep Dive · Nacos 3.x
1.x 配置变更用长轮询(客户端 30s 挂住,配置 MD5 变化立即返回);2.x 起默认 gRPC 长连接双向推送。细节见配置中心 deck。
Nacos 3.0(2025-04)起内置 MCP Registry(AI 工具服务注册),3.1 增加 A2A 注册——AI Agent/工具服务发现复用同一套注册模型;当前 GA 3.2.x,架构上控制面与数据面分离(console/server 拆分)。
高频追问"Nacos 为什么又 AP 又 CP"——按实例类型选协议:临时实例(微服务进程)生命周期跟客户端走,AP 保证分区可用;持久实例(DB、静态服务)生命周期跟数据走,CP 保证元数据不丢。一套系统按数据语义选一致性,这是 Nacos 最值得背的设计。
Landscape
| 产品 | 一致性 | 健康检查 | 推送 | K-V/特性 | 定位与现状 |
|---|---|---|---|---|---|
| etcd v3.7 | CP(Raft) | lease TTL 续约 | watch(revision) | 强一致 K-V + MVCC + 事务 | K8s 元数据存储事实标准;选主/锁首选;海量高频心跳不适合 |
| Consul | CP(Raft)+ 局部 AP | agent 多种(TCP/HTTP/gRPC/脚本) | blocking query 长轮询 + watch | K-V + 多数据中心 + 服务网格(Connect) | 多 DC 场景强;HashiCorp 许可证变更后社区分化,仍维护 |
| Nacos 3.2.x | 临时 AP(Distro)+ 持久 CP(JRaft) | 心跳 5s / 服务端探测 | gRPC 长连接推送 + 拉兜底 | 注册 + 配置一体,控制台完善 | 国内 Java 系标配;3.x 加 MCP/A2A Registry(AI 时代再定位) |
| ZooKeeper | CP(ZAB) | 会话(session)+ 临时节点 | watcher(一次性,需重设) | 树形 znode | 老牌协调者;watcher 语义弱、JVM 心跳开销,新项目少选 |
| Eureka 2.x | AP(对等复制) | 心跳 30s + 自保护 | 客户端 30s 拉增量 | 纯注册中心 | Netflix 系经典;2.x 停滞,仅存量维护——面试考概念为主 |
| K8s Service | etcd CP 存储 + AP 式读取 | readiness/liveness 探针 | informer watch 缓存 | EndpointSlice + DNS(CoreDNS) | 云原生默认答案:K8s 内运行时基本不再需要外置注册中心 |
K8s 内 → Service/DNS;自建 IDC 多语言 → Nacos(要控制台)或 etcd(要强一致/选主);多数据中心 → Consul;纯面试背诵顺序:etcd(Raft/lease/watch)→ Nacos(双协议)→ Consul(多 DC)。
一分钟内心跳低于阈值(判定可能是注册中心自身网络故障)时,停止摘除过期实例,宁可保留可能已死的列表——AP 极端化的体现:注册中心"怀疑自己"而不是怀疑全部服务。
Kubernetes Native
order.prod.svc.cluster.local 解析到 ClusterIP(A 记录);headless service 直接解析出所有 Pod IP稳定 VIP + 内核转发;四层负载均衡,无应用层路由——配合 Mesh 的 DestinationRule 才有细粒度流量治理
clusterIP: None,DNS 直接返回全部 Pod IP——有状态服务(StatefulSet)与客户端直连场景(gRPC 长连接池)常用
gRPC 基于 HTTP/2 长连接,ClusterIP 的内核 LB 只在建连时分流——长连接会固化到单 Pod。解法:headless + 客户端 LB,或 Mesh/服务端 LB(详见负载均衡 deck)
Graceful Lifecycle
/readyz 才返回 200 → ④ 注册中心/endpoint 收录 → ⑤ 流量进入minReadySeconds + readinessGate 控制放量节奏GracefulStop():停止接新请求,等 in-flight RPC 结束实例列表缓存在每个调用方的进程内,推送延迟 + 客户端刷新周期意味着最长几秒窗口内仍有流量进来。所以下线顺序必须先 deregister(新流量停)+ drain(存量跑完),K8s 里对应 preStop 钩子 + terminationGracePeriodSeconds 的配合。
readiness 通过 ≠ 功能正确:金丝雀放量 + 核心指标(错误率/延迟 P99)自动观测,异常自动回滚——发布安全的最后防线在可观测与自动决策,不在探针(见可观测 deck)。
Go Practice
// Kratos: 注册到 etcd 只需Registrar
import (
"github.com/go-kratos/kratos/contrib/registry/etcd/v2"
)
r := registry.New(cli)
app := kratos.New(
kratos.Registrar(r), // 上线自动注册
kratos.Discovery(r), // 下游自动发现
)
// go-zero: 配置即用
Discovery:
Etcd:
Hosts: ["etcd:2379"]
Key: order.rpc
go-zero/Kratos 都把注册发现做进框架:服务结构体 + Registrar 接口即完成全生命周期;下下游发现自动配合内置负载均衡(P2C/EWMA)。
多环境隔离两板斧:命名空间 namespace(dev/staging/prod 数据隔离)与分组 group / 集群 cluster(同城分区、单元化隔离)。同一套注册中心,用元数据切环境比部署多套更常见;混合云/多机房则推荐每机房一套 + 数据同步(Nacos sync / etcd mirror)。
Cheat Sheet
| Register | 实例上线写入地址,并绑定租约(TTL) |
| Renew | 周期续约(心跳)证明活着;停止续约 → 租约过期 → 自动摘除(宕机也能摘) |
| Deregister | 正常下线主动删除,立即摘流量 |
| Query + Watch | 拉全量列表 + 订阅增量变更(定时拉取兜底) |
| 客户端发现 ★ | 调用方自己查列表、进程内 LB 直连:少一跳、可做 P2C/EWMA;代价是 SDK 与语言绑定 |
| 服务端发现 | 调用方只认 VIP/LB,由它转发:客户端零成本、跨语言;K8s 用 kube-proxy 在节点内核转发规避单点 |
| 结论 | 发现场景偏 AP:拿"稍旧的列表"远好于"拿不到列表",调用层本就有超时重试兜底 |
| CP 代表 | etcd / ZK:Raft 多数派,分区时拒绝服务;适合选主、锁这类强一致元数据 |
| AP 代表 | Eureka / Nacos 临时实例(Distro):分区时继续可读写,接受短暂不一致 |
| K8s 的解法 | etcd 强一致存 + informer 弱一致读——兼取两家,是加分答案 |
| K8s 内 | Service / EndpointSlice / DNS 原生即可,通常不需要外置注册中心 |
| 自建 IDC 多语言 | Nacos(要控制台、注册配置一体)或 etcd(要强一致 / 选主) |
| 多数据中心 | Consul(多 DC 能力最强) |
| etcd 的坑 | 写走多数派确认,不能当高频心跳用(用 lease KeepAlive,别用 Put 当心跳) |
| K8s 之外 | DB、老机房、三方服务不在 K8s 模型内 → 仍需外置注册中心 |
| 上线 | 依赖就绪(连接池/缓存预热)→ /readyz 才返回 200 → 进流量 |
| 下线 | SIGTERM → deregister(停新流量)→ drain(存量跑完) → 关连接退出 |
| drain 时长公式 | ≥ 摘除传播延迟 + 最长请求时长(K8s:preStop + terminationGracePeriodSeconds) |
| 为什么不能只靠摘除 | 列表缓存在每个调用方进程内,推送有秒级窗口 → 靠调用方重试兜底 |
Interview QA · 1/2
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回上一页速查表。
能。实例列表缓存在每个调用方进程内,注册中心短暂不可用时现有拓扑照常调用;影响的是拓扑变化感知(新实例上线、坏实例摘除)。这正是发现系统"数据可缓存"的设计收益——注册中心不挡在调用路径上。
心跳证明进程活着(续约 lease),无法发现"活着但不可用"(DB 挂了/线程池打满);主动探测(readiness)验证服务能力,可纳入依赖健康。生产组合:SDK 心跳保活 + 平台就绪探针 + 调用失败被动摘除,三层兜底。
实例列表是可过期数据:分区时拿"稍旧的列表"绝大多数实例仍可调,调用层有超时重试兜底;CP 系统分区时拒绝读会导致"注册中心故障放大成全站故障"。强一致需求(选主/锁)才值得 CP。K8s 用"etcd 强一致存 + informer 弱一致读"兼得。
etcd watch 按 revision 顺序推送事件、可从任意历史 revision 续看(MVCC 保留)、断线自动重连续传,不丢事件;ZK watcher 是一次性的(触发后需重设),重设间隙的事件要靠重新拉全量补齐,容易踩丢事件坑。
实例列表在各调用方进程内缓存,摘除通知有秒级窗口。标准解法:下线先 deregister + drain(存量跑完再退),调用方对连接拒绝/快速失败做幂等重试到其他实例。两边配合,5xx 才能压到零。
K8s 内服务:不需要——Service/EndpointSlice/DNS 由平台代管(探针验证健康),客户端 watch endpoint 即可。需要外置的场景:K8s 之外的实例(老机房/DB/三方服务)要进同一套发现;或跨集群/混合云统一寻址。常见架构:K8s 内原生 + 边界网关注册进外置中心。
Interview QA · 2/2
同样建议先自答。这一页的题都要"结论 + 一句代价/边界"才完整——只答结论容易被追问。
可以:key 前缀 + lease + watch 即是完整方案。注意三点:① 心跳用 lease KeepAlive(低频续约),不要用 Put 当心跳(写放大打爆 Raft);② 实例多时前缀 watch 事件量大,客户端要做快照+增量的防抖合并;③ 3 节点起步,做好 etcd 本身的监控与备份。
流程:SIGTERM → preStop(sleep 数秒 + readiness 失败,让 LB 摘除)→ deregister → gRPC GracefulStop/HTTP server Shutdown 等存量请求(受 terminationGracePeriodSeconds 限制)→ flush 日志/指标 → 退出。关键数字:drain 时长 ≥ 摘除传播延迟 + 最长请求时长。
① 推送合并:一次发布引发上万事件,按服务聚合增量推送(Nacos Distro 批量同步);② 分片:按服务名哈希分片到多节点(Distro 天然分片),避免单节点全量;③ 快照+增量:新客户端先拉快照再续增量;④ 心跳降频:拉长 TTL 换取服务端容量。
原则:发现优先机房内闭环(同机房实例优先),跨机房只是兜底。手段:实例 metadata 打 region/az 标签,客户端按标签过滤+优先级排序;注册中心每机房独立部署、实例数据按需同步;单元化场景按用户分片把"接入-服务-数据"整链路钉在单元内。Nacos 同城双活/Consul 多 DC 都是这套思路。
① 部署:3/5 节点集群 + 跨可用区打散;② 降级设计:调用依赖本地缓存,注册中心全挂也不影响存量调用——把"注册中心故障"从 P0 降级成 P1(新实例无法上线而已);③ 监控:Raft 延迟、磁盘(etcd 对 fsync 敏感)、lease 数量。
看生命周期跟着谁走:跟客户端进程走(微服务 Pod,发布即消失)→ 临时实例(AP,自动摘除);跟数据走(DB、静态配置的依赖、第三方)→ 持久实例(CP,服务端探测,失败只标记)。Nacos 的双协议就是为这两种语义并存设计的。
Related & References