写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 12 章。前面第 10 章我们把单体拆成了微服务,第 11 章把数据零停机迁到了新库——服务拆好了、数据也准备好了,但有一个问题没解决:这些散落各地的服务,彼此之间怎么找到对方?
这一章你会学到微服务世界里最重要的一张「通讯录」。它会回答三个问题:服务实例的地址总在变,怎么让调用方永远拿到最新地址?注册中心挂了服务还能不能互相调用?为什么面试必问 CAP 理论?
这一版做了三件事:
- 每一步都讲透再动手。CAP 理论、心跳、Lease 租约、Resolver 这些你大概率没听过,本章全部就地补「补课胶囊」,不让你去搜。
- 用类比先让你看见。电话簿、查号台、外卖骑手、租房合同——先用生活场景建立直觉,再上代码和图。
- 结尾有 12 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。
如果你想要「高密度速查版」,可以看原版第 12 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
上一章我们刚把 user 表零停机迁到了新库,微服务的「数据」准备就绪。但第 10 章留了一个尾巴:post 服务调 user 服务时,用的是写死在配置文件里的 127.0.0.1:9090。
这在单体时代没问题——只有一个进程、一个固定地址。但微服务时代,user 服务可能同时跑 5 个实例,IP 和端口是容器随机分配的,今天在 10.0.1.5、明天重启变成 10.0.1.9;为了扛住流量还会随时扩缩容(从 3 个实例加到 8 个,再缩回 5 个)。把地址写死,等于每次变更都要改代码、重新发版——这显然不可能。
这一章要解决的就是这个问题:服务之间怎么动态地找到对方?我们会建立微服务的「通讯录」——服务注册与发现。你会学到:
- 为什么「硬编码 IP」在微服务里行不通,几种替代方案(DNS、注册中心、服务自省)各有什么取舍;
- 注册中心的核心机制:启动注册、心跳续租、节点变更通知、客户端发现时机;
- CAP 理论(面试必考题,本章重点讲透并配图);
- 用 Go + etcd 在 gRPC 中真正接入一套可用的服务发现;
- K8s 环境里为什么常常不需要注册中心,直接用 DNS 就够了;
- 第八节「故障容错」是面试高频:注册中心挂了,服务还能不能互相调用?
学完这一章,你的微服务之间就具备了「互相找到对方」的能力。下一章(第 13 章)我们解决「找到之后把请求发给谁」——负载均衡。
开读前先看一眼全局:微服务拆开后第一个问题就是"我调用的那个服务到底在哪"。本章用 etcd 解决它,分成两半:服务方上线时注册(自报家门 + 租约心跳),调用方发现(watch 实例变化,本地缓存列表)。下面两张小图合起来就是全章内容。
flowchart LR
subgraph REG["注册:服务上线自报家门"]
A["user-service 启动"] --> B["向 etcd 写入
/services/user/实例地址
并绑定租约"]
B --> C["定期心跳续租"]
C -.->|"宕机:租约到期自动删除"| D["坏实例被摘除"]
end
subgraph DISC["发现:调用方动态感知"]
E["webook 要调 user-service"] --> F["watch /services/user/*"]
F --> G["本地缓存实例列表
实例增减实时更新"]
end这张图怎么读:上下两个子图各走一条线。上面「注册」线:user-service 启动后向 etcd 写入 /services/user/实例地址 并绑定租约,之后定期心跳续租证明自己活着;虚线是失败路径——实例宕机后没人续租,租约到期 etcd 自动删除,坏实例被摘除。下面「发现」线:webook 调 user-service 前先 watch /services/user/*,把可用实例缓存到本地,实例增减时由 watch 实时刷新这张列表。两半合起来,调用方永远拿着一份最新的健康名单。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用「电话簿 / 查号台」类比,向一个完全不懂的人讲清楚服务注册与发现解决了什么问题。
- 画出服务发现的三要素:注册中心、服务端(提供者)、客户端(调用者),并说清它们之间注册、心跳、查询、通知的关系。
- 讲清 CAP 三者的含义,并能在白板上画出「三选二」的取舍图,解释为什么分布式系统通常必选 P。
- 用 Go + etcd 写出「服务端注册 + Lease 续约 + 客户端 Resolver」的最小可用代码,并解释每一行为什么这么写。
- 面对「注册中心崩溃」的故障场景,说清客户端和服务端各自应该怎么办(本地缓存降级 + 老节点继续服务)。
理解(能讲清原理和取舍,不要求背代码)
- 四种服务发现模型的取舍:IP 直连、DNS、注册中心、服务自省(含网关这一特殊形态)。
- 心跳机制的两种模式、心跳间隔与失败判定次数的权衡、优雅下线的步骤。
- 注册中心为什么面试标准答案选 AP 而不是 CP,以及 etcd 为什么是「AC 模型」。
- gRPC 的 Resolver 抽象层为什么存在、它和负载均衡(Balancer)怎么分工。
- etcd 的 Lease 租约机制:为什么宕机没主动删除,key 也能自动过期下线。
了解(知道有这回事,面试能说出名字和一句话定位)
- ZooKeeper / Eureka / Nacos 作为注册中心的差异(CP / AP / 可切换),以及各自适用规模。
- K8s 里 Service + DNS 的服务发现形态,以及它和注册中心方案的本质区别。
- go-zero / Kratos / ego 等 Go 微服务框架如何统一接入服务发现(
Registry/Discovery接口层)。
前置依赖与补课清单
本章用到了前文的技术,下面这些前文已讲过,你可以直接衔接;凡前文没讲过的,本章正文会就地补「补课胶囊」。
| 需要你已掌握 | 在哪章学过 | 本章还会补 | 补在哪一节 |
|---|---|---|---|
| 微服务拆分、端口动态变化 | 第 10 章 | ❌ 不补 | — |
gRPC 基本使用(grpc.Dial、客户端连服务端) | 第 10 章 | ❌ 不补 | — |
| 基础网络概念(客户端 / 服务端、心跳、租约直觉) | 第 07 章 Kafka | ❌ 不补 | — |
| etcd 是什么、怎么起一个单节点 | 未讲过 | ✅ 补 | 3.6 节 补课胶囊 |
| CAP 理论 | 未讲过 | ✅ 补 | 3.4 节 补课胶囊 |
| gRPC Resolver 接口 | 未讲过 | ✅ 补 | 3.6 节 补课胶囊 |
| K8s 的 Service / DNS | 未讲过 | ✅ 补 | 3.7 节 补课胶囊 |
不需要你提前懂的:分布式共识算法 Raft 的内部实现、etcd 集群部署细节——本章只用到 etcd 的「存 key / 监听 key」能力,把它当成一个带过期时间的 KV 数据库来理解即可。
预计时长与学习成果
预计时长:3 小时(精读 + 跟着敲 etcd 注册 / 发现代码);如果只求主干,1.5 小时可过一遍概念。
学完你能跑出来的东西(这是本章的验收标准,做到才算过关):
一个 etcd 单节点 + 一个 user 服务端 + 一个 post 客户端:服务端启动后自动注册到 etcd 并持续心跳;客户端用
etcd:///user-service这个地址连上它并成功调用一次接口;你kill -9服务端后,约 10 秒 etcd 自动把它剔除,客户端不再往死节点发请求。
flowchart LR
A["3.1 为什么需要"] --> B["3.2 服务发现模型对比"]
B --> C["3.3 注册中心机制
注册/心跳/下线/发现时机"]
C --> D["3.4 CAP 理论"]
D --> E["3.5 注册中心对比选型"]
E --> F["3.6 gRPC 接入 etcd
Resolver + Lease"]
F --> G["3.7 K8s 用 DNS"]
G --> H["3.8 选用关心什么
3.9 其他框架"]
H --> I["3.10 高可用与容错"]
I --> J["第四节 跟着写"]
J --> K["第七节 面试问答"]这张图怎么读:从「3.1 为什么需要」到「3.10 高可用与容错」是一条线性主线:先看痛点与模型对比(3.13.2),再拆注册中心机制(3.3),然后是理论重头戏 CAP 与选型(3.43.5),接着落到 gRPC + etcd 的动手实现(3.6)和 K8s 的 DNS 替代方案(3.7),最后收在面试高频的故障容错(3.10);之后转入「第四节 跟着写」动手、以「第七节 面试问答」收口。每条边就是「读完上一节再读下一节」,没有分支。
三、正文
3.1 为什么需要服务注册与发现
先抛问题
假设你是 post 服务,现在要调 user 服务查一篇帖子的作者信息。在单体时代,你只需要写死一行:
// 单体时代:就一个进程,地址永远不变
const userAddr = "127.0.0.1:9090"
进入微服务之后,这一行立刻崩塌,原因有三个:
- 实例会变:容器重启、节点宕机,IP 会漂移(今天
10.0.1.5,明天10.0.1.9)。 - 会扩缩容:大促时 user 服务从 3 个实例加到 8 个,活动结束又缩回 3 个。新实例的 IP 你根本不知道。
- 多实例要分摊:你总不能每次都打给同一个
9090,得把请求分发给所有健康实例。
如果还把地址写死,每次变更都要改配置、重新发版——运维会疯,而且根本来不及。
给个直觉:114 查号台
补课胶囊 · 服务注册与发现 一句话:让调用方不用记死地址,而是「先问一个总机,拿到当前可用地址列表」。 类比:微服务之前,你打电话给朋友是直接拨他手机号(IP 直连)。微服务之后,朋友今天用 A 号、明天换 B 号、出差又用 C 号——你根本记不住。于是你们约定:所有人的当前号码都报到**「总机 / 查号台」(注册中心),你打之前先问总机「他现在用哪个号」,总机还会主动告诉你「他换号了」**。这就是服务注册与发现。 回到主线:下面我们就把「总机」落成具体的组件——注册中心,并拆解它和两端的协作流程。
一张图看清三方关系
flowchart TD
C[客户端 post 服务] -->|2 查询可用实例| G[注册中心 etcd]
G -->|3 返回实例列表| C
S1[服务端 user 实例1] -->|1 注册 + 心跳| G
S2[服务端 user 实例2] -->|1 注册 + 心跳| G
S3[服务端 user 实例3] -->|1 注册 + 心跳| G
C -. 4 调用 .-> S1
C -. 4 调用 .-> S2
C -. 4 调用 .-> S3流程只有四步,但每一句都有讲究:
- 注册:user 服务每个实例启动时,把自己的「IP:端口」报到注册中心。
- 心跳:之后持续向注册中心发心跳,证明「我还活着」。
- 查询:post 服务(客户端)调用前,先问注册中心「user 现在有哪些实例可用」。
- 调用:post 拿着最新列表去打 user 实例;一旦某个实例挂了,注册中心通知 post 把它从列表里划掉。
一个具体例子:从写死到解耦
光说概念太虚,看两行代码对比就懂了:
// 写死时代:post 服务里散落着这样的常量,user 一重启全废
const userAddr = "10.0.1.5:9090" // 明天这个 IP 可能就变了,调用全断
// 接入注册中心后:只认「服务名」,地址由注册中心实时给
conn, _ := grpc.Dial("etcd:///user-service") // 永远连得到当前健康的实例
这一行从 IP:端口 变成 服务名 的转变,就是服务注册与发现给你的核心收益:调用方再也不关心对方具体在哪。user 实例怎么扩、怎么缩、怎么换机器,post 一行代码都不用改。
常见坑:把注册中心当成「银弹」
小收口
这一节回答了「为什么需要服务注册与发现」——因为微服务实例的地址是动态变化的,写死不可行。下一节我们看「除了注册中心,还有哪些别的办法」,以及它们各自的代价。
3.2 几种服务发现模型对比
先给一张总表,建立全局视角,再逐个展开。
| 模型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| IP 直连 | 最简单,无额外组件 | IP 会变,完全没法维护 | 联调、DEBUG 特定实例 |
| 域名 + DNS | 简单,不需要额外组件 | 不缓存则每次多一跳;缓存则变更不及时 | 中小规模、K8s 内部 |
| 注册中心 | 主动通知节点变化,实时性最好 | 大规模下注册中心可能成瓶颈 | 主流方案 |
| 服务自省 | 注册中心只存最小数据,元数据走元数据服务 | 实现复杂 | 超大规模集群(阿里 Dubbo 提出) |
| 借助网关 | 客户端只需知道网关 | 网关本身也要被发现 | 网关作为统一入口 |
模型一:IP 直连(原始但有用)
虽然最原始,但工程里偶尔真会用:
- 联调:和同事合作开发,直接调同事电脑的 IP,方便对方本地 DEBUG;
- DEBUG 特定实例:某些 bug 只在某一个实例上出现,直接把请求钉到那个实例上复现。
它的问题恰恰也是它的「定位」——只能用于特殊场景,没法当通用方案。
模型二:域名 + DNS
把 127.0.0.1:9090 换成 user.mycompany.com:9090,让 DNS 把域名解析成 IP。
- 优点:简单,不需要引入任何新组件,运维成本最低。
- 缺点来自一个两难:
- 不缓存 DNS:每次调用前都去查一次 DNS,多一跳网络开销,延迟变高;
- 缓存 DNS:本地缓存了 IP,节点变了客户端感知不到,还会继续打旧地址。
DNS 本质上是「拉(pull)」模型——客户端主动去查,查的时机和频率都不受服务端控制。这就是它实时性差的病根。
模型三:注册中心(本章主角)
注册中心的核心思路,正好补齐 DNS 的短板:
- 服务端启动时主动在注册中心注册(而不是等被查);
- 客户端首次调用前查询注册中心,缓存可用节点;
- 注册中心在节点变动时主动通知客户端(「推(push)」模型)。
「主动通知」是它相对 DNS 的最大优势,也是它成为主流方案的原因。
模型四:服务自省(Dubbo 提出,了解即可)
注册中心在超大规模集群下会成为瓶颈——几万个实例同时心跳、同时变更通知,单台注册中心扛不住。
服务自省的思路是给注册中心「减负」:
- 注册中心只存最小化数据(比如「服务名 → 实例定位信息」这种最基本的映射);
- 其余元数据(权重、版本、机房等治理信息)通过单独的「元数据服务」暴露;
- 元数据一般不变,客户端只需查询一次,不用反复打扰注册中心。
这套方案实现复杂,本章不展开,知道有这回事、面试能说出一句话定位即可。
模型五:借助网关(两次发现)
严格说这不是服务发现的一种,但你一定会遇到:请求先进微服务网关(gateway),再由网关转发给真实服务端。
这里其实藏着两次「找地址」:
- 客户端找网关:一般通过域名(如
gateway.mycompany.com),或从配置项拿到所有网关节点地址; - 网关找服务端:网关要把请求转发到真实服务端,同样需要知道服务端地址——这又是一个服务发现过程,同样可以借助注册中心或 DNS。
所以网关并没有消灭服务发现,只是把它从「每个客户端各自做」变成了「网关一处集中做」。
3.2.6 客户端发现 vs 服务端发现(补充视角)
上面五种模型还可以从「谁去查注册中心」这个角度再切一刀,得到两个大类:
- 客户端发现(Client-Side Discovery):客户端自己负责查询注册中心、拿到实例列表、并决定打哪一个。本章讲的 etcd + gRPC Resolver 就是典型——
post服务自己通过 Resolver 拿到user的地址列表。优点:注册中心压力小(只存数据),客户端灵活;缺点:每个语言 / 框架都要实现一套发现逻辑。 - 服务端发现(Server-Side Discovery):客户端只管打一个固定地址(通常是负载均衡器或网关),由中间层去查注册中心、挑实例。优点:客户端零逻辑;缺点:多一跳、中间层要自己保证高可用。
一句话记:客户端发现是「自己查自己挑」,服务端发现是「甩给中间层查和挑」。本章选客户端发现(gRPC Resolver 即此),因为它和 gRPC 集成最自然、和后续负载均衡的代码也最好衔接。
小收口
五种模型各有取舍。IP 直连只用于联调,DNS 简单但实时性差,注册中心靠「主动通知」成为主流,服务自省给超大规模减负,网关则是把发现集中到一处。从「谁查注册中心」看又有客户端发现与服务端发现之分。本章后续围绕注册中心 + 客户端发现这一种展开——因为它最能讲清机制,也是面试最爱问的。
3.3 注册中心机制详解
这一节把注册中心内部的「生命周期」拆开看:一个实例从出生到死亡,注册中心都参与了什么。
3.3.1 启动注册:连不上就「宁可起不来」
服务端启动时做的事:
- 主动注册:把自己(IP + 端口 + 治理信息)写入注册中心;
- 怎么知道注册中心在哪:一般通过「域名 + 端口」(如
etcd.mycompany.com:2379)或「直接指定 IP + 端口」配置; - 连不上注册中心,服务端直接退出(fail-fast):启动阶段连不上,就别启动了。
补课胶囊 · fail-fast(快速失败) 一句话:启动前置校验不过,立刻报错退出,而不是带着错误继续跑。 类比:就像登机前安检,身份证不对当场拦下,而不是让你上了飞机再发现。 回到主线:注册中心连不上就 fail-fast,是为了避免「注册了脏数据(比如写错了 IP)让客户端调不通」。
3.3.2 注册什么数据:两类信息
注册不是只写一个 IP 那么简单,分成两类:
- 定位信息(最关键):IP + 端口。客户端要的就是这个,缺了就打不通。
- 其他信息(治理用):分组、权重、协议版本、机房等。这些信息不直接用于「连」,而是用于后续的服务治理(比如第 13 章负载均衡要用权重)。
3.3.3 心跳机制:用「租约」证明自己还活着
注册成功后,注册中心怎么知道这个实例没挂?靠心跳(heartbeat)——相当于你租了房,每隔一段时间给房东发个消息「我还在、没跑路」。
补课胶囊 · 心跳 / 租约 一句话:服务端定时给注册中心发信号,证明自己活着;不发的就判定死亡。 类比:租房合同有期限,你每年续一次租(心跳 = 续租),不续房东就当房子空了租给别人。 最小示例:
// 伪代码:每 5 秒发一次心跳
ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
registry.Heartbeat(instanceID) // 告诉注册中心:我还活着
}
回到主线:心跳失败 → 注册中心判定服务端崩溃 → 通知客户端别再用这个节点。
心跳有两种模式:
| 模式 | 说明 |
|---|---|
| 服务端主动 | 服务端每隔一段时间朝注册中心发心跳,注册中心可应答可不应答 |
| 注册中心主动 | 注册中心朝所有注册的服务端发心跳 |
实践中更常见服务端主动——谁的生命状态谁自己最清楚。心跳失败时,注册中心判定服务端崩溃,通知客户端不要使用该节点。
一个具体例子:假设心跳间隔 5 秒、连续失败 3 次才判定死亡。
| 时间点 | 事件 | 注册中心判定 |
|---|---|---|
| 0s | 实例正常,每 5s 发一次心跳 | 存活 |
| 5s | 第 1 次心跳没收到 | 存活(未到 3 次) |
| 10s | 第 2 次心跳没收到 | 存活(未到 3 次) |
| 15s | 第 3 次心跳也没收到 | 判定死亡,通知客户端摘除此节点 |
注意:前 10 秒的偶发抖动被「连续 3 次」这个阈值挡住了——正好 3 次失败(15s)才摘除,这正是 3.3.4 权衡里「次数多能避免偶发失败」的体现。
3.3.4 心跳参数权衡:快了累、慢了晚
心跳的「频率」和「判定死亡的次数」是两个核心旋钮,它们互相拉扯:
| 参数 | 调短 / 调少 | 调长 / 调多 |
|---|---|---|
| 心跳间隔 | 压力小?不——间隔越短,网络压力越大 | 间隔越长,压力越小,但发现崩溃越晚 |
| 失败判定次数 | 次数少 → 快发现崩溃,但难应对偶发失败(一次抖动画就删) | 次数多 → 慢发现崩溃,但能避免偶发性失败 |
核心权衡记三句话:
- 间隔越短,压力越大;
- 次数越少,越快发现崩溃,但难应对偶发性心跳失败;
- 次数越多,越慢发现崩溃,但能避免偶发性失败。
工程上常见取值:心跳间隔 5~10 秒,连续失败 3 次才判定死亡。这两个数的乘积,大致等于「从崩溃到被发现」的延迟上界。
3.3.5 高级心跳(少见,了解)
- 双向心跳:服务端和注册中心都主动发心跳(双保险,但成本翻倍)。
- 单向失败后切换:平时服务端主动发,注册中心一段时间没收到后主动发起探测。
性价比不高,比较少采用,知道有这回事即可。
3.3.6 优雅下线:别撂挑子就跑
服务端 A 不能在告诉注册中心「我下线了」之后就立刻 exit——因为:
- 它可能还有正在处理的请求(比如一个写数据库的事务跑到一半);
- 网络上可能还有客户端正在发来的请求(包已经在路上了)。
优雅下线步骤:
- 通知注册中心:本节点要下线;
- 服务端不再接受新请求(包括已经读到一半的请求,读完直接拒绝);
- 等待正在处理的请求结束(包括定时任务);
- 所有请求处理完毕后退出;
- 设置超时时间,超时强制退出(防止某个超长任务卡住,永远下不了线)。
3.3.7 客户端的服务发现时机
客户端「什么时候」去注册中心拉列表,也有两种选择:
| 时机 | 行为 |
|---|---|
| 启动时发现 | 启动时就把所有要用的微服务拉到手,初始化阶段执行发现。发现失败则客户端不启动 |
| 首次调用时发现 | 启动时不发现,第一次调用某服务时才执行发现(懒加载) |
此外,客户端也要和注册中心保持心跳——确保注册中心在节点变动时能通知到自己(否则注册中心有变更也没法推给你)。
两种时机怎么选:如果一个服务依赖很多下游(比如 post 依赖 user、interactive、feed 等七八个),「启动时发现」能让它在启动阶段就一次性拉全所有依赖的列表,运行时不依赖发现;但代价是启动变重,任一依赖发现失败就起不来。如果依赖少、或想让启动轻量,用「首次调用时发现」更合适——只有真正第一次打某个服务时才去查。工程上常见折中:核心依赖启动时发现,非核心依赖首次调用时发现。
一张时序图收口
sequenceDiagram
participant S as 服务端 user
participant R as 注册中心 etcd
participant C as 客户端 post
S->>R: 启动注册(IP:Port)
S->>R: 心跳续租(持续)
C->>R: 查询 user 实例列表
R-->>C: 返回[实例1, 实例2]
S->>R: 下线/心跳断开
R-->>C: 主动通知: 实例2 没了
C->>S: 只调实例1(failover 换节点)小收口
注册中心的机制可以浓缩成一句话:服务端注册 → 心跳续租 → 节点变更主动通知客户端 → 客户端 failover 换节点。这套机制就是后面所有代码的主线。下一节我们升级视角,从「单个注册中心」跳到「分布式系统」的高度,引出面试必考题 CAP 理论。
3.4 CAP 理论(面试必考,本节讲透)
这一节是本章的面试重头戏。请不要跳读,把图看明白。
先抛问题
如果注册中心本身也是一个分布式系统(它通常部署成 3 节点集群),那它自己也可能出故障:比如机房之间网络断了,A 机房和 B 机房的注册中心节点联系不上彼此。
这时候问题来了:注册中心应该优先保证数据一致(所有节点看到的服务列表一模一样),还是优先保证可用(哪怕数据暂时不一致,也照常对外服务)?
这个问题没有「都想要」的选项——这正是 CAP 理论要说的。
补课胶囊:CAP 三个字母
一句话:分布式系统最多同时满足一致性、可用性、分区容错性中的两个,不可能三个都要。
三个词分别是什么:
- C 一致性 Consistency:数据在多个副本之间保持一致,任意节点读到的数据都一样。
- A 可用性 Availability:系统一直可用,每个请求都能在有限时间内返回结果。
- P 分区容错性 Partition Tolerance:出现网络分区(网络故障导致节点间通信中断)时,系统仍能正常工作。
类比:你和室友共养一盆花,约定「谁浇水谁记在本子上」。某天你们吵架不说话了(网络分区):
- 要一致性(C):本子必须两边一模一样,那吵架期间谁都别浇水(牺牲可用性 A);
- 要可用性(A):各自继续浇水,但本子可能对不上(牺牲一致性 C)。 只要「吵架」这种事客观存在,你就必须接受 P。
回到主线:下面解释「为什么分布式系统通常必选 P」。
graph TD
CAP[分布式系统三选二] --> C[一致性 C]
CAP --> A[可用性 A]
CAP --> P[分区容错 P]
C -. 与 .- A
A -. 与 .- P
C -. 与 .- P
note[网络分区客观存在
通常必选 P
剩下 C 与 A 二选一]为什么大多数时候选 P
在分布式环境下,网络分区是客观存在的——光缆被挖断、机房断电、交换机故障,你无法阻止。所以 P 大多数时候是「必须选」的(不选 P 意味着假设网络永不故障,这不现实)。
一旦网络分区发生,C 和 A 只能二选一:
- 放弃 C(选 AP):A、B 两个分区各自对外服务,数据暂时不一致,但系统还能用;
- 放弃 A(选 CP):A、B 不能互相同步数据,干脆整体拒绝服务,保证数据一致。
例外提醒:etcd 是 CP 模型——它用 Raft 保证强一致,网络分区时只有**多数派(quorum)**这一侧能继续服务,少数派一侧不可用(宁可停机也不给不一致的数据)。注意这里不可用的是「少数派」,不是「集群整体」;真正的 CA 系统无法容忍任何分区,现实中不存在。这是它和「选 AP」的注册中心最大的区别,面试常被追问,记住即可。
实践心法:CAP 的取舍一般是在设计中间件时就要第一时间确认清楚的;而作为中间件的使用者(比如你只是用 etcd 做注册中心),你只需要想清楚自己的业务是侧重一致性还是侧重可用性,就能做出决策。
注册中心应该选 CP 还是 AP
面试标准答案:选 AP。
理由:在服务发现场景下,「拿到错误的数据也好过拿不到数据」。哪怕注册信息略有偏差(比如某个刚下线的实例还在列表里),客户端仍可以尝试调用、或者 failover 到别的节点;如果注册中心为了强一致直接「不可用」,那所有服务都互相找不到了,远比「数据稍旧」要命。
实践看法:
- 小规模集群:随便选,负载小、流量小,注册中心很难崩溃,CP/AP 差别不大;
- 大规模集群:优先 AP,因为节点变更频繁、对可用性要求更高。
一个具体场景帮你理解 CP vs AP 的代价:假设注册中心某一刻收到「user 实例 A 下线」的通知,但这个通知还没同步到所有副本,出现了分歧:
| 选 CP(如 ZooKeeper) | 选 AP(如 Eureka) |
|---|---|
| 宁可让注册中心在选举 / 同步期间短暂不可用,也要保证所有客户端看到的列表绝对一致 | 允许客户端短时间内还看到已下线的 A,但系统照常可用,客户端调 A 失败就 failover 到 B |
| 后果:所有服务发现暂时卡住 | 后果:个别请求失败,但整体能跑 |
服务发现场景下,显然「个别请求失败可重试」远比「整个发现系统停摆」要好——这就是选 AP 的直觉来源。也正因为如此,etcd 虽然是 CP(强一致、分区时少数派不可用),在「仅做服务发现」时仍被广泛接受:它的强一致换来的是「数据绝对准」,而 gRPC 客户端的本地缓存 + failover 又补上了「短暂不可用」的短板。
小收口
CAP 理论的核心结论记住三句话就行:① 三选二;② 网络分区客观存在,通常必选 P;③ 注册中心场景选 AP 是标准答案,但 etcd 是 CP 模型的特例。下一节我们用这个结论去对比四个主流注册中心,并给出选型建议。
3.5 主流注册中心对比与选型结论
这一节把「理论」落到「选型」——四个你面试一定会听到的名字:ZooKeeper、Eureka、Nacos、etcd。
ZooKeeper(CP 模型)
- 架构:主从结构(一主多从),树形数据存储(如
/app1/p_1表示服务 app1 的一个节点); - 机制:客户端用 Watcher 监听节点变化——节点变了 ZooKeeper 推一个一次性通知,客户端收到后再主动重新读取数据并重新注册 Watch(「通知 + 拉取」混合,不是纯 Push);
- 优点:API 简单、成熟度高、多语言客户端支持好、实时通知;
- 缺点:
- 无法支撑超大规模集群(TCP 连接数有限制);
- 主节点是写瓶颈;
- 主从模式可能脑裂(见下);
- 主节点选举期间,集群整体不可用;
- 本质:CP 模型,中小规模没问题。
脑裂是怎么发生的:老的主节点因为网络问题和从节点失去连接 → 从节点等不到心跳,选举出一个新主 → 网络恢复后,集群里同时存在两个「主」。两个主都可能接受写入,数据就乱了。ZooKeeper 用 quorum(多数派) 机制避免双主同时写入——代价是主节点选举期间整个集群不可用。
Eureka(AP 模型)
- 架构:对等集群,所有节点地位平等,相互同步数据;
- 优点:可用性高、支持横向扩展、无单点写瓶颈;
- 缺点:服务发现慢(数据要扩散到全集群才一致)、多语言支持差(Java 生态为主);
- 本质:AP 模型,契合「注册中心选 AP」的标准答案。
Nacos(同时支持 CP 和 AP)
- 优点:高可用(AP 模式 + 熔断降级)、中文社区无语言壁垒、功能丰富(namespace、Group 区分环境);
- 缺点:部署运维难度高、CP/AP 集成在一起源码复杂、多语言支持较差;
- 本质:可切换——你能按业务需要让它临时切到 CP 或 AP。
etcd(CP 模型)
- 优点:Go 编写、部署简单、提供 HTTP 接口、用 Raft 算法保证强一致性、大规模集群表现优秀;
- 缺点:网络稳定性差时不合适(网络分区时少数派不可用);
- 本质:Go 生态首选,本课程就用它。
一张对比表收口
| 注册中心 | CAP 倾向 | 架构 | 优点 | 缺点 | 适用规模 |
|---|---|---|---|---|---|
| ZooKeeper | CP | 主从 | 成熟、多语言、实时通知 | 脑裂、主写瓶颈、选举期不可用 | 中小 |
| Eureka | AP | 对等 | 高可用、横向扩展 | 发现慢、多语言差 | 中 |
| Nacos | CP/AP 可切 | 混合 | 中文社区、功能丰富 | 运维难、源码复杂 | 中/大 |
| etcd | AC | 共识集群 | 轻量、Go 友好、强一致 | 网络差时不可用 | 大(Go 生态首选) |
选型结论
选型实战:三个真实场景怎么选
- 场景一:3 人小团队,单体刚拆出 3 个服务。别纠结,选你们最熟的(甚至直接 K8s DNS 都行)。集群小、流量小,任何方案都不会崩,重点是别在选型上浪费时间。
- 场景二:Go 技术栈,要支撑 50+ 微服务的电商。优先 etcd——Go 原生、部署简单、Raft 强一致、大规模表现好;和你们 gRPC 体系无缝衔接(本章就是 etcd)。
- 场景三:Java 老系统,已有 Dubbo / Spring Cloud 体系。选 Nacos 或 Eureka 更顺——中文社区、和 Java 生态绑定深;Nacos 还能在 CP/AP 间切换,治理功能丰富。
记住一句话:没有「最好」的注册中心,只有「最适合你团队」的。
理论上的选型标准:
- 通用标准:成熟度高、社区完善;
- 扩展性:能跟着业务增长扩展;
- 成本:部署集群所需节点数;
- 集群模式:对等集群优于主从集群(无单点写瓶颈);
- CAP:优先 AP。
现实心法:写文档罗列优缺点、给个建议,最后往往老板拍板。这些成熟中间件选哪个都不会出大问题,也都有可能出小问题——选你团队最熟的那个,比选理论上最优的那个更重要。
3.6 在 gRPC 中接入注册中心
前面铺垫了这么多机制,这一节终于动手:怎么让 gRPC 客户端通过 etcd 找到服务端。
3.6.1 gRPC 默认的服务发现方案:DNS
如果你什么都不做,gRPC 默认用的是域名解析策略:
- 传入
user.mycompany.com:8090,gRPC 会定期访问域名服务器,更新本地缓存的可用 IP; - 如果你直接传 IP 地址(如
10.0.1.5:8090),连域名解析都省了; - 在 K8s 中推荐使用这种形态(用 Service 名通信,见 3.7 节)。
gRPC 内部对应这个默认实现的叫 dnsResolver。但对于「需要主动通知 + 多实例动态上下线」的场景,DNS 不够用——于是我们要接入注册中心。
3.6.2 Resolver 接口:gRPC 的「查通讯录」抽象层
补课胶囊 · gRPC Resolver 一句话:Resolver 是 gRPC 留给我们的「服务发现接口」,你只要实现一个能返回「地址列表」的 Resolver,gRPC 就会在调用前用它来解析目标地址。 类比:gRPC 是个快递柜,它只认「给我一个能查地址的插件」,至于这个插件是查 DNS、查 etcd 还是查你自家的数据库,gRPC 根本不在乎。这就是「依赖抽象、不依赖具体实现」。 最小示例(gRPC Resolver 的核心接口,简化版):
// gRPC 的 Resolver 接口(简化版,理解用)
type Resolver interface {
ResolveNow(ResolveNowOptions) // 立即重新解析
Close() // 关闭
}
// Builder 负责构造 Resolver
type Builder interface {
Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
Scheme() string // 返回协议前缀,如 "etcd"
}
回到主线:在 gRPC 中接入 etcd,本质就是「提供一个用 etcd 实现的 Resolver」。
关键认知:gRPC 根本不知道你用的是不是注册中心,它只认客户端这边的 Resolver 接口。它的工作流程(以 etcd 为例):
- 客户端
grpc.Dial("etcd:///service/interactive/")时,gRPC 从目标地址里取出etcd这个 scheme(协议前缀); - 在已注册的 Builder 里找到
Scheme()返回"etcd"的那一个; - 调用
Builder.Build创建 Resolver;Resolver 从 etcd 查到 endpoint 列表后,通过ClientConn把结果回报给 gRPC; - 之后 etcd 上的节点有增删,Resolver 收到 watch 通知,再次把最新列表回报——gRPC 内部的负载均衡组件就能拿到最新的可用连接列表。
一句话分工:Resolver 负责「提供名单」,Balancer / Picker 负责「从名单里挑一个」——两者配合,才完成一次调用目标的选定。(「挑一个」就是下一章负载均衡的内容。)
用一张图看清 Resolver 在 gRPC 调用链路里的位置:
flowchart LR
A["grpc.Dial(etcd:///user-service)"] --> B["Resolver.Build
查 etcd 全量列表"]
B --> C["ClientConn.UpdateState
回报地址列表"]
C --> D["Balancer/Picker
从名单里挑一个"]
D --> E["建立连接
发起 RPC 调用"]
F["etcd 节点变更"] -->|Watch 通知| B注意箭头 F:etcd 上任何实例上下线,都会经 Watch 通知到 Resolver,Resolver 重新 UpdateState,Balancer 立刻拿到新名单——全程你的业务代码一行都不用改。这就是为什么 gRPC 要把「发现」抽象成 Resolver:换注册中心,只是换一个 Builder 实现,调用方完全无感。
3.6.3 etcd 服务端注册示例
下面是一段完整的服务端注册代码,注意它如何把前面讲的心跳(Lease 续约)真正落地。
补课胶囊 · etcd 的 Lease 租约 一句话:etcd 的 key 可以绑定一个「带 TTL 的租约」,租约到期 key 自动消失;只要持续续约,key 就一直活着。 类比:租房合同签 30 天,你每 10 天续一次;哪天你跑了不续了,30 天后房子自动收回(key 自动删除)。 回到主线:这正是「宕机没主动删除也能自动下线」的实现原理——进程
kill -9后没人续约,TTL 一到 etcd 自己把 key 删了。
// webook/internal/interactive/ioc/registry.go
package ioc
import (
"context"
"fmt"
"log"
"net"
"time"
"go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/naming/endpoints"
)
// InitRegistry 在 etcd 中注册本服务实例,并开启 Lease 自动续约
func InitRegistry(etcdClient *clientv3.Client) {
// 步骤 1:创建 endpoint.Manager,负责增删 endpoint
em, err := endpoints.NewManager(etcdClient, "service/interactive/")
if err != nil {
panic(err)
}
// 步骤 2:创建租约(关键!TTL=30s,用于宕机自动剔除)
leaseResp, err := etcdClient.Grant(context.Background(), 30)
if err != nil {
panic(err)
}
leaseID := leaseResp.ID
// 步骤 3:开启自动续约,gRPC 默认每 TTL/3 续约一次
kaCtx, _ := context.WithCancel(context.Background())
ch, err := etcdClient.KeepAlive(kaCtx, leaseID)
if err != nil {
panic(err)
}
go func() {
for resp := range ch {
log.Println("续约成功", resp.ID)
}
}()
// 步骤 4:获取本机对外 IP(不能用 127.0.0.1!)
ip := GetOutboundIP()
addr := fmt.Sprintf("%s:8091", ip.String())
// 步骤 5:注册 endpoint,绑定 leaseID
err = em.AddEndpoint(context.Background(),
"service/interactive/"+addr,
endpoints.Endpoint{
Addr: addr,
Metadata: map[string]interface{}{"version": "v1"},
},
clientv3.WithLease(leaseID),
)
if err != nil {
panic(err)
}
}
GetOutboundIP 通过对外发一个 UDP 报文来确定本机出口 IP(UDP 不会真正建立连接,只是让内核决定路由):
// 通过对外发 UDP 探测本机对外通信 IP
func GetOutboundIP() net.IP {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
panic(err)
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP
}
关于取 IP 的补充说明:除了 UDP 探测,还有一种常见做法——有些公司在节点环境变量里注入 IP(比如 K8s 通过 Downward API 把 Pod IP 注入环境变量),这种场景直接读环境变量即可。但尽量不要从配置文件读——配置文件是给人维护的,机器换了 IP 忘了改,注册上去的就是一个连不上的死地址。
127.0.0.1 或 localhost。服务端在自己机器上 net.Listen("tcp", "127.0.0.1:8091"),顺手把 127.0.0.1 注册到 etcd。结果别的机器上的客户端拿到 127.0.0.1:8091,连的是自己本机,根本连不上目标服务。必须用「对外通信 IP」——示例里 GetOutboundIP 靠 UDP 报文探测出口 IP,才是客户端真能连的地址。3.6.4 更新元数据
etcd 没有提供单独的「Edit」操作,只能用 Add 覆盖(key 已存在时就是更新):
// 更新某个 endpoint 的元数据(key 已存在则覆盖)
em.AddEndpoint(ctx, key, endpoints.Endpoint{
Addr: addr,
Metadata: newMetadata, // 新的权重 / 版本等
}, clientv3.WithLease(leaseID))
3.6.5 删除 Endpoint(服务退出时)
// 服务退出时先删除自己注册的 Endpoint(优雅下线配合)
em.DeleteEndpoint(ctx, "service/interactive/"+addr)
3.6.6 Lease 续约机制:宕机了没删除怎么办
问题:如果服务器直接宕机(kill -9),来不及调用 DeleteEndpoint,etcd 上还会残留这个节点。
解决:用 Lease(租约)机制(回忆 3.6.3 的补课胶囊):
- 创建租约,设置 TTL(如 30 秒);
- 注册 endpoint 时带上 leaseID;
- 开启自动续约
KeepAlive,每TTL/3时间续约一次; - 服务宕机后无法续约,TTL 到期后 etcd 自动剔除该节点。
etcd 默认续约间隔:每 TTL/3 续约一次(30s 的 TTL 就是每 10s 续一次)。
缺点:etcd 自带的 KeepAlive 实现没有暴露很多控制选项(比如失败重试策略),难以在面试中「刷亮点」,但实践中完全够用。想要精细控制,可以自己手写续约(第 4 节跟着写就这么做)。
sequenceDiagram
participant S as 服务端
participant E as etcd
S->>E: Grant 租约 TTL=30s
S->>E: AddEndpoint + leaseID
loop 每 10s 续约
S->>E: KeepAlive
E-->>S: 续约成功, TTL 重置
end
Note over S: 突然 kill -9
Note over E: 30s 内无续约
E->>E: TTL 到期, 自动删除 endpoint3.6.7 客户端服务发现
客户端这边非常简单——构造一个 etcd 的 Resolver,用 etcd:/// 前缀拨号即可,同步细节全交给 SDK:
// webook/internal/web/ioc/grpc.go
package ioc
import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
etcdresolver "go.etcd.io/etcd/client/v3/naming/resolver"
)
// InitInteractiveClient 用 etcd 构造能自动发现服务端的 gRPC 客户端
func InitInteractiveClient(etcdClient *clientv3.Client) interactivev1.InteractiveServiceClient {
// 步骤 1:用 etcd 客户端构造一个 gRPC Resolver(NewBuilder 只返回一个 builder,不返回 error)
r := etcdresolver.NewBuilder(etcdClient)
// 步骤 2:拨号,目标用 etcd:// 协议前缀;测试环境用 insecure 跳过 TLS
conn, err := grpc.Dial(
"etcd:///service/interactive/",
grpc.WithResolvers(r),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
panic(err)
}
// 步骤 3:用连接初始化客户端,后续 SDK 自动同步节点列表
return interactivev1.NewInteractiveServiceClient(conn)
}
客户端不需要关心「节点列表怎么更新的」这种事,SDK 都处理好了。你只需记住:客户端的「目标地址」从 IP:端口 变成了 etcd:///服务名。
对比一下三种环境下的拨号写法,体会「调用方彻底不关心地址」:
// 单体 / 联调:写死 IP(仅调试用,生产禁用)
grpc.Dial("10.0.1.5:9090")
// 接入注册中心后:只写服务名,地址由 etcd 实时给
grpc.Dial("etcd:///user-service")
// K8s 环境:连 Service 名,由集群 DNS 解析到 Pod
grpc.Dial("user-service:9090")
注意第二行和第三行的共同点——业务代码里再也找不到任何具体的 IP。这就是服务注册与发现(或其 K8s 等价物)带来的最大工程收益:地址变更对调用方完全透明。
3.6.8 改造 interactive 的服务启动
核心修改:让 ginx.Server 负责和注册中心打交道——先开端口、再注册、最后 Serve。
// webook/pkg/ginx/server.go
package ginx
type Server struct {
*grpc.Server
addr string
registry *Registry
}
// RegisterAndServe 先注册再服务
func (s *Server) RegisterAndServe(lis net.Listener) error {
// 步骤 1:先注册(端口已经 Listen 成功)
if s.registry != nil {
if err := s.registry.Register(s.addr); err != nil {
return err
}
defer s.registry.Deregister() // 退出时主动注销
}
// 步骤 2:启动 gRPC Server
return s.Server.Serve(lis)
}
注意:注册必须在端口 Listen 成功之后,否则客户端拿到地址却连不上。
这种把注册逻辑直接写进 Server 的写法有一个缺陷:和 etcd 强绑定。哪天要换注册中心(比如换成 Consul),就得改这段代码。go-zero、Kratos、ego 这类框架的做法是抽一个 Registry / Discovery 接口层,把「注册到哪」和「怎么注册」解耦——思路在 3.9 节还会展开。
Serve。如果 RegisterAndServe 里先 registry.Register 再 Server.Serve,而 Serve 内部才真正 Listen 端口——那客户端可能在「端口还没开」时就拿到了地址,一连就失败、还触发 failover 白忙活。正确顺序:先 Listen 开端口 → 注册 → 再 Serve 接受连接。小收口
这一节是本章「最硬核」的部分:gRPC 的 Resolver 是抽象层,etcd 的 Lease 是自动下线的保证,客户端用 etcd:/// 拨号、服务端用 endpoint.Manager 注册。记住一条铁律:先 Listen、再注册、最后 Serve。下一节我们看 K8s 环境下为什么常常不需要注册中心。
3.7 K8s 中的服务注册与发现(推荐直接用 DNS)
补课胶囊 · K8s 的 Service 与 DNS 一句话:K8s 里每个 Service 有固定名字,K8s 自动维护「Service 名 → 背后 Pod IP」的映射,并通过集群内部 DNS 让你用名字就能访问。 类比:Service 像个「部门总机号」,你只记部门名(如
user-service),不用管底下换了多少个员工(Pod);K8s 的 DNS 自动把名字翻译成当前真实的员工分机号。 回到主线:既然 K8s 已经帮我们做好了「名字 → 地址」的发现,就没必要再自己搭一套注册中心了。
用 K8s 时要注意的两点
如果你在 K8s 里仍要用注册中心(比如跨集群),要小心:
- 注册的 IP 必须是外部通信 IP(K8s Pod 之间通信的 IP);
- IP 漂移:K8s Pod 崩溃后重新启动,IP 可能变化;网络配置变化也会导致 IP 漂移。
推荐:直接用 DNS
在 K8s 里虽然也能用注册中心,但推荐直接用 DNS——用 Service 名字通信,K8s 自动维护 Service → Pod 的映射。它的效果和注册中心类似(名字稳定、背后 Pod 动态变),但零额外组件、零运维成本,而且天然解决了 IP 漂移问题(你从来不直接碰 Pod IP,只碰 Service 名)。
看一个最典型的 K8s Service 定义,体会「名字稳定、Pod 动态」:
# K8s 里定义一个 user-service,背后挂任意数量的 Pod
apiVersion: v1
kind: Service
metadata:
name: user-service # 这个名字就是「总机号」,集群内 DNS 自动解析
spec:
selector:
app: user # 自动把标签为 app=user 的 Pod 收进来
ports:
- port: 9090
targetPort: 9090
其他服务只要 grpc.Dial("user-service:9090") 就能调它——Pod 扩到 8 个、缩到 3 个、某个 Pod 崩了重启换了 IP,调用方完全无感知,因为打的一直是 user-service 这个名字。这就是 K8s 用 DNS 做服务发现的全部秘密。
小收口
到这里可以做一个判断:非 K8s 环境,用注册中心(etcd);K8s 环境,优先用 Service + DNS。两者本质是同一件事的两种实现——「名字 → 动态地址」的映射。下一节我们总结「选注册中心时要关心 SDK 的哪些细节」。
3.8 选用注册中心要关心什么
接入一个注册中心时,关于它的 SDK(比如 etcd 客户端)你要搞清楚这几件事:
- 有没有自动续约机制?没有就要自己手写续约;
- 续约间隔多长?(关系到故障发现的快慢);
- 续约失败,SDK 有没有处理机制?(比如连续失败告警);
- 注册中心和客户端是什么模型?客户端多久能知道服务端变化?(push 还是 pull)。
把这几件事落成一张「SDK 关心清单」,接入前先逐项确认:
| 关心项 | 好答案(etcd 示例) | 坏答案(要警惕) |
|---|---|---|
| 自动续约 | SDK 提供 KeepAlive,或自己 KeepAliveOnce | 完全要手写,易漏 |
| 续约间隔 | 明确(TTL/3,如 30s→10s) | 文档没写,踩坑才知道 |
| 续约失败处理 | 返回 error,可计数告警 | 静默吞掉,死了都不知道 |
| 发现模型 | Watch 推送 + 本地缓存兜底 | 只能定时拉,实时性差 |
知道这些,遇到服务注册与发现相关问题就更容易定位。比如「为什么某节点挂了 30 秒客户端还在打它」——很可能是 TTL 设太长,或续约间隔没配对。
3.9 其他 Go 微服务框架的发现方案
所有依托于 gRPC 的微服务框架(go-zero、Kratos、ego 等)接入服务注册与发现都大同小异,因为它们底层都是 gRPC:
- 必然实现 gRPC 的 Resolver 接口(把自家的注册中心适配成 gRPC 能用的 Resolver);
- 必然有一个 register 过程(服务端启动时注册);
- 必然有续约过程(自己写或依赖客户端 SDK);
- 如果要支持多种注册中心,会有一个
Registry/Discovery接口层做抽象(解耦「注册到哪」和「怎么注册」)。
学习不同框架时,沿着这个思路找源码就容易理解。个人感觉 Kratos 的源码更直观,新手想深入可以从它入手。
为什么要有 Registry / Discovery 接口层:回到 3.6.8 的坑——如果注册逻辑直接写死在 ginx.Server 里、和 etcd 强绑定,哪天要换成 Consul 就得改 Server 代码。框架的做法是把「注册到哪」和「怎么注册」解耦,抽成接口:
// 框架通用的抽象:业务代码只依赖接口,不依赖具体注册中心
type Registry interface {
Register(ctx context.Context, ins *ServiceInstance) error
Deregister(ctx context.Context, ins *ServiceInstance) error
}
type Discovery interface {
// 返回一个能解析目标服务的 Resolver 构造器
Build(target string) (resolver.Builder, error)
}
有了这层抽象,服务端启动时注入一个 etcdRegistry 或 consulRegistry 即可,业务代码一行都不用改——这正是「面向接口编程」在微服务框架里的标准落地。本章第 4 节手写 pkg/registry 也是这个思路:哪天换 Consul,只动这个包。
3.10 注册中心高可用与故障容错(面试高频)
这一节是第八章「故障容错」的提前展开,也是面试必问。先记住这张关系图:
flowchart TD
Q{哪个组件挂了?}
Q -->|服务端崩| A[客户端 failover 换节点]
Q -->|注册中心崩| B[客户端用本地缓存
连不上就剔除+报错]
Q -->|注册中心↔服务端断| C[服务端告警+考虑退出
客户端暂不受影响]
Q -->|客户端崩| D[无需处理]这张图怎么读:从「哪个组件挂了?」这个菱形出发,按故障主体走四条分支——「服务端崩」时靠客户端 failover 换节点兜底;「注册中心崩」时客户端退回本地缓存的实例列表继续调,连不上的节点就地剔除并报错;「注册中心↔服务端断」最微妙:etcd 会误判服务端死亡并摘除它,所以服务端要告警甚至主动退出,而客户端因为本地缓存还在、暂不受影响;「客户端崩」无需任何处理。前两条分支的细节,正是本节后面几小节逐个展开的内容。
我们要回答的核心问题:注册中心挂了,服务还能不能互相调用? 下面分情况拆解。
高可用关键点:三个节点 + 三条边
客户端 ←→ 注册中心 ←→ 服务端
要思考的问题:
- 服务端崩溃怎么办?
- 注册中心崩溃怎么办?
- 客户端崩溃?(不需要处理,客户端都崩了就没法调用)
- 注册中心和服务端无法通信怎么办?
- 注册中心和客户端无法通信怎么办?(按「注册中心崩溃」一致行为容错)
- 客户端和服务端无法通信怎么办?(failover 策略)
服务端崩溃
注册中心发现服务端崩溃有秒级延迟。客户端要能正常处理:发现连不上服务端,换一个节点重试(failover)。
注册中心高可用方案
| 方案 | 说明 |
|---|---|
| 集群部署 | 多活方案,避免单点 |
| 双注册中心 | 同时注册两个,一个崩了用另一个 |
| 按业务拆分 | 核心业务和非核心业务用不同注册中心 |
注册中心崩溃 —— 客户端怎么办
后果:客户端无法收到服务端变动信息(上线 / 下线)。
容错策略:
- 使用本地缓存的可用节点信息(缺陷:可能不准,有节点已下线,有节点新加进来没感知到);
- 调用时发现无法连通,把该节点移出可用列表;
- 如果是新服务(没有任何缓存),返回特定错误,让调用者决定怎么处理。
注册中心崩溃 —— 服务端怎么办
服务端无法更新注册信息:
- 新节点:注册失败时直接退出服务(fail-fast,别注册脏数据);
- 老节点:继续提供服务(客户端可能还在用本地缓存的注册数据)。
注册中心和服务端无法通信
注册中心会判定服务端崩溃,但服务端实际还活着(只是网络闪断)。
- 客户端在收到错误判定前,依旧能调用服务端;
- 客户端、服务端、注册中心都不需要做什么(等网络恢复即可);
- 但服务端发现自己一段时间无法和注册中心保持心跳后,要告警,并考虑是否退出(避免「注册中心以为我死了、客户端还在打我」的长期不一致)。
综合推演:一次真实的「注册中心抖动」
把上面几种情况串起来,想象一个真实故障时间线,你会更踏实:
- t=0s:etcd 集群网络抖动,和 user 实例 2 失联 → etcd 判定实例 2 死亡,通知 post;
- t=0.1s:post 收到通知,把实例 2 从本地列表划掉,之后只打实例 1;
- t=2s:网络恢复,user 实例 2 发现自己连不上 etcd 已告警,触发重新注册;
- t=2.1s:etcd 重新收到实例 2 的注册 + 心跳,再次通知 post;
- t=2.2s:post 把实例 2 加回列表,流量恢复分摊。
整个过程用户几乎无感知——这正是注册中心「主动通知 + 客户端 failover + 自动重新注册」三者配合的效果。它也从侧面说明:故障恢复不是「一键重启」,而是一连串自动化的状态同步。
小收口
故障容错的核心是「每一端都为最坏情况准备了兜底」:客户端有本地缓存、服务端老节点继续顶着、新节点 fail-fast 退出。这一节的内容会在第八章小结里再次浓缩成面试清单。
四、跟着写:分步实现
上一章我们把 user 表零停机迁到了新库,「数据」已经就绪;但第 10 章留下的尾巴还在——post 调 user 用的是写死的 127.0.0.1:9090。本节动手把这行配置换成真正的服务注册与发现:自己封装 pkg/registry(注册 + Lease 心跳 + 注销),自己实现 gRPC Resolver(前缀查询 + Watch + 本地缓存降级),最后起两个实例做多副本验证。
说明:原版 3.6.3 用的是 etcd SDK 官方的
endpoints.Manager,几行就搞定注册;本节按本章规格从零手写——官方封装把 Lease、Watch 都藏起来了,手写一遍才能把机制吃透,还能顺手补上它没有的两样东西:心跳失败告警 + 客户端本地缓存降级。
步骤总览:
- 部署 etcd(docker-compose 单节点,开发用);
- 定义注册模型
ServiceInstance——注册到 etcd 的数据长什么样; - 封装
pkg/registry:Register/Deregister/ Lease 心跳; - 实现自定义 gRPC Resolver:前缀查询 + Watch + 本地缓存降级;
- user-service 接入:启动时注册、优雅停机时注销;
- post-service 接入与多实例验证:扩容分发、宕机自动摘除、注册中心崩溃降级。
第 1 步:部署 etcd
选型理由见 3.5 节(Go 生态、轻量、K8s 友好)。开发环境 docker-compose 起单节点即可;生产环境建议 3 节点集群(高可用方案见 3.10 节)。
# docker-compose.yml(在已有文件里追加 mysql、redis、kafka 等保持不变)
services:
# ......原有的 mysql、redis、kafka 保持不变......
etcd:
image: bitnami/etcd:3.5
container_name: content-etcd
restart: always
environment:
# 开发环境单节点、免鉴权;生产环境务必 3 节点集群 + 开启鉴权
- ALLOW_NONE_AUTHENTICATION=yes
- ETCD_ADVERTISE_CLIENT_URLS=http://etcd:2379
ports:
- "2379:2379" # 客户端端口,Go SDK 连这个
volumes:
- etcd_data:/bitnami/etcd # 数据落盘,etcd 重启不丢注册信息
volumes:
etcd_data:
# configs/config.yaml 追加(viper 统一读取配置;etcd 客户端初始化见第 5 步)
etcd:
addr: "localhost:2379"
验证:
docker compose up -d etcd
curl http://localhost:2379/health
# 预期输出:{"health":"true"}
第 2 步:定义注册模型 ServiceInstance
先回答 3.3.2 的问题「注册什么数据」:定位信息(IP + 端口)+ 治理信息(权重、版本等)。按本章规格:key 用 /services/{service_name}/{instance_id},value 是一段 JSON。
// pkg/registry/instance.go
package registry
import "fmt"
// ServiceInstance 一个服务实例的注册信息
// etcd 里:key = /services/{service_name}/{instance_id},value = 本结构体的 JSON
type ServiceInstance struct {
ServiceName string `json:"service_name"` // 服务名,如 user-service
InstanceID string `json:"instance_id"` // 实例唯一标识:服务名-IP:端口
Address string `json:"address"` // 必须是客户端能连到的 IP(坑见 3.6.3)
Port int `json:"port"` // gRPC 端口
Protocol string `json:"protocol"` // 协议,本章固定 grpc
Weight int `json:"weight"` // 权重,第13章负载均衡用
Metadata map[string]string `json:"metadata"` // 元数据:版本、环境等
}
// Addr 拼出客户端真正要拨的地址 "IP:端口"
func (s *ServiceInstance) Addr() string {
return fmt.Sprintf("%s:%d", s.Address, s.Port)
}
// instanceKey 本实例在 etcd 里的完整 key
func instanceKey(ins *ServiceInstance) string {
return fmt.Sprintf("/services/%s/%s", ins.ServiceName, ins.InstanceID)
}
// servicePrefix 一个服务所有实例的 key 前缀,Resolver 按它做前缀查询和 Watch
func servicePrefix(serviceName string) string {
return "/services/" + serviceName + "/"
}
验证:执行 go build ./...,无任何输出即通过(本步只是数据模型,还没有行为)。
第 3 步:封装 pkg/registry——注册、心跳与注销
Lease 机制原理见 3.6.6。这里和 3.6.3 的官方写法有一点不同:不用 SDK 的自动续约,而是自己每 5 秒 KeepAliveOnce 一次——这样才能数失败次数、打告警日志(对应 3.10 节的告警要求)。
// pkg/registry/registry.go
package registry
import (
"context"
"encoding/json"
"fmt"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
"go.uber.org/zap"
)
const (
leaseTTL = 10 // 租约 TTL:10 秒——服务挂了最多 10 秒被剔除
heartbeatPeriod = 5 * time.Second // 心跳间隔:每 5 秒续约一次(TTL 的一半)
maxHeartbeatFail = 3 // 连续失败 3 次触发告警
)
// Registry 负责"把本实例注册到 etcd,并维持心跳"
type Registry struct {
client *clientv3.Client
logger *zap.Logger
leaseID clientv3.LeaseID
key string
cancel context.CancelFunc
}
func NewRegistry(client *clientv3.Client, logger *zap.Logger) *Registry {
return &Registry{client: client, logger: logger}
}
(接上,注册与心跳逻辑)
// pkg/registry/registry.go(续)
package registry
// Register 注册服务实例:申请租约 → 写 key(绑定租约)→ 启动心跳
func (r *Registry) Register(ins *ServiceInstance) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := r.client.Grant(ctx, leaseTTL) // 申请一个 TTL=10s 的租约
if err != nil {
return fmt.Errorf("申请租约失败: %w", err)
}
r.leaseID = resp.ID
val, _ := json.Marshal(ins) // 实例信息序列化成 JSON 写入 etcd,绑定租约
r.key = instanceKey(ins)
if _, err = r.client.Put(ctx, r.key, string(val),
clientv3.WithLease(r.leaseID)); err != nil {
return fmt.Errorf("写入注册信息失败: %w", err)
}
hbCtx, cancelHB := context.WithCancel(context.Background())
r.cancel = cancelHB
go r.heartbeat(hbCtx) // 启动心跳 goroutine
return nil
}
// heartbeat 每 5 秒手动续一次约,顺便统计失败次数
func (r *Registry) heartbeat(ctx context.Context) {
ticker := time.NewTicker(heartbeatPeriod)
defer ticker.Stop()
failCount := 0
for {
select {
case <-ctx.Done(): // Deregister 时退出
return
case <-ticker.C:
if _, err := r.client.KeepAliveOnce(ctx, r.leaseID); err != nil {
failCount++
r.logger.Error("心跳续约失败", zap.Int("连续失败", failCount), zap.Error(err))
if failCount == maxHeartbeatFail {
r.logger.Warn("连续多次续约失败,请检查与 etcd 的连接")
}
continue
}
failCount = 0 // 续约成功,计数清零
}
}
}
// pkg/registry/registry.go(续):注销
package registry
// Deregister 注销服务:先停心跳,再撤销租约——租约一撤,绑定的 key 被 etcd 自动删除
func (r *Registry) Deregister() error {
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
if r.leaseID == 0 {
return nil // 没注册过,幂等返回
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := r.client.Revoke(ctx, r.leaseID); err != nil {
return fmt.Errorf("撤销租约失败: %w", err)
}
r.leaseID = 0
return nil
}
验证:go build ./... 通过即可,注册的真实效果在第 5 步用 etcdctl 看。
第 4 步:实现自定义 gRPC Resolver——Watch + 本地缓存
Resolver 是 gRPC 的「查通讯录」组件(接口见 3.6.2):Build 时全量拉一次地址列表,之后 Watch etcd 前缀,实例上下线实时刷新;拉取失败时用本地缓存顶上——这正是 3.10 节说的降级策略。
// pkg/registry/resolver.go
package registry
import (
"context"
"encoding/json"
"fmt"
"sync"
clientv3 "go.etcd.io/etcd/client/v3"
"google.golang.org/grpc/resolver"
)
const scheme = "etcd" // 客户端目标地址前缀:etcd:///user-service
// NewBuilder 构造 Resolver 的 Builder
func NewBuilder(client *clientv3.Client) resolver.Builder {
return &etcdBuilder{client: client}
}
type etcdBuilder struct{ client *clientv3.Client }
func (b *etcdBuilder) Scheme() string { return scheme }
(接上,Build 与 resolve)
// pkg/registry/resolver.go(续)
package registry
func (b *etcdBuilder) Build(target resolver.Target, cc resolver.ClientConn,
opts resolver.BuildOptions) (resolver.Resolver, error) {
ctx, cancel := context.WithCancel(context.Background())
r := &etcdResolver{
client: b.client,
serviceName: target.Endpoint(), // 从 etcd:///user-service 取 "user-service"
cc: cc, ctx: ctx, cancel: cancel,
}
if err := r.resolve(); err != nil { // 先全量拉一次
cancel()
return nil, err
}
go r.watch() // 再起 goroutine 持续 Watch
return r, nil
}
type etcdResolver struct {
client *clientv3.Client
serviceName string
cc resolver.ClientConn
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
cache []resolver.Address // 本地缓存:最近一次成功拉到的地址列表
}
// resolve 全量拉取 /services/{name}/ 下所有 key,解析成地址交给 gRPC
func (r *etcdResolver) resolve() error {
resp, err := r.client.Get(r.ctx, servicePrefix(r.serviceName),
clientv3.WithPrefix())
if err != nil {
return fmt.Errorf("拉取服务列表失败: %w", err)
}
addrs := make([]resolver.Address, 0, len(resp.Kvs))
for _, kv := range resp.Kvs {
var ins ServiceInstance
if err := json.Unmarshal(kv.Value, &ins); err != nil {
continue // 个别脏数据跳过,不影响其他实例
}
addrs = append(addrs, resolver.Address{Addr: ins.Addr()})
}
r.mu.Lock()
r.cache = addrs
r.mu.Unlock()
r.cc.UpdateState(resolver.State{Addresses: addrs})
return nil
}
// pkg/registry/resolver.go(续):watch 与降级
package registry
// watch 持续监听前缀:任何实例注册、注销、租约过期,etcd 都会推事件
func (r *etcdResolver) watch() {
ch := r.client.Watch(r.ctx, servicePrefix(r.serviceName),
clientv3.WithPrefix())
for range ch {
if err := r.resolve(); err != nil {
// 降级(3.10 节):etcd 恰好挂了就把本地缓存再推给 gRPC
r.mu.Lock()
cached := append([]resolver.Address(nil), r.cache...)
r.mu.Unlock()
if len(cached) > 0 {
_ = r.cc.UpdateState(resolver.State{Addresses: cached})
}
}
}
}
// ResolveNow gRPC 主动要求重新解析的钩子,Watch 已实时更新,留空
func (r *etcdResolver) ResolveNow(resolver.ResolveNowOptions) {}
// Close 连接关闭时停掉 Watch
func (r *etcdResolver) Close() { r.cancel() }
/services/user-service/{id},查询却多了或少了一个斜杠、或服务名大小写不一致,都会查不到且不报错,表现为「注册成功了但客户端拿不到地址」。排查时先用 etcdctl get /services/user-service/ --prefix 肉眼比对两边 key 一字不差。验证:go build ./... 通过;发现效果在第 6 步整体验证。
第 5 步:user-service 接入——启动注册、停机注销
第 10 章 user 的 gRPC 服务和 HTTP 跑在同一个 main 里;要验证多实例,先把启动入口拆成 cmd/user、cmd/post 两个(只是入口拆分,internal/ 业务代码一行不动)。注册时牢记 3.6.8 的顺序:先 Listen 开端口 → 再注册 → 最后 Serve。
// cmd/user/main.go:user 的 gRPC 服务入口(节选核心逻辑)
package main
import (
"flag"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"github.com/yourname/content-community/internal/user/ioc"
"github.com/yourname/content-community/pkg/registry"
"go.uber.org/zap"
)
func main() {
port := flag.Int("port", 9090, "gRPC 监听端口") // 本地起第二个实例用 -port 9091
flag.Parse()
logger := zap.L()
etcdClient := ioc.InitEtcdClient(logger)
grpcServer := ioc.NewGRPCServer(logger /* 第10章的 db、userSvc 等依赖 */)
// 第 1 步:先 Listen——端口开了才注册(顺序错见 3.6.8 的坑)
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
if err != nil {
panic(err)
}
// 第 2 步:注册本实例(GetOutboundIP 见 3.6.3)
ip := registry.GetOutboundIP()
ins := ®istry.ServiceInstance{
ServiceName: "user-service",
InstanceID: fmt.Sprintf("user-service-%s:%d", ip, *port),
Address: ip.String(), Port: *port, Protocol: "grpc",
Weight: 100, Metadata: map[string]string{"version": "v1.0.0", "env": "dev"},
}
reg := registry.NewRegistry(etcdClient, logger)
if err := reg.Register(ins); err != nil {
panic(err) // fail-fast:注册不上就退出,别让客户端拿到死地址(见 3.3.1)
}
// 第 3 步:优雅停机——收到退出信号先注销,客户端立刻感知
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
if err := reg.Deregister(); err != nil {
logger.Error("注销失败", zap.Error(err))
}
grpcServer.GracefulStop() // 等在途请求处理完再退(完整步骤见 3.3.6)
}()
if err := grpcServer.Serve(lis); err != nil {
panic(err)
}
}
验证:
go run ./cmd/user
# 另开终端,前缀查询 etcd 上的注册数据
docker exec content-etcd etcdctl get /services/user-service/ --prefix
# 预期输出(IP 换成你的本机地址):
# /services/user-service/user-service-192.168.1.10:9090
# {"service_name":"user-service","instance_id":"user-service-192.168.1.10:9090",...}
# Ctrl+C 优雅停机后再查——预期输出:(空)主动注销,key 立刻消失
第 6 步:post-service 接入与多实例验证
第 10 章 post 调 user 写死了 127.0.0.1:9090,现在换成 etcd:///user-service(post 入口同样先建 etcd 客户端再传给 InitUserClient)。对比 3.6.7 官方写法:形似,但 Resolver 换成了我们自己的,还多了一行负载均衡配置。
// internal/post/ioc/grpc.go(节选核心逻辑)
package ioc
func InitUserClient(etcdClient *clientv3.Client) userv1.UserServiceClient {
conn, err := grpc.Dial(
"etcd:///user-service", // 目标从 "IP:端口" 换成 "服务名"
grpc.WithResolvers(registry.NewBuilder(etcdClient)), // 用我们自己的 Resolver
// 多实例分发必须显式指定 round_robin,细节下一章展开
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
panic(err)
}
return userv1.NewUserServiceClient(conn)
}
etcdresolver 的 scheme 都是 etcd,而 grpc.WithResolvers 注册进的是同一个全局 map——同一进程里两种都注册,后注册的会覆盖先注册的,行为不可预测。本章统一用自己的 Builder,别再混入官方的。若启动报 no resolver registered for scheme "etcd",说明 Dial 时忘了传 grpc.WithResolvers。验证(多实例:扩容 → 宕机摘除 → 注册中心崩溃降级):
# 终端 1、2:起两个 user 实例(端口不同 → InstanceID 不同 → etcd 上两个 key)
go run ./cmd/user -port 9090
go run ./cmd/user -port 9091
docker exec content-etcd etcdctl get /services/user-service/ --prefix # 终端 3:确认都注册上
# 预期输出:两个 key,分别对应 9090 和 9091 两份实例 JSON
go run ./cmd/post # 终端 4:启动 post-service
for i in $(seq 1 10); do curl -s http://localhost:8080/post/list; echo; done
# 预期输出:10 次请求全部成功,终端 1、2 都打印了处理日志——请求被分到两个实例
# 宕机自动摘除:kill -9 第二个实例(模拟断电,来不及优雅注销)
kill -9 $(lsof -ti :9091)
sleep 12 # 心跳断 → 租约 10 秒 TTL 到期 → etcd 自动剔除
docker exec content-etcd etcdctl get /services/user-service/ --prefix
# 预期输出:只剩 9090 一个 key
for i in $(seq 1 10); do curl -s http://localhost:8080/post/list; echo; done
# 预期输出:请求全部成功(偶发一两次报错属正常,gRPC 把断连节点标记不可用后不再分发)
# 注册中心崩溃降级:停掉 etcd,客户端用本地缓存继续调(见 3.10 节)
docker stop content-etcd
for i in $(seq 1 5); do curl -s http://localhost:8080/post/list; echo; done
# 预期输出:请求仍然成功——缓存的地址列表已交给 gRPC,etcd 挂了照样能调
docker start content-etcd # 恢复后 Watch 自动续上
本章完成后的项目结构
content-community/
├── configs/
│ └── config.yaml # 修改:新增 etcd.addr
├── docker-compose.yml # 修改:新增 etcd 服务
├── pkg/
│ └── registry/ # 新增:本章核心——注册与发现
│ ├── instance.go # ServiceInstance 模型与 key 规则
│ ├── registry.go # Register / Deregister / Lease 心跳
│ └── resolver.go # gRPC Resolver:前缀查询 + Watch + 本地缓存降级
├── cmd/
│ ├── user/main.go # 新增:user gRPC 入口(注册 + 优雅停机注销)
│ └── post/main.go # 新增:post 入口(HTTP 组装从旧 main.go 搬来)
└── internal/
├── user/ioc/etcd.go # 新增:etcd 客户端初始化
└── post/ioc/grpc.go # 修改:user 客户端改走 etcd:///user-service
验证清单
-
curl http://localhost:2379/health返回{"health":"true"} - user-service 启动后,
etcdctl get /services/user-service/ --prefix能查到本实例 JSON -
Ctrl+C优雅停机后 key 立刻消失(主动注销,不等 TTL) -
kill -9实例后约 10 秒,key 自动消失(租约过期兜底) - 两个实例 + round_robin 能分发请求;停掉一个后请求全落到存活实例(failover)
- 停掉 etcd 后调用仍成功(本地缓存降级),恢复 etcd 后一切照旧
- post-service 代码里已找不到任何写死的 user-service 地址
五、工程实践要点
这一节把生产环境里最容易出问题的点,浓缩成一份「避坑清单」。建议和第 3.10 节一起看。
服务端启动失败要 fail-fast:连不上注册中心直接退出,避免注册了写错的 IP 这种脏数据,让客户端调不通。
注册必须在端口 Listen 成功之后:否则客户端拿到地址却连不上(见 3.6.8 的坑)。
IP 不能用 127.0.0.1:必须注册客户端能连的对外 IP,通过 UDP 报文或环境变量确定(见 3.6.3)。
续约结果要处理:简单打日志就够,但要有监控——连续失败要告警(见第 4 节
maxHeartbeatFail)。优雅下线要等请求处理完:但也要有超时控制,防止超长任务卡住(见 3.3.6)。
客户端要本地缓存注册数据:注册中心崩溃时能继续调用(见 3.10 节容错)。
failover 必备:客户端发现某节点连不上要换节点重试(见 3.10 服务端崩溃)。
K8s 优先用 DNS:避免 IP 漂移问题(见 3.7 节)。
核心业务单独注册中心:避免非核心业务故障影响核心(见 3.10 高可用方案)。
测试环境模拟注册中心崩溃:验证客户端容错是否真生效(见 3.10 的 hint)。具体做法就是把 etcd 容器
docker stop一下,观察客户端日志有没有「续约失败」告警、调用有没有降级到本地缓存——很多团队直到线上真出事才发现客户端根本没做容错。续约间隔与 TTL 要配套:常见错误是 TTL 设 30s 但续约间隔写成 60s——结果每次都来不及续约,key 永远在过期边缘。经验值:续约间隔 = TTL / 3(30s 配 10s),留足网络抖动余量。
六、自测题与动手练习
下面 5 道自测题不在这里给答案——合上书试着答,答不上来的回看对应小节;答案都藏在第七节「模拟面试问答」里(自测题和面试问答的知识点一一对应)。
- 微服务项目里直接把服务端 IP 写死在客户端,会遇到什么问题?注册中心相比 DNS 的核心优势是什么?(对应 3.1、3.2)
- 注册中心机制里「注册、心跳、节点变更通知、客户端发现」四步分别解决什么?(对应 3.3)
- CAP 三选二,为什么注册中心通常选 AP 而不是 CP?etcd 为什么被说成「AC 模型」?(对应 3.4)
- etcd 服务端注册为什么必须带 Lease?如果服务端
kill -9没调DeleteEndpoint,etcd 怎么自动剔除它?(对应 3.6.3、3.6.6) - 注册中心整个崩了,客户端和服务端各自应该怎么容错?新服务(没有任何缓存)调用会怎样?(对应 3.10)
动手练习(建议真做一遍):
- 跑通注册 + 发现:按第 4 节起一个 etcd,服务端用
InitRegistry注册service/interactive/,客户端用 3.6.7 的etcd:///连上并成功调一次接口。 - 观察 Lease 剔除:服务端注册成功后
kill -9掉它,盯着 etcd(或客户端日志),确认过 TTL(默认 30s)后这个 endpoint 自动消失。 - 模拟注册中心崩溃:把 etcd 进程停掉,观察客户端是否能用本地缓存继续调用老节点、连不上时能否 failover;再恢复 etcd,看节点列表是否重新同步。
- 换一个注册中心再写一遍(课后作业):用单元测试写一个简单 demo——一个方法启动服务端,一个方法启动客户端并成功发起调用。中间件随便选(Consul、ZooKeeper、Nacos 都行)。写完后假设面试官问你「你为什么选这个中间件做注册中心?」——把选型理由(CAP 取舍、成熟度、多语言支持、部署成本)整理成一段能说出口的话。
七、模拟面试问答
这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。
一句话结论:微服务实例的 IP 和端口是动态变化的(重启、扩容、宕机、IP 漂移),写死地址根本维护不过来;服务注册与发现解决的是「调用方如何动态找到当前可用的服务端实例」。
展开:单体时代只有一个进程、固定地址,写死没问题。微服务时代,一个服务可能同时跑多个实例,地址由容器随机分配,还会随时扩缩容。注册中心让服务端启动时主动上报地址、节点变动时主动通知客户端,客户端据此更新本地可用列表。本质是给分布式系统建了一张「动态通讯录」。
面试官想听的关键词:动态地址、扩缩容、注册中心、主动通知、客户端本地缓存。
常见失分点:只说「IP 会变」却说不清「变的具体场景」;或者把「服务注册与发现」和「负载均衡」混为一谈(前者解决「找到谁」,后者解决「发给谁」)。
一句话结论:主流五类——IP 直连、DNS、注册中心、服务自省、借助网关;其中注册中心是主流方案,DNS 在 K8s 里首选。
展开:IP 直连最简单但只适用于联调 / DEBUG 特定实例;DNS 简单无额外组件,但缓存与不缓存两难、实时性差;注册中心靠「主动通知」实时性最好,是主流;服务自省(Dubbo 提出)给超大规模集群减负,注册中心只存最小数据、元数据走独立服务;借助网关则是把「找地址」集中到网关一处做(内部仍可能是注册中心或 DNS)。
面试官想听的关键词:主动通知 vs 拉模型、实时性、服务自省减负、网关两次发现。
常见失分点:漏掉「服务自省」和「网关」两种;或把 DNS 说成「完全不能用」(K8s 里它就是首选方案)。
一句话结论:心跳是服务端向注册中心「续租」证明自己还活着的机制;服务端启动时「主动注册自身信息 → 连不上则 fail-fast 退出 → 之后持续心跳」。
展开:注册两类数据——定位信息(IP+端口,最关键)和治理信息(权重、版本)。注册中心靠心跳判断实例是否存活,心跳失败则判定崩溃并通知客户端摘除此节点。启动阶段若连不上注册中心,服务端应直接退出(fail-fast),避免注册脏数据(如写错的 IP)让客户端调不通。
面试官想听的关键词:续租、fail-fast、定位信息 vs 治理信息、心跳失败判定死亡。
常见失分点:说不清「注册了什么数据」(漏掉治理信息);或不知道启动连不上注册中心应该 fail-fast 退出。
一句话结论:优雅下线 = 先通知注册中心要下线 → 不再接受新请求 → 等正在处理的请求结束 → 全部完成后退出 → 设超时强制退出。
展开:不能直接 exit,因为可能还有正在处理的事务、网络上也还有在途请求。先注销让客户端立刻感知(不用干等 TTL),再拒绝新请求,等存量请求处理完才真正退出;设超时是为了防止某个超长任务卡住永远下不了线。这和第 4 节 GracefulStop 一脉相承。
面试官想听的关键词:先注销再拒流、等在途请求、超时强制退出、防止请求丢失。
常见失分点:只说「注销」漏掉「等请求处理完」;或不知道还要设超时兜底。
一句话结论:CAP 指一致性、可用性、分区容错性,分布式系统最多同时满足两个;因为网络分区客观存在,通常必选 P,剩下 C 与 A 二选一。
展开:C 是副本数据一致,A 是每请求都能返回结果,P 是网络分区时仍能工作。网络分区(光缆挖断、机房断电)无法完全避免,所以 P 必选。一旦分区发生,要么放弃 C(各分区继续服务,数据暂不一致,选 AP),要么放弃 A(拒绝服务保证一致,选 CP)。etcd 是 AC 特例——网络分区时整体不可用。
面试官想听的关键词:三选二、网络分区客观存在、CP vs AP、etcd 是 AC 特例。
常见失分点:说「可以三者兼得」(错,定理是至多两个);或答不出「为什么必选 P」的根因(网络分区不可消除)。
一句话结论:面试标准答案选 AP;理由是在服务发现场景「拿到错误/稍旧的数据也好过拿不到数据」。
展开:如果注册中心为了强一致(CP)在网络抖动时直接不可用,所有服务就互相找不到了,远比「列表里多了一个刚下线的节点」要命。客户端拿到稍旧的列表仍可以试调用或 failover。实践中小规模集群 CP/AP 差别不大随便选,大规模优先 AP,而 etcd 虽然是 AC 模型但 Go 生态首选。
面试官想听的关键词:AP 标准答案、容忍旧数据、failover、小规模随便选、etcd AC 特例。
常见失分点:只答「选 AP」不给理由;或不知道补充「etcd 其实是 AC」「小规模可随意」显得太绝对。
一句话结论:ZK 是 CP(主从、脑裂、选举期不可用)、Eureka 是 AP(对等、发现慢)、Nacos 可切 CP/AP(中文社区、运维复杂)、etcd 是 AC(Raft 强一致、Go 首选)。
展开:ZK 成熟但主节点是写瓶颈、可能脑裂;Eureka 对等无单点但服务发现慢、多语言差;Nacos 功能丰富但 CP/AP 集成源码复杂;etcd 轻量、Go 友好、大规模表现好,但网络差时不可用。选型:小规模随便选,大规模优先 etcd。
面试官想听的关键词:CP/AP 倾向、主从 vs 对等、脑裂、Raft、Go 生态首选。
常见失分点:把四个的特性张冠李戴(比如把 Eureka 说成 CP);或选型时只背「用 etcd」说不出其他方案的权衡。
一句话结论:gRPC 通过自定义 Resolver 接入注册中心——Resolver 是 gRPC 的「服务发现抽象层」,你实现它就能把任意注册中心适配成 gRPC 可用的地址源。
展开:gRPC 默认用 DNS 解析(dnsResolver)。要接 etcd,就实现一个 Builder(返回 scheme 如 etcd)+ Resolver,在 Build 时全量拉地址、之后 Watch 变更,通过 ClientConn.UpdateState 把最新列表回报给 gRPC。分工上:Resolver 负责「提供名单」,Balancer/Picker 负责「从名单挑一个」(负载均衡下一章讲)。
面试官想听的关键词:Resolver 抽象层、Builder、scheme、Watch、名单 vs 挑选。
常见失分点:以为 gRPC 内置了某某注册中心的支持(其实只内置 DNS,其余都要自己实现 Resolver);或混淆 Resolver 与负载均衡的职责。
一句话结论:Lease 是带 TTL 的租约,key 绑定租约后只要持续续约就一直存活;进程宕机无法续约,TTL 一到 etcd 自动删除 key,从而实现宕机自动下线。
展开:注册时 Grant 申请租约(如 30s),AddEndpoint 绑定 leaseID,并开 KeepAlive 每 TTL/3 续约。若进程 kill -9 来不及 DeleteEndpoint,没有人续约,TTL 到期后 etcd 自行剔除该 endpoint。TTL 长短是权衡:太短易误删健康节点,太长宕机后发现慢。
面试官想听的关键词:TTL、KeepAlive 续约、自动过期、优雅下线兜底、TTL 权衡。
常见失分点:答不出「为什么必须带 Lease」(以为 DeleteEndpoint 就够了,却忽略宕机来不及删的场景);或不知道默认续约间隔是 TTL/3。
一句话结论:能,但有限度——老节点照常服务、客户端用本地缓存继续调、连不上的节点 failover 剔除;只有「全新服务」因无任何缓存而无法发现,返回错误让调用者决定。
展开:客户端收不到节点变动通知,于是用最近一次本地缓存的列表继续调用,调用失败就把该节点移出;服务端侧老节点继续提供服务(客户端可能还在用缓存),新节点注册失败直接 fail-fast 退出。注册中心恢复后,Watch 自动重新同步列表。这正是「选 AP、容忍旧数据」的价值——注册中心崩溃 ≠ 系统整体不可用。
面试官想听的关键词:本地缓存降级、老节点继续服务、新节点 fail-fast、failover、AP 价值。
常见失分点:直接答「注册中心挂了系统就挂了」(错,这是面试想听到的关键区分点);或漏掉「新服务无缓存会失败」这个边界。
一句话结论:K8s 的 Service + 集群 DNS 已经帮你做好了「名字 → 动态 Pod IP」的映射,零额外组件、零运维,还能天然解决 IP 漂移,所以通常直接用 DNS 即可。
展开:K8s 中 Service 是稳定名字,背后 Pod 可随意增删,集群内部 DNS 自动把名字翻译成当前真实 Pod IP。注册中心和它本质是同一件事(名字到动态地址的映射)的两种实现,只是注册中心额外支持主动通知和多语言客户端。非 K8s 环境才需要自建注册中心(如 etcd)。
面试官想听的关键词:Service 名稳定、DNS 自动映射、IP 漂移、零额外组件、同本质异实现。
常见失分点:说「K8s 不能用注册中心」(可以用,只是通常不需要);或不知道 Service 背后是 Pod IP 动态变化。
一句话结论:两种——启动时发现(初始化阶段拉全列表,发现失败则客户端不启动)和首次调用时发现(懒加载,第一次调某服务才去发现)。
展开:启动时发现让客户端一启动就持有完整可用列表,调用时无额外延迟,但启动变重、发现失败直接起不来;首次调用时发现启动轻量,但第一次调用某个服务会有发现的开销。两者都要求客户端与注册中心保持心跳,以便及时收到节点变动通知。
面试官想听的关键词:启动时 vs 首次调用、懒加载、fail-fast 不启动、心跳保持通知。
常见失分点:只答出一种;或不知道「客户端也要和注册中心保持心跳」才能收到变更。
八、本章小结
学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。
可勾选清单(面试前 5 分钟扫一遍)
- 为什么需要:微服务实例地址动态变化(重启/扩容/漂移),写死 IP 不可维护;注册中心靠「主动通知」成为主流。
- 四种模型:IP 直连(联调)、DNS(K8s 首选)、注册中心(主流)、服务自省(超大规模);网关是「集中做发现」。
- 注册中心机制:服务端注册 → 心跳续租(租约)→ 节点变更主动通知客户端 → 客户端 failover。
- CAP 理论:分布式三选二;网络分区客观存在,通常必选 P;注册中心面试标准答案选 AP,etcd 是 AC 特例。
- 主流注册中心:ZooKeeper(CP/主从/脑裂)、Eureka(AP/对等/慢)、Nacos(可切/中文社区)、etcd(AC/Raft/Go 首选)。
- gRPC 接入:实现 Resolver 抽象层(Builder+Resolver),用
etcd:///服务名拨号;Resolver 提供名单,Balancer 挑选。 - etcd Lease:key 绑 TTL 租约 + KeepAlive 续约;宕机不续约 → TTL 到期自动剔除;TTL 太短误删、太长发现慢。
- 铁律:先 Listen 开端口 → 再注册 → 最后 Serve;IP 不能用 127.0.0.1;注册中心连不上 fail-fast。
- K8s:优先用 Service + DNS,零额外组件、天然解决 IP 漂移。
- 故障容错:注册中心崩 → 客户端用本地缓存降级 + 老节点继续服务 + 新节点 fail-fast;服务端崩 → 客户端 failover。
- 高可用:集群部署 / 双注册中心 / 按业务拆分;测试环境主动模拟注册中心崩溃验证容错。
- 其他框架:go-zero/Kratos/ego 都绕不开 Resolver + register + 续约,并用 Registry/Discovery 接口层解耦。
下一章我们将进入负载均衡(第 13 章):这一章我们让 post 服务「找到」了 user 服务的多个实例,但请求具体发给哪一个?轮询、随机、加权、一致性哈希有什么区别?gRPC 的 Balancer 怎么挑?
grpc.Dial 加了一行 WithDefaultServiceConfig('{"loadBalancingPolicy":"round_robin"}')——但你有没有想过,如果不写这行,gRPC 默认只会连解析出来的第一个地址?这就是下一章要解决的「从名单里挑谁」的问题。带着「多实例如何均匀分发」的疑问进入第 13 章,会学得更扎实。