第12章 · 服务注册与发现(正式版)

2023-02-17T14:11:02+08:00 | 60分钟阅读 | 更新于 2023-02-17T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 12 章。前面第 10 章我们把单体拆成了微服务,第 11 章把数据零停机迁到了新库——服务拆好了、数据也准备好了,但有一个问题没解决:这些散落各地的服务,彼此之间怎么找到对方?

这一章你会学到微服务世界里最重要的一张「通讯录」。它会回答三个问题:服务实例的地址总在变,怎么让调用方永远拿到最新地址?注册中心挂了服务还能不能互相调用?为什么面试必问 CAP 理论?

这一版做了三件事:

  1. 每一步都讲透再动手。CAP 理论、心跳、Lease 租约、Resolver 这些你大概率没听过,本章全部就地补「补课胶囊」,不让你去搜。
  2. 用类比先让你看见。电话簿、查号台、外卖骑手、租房合同——先用生活场景建立直觉,再上代码和图。
  3. 结尾有 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 实时刷新这张列表。两半合起来,调用方永远拿着一份最新的健康名单。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用「电话簿 / 查号台」类比,向一个完全不懂的人讲清楚服务注册与发现解决了什么问题。
  2. 画出服务发现的三要素:注册中心、服务端(提供者)、客户端(调用者),并说清它们之间注册、心跳、查询、通知的关系。
  3. 讲清 CAP 三者的含义,并能在白板上画出「三选二」的取舍图,解释为什么分布式系统通常必选 P。
  4. 用 Go + etcd 写出「服务端注册 + Lease 续约 + 客户端 Resolver」的最小可用代码,并解释每一行为什么这么写。
  5. 面对「注册中心崩溃」的故障场景,说清客户端和服务端各自应该怎么办(本地缓存降级 + 老节点继续服务)。

理解(能讲清原理和取舍,不要求背代码)

  1. 四种服务发现模型的取舍:IP 直连、DNS、注册中心、服务自省(含网关这一特殊形态)。
  2. 心跳机制的两种模式、心跳间隔与失败判定次数的权衡、优雅下线的步骤。
  3. 注册中心为什么面试标准答案选 AP 而不是 CP,以及 etcd 为什么是「AC 模型」。
  4. gRPC 的 Resolver 抽象层为什么存在、它和负载均衡(Balancer)怎么分工。
  5. etcd 的 Lease 租约机制:为什么宕机没主动删除,key 也能自动过期下线。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. ZooKeeper / Eureka / Nacos 作为注册中心的差异(CP / AP / 可切换),以及各自适用规模。
  2. K8s 里 Service + DNS 的服务发现形态,以及它和注册中心方案的本质区别。
  3. 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);之后转入「第四节 跟着写」动手、以「第七节 面试问答」收口。每条边就是「读完上一节再读下一节」,没有分支。

服务注册与发现为什么需要注册中心机制CAP 与选型硬编码 IP 的痛点4 种发现模型对比查号台类比启动注册 + 心跳续租优雅下线 + 发现时机主动通知节点变更CAP 三选二必选 PZK/Eureka/Nacos/etcd选 AP 标准答案
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。第 3.4 节 CAP 理论和第 3.10 节故障容错是面试最高频的两块,建议优先啃透;第 4 节跟着写需要你本地有 Docker(起一个 etcd),没有的话把代码读懂、把验证输出看明白也算过关。

三、正文

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

流程只有四步,但每一句都有讲究:

  1. 注册:user 服务每个实例启动时,把自己的「IP:端口」报到注册中心。
  2. 心跳:之后持续向注册中心发心跳,证明「我还活着」。
  3. 查询:post 服务(客户端)调用前,先问注册中心「user 现在有哪些实例可用」。
  4. 调用: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 的短板:

  1. 服务端启动时主动在注册中心注册(而不是等被查);
  2. 客户端首次调用前查询注册中心,缓存可用节点;
  3. 注册中心在节点变动时主动通知客户端(「推(push)」模型)。

「主动通知」是它相对 DNS 的最大优势,也是它成为主流方案的原因。

模型四:服务自省(Dubbo 提出,了解即可)

注册中心在超大规模集群下会成为瓶颈——几万个实例同时心跳、同时变更通知,单台注册中心扛不住。

服务自省的思路是给注册中心「减负」:

  • 注册中心只存最小化数据(比如「服务名 → 实例定位信息」这种最基本的映射);
  • 其余元数据(权重、版本、机房等治理信息)通过单独的「元数据服务」暴露;
  • 元数据一般不变,客户端只需查询一次,不用反复打扰注册中心。

这套方案实现复杂,本章不展开,知道有这回事、面试能说出一句话定位即可。

模型五:借助网关(两次发现)

严格说这不是服务发现的一种,但你一定会遇到:请求先进微服务网关(gateway),再由网关转发给真实服务端。

这里其实藏着两次「找地址」:

  1. 客户端找网关:一般通过域名(如 gateway.mycompany.com),或从配置项拿到所有网关节点地址;
  2. 网关找服务端:网关要把请求转发到真实服务端,同样需要知道服务端地址——这又是一个服务发现过程,同样可以借助注册中心或 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 启动注册:连不上就「宁可起不来」

服务端启动时做的事:

  1. 主动注册:把自己(IP + 端口 + 治理信息)写入注册中心;
  2. 怎么知道注册中心在哪:一般通过「域名 + 端口」(如 etcd.mycompany.com:2379)或「直接指定 IP + 端口」配置;
  3. 连不上注册中心,服务端直接退出(fail-fast):启动阶段连不上,就别启动了。

补课胶囊 · fail-fast(快速失败) 一句话:启动前置校验不过,立刻报错退出,而不是带着错误继续跑。 类比:就像登机前安检,身份证不对当场拦下,而不是让你上了飞机再发现。 回到主线:注册中心连不上就 fail-fast,是为了避免「注册了脏数据(比如写错了 IP)让客户端调不通」。

复习提示:⚠️ 新手必踩的坑:fail-fast 会不会误伤。会。如果注册中心只是短暂抖动(网络闪了一下),所有服务端一起退出,反而引发雪崩。生产上常配「启动期有限次重试 + 告警」,确认注册中心真挂了才退。但核心原则不变:宁可起不来,也别注册了脏数据让客户端调不通。

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——因为:

  • 它可能还有正在处理的请求(比如一个写数据库的事务跑到一半);
  • 网络上可能还有客户端正在发来的请求(包已经在路上了)。

优雅下线步骤:

  1. 通知注册中心:本节点要下线;
  2. 服务端不再接受新请求(包括已经读到一半的请求,读完直接拒绝);
  3. 等待正在处理的请求结束(包括定时任务);
  4. 所有请求处理完毕后退出;
  5. 设置超时时间,超时强制退出(防止某个超长任务卡住,永远下不了线)。

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,因为节点变更频繁、对可用性要求更高。
复习提示:记忆口诀:服务发现 = 找地址 = 容忍旧数据但要能找到 → 选 AP。反过来,像「银行转账余额」这种场景,数据错一点都不能忍 → 选 CP(典型如 ZooKeeper、etcd)。

一个具体场景帮你理解 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 倾向架构优点缺点适用规模
ZooKeeperCP主从成熟、多语言、实时通知脑裂、主写瓶颈、选举期不可用中小
EurekaAP对等高可用、横向扩展发现慢、多语言差中
NacosCP/AP 可切混合中文社区、功能丰富运维难、源码复杂中/大
etcdAC共识集群轻量、Go 友好、强一致网络差时不可用大(Go 生态首选)

选型结论

复习提示:小规模集群随便选,熟悉哪个用哪个;大规模集群优先考虑 etcd。

选型实战:三个真实场景怎么选

  • 场景一: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 为例):

  1. 客户端 grpc.Dial("etcd:///service/interactive/") 时,gRPC 从目标地址里取出 etcd 这个 scheme(协议前缀);
  2. 在已注册的 Builder 里找到 Scheme() 返回 "etcd" 的那一个;
  3. 调用 Builder.Build 创建 Resolver;Resolver 从 etcd 查到 endpoint 列表后,通过 ClientConn 把结果回报给 gRPC;
  4. 之后 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 的补课胶囊):

  1. 创建租约,设置 TTL(如 30 秒);
  2. 注册 endpoint 时带上 leaseID;
  3. 开启自动续约 KeepAlive,每 TTL/3 时间续约一次;
  4. 服务宕机后无法续约,TTL 到期后 etcd 自动剔除该节点。

etcd 默认续约间隔:每 TTL/3 续约一次(30s 的 TTL 就是每 10s 续一次)。

缺点:etcd 自带的 KeepAlive 实现没有暴露很多控制选项(比如失败重试策略),难以在面试中「刷亮点」,但实践中完全够用。想要精细控制,可以自己手写续约(第 4 节跟着写就这么做)。

复习提示:TTL 设多长是一门权衡:太短(如 5s)→ 网络一抖续约失败,健康节点被误剔除;太长(如 5 分钟)→ 宕机后客户端最长要等这么久才知道节点没了,期间一直往死节点上发请求。课程里 30s 是工程上常用的折中值;第 4 节为了演示快一点用了 10s。
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 到期, 自动删除 endpoint

3.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 里仍要用注册中心(比如跨集群),要小心:

  1. 注册的 IP 必须是外部通信 IP(K8s Pod 之间通信的 IP);
  2. 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)。

注册中心高可用方案

方案说明
集群部署多活方案,避免单点
双注册中心同时注册两个,一个崩了用另一个
按业务拆分核心业务和非核心业务用不同注册中心
复习提示:两件很多公司没做、但值得做的事:① 如果线上和测试环境还在混用同一个注册中心——拆开,测试环境的频繁发版会污染线上节点列表;② 在测试环境主动模拟注册中心崩溃(把 etcd 停掉),验证客户端的容错措施是否真生效。如果客户端没有容错,可以改造它:注册中心崩溃后保持重试重连,期间继续使用本地缓存的可用节点。

注册中心崩溃 —— 客户端怎么办

后果:客户端无法收到服务端变动信息(上线 / 下线)。

容错策略:

  • 使用本地缓存的可用节点信息(缺陷:可能不准,有节点已下线,有节点新加进来没感知到);
  • 调用时发现无法连通,把该节点移出可用列表;
  • 如果是新服务(没有任何缓存),返回特定错误,让调用者决定怎么处理。

注册中心崩溃 —— 服务端怎么办

服务端无法更新注册信息:

  • 新节点:注册失败时直接退出服务(fail-fast,别注册脏数据);
  • 老节点:继续提供服务(客户端可能还在用本地缓存的注册数据)。

注册中心和服务端无法通信

注册中心会判定服务端崩溃,但服务端实际还活着(只是网络闪断)。

  • 客户端在收到错误判定前,依旧能调用服务端;
  • 客户端、服务端、注册中心都不需要做什么(等网络恢复即可);
  • 但服务端发现自己一段时间无法和注册中心保持心跳后,要告警,并考虑是否退出(避免「注册中心以为我死了、客户端还在打我」的长期不一致)。
复习提示:面试高频结论:注册中心挂了,已经建立连接的调用不受影响,老节点照常服务,客户端用本地缓存继续调,连不上的节点 failover 剔除;只有「全新服务」会因没有任何缓存而无法发现。 所以注册中心崩溃 ≠ 系统整体不可用,这正是「选 AP、容忍旧数据」的价值所在。

综合推演:一次真实的「注册中心抖动」

把上面几种情况串起来,想象一个真实故障时间线,你会更踏实:

  1. t=0s:etcd 集群网络抖动,和 user 实例 2 失联 → etcd 判定实例 2 死亡,通知 post;
  2. t=0.1s:post 收到通知,把实例 2 从本地列表划掉,之后只打实例 1;
  3. t=2s:网络恢复,user 实例 2 发现自己连不上 etcd 已告警,触发重新注册;
  4. t=2.1s:etcd 重新收到实例 2 的注册 + 心跳,再次通知 post;
  5. 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 都藏起来了,手写一遍才能把机制吃透,还能顺手补上它没有的两样东西:心跳失败告警 + 客户端本地缓存降级。

步骤总览:

  1. 部署 etcd(docker-compose 单节点,开发用);
  2. 定义注册模型 ServiceInstance——注册到 etcd 的数据长什么样;
  3. 封装 pkg/registry:Register / Deregister / Lease 心跳;
  4. 实现自定义 gRPC Resolver:前缀查询 + Watch + 本地缓存降级;
  5. user-service 接入:启动时注册、优雅停机时注销;
  6. 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() }
复习提示:⚠️ 新手必踩的坑:注册 key 和查询前缀对不上。etcd 的前缀查询是纯字符串匹配——注册写 /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 := &registry.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)
}
复习提示:⚠️ 新手必踩的坑:自定义 Resolver 和官方 resolver 的 scheme 冲突。我们和 3.6.7 官方 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 自动续上
复习提示:⚠️ 新手必踩的坑:etcd 停得比 TTL 还久。租约 TTL 只有 10 秒,etcd 停机超过 10 秒,恢复后租约可能已过期、注册信息被清空——心跳会一直报 lease not found,服务端却毫无感知。生产上要监听 etcd 客户端连接状态回调,断连恢复后主动重新注册(本章留作练习,思路见 3.10 节)。

本章完成后的项目结构

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 节一起看。

  1. 服务端启动失败要 fail-fast:连不上注册中心直接退出,避免注册了写错的 IP 这种脏数据,让客户端调不通。

  2. 注册必须在端口 Listen 成功之后:否则客户端拿到地址却连不上(见 3.6.8 的坑)。

  3. IP 不能用 127.0.0.1:必须注册客户端能连的对外 IP,通过 UDP 报文或环境变量确定(见 3.6.3)。

  4. 续约结果要处理:简单打日志就够,但要有监控——连续失败要告警(见第 4 节 maxHeartbeatFail)。

  5. 优雅下线要等请求处理完:但也要有超时控制,防止超长任务卡住(见 3.3.6)。

  6. 客户端要本地缓存注册数据:注册中心崩溃时能继续调用(见 3.10 节容错)。

  7. failover 必备:客户端发现某节点连不上要换节点重试(见 3.10 服务端崩溃)。

  8. K8s 优先用 DNS:避免 IP 漂移问题(见 3.7 节)。

  9. 核心业务单独注册中心:避免非核心业务故障影响核心(见 3.10 高可用方案)。

  10. 测试环境模拟注册中心崩溃:验证客户端容错是否真生效(见 3.10 的 hint)。具体做法就是把 etcd 容器 docker stop 一下,观察客户端日志有没有「续约失败」告警、调用有没有降级到本地缓存——很多团队直到线上真出事才发现客户端根本没做容错。

  11. 续约间隔与 TTL 要配套:常见错误是 TTL 设 30s 但续约间隔写成 60s——结果每次都来不及续约,key 永远在过期边缘。经验值:续约间隔 = TTL / 3(30s 配 10s),留足网络抖动余量。

复习提示:一句话工程心法:注册中心崩溃不等于系统整体不可用。只要客户端有本地缓存、老节点照常服务、新节点 fail-fast 退出,系统就能在注册中心宕机期间「带病运行」。这正印证了 3.4 节「选 AP」的价值——拿到稍旧的数据,也好过整个服务发现不可用。

六、自测题与动手练习

下面 5 道自测题不在这里给答案——合上书试着答,答不上来的回看对应小节;答案都藏在第七节「模拟面试问答」里(自测题和面试问答的知识点一一对应)。

  1. 微服务项目里直接把服务端 IP 写死在客户端,会遇到什么问题?注册中心相比 DNS 的核心优势是什么?(对应 3.1、3.2)
  2. 注册中心机制里「注册、心跳、节点变更通知、客户端发现」四步分别解决什么?(对应 3.3)
  3. CAP 三选二,为什么注册中心通常选 AP 而不是 CP?etcd 为什么被说成「AC 模型」?(对应 3.4)
  4. etcd 服务端注册为什么必须带 Lease?如果服务端 kill -9 没调 DeleteEndpoint,etcd 怎么自动剔除它?(对应 3.6.3、3.6.6)
  5. 注册中心整个崩了,客户端和服务端各自应该怎么容错?新服务(没有任何缓存)调用会怎样?(对应 3.10)

动手练习(建议真做一遍):

  1. 跑通注册 + 发现:按第 4 节起一个 etcd,服务端用 InitRegistry 注册 service/interactive/,客户端用 3.6.7 的 etcd:/// 连上并成功调一次接口。
  2. 观察 Lease 剔除:服务端注册成功后 kill -9 掉它,盯着 etcd(或客户端日志),确认过 TTL(默认 30s)后这个 endpoint 自动消失。
  3. 模拟注册中心崩溃:把 etcd 进程停掉,观察客户端是否能用本地缓存继续调用老节点、连不上时能否 failover;再恢复 etcd,看节点列表是否重新同步。
  4. 换一个注册中心再写一遍(课后作业):用单元测试写一个简单 demo——一个方法启动服务端,一个方法启动客户端并成功发起调用。中间件随便选(Consul、ZooKeeper、Nacos 都行)。写完后假设面试官问你「你为什么选这个中间件做注册中心?」——把选型理由(CAP 取舍、成熟度、多语言支持、部署成本)整理成一段能说出口的话。

七、模拟面试问答

这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。

面试官
【基础】 为什么微服务里不能把服务端 IP 写死在客户端?服务注册与发现到底解决了什么?
候选人

一句话结论:微服务实例的 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?
候选人

一句话结论:CAP 指一致性、可用性、分区容错性,分布式系统最多同时满足两个;因为网络分区客观存在,通常必选 P,剩下 C 与 A 二选一。

展开:C 是副本数据一致,A 是每请求都能返回结果,P 是网络分区时仍能工作。网络分区(光缆挖断、机房断电)无法完全避免,所以 P 必选。一旦分区发生,要么放弃 C(各分区继续服务,数据暂不一致,选 AP),要么放弃 A(拒绝服务保证一致,选 CP)。etcd 是 AC 特例——网络分区时整体不可用。

面试官想听的关键词:三选二、网络分区客观存在、CP vs AP、etcd 是 AC 特例。

常见失分点:说「可以三者兼得」(错,定理是至多两个);或答不出「为什么必选 P」的根因(网络分区不可消除)。

面试官
【进阶】 注册中心应该选 CP 还是 AP?你的理由是什么?
候选人

一句话结论:面试标准答案选 AP;理由是在服务发现场景「拿到错误/稍旧的数据也好过拿不到数据」。

展开:如果注册中心为了强一致(CP)在网络抖动时直接不可用,所有服务就互相找不到了,远比「列表里多了一个刚下线的节点」要命。客户端拿到稍旧的列表仍可以试调用或 failover。实践中小规模集群 CP/AP 差别不大随便选,大规模优先 AP,而 etcd 虽然是 AC 模型但 Go 生态首选。

面试官想听的关键词:AP 标准答案、容忍旧数据、failover、小规模随便选、etcd AC 特例。

常见失分点:只答「选 AP」不给理由;或不知道补充「etcd 其实是 AC」「小规模可随意」显得太绝对。

面试官
【进阶】 ZooKeeper、Eureka、Nacos、etcd 作为注册中心各有什么特点?怎么选?
候选人

一句话结论: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 是什么,为什么需要它?
候选人

一句话结论: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 与负载均衡的职责。

面试官
【追问】 etcd 的 Lease 租约机制是什么?为什么用它就能实现「宕机自动下线」?
候选人

一句话结论: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 里为什么常常不需要注册中心?它和注册中心方案的本质区别是什么?
候选人

一句话结论: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 怎么挑?

复习提示:给下一章留个伏笔:本章第 6 步验证里,我们给 grpc.Dial 加了一行 WithDefaultServiceConfig('{"loadBalancingPolicy":"round_robin"}')——但你有没有想过,如果不写这行,gRPC 默认只会连解析出来的第一个地址?这就是下一章要解决的「从名单里挑谁」的问题。带着「多实例如何均匀分发」的疑问进入第 13 章,会学得更扎实。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!