微服务互相找不到人?用 Rust 从零写个注册中心
"订单服务刚扩了 3 台机器,用户服务还攥着旧的 IP 列表狂发请求,一半流量打到已经下线的实例上,报错率直接翻倍。"
—— 某团队发版当晚的真实事故
这段话描述的场景,每个做过微服务的人都遇到过。解法听起来朴素得可笑——给所有服务配一本「电话簿」,而且能自动划掉已经离职的人。 这本电话簿,就是注册中心。
但「电话簿」三个字骗了很多人。真要把它做对,背后藏着一个吵了十年的分歧:注册中心到底要强一致还是高可用?etcd 选了前者,Eureka 偏要选后者。这一个取舍,决定了心跳、变更通知、集群部署全部长什么样。
这篇就从零实现一个注册中心,重点不是「能跑就行」,而是把每个设计背后的原理讲清楚——它为什么是这样,不这样会怎样。
上一篇 搭分布式 KV 存储 结尾说,服务发现推荐直接用 etcd。这篇就来兑现——既然已经手撕了 OpenRaft,为什么不自己造一个?
架构全景
先把注册中心放进整个调用链里:
┌──────────────────────────────────────────────────────────────┐
│ 调用方(user-service) │
│ "我要调 order-service" │
│ 查本地缓存 ──→ 没有?问注册中心 │
│ 拿到实例 [10.0.0.2, 10.0.0.3, 10.0.0.4] → LB 选一个 → 发请求 │
└──────────────┬───────────────────────────────────────────────┘
│ 查询/订阅
▼
┌─────────────┐
│ 注册中心 │ ← 本文主角:一张分布式电话簿
└──────┬──────┘
│ 注册 / 心跳 / 注销
┌────┴────┬─────────┐
▼ ▼ ▼
order-1 order-2 order-3
每个实例干三件事:启动时注册、运行中发心跳、下线时注销。注册中心内部维护的数据,本质就是一个 KV——key 是服务名,value 是实例列表。
关键洞察:注册中心 = 一个会自动过期、能实时通知变更的分布式 KV。这就是为什么上一篇的 OpenRaft 能直接拿来用。
数据模型:先承认它就是个 KV
很多人一上来就设计「服务表、实例表、健康状态表」三张关系表,把简单问题搞复杂。注册中心的数据模型,最自然的表达就是一个嵌套 map:服务名 → 实例列表。
为什么不用关系表?
因为数据结构应该跟着访问模式走。注册中心的访问模式是「按服务名取一整坨实例」——调用方要的是"order-service 有哪些实例",而不是"按实例 ID 查单条""按某个字段过滤"。关系表的优势是「按字段查询、事务、联结」,这些注册中心一个都用不上。嵌套 map(服务名 → Vec<实例>)反而最贴合:一次查找拿到全部实例,O(1)。
实例里装着地址、元数据(版本、权重、区域)、租约时长、最近心跳时间。它和一个 HashMap<String, Vec<Instance>> 没有任何区别。 注册中心的复杂性根本不在数据结构上,而在「这张表怎么在多台机器间保持一致」和「实例挂了怎么自动划掉」上——这也是后面所有章节真正要解决的问题。
坑一:用 IP 当实例 ID,发布时就翻车
用 IP:port 当唯一 ID 看起来天经地义,但滚动发布时,新实例常常复用同一个端口——注册中心以为是同一个实例,只刷新了心跳,新实例的版本号等元数据被丢了,灰度发布悄悄失效。
实例 ID 必须包含一个每次启动都变化的随机部分:服务名#地址#UUID。重启即变化,才能保证全局唯一、发布可识别。
经验法则:实例 ID 要「服务内唯一」且「重启即变化」。别图省事用纯 IP。
注册与注销
注册有两种语义,差一个字,设计差出一座山:
永久注册(ZooKeeper 早期用法): 租约注册(etcd / Eureka / Consul)✅
写一条记录 → 永远有效 注册时带 TTL → 拿到"租约" → 必须持续续租
实例崩溃来不及注销 → 僵尸数据 不续租 → 租约到期自动失效
租约模型把「实例下线」从主动操作变成了被动超时。崩溃、断网、kill -9——所有非正常退出都被同一个机制兜住了。这是注册中心能「自愈」的根基。
原理:过期为什么靠时间,不靠网络?
lease 的失效判定只依赖时间流逝,不依赖任何网络通信。实例崩了 → 不再发 keep-alive → 时间一到 lease 自然失效 → 关联实例被摘除。
整个过程注册中心不需要"主动联系"实例,更不需要知道它为什么没续租。
为什么这比"主动检测"更鲁棒?因为主动检测要假设网络是通的——实例可能好好的,只是探测包丢了,这时摘除就是误杀。
而被动超时把判断权交给时间:时间不会骗人(只要各节点时钟大致同步,再加一个安全边际抵消时钟漂移),到期就是到期。
把不可靠的「网络信号」换成可靠的「时间信号」,是租约模型的核心机巧。
坑二:优雅下线,顺序反了就是事故
实例主动下线,正确顺序很反直觉:先排空,再注销。
❌ 先注销,再停服务
注册中心立刻摘掉 → 但调用方本地缓存还没更新 → in-flight 请求全部失败
✅ 先标记 draining(不接收新请求)→ 处理完手上的请求 → 才注销
中间要留一个宽限期(一般是 TTL 的 1~2 倍)。这和 API 网关 讲的「连接排空」是同一件事——零停机发布的前提。
健康检查:心跳和探测是两码事
实例注册之后,怎么知道它还活着?两种完全不同的思路:
客户端心跳(Eureka): 服务端探测(Consul):
实例每 N 秒主动喊"我还活着" 注册中心每 N 秒去请求 /health
简单,但进程卡死时心跳照发不误 能发现"半死不活",但注册中心压力大
心跳只能证明「进程还在」,探测才能证明「服务可用」。一个进程可能没死,但线程池耗尽、死锁、依赖的数据库挂了——这时心跳照发,调用方却根本连不上。所以两者要配合:客户端心跳做「快速续租」(5~10s,判进程死活),服务端探测做「深度体检」(30s,判服务可用性)。
坑三:摘除抖动(flapping)
网络偶尔抖一下,心跳丢了,注册中心立刻摘除——流量涌向其他实例可能引发雪崩;下一秒心跳补上又加回来。这种反复横跳叫 flapping。
根因在于:健康判定是在不完整的信号上做判断。心跳可能丢、探测可能超时,单次信号根本不可靠。要降噪,必须靠「多次信号的累积」。所以解法和 API 网关 的熔断器一模一样——留一个怀疑态:
healthy ──1 次失败──→ suspect ──再失败──→ unhealthy(摘除)
↑ │
└── 连续成功 ─────────┘ (恢复也要试探,不直接转 healthy)
原理:为什么恢复也要慢?
注意「坏→好」的转换比「好→坏」更值得怀疑。一个刚崩过的实例,恢复的瞬间很可能再次崩掉(资源还没释放干净、依赖还在重连)。
所以从 unhealthy 回来要先在 suspect 态待一会、连续成功几次才转 healthy——这相当于统计上「样本不足时不下结论」。
suspect 态的本质是给系统一个确认窗口,用时间换判断的置信度。
经验法则:健康判定必须是状态机,不能「一次定生死」。引入 suspect 中间态 + 连续失败计数,能消灭 90% 的 flapping。摘除要慢,恢复也要慢。
CP 还是 AP?etcd 和 Eureka 吵了什么
前面都在讲「单机」注册中心。但生产环境是集群,这就引出了全篇最核心的抉择。
CAP 定理(Brewer, 2000)说:网络分区(Partition)迟早会发生,届时你只能在 一致性(Consistency) 和 可用性(Availability) 之间二选一——P 是躲不掉的。
注册中心集群:A、B、C 三节点
网络分区(C 和 A、B 断了)
┌─────────────────┴─────────────────┐
▼ ▼
[A、B 多数派分区] [C 少数派分区]
CP 派(etcd):C 要保 → 少数派 C 直接拒绝读写
AP 派(Eureka):A 要保 → 每个分区照常读写,但 C 上的注册表是旧的
| 维度 | CP 派(etcd / ZooKeeper) | AP 派(Eureka / Nacos) |
|---|---|---|
| 一致性 | 强一致(Raft / Zab 多数派) | 最终一致(Gossip 扩散) |
| 分区时少数派 | 拒绝服务 | 照常服务,可能返回旧数据 |
| 写入路径 | 必经 Leader + 多数确认 | 任意节点可写 |
| 代表场景 | 配置中心、分布式锁、选主 | 纯服务发现 |
CP 派为什么「少数派必须拒绝」
这是理解 etcd 的关键。CP 派用多数派(quorum)写:一条数据要写入,必须有多数节点(3 个里至少 2 个)确认,才算成功。少数派分区只有 1 个节点,凑不齐多数,写根本无法 commit。
那如果它硬要接受客户端的写、还返回「成功」呢?那就是在撒谎——这个写没被持久化、没被复制,网络一恢复就可能丢。更糟的情况:如果两个分区都接受写,恢复时就出现双写冲突(A 区写了 x=1,B 区写了 x=2),必须靠复杂的冲突解决(向量时钟、CRDT),否则就丢数据。
Raft 的选择是「不撒谎」:写不进去就明确拒绝。宁可这一小段时间不可用,也不给出一个无法保证一致性的回答。这就是 C 优先于 A。
AP 派为什么「宁可给旧数据也要响应」
AP 派不用 quorum,每个节点都能直接响应读写,靠 Gossip(流言)协议在后台把数据扩散到全网:每个节点周期性地随机挑几个邻居交换状态,邻居再传给邻居的邻居,像传染病一样。N 个节点,大约 O(log N) 轮就能让全网收敛到一致。
Gossip 扩散(每个节点只跟随机几个邻居聊):
T0: 只有 A 知道新实例
T1: A 告诉 B、C → A B C 知道
T2: B 告诉 D、C 告诉 E → A B C D E 知道
... 几轮之内全网收敛
代价是:收敛需要时间(所以叫最终一致,不是立刻),而且收敛过程中各节点看到的数据可能不一样——你查到的实例列表和我查到的不一样。但对服务发现,这点不一致能被调用方的重试/熔断兜住,可接受。
原理:为什么 Netflix 偏要选 AP?
因为对注册中心,可用性比强一致更要命。注册中心一旦挂了,所有服务当场互相找不到,整个系统瘫痪——这是「全局性故障」。而返回一个略微过时的实例列表,最坏只是让调用方偶尔打到一个已死实例,然后重试就行——这是「局部、可恢复的故障」。两害相权取其轻:宁可给可能过时的指路,也不能闭门谢客。 这就是 A 优先于 C。
那 etcd 为什么敢选 CP?因为它不止是注册中心,还要扛配置、锁、选主——这些场景给错数据的代价远高于短暂不可用(比如两把分布式锁发给同一个人,数据就损坏了)。Kubernetes 用 etcd,是把「可用性」交给控制面的多副本 + 调度,存储层只管「绝对正确」。
决策树:
还要承担配置中心 / 分布式锁吗?
├─ 是 → CP(一致性优先)
└─ 否 → 纯服务发现 → AP(可用性优先)
经验法则:纯服务发现选 AP;带配置/锁/选主选 CP。这是整个设计的总开关,选错了,后面所有组件怎么调参都别扭。
用 OpenRaft 实现一个 CP 型注册中心
我们选 CP 型——上一篇已经手撕了 OpenRaft,直接复用,这是本系列最大的爽点。
注册中心和 KV 的唯一区别,是状态机 apply 的逻辑:从 Set/Get 换成了 Register/Deregister/Heartbeat。
// 复用上一篇的状态机,业务逻辑全在这
async fn apply(&mut self, entry: &Entry<ClientRequest>) -> ClientResponse {
match &entry.payload {
EntryPayload::Normal(ClientRequest::Register { service, instance }) => {
self.services.entry(service.clone()).or_default().push(instance.clone());
ClientResponse::Ok
}
EntryPayload::Normal(ClientRequest::Deregister { service, id }) => {
self.services.get_mut(service).map(|l| l.retain(|i| i.id != *id));
ClientResponse::Ok
}
EntryPayload::Normal(ClientRequest::Heartbeat { service, id }) => {
// ⚠️ 心跳也走 Raft?往下看,这是个坑
self.refresh_heartbeat(service, id);
ClientResponse::Ok
}
_ => ClientResponse::Ok,
}
}注册、注销都交给 Raft 复制到多数节点后才生效,整集群的注册表强一致。
坑四:心跳到底要不要走 Raft?(最容易被坑的地方)
上面把 Heartbeat 也写进了共识流程,意味着每次心跳都走一次 Raft。算笔账:1000 个实例、每个 10s 心跳一次,就是每秒 100 次 Raft 写。每次写 = Leader 落盘 + 复制到多数 Follower + Follower 落盘。日志疯狂膨胀,磁盘 I/O 打满。
但心跳只是「续租」,根本不改变注册表结构——为什么要为它付出一次共识的代价?
最直觉的改进是「心跳只刷 Leader 内存,不进日志」。但这个方案有个致命缺陷:
原理:为什么「只刷 Leader 内存」不行? Raft 的保证是「所有节点按相同顺序应用相同的日志」。心跳如果不进日志,这次「刷新」就没被复制——它只活在 Leader 的内存里。Leader 一旦切换(崩溃、网络分区、重新选举),新 Leader 的状态机里根本没有这些心跳记录,它会用旧的
last_heartbeat去判断过期。如果上次同步后实例续了好几次租,新 Leader 看不到这些续约,可能把一个还活着的实例判死,并发起 Deregister——一次 Leader 切换引发雪崩。这就是「状态没被复制」的代价:单机内存状态在故障面前一文不值。
那 etcd 是怎么做到「续约不写放大,又不丢状态」的?它的 lease 机制很精妙:
etcd 的 lease 三层设计:
① lease 的「创建 / 撤销」走 Raft → 全集群都知道"有这个 lease"
② lease 的「续约(keep-alive)」 → 只刷新 Leader 内存的剩余 TTL,不写日志(不放大)
③ Leader 定期(约 TTL/3)把所有 → 一条日志同步给 Follower(批量,不是每次续约一条)
活跃 lease 的剩余 TTL 打包
这样续约不产生写放大(不是每次续约都一条日志),且 Leader 切换后,新 Leader 能从最近一次同步推算出每个 lease 还剩多久,再加一个安全边际抵消时钟漂移。
我们的折中方案是 lease 思路的简化版:心跳只刷 Leader 内存,由 Leader 开一个后台 ticker 扫描过期实例,过期了才发一条 Deregister 走 Raft。
// Leader 后台扫描过期实例:心跳只刷内存,摘除才走共识
async fn reaper(self: Arc<Self>) {
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
if !self.is_leader().await { continue; }
for (service, id) in self.scan_expired() {
let _ = self.raft.client_write(
ClientRequest::Deregister { service, id } // 改变注册表,才花这次共识代价
).await;
}
}
}要诚实指出它的缺陷:Leader 切换的瞬间,新 Leader 不知道谁续过租,会有一个 TTL 窗口的误判期——可能把活实例短暂判死。但这个误判能被调用方的「重试 + 熔断」兜住,所以工程上可接受。etcd 的 lease 方案就是为了消灭这个窗口,代价是实现更复杂。
关键洞察:「改变注册表的操作」才需要共识,「维持状态的信号」不需要。 注册、注销、摘除走 Raft;心跳、健康探测走内存。这是 etcd 把 lease 和 KV 分开存的同一个道理。很多人把心跳塞进共识流程,集群一上规模就卡死,还找不到原因。
经验法则:判断一个操作要不要走共识,只问一句——"它会不会改变别人看到的数据?" 会,走共识;不会,走内存。
变更通知:Watch 还是轮询?
调用方拿到实例列表后,实例动态上下线,它怎么知道?
定时拉取(Pull,Eureka 默认): 订阅推送(Watch,etcd / Consul):
每 30s 全量拉一次 订阅一次,注册表一变就主动推
实现极简,但变更感知慢(最坏 30s) 毫秒级感知,但注册中心要维护长连接
原理:为什么 Watch + Pull 必须组合?
Watch 用长连接推送,延迟低,但长连接有个隐患——它会静默断开(TCP 半开:双方以为连着,其实中间的网络早断了,谁都没发现)。
断开期间的变更全部丢失,而注册中心和客户端都还蒙在鼓里。所以必须有一个低频的定时全量拉取(比如 60s 一次)做对账:哪怕 Watch 漏了一堆,下次 Pull 也会把完整状态拉回来,自我修正。
Watch 负责「快」,Pull 负责「兜底」,缺了 Pull,数据漂移了你都不知道。
坑五:慢消费者会拖垮 Watch
一个客户端订阅了却处理很慢,注册中心每次变更都往它的通道塞数据。通道塞满了要么阻塞推送,要么丢消息。
解法是通道必须有界,满了就丢——丢的变更会被低频的全量拉取兜回来。宁可让一个慢客户端少收几次增量,也不能让它拖垮整个注册中心。这就是为什么 Pull 兜底不能省。
客户端集成:把注册中心搬到本地
调用方不该每次发请求都查注册中心(那会把注册中心拖成瓶颈)。
正确做法是本地缓存 + 后台 Watch:首次查询后缓存到内存,之后靠 Watch 增量更新,调用时直接读本地缓存、用客户端负载均衡挑一个实例。
这引出两条技术路线:
客户端发现:Consumer 自己查注册表、自己 LB 服务端发现:Consumer → Sidecar/网关 → Backend
少一跳,延迟低 Sidecar 查注册表 + LB,逻辑收敛一处
缺点:每种语言都要写客户端 多一跳,但语言无关
代表:Eureka + Ribbon 代表:K8s Service、Envoy
关键洞察:服务端发现里那个 Sidecar / 网关,正是上一篇 API 网关 的角色。你的基础设施线到这里彻底闭环了:注册中心告诉网关有哪些实例,网关用负载均衡挑一个转发,连接池管理到实例的连接,熔断器在故障时快速失败。
经验法则:语言单一的小团队用客户端发现(少一跳);多语言、想统一治理的大团队用服务端发现。别一上来就上 Sidecar,那是给规模付费的。
坑六:本地缓存会撒谎
本地缓存是「最终一致」的,永远比注册中心慢半拍。某个实例刚下线,你的缓存还没更新,就会打到死实例上。
原理:本地缓存到注册中心之间,隔着三层延迟——Raft 共识延迟(写要等多数派确认,毫秒级)+ 推送延迟(Watch 或 Pull 的网络往返)+ 客户端处理延迟。再叠加 Watch 可能静默断开漏消息,本地缓存物理上就是一个「T 秒前的快照」,T 最坏等于 Pull 间隔。所以「永不信任本地缓存」不是谨慎,是事实。
防御组合缺一不可:请求失败立刻乐观摘除 → 重试换一个 → 连续失败触发熔断 → 定时全量拉取修正偏差。
经验法则:注册中心负责「大致指路」,调用方负责「撞墙后自救」。把容错的最后一道防线,永远放在调用方。
和现成方案对比
| 方案 | 一致性 | 适用场景 |
|---|---|---|
| etcd | CP | 配置中心 + 服务发现 + 锁 |
| Consul | CP/AP 可选 | 服务发现 + 强健康检查 |
| Eureka | AP | 纯服务发现(Java 生态) |
| Nacos | CP/AP 可切换 | 服务发现 + 配置(国内主流) |
| 自研(OpenRaft) | CP | 嵌入式、轻量、学习 |
团队用 Java/Go、规模不大 → 直接上 Nacos / Eureka;已经是 Rust 技术栈、想把注册中心嵌进自己应用 → 自研收益最大;想彻底搞懂原理 → 自研一遍,比读十篇博客都管用。
总结:原理清单
| 原理 | 核心思想 |
|---|---|
| 数据结构跟着访问模式走 | 注册中心按服务名取一坨实例,嵌套 map 比关系表更贴合 |
| 租约模型 | 过期靠时间不靠网络,把不可靠的网络信号换成可靠的时间信号 |
| 心跳 vs 探测 | 心跳判进程死活,探测判服务可用,两者配合 |
| 健康状态机 | 在不完整信号上判断要累积降噪,suspect 态用时间换置信度 |
| CAP 少数派拒绝 | 凑不齐 quorum 就写不进去,拒绝比撒谎更安全(防双写冲突) |
| Gossip 收敛 | 随机扩散 O(log N) 轮收敛,最终一致但延迟可被重试兜住 |
| CP vs AP 取舍 | 纯服务发现选 AP(可用性更要命),带锁/配置选 CP |
| 心跳不进共识 | 改变注册表才走 Raft;状态不复制,Leader 切换就丢 |
| lease 三层设计 | 创建走共识、续约刷内存、定期批量同步,兼顾一致与性能 |
| Watch + Pull | Watch 求快,Pull 兜底防静默断开丢消息 |
| 慢消费者隔离 | 有界通道满了就丢,靠 Pull 补偿 |
| 本地缓存的三层延迟 | 共识 + 推送 + 处理,本地缓存物理上就是「T 秒前的快照」 |
| 客户端 vs 服务端发现 | 少一跳 vs 统一治理 |
| 乐观摘除 + 重试 | 永不信任本地缓存,靠重试/熔断自救 |
最后的教训:注册中心的价值不在于「存了一张表」,而在于「让这张表在机器崩溃、网络分区、滚动发布的混乱中,依然能给调用方一个基本靠谱的指路」。
它允许偶尔犯错——因为调用方有重试和熔断兜底。这种「不追求绝对正确,追求整体可用」的工程哲学,正是分布式系统设计的精髓。
相关文章:
- 用 Rust 搭分布式 KV 存储 —— 本文复用它的 OpenRaft 共识层
- Rust 从零实现 API 网关 —— 网关的负载均衡、熔断、连接排空,与服务发现闭环
- 连接池设计原理 —— 拿到实例列表后,连接怎么管
- Rust 微服务开发 —— 注册中心在微服务架构中的定位