返回文章列表

微服务互相找不到人?用 Rust 从零写个注册中心

784·5 分钟阅读
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 统一治理
乐观摘除 + 重试 永不信任本地缓存,靠重试/熔断自救

最后的教训:注册中心的价值不在于「存了一张表」,而在于「让这张表在机器崩溃、网络分区、滚动发布的混乱中,依然能给调用方一个基本靠谱的指路」。

它允许偶尔犯错——因为调用方有重试和熔断兜底。这种「不追求绝对正确,追求整体可用」的工程哲学,正是分布式系统设计的精髓。


相关文章: