Chapter 02

服务发现原理:AP 这一半

上一章你知道了临时实例(默认)走 AP·Distro。这一章拆开 Distro:临时实例的数据, 到底怎么在一个 Nacos 集群的多个节点之间传播、又怎么推到客户端、以及服务端全挂时客户端凭什么还能调用。

本章你将建立的 schema

  • Distro 怎么"分片":集群里每个节点只对一部分服务数据负责,负责者是谁由一个哈希算出。
  • 一次注册写请求的完整路径:任意节点接收 → 转发给 owner → owner 异步复制给其他节点。
  • 节点间靠 checksum 只同步差异,新节点冷启动则先拉全量快照再对外服务。
  • 服务端推送从 1.x 的 UDP + 客户端轮询,换成 2.x 的 gRPC 长连接,换来了什么。
  • 客户端本地快照如何让"Nacos 挂了,调用照常"。

2.1Distro:分片与异步复制

Distro 是 AP 协议:集群中每个节点只对一部分服务"负责",负责节点先写本地、再异步复制给其他节点。

为什么这么设计

临时实例数量可达上百万,且变更频繁(每 5s 一次心跳,见 §1.4)。若每条写入都要全集群强一致确认,吞吐撑不住、可用性也差。Distro 的回答是:把数据切片分给不同节点各自负责,写入只在负责节点上同步完成,再异步扩散——牺牲"瞬时一致",换吞吐和分区可用性。

"谁负责哪个服务"不是配置出来的,是算出来的。Nacos 对服务名做哈希再对健康节点数取模:

DistroMapper(简化) Java
// 对服务名哈希,落到某个健康节点的索引区间,即为该服务的 owner
int target = distroHash(serviceName) % healthyNodeCount;
boolean responsible = (target 落在本节点负责的索引区间);
// 非 owner 收到写请求时,转发给 owner 处理
客户端 注册 svc-X Node A 非 owner Node B svc-X 的 owner Node C 非 owner ①写·任意节点 ②转发 ③异步复制 ③异步复制 owner = distroHash(svc-X) % 健康节点数
图 2.1写请求总能被任意节点接收,但只有 owner 真正落库,再异步扩散。 注意:第 ③ 步是虚线——fire-and-forget 的异步复制,发出去不等确认。这就是"最终一致"四个字的来源:其他节点拿到这条数据有个时间差。
代价

owner 是按"哈希 % 健康节点数"算的,所以集群扩缩容(节点数变化)会让大量服务重新分配 owner。重新分配期间要重新计算校验和、重新拉数据,曾出现过缓存被瞬时清空、服务列表"忽有忽无"的问题(参见 GitHub issue #10323)。这是 4.5 节失败模式的伏笔。

2.2节点间校验与新节点冷启动

节点每 5s 广播自己负责数据的 checksum,对端只拉差异;新节点启动则先从健康节点拉全量快照,再对外服务。

异步复制不保证送达,所以 Distro 还有一层定时校验兜底:每个节点每 5s 把自己负责数据的校验和广播出去,对端比对后只拉取有差异的那部分(toUpdate / toRemove),而不是全量交换。这让常态同步很省带宽。

但有一个例外——新节点冷启动。一个刚加入集群的节点没有任何数据,必须先做一次全量同步:它会等集群里至少有 2 个节点,从一个健康对端拉取全量快照,灌进内存之后才开始对外提供服务。

表 2.1 · 两种同步路径
场景同步方式代价 / 含义
常态运行每 5s checksum 比对,只传差异省带宽;存在最长 ~5s 的校验窗口
新节点冷启动从健康对端拉全量快照后才服务有"预热"窗口,期间不接流量,避免给出空列表
洞察 · 为什么冷启动要先全量

如果新节点空着就对外服务,消费方一旦把请求打到它,会查到一个空的服务列表——比"列表稍旧"严重得多。先全量再服务,是 AP 系统里对"可用但别给错误答案"的一个具体权衡。

2.3服务端推送:从 UDP 到 gRPC 长连接

1.x 用 UDP 推变更 + 客户端定时轮询补偿;2.x 改成一条 gRPC 长连接,推送可靠且省连接。

实例列表变了,服务端怎么让订阅它的客户端尽快知道?这是 1.x 与 2.x 差别最大的地方,也是你升级时最该理解的一处。

Nacos 1.x Nacos 2.x Server Client UDP推送 轮询补偿 推送不可靠 · QPS 高 Server Client gRPC 长连接 双向·可靠 一条连接 · 推送可靠
图 2.2左边两根箭头(推 + 补偿)合并成右边一根双向长连接。 注意:1.x 的 UDP 是虚线(不可靠,所以才需要轮询补偿);2.x 把推送和上报压进同一条 gRPC 连接,于是推送变可靠、连接数大降。
表 2.2 · 推送机制 1.x vs 2.x
维度Nacos 1.xNacos 2.x(2021-03 起)
推送通道UDP 推送(不可靠)gRPC 长连接(可靠、双向)
客户端补偿需定时轮询兜底连接在即可靠,轮询间隔可拉长
连接 / 容量短连接多、QPS 高连接复用,单机实例承载约 10×
新增端口仅 88488848 + 9848(客户端 gRPC) + 9849(节点间)
陷阱(第 4 章细讲)

2.x 的 9848 / 9849 是按主端口 +1000 / +1001 自动推算的。只在防火墙开了 8848、没开 9848 的话,2.x 客户端会连不上而 8848 看着是通的——报错指向 8848,极具误导性。

2.4客户端缓存与容灾

客户端把拿到的服务列表写成本地快照文件;服务端不可达时,用本地快照兜底,让调用不中断。

回到 §1.4 那条留下的尾巴:进程崩溃后,调用方"看到它消失"为什么比 30s 更晚?因为消费方并不每次都去问服务端,它本地存了一份服务列表快照。这份快照平时是性能优化,故障时则是容灾。客户端解析一份列表时的取数顺序是:

需要一份服务列表 failover 开启? 是 读 failover 人工兜底 否 服务端可达? 是 server 返回 并写 snapshot 否 读本地 snapshot 兜底
图 2.3取数顺序:failover 目录(人工)→ 服务端(顺便写快照)→ 本地快照(兜底)。 注意:最后这条"否 → 读本地 snapshot"就是"Nacos 全挂、调用照常"的底气;代价是这期间感知不到实例的上下线。
想一想

整个 Nacos 集群都挂了,此刻正在运行的 order-service 还能不能调用 user-service?新启动的一个 user-service 实例能被发现吗?

展开答案(先停 10 秒)

已有的调用:能。order-service 用本地 snapshot 里的 user-service 列表继续调。新实例:不能被发现——注册和变更推送都依赖服务端,服务端没了,这段时间的上下线全部"冻结"在快照那一刻。所以 Nacos 短时不可用不致命,但长时间不可用会让集群拓扑严重失真。

§本章 self-check

先合上教程作答,再展开对照。

  1. Distro 里"哪个节点负责哪个服务"是怎么决定的?用一句话描述这个算法。
  2. 节点间常态同步是全量传输还是只传差异?新节点冷启动时又是哪种?为什么不一样?
  3. 1.x 用 UDP 推 + 客户端轮询,2.x 换成了什么?最主要的两个收益是什么?
  4. 为什么 Nacos 服务端全挂了,消费方短时间内还能继续发起调用?此时它"看不到"什么?
  5. (综合 01)一个临时实例进程崩溃,服务端约 30s 后删除它。为什么调用方"彻底不再打它"会明显晚于这 30s?把 §1.4 的删除时间线和本章的客户端缓存接起来回答。
答案(先做完再展开)
  1. 对服务名哈希再对健康节点数取模:distroHash(serviceName) % healthyNodeCount,落在某节点负责的索引区间,它就是该服务的 owner。
  2. 常态:每 5s 比对 checksum,只传差异;冷启动:从健康对端拉全量快照再对外服务。冷启动空数据若直接服务会给出空列表,比"稍旧"严重,所以先全量。
  3. 换成一条 gRPC 长连接。收益:① 推送可靠(不再靠 UDP + 轮询);② 连接复用,单机承载约 10×。
  4. 因为消费方本地有服务列表 snapshot,服务端不可达时用它兜底。此时看不到任何实例的上下线变化(拓扑冻结在快照那一刻)。
  5. 服务端删除只更新服务端视图;消费方要等本地缓存的下一个刷新周期(2.x 长连接推送较快,但仍非即时)才更新列表,且若推送/刷新延迟,崩溃实例会在调用方列表里多停留一段。删除时间线 + 客户端缓存两段叠加,才是"调用方彻底不打它"的真实耗时。
进阶挑战 · 刚好够不着

扩容瞬间,为什么服务列表"忽多忽少"

线上把 Nacos 集群从 5 个节点扩到 10 个,扩容后的几十秒内,部分服务的消费方观察到服务列表抖动(一会儿能查到、一会儿空)。用本章的三个机制解释这个现象的因果链。

提示(卡住再展开)

owner = 哈希 % 节点数(§2.1)→ 节点数从 5 变 10,大量服务的 owner 重新分配 → 新 owner 上的数据要靠 checksum 比对 + 拉取重建(§2.2)→ 重建窗口内该服务数据可能短暂缺失,叠加推送(§2.3)把"暂时为空"的列表推给了客户端 → 表现为抖动。这正是 GitHub #10323 描述的场景,也说明:扩缩容应在低峰、分批进行。