从1.x升到2.x之后,最先察觉到变化的人通常是运维:端口从一个变成三个,应用日志里突然多了一堆nacos-grpc-client的连接日志。再往后,细心的开发者会发现,服务上下线变快了,客户端机器上的线程占用也降下来了。这些表象背后其实是同一个决定:Nacos 2.x 把核心通信底座从 HTTP 长轮询换成了 gRPC,并且顺手把服务健康检查、集群数据同步、订阅方推送这些老链路全部重构了一遍。
这篇就来拆一下 Nacos 2.x 源码里的四条核心链路:gRPC 客户端/服务端初始化、订阅方推送、服务健康检查、集群数据同步。全文以 Nacos 2.2.x 版本为主线,具体类名和变量在不同小版本里会有微调,但整体思路是一致的,照着这套链路去啃源码,比从头一行行看效率高得多。
1. 从1.x到2.x:长轮询那条老路到底堵在哪
1.1 1.x 的推送模型为什么让人头疼
Nacos 1.x 的客户端通信模型可以理解成两个组成部分:客户端主动发起的 HTTP 请求,和服务端单向的 UDP 推送。
客户端注册实例、查询服务列表,走的是 HTTP 接口;服务端要通知订阅方“服务有变化”,依赖的是 UDP 数据包。而所谓的服务订阅,在 1.x 里本质上是一个挂起的 HTTP 长轮询请求。客户端每隔 30 秒左右发起一次轮询,服务端收到之后不立刻返回,而是把请求挂住;如果在挂住的窗口内有服务变更,服务端通过 UDP 推给客户端,同时立刻返回这个轮询请求;如果一直没变更,等超时时间到了再返回空结果。
这个模型的问题很明显:
- UDP 丢包不可感知。服务端推了,客户端没收到,服务端并不知道,只能等客户端下一次长轮询主动来拉。在变更频繁的场景下,客户端拿到的一定是“上一次通知之后”的数据,中间可能隔着好几个版本。
- 每个挂起的长轮询都占着一个线程。虽然 Nacos 对连接做了不少优化,但本质上还是“大量挂起 + 定时唤醒”的模型,线程调度成本高。
- 服务端缺乏主动推送的能力。1.x 里服务端能做的只是“通知你去拉”,而不是“把数据直接给你”。
1.2 gRPC 为什么适合当这个底座
gRPC 在 Nacos 2.x 里解决的,本质上是三个问题:连接能否复用、服务端能否主动推送、数据能否更轻量。
先说连接复用。gRPC 基于 HTTP/2,一个 TCP 连接上可以跑多个 stream,多路复用意味着客户端只需要维护很少的连接,就能承载所有注册和订阅请求。1.x 里动辄几十个线程做轮询的场景,在 2.x 里变成了一两条长连接上的高并发流。
再说服务端主动推送。gRPC 的双向流特性允许服务端在客户端建立的连接上直接向下发数据。这个能力是 2.x 订阅方推送链路的基石。服务端不再需要 UDP,不再需要挂起 HTTP 请求,它只需要在一条已经建立的 gRPC 连接上把变更数据直接推过去。
最后是数据序列化。gRPC 默认用 Protobuf,序列化后的体积比 JSON 小很多,CPU 开销也更低。服务列表这种数据在推送时能省掉不少带宽。
1.3 2.x 的端口规划是理解源码的第一把钥匙
Nacos 2.x 部署完成后,一个节点会监听三个端口:
| 端口 | 用途 | 默认值 |
|---|---|---|
| 主端口 | HTTP/gRPC 对外服务入口 | 8848 |
| gRPC 请求端口 | 客户端发起注册、订阅、心跳等请求 | 主端口 + 1000,即 9848 |
| gRPC 推送端口 | 服务端向客户端推送变更数据 | 主端口 + 1001,即 9849 |
我第一次看 2.x 源码时绕了很久才反应过来:客户端不是只连了一条 gRPC 连接,而是连了两条。一条发请求,一条收推送。搞清楚这个前提,后面的源码脉络会清晰很多。
这个端口偏移可以在配置里调整,nacos.core.gRPC.grpcPort.offset就是干这个的。线上如果遇到端口冲突,或者云安全组里只放行了 8848 忘记放行 9848/9849,客户端会一直在日志里打印连接失败,但表面看起来 Nacos 控制台又是好的,这个坑后面我会单独展开。
2. gRPC 连接的生命周期:从客户端双端口建立到服务端状态机
2.1 客户端初始化的核心对象:RpcClient 与它的状态机
客户端侧,所有 gRPC 逻辑都收敛在RpcClient这个抽象类里。不管是 NacosNamingService 还是 NacosConfigService,最终都会创建自己的RpcClient实现,然后通过它注册到服务端。
RpcClient内部有三个关键组件:
ConnectionEmitter:负责发起连接的单线程执行器。客户端所有“连到哪个服务器”“连接断了要重连”的动作,都由它驱动。ConnectionKeeper:连接建立后负责保活和健康检查,定时向服务端发送心跳确认连接可用。ServerCheckTask:连接建立后发送ServerCheckRequest,确认服务端真正就绪,而不是 TCP 通了就算成功。
RpcClient的状态机是一个很值得断点观察的点:
INITIALIZED -> STARTING -> UNHEALTHY -> WAIT_SERVER_READY -> HEALTHY一开始是INITIALIZED,调用start()后进入STARTING。此时ConnectionEmitter开始尝试连接服务器。TCP 建立成功后,状态变为UNHEALTHY,接着发送ServerCheckRequest等服务端返回确认,进入WAIT_SERVER_READY,最后检查通过才变成HEALTHY。
这个状态机是理解客户端日志的钥匙。比如线上经常看到“Client not connected, current status:STARTING”,说明连接还在初始化阶段,未完成服务端校验,不能发请求。很多人在这个阶段会误判为注册中心挂了,其实可能只是服务端压力大,连接建立后握手过程迟迟没走完。
2.2 客户端的两条连接是怎么建立起来的
以服务发现客户端为例,NamingGrpcClientProxy内部持有GrpcClient。启动时它会先向 gRPC 请求端口 9848 发起连接。
连接建立后,客户端发送ServerCheckRequest,服务端返回ServerCheckResponse。这一步我建议在源码里重点看,因为它是一个握手确认,能确认服务端版本兼容性以及连接是否真正可用。
之后客户端会再建立一条连接到 gRPC 推送端口 9849,这条连接专门用来接收服务端主动推送的数据。服务端持有这条连接上的 stream,在数据变更时向客户端下发ServiceListUpdateRequest或配置变更通知。
这里有个很容易被忽略的细节:ServerCheckRequest不只是建立连接时发一次,客户端在连接空闲时也会用它做心跳保活,只是不会像注册请求那样频繁。在GrpcClient的保活机制里,连接空闲超过一定时间后,会通过发送心跳请求来确认连接是否仍然健康,如果服务端没有响应,客户端会主动断开并重建连接。
2.3 服务端如何承载这些连接
服务端侧,gRPC 服务是独立的 Netty Server,监听在 9848/9849 端口。服务端启动时通过GrpcServer创建NettyServerBuilder,绑定端口后注册三个核心服务:
GrpcCommonRequestAcceptor:处理公共请求,比如ServerCheckRequest。GrpcRequestAcceptor:处理普通请求,比如注册实例、查询服务。GrpcBiStreamRequestAcceptor:处理双向流请求,这条实际上接管了连接建立和主动推送的语义。
服务端收到新连接后,会为连接创建一个Connection对象,并把连接元数据ConnectionMeta注册到ConnectionManager里。ConnectionMeta里记录了什么?connectionId、clientIp、clientPort、localPort、连接建立时间、最近活跃时间、应用名、租户信息、标签。这些信息在后面做订阅者索引时非常关键。
ConnectionManager会定期扫描连接,把超过一定时间没有活跃的连接清理掉。这个超时时间可以在服务端配置里调。默认情况下,如果一个连接长时间没有任何请求,服务端会认为它已经失活,主动断开。客户端检测到连接断开后会自动发起重连,重连完成后会重新执行订阅逻辑,把订阅关系恢复到服务端。
这块容易踩的坑是:服务端配置的连接空闲超时时间太短,导致客户端在低峰期频繁被踢下线重连。日志里表现为周期性的“connection shutdown”和“reconnect”,但不影响业务,只是会产生大量无意义的连接重建日志,干扰排查。
3. 注册与订阅请求在 gRPC 链路上的路由与处理
3.1 客户端请求如何找到对应的 Handler
理解了连接之后,下一个问题就是:客户端在 gRPC 连接上发了一个InstanceRequest,服务端怎么知道该交给哪个逻辑处理?
答案在RequestHandlerRegistry。这是一个按RequestType注册的 Handler 映射表。服务端启动时,所有标注了@RequestHandler的处理器都会注册进来。比如:
InstanceRequestHandler处理实例注册/注销SubscribeServiceRequestHandler处理服务订阅ServiceQueryRequestHandler处理服务查询ClientBeatHandler处理心跳ServerCheckRequestHandler处理连接握手
当 gRPC 请求到达时,GrpcRequestAcceptor解析请求类型,从注册表里找到对应的 Handler,然后调用handle(request, context)方法。这个分发机制是 Nacos 2.x 扩展性比较好的地方,新增一种请求类型只需要实现一个 Handler,不需要改动主请求分发逻辑。
3.2 注册链路:InstanceRequest 到一致性协议
以服务注册为例,客户端发起InstanceRequest,服务端GrpcRequestAcceptor接收后找到InstanceRequestHandler。
Handler 内部会调用InstanceOperatorClientImpl.handleInstanceRequest,这里会做一系列校验:命名空间是否存在、服务名是否合法、集群是否配置、实例参数是否完整等。校验通过后,根据实例的ephemeral属性走不同的存储路径:
- 临时实例走
DelegateConsistencyServiceImpl,这个实现的背后是 Distro 一致性协议。 - 持久实例走
JRaft协议(在 2.2.x 里默认使用,不同版本有差异)。
这一步是注册链路里最关键的分叉点。临时实例数据保存在内存中,用 AP 模型保证可用性;持久实例数据通过 Raft 协议复制到多数派节点,用 CP 模型保证一致性。两类实例的超时阈值、健康检查方式完全不同,下面的章节会展开。
3.3 订阅链路:SubscribeServiceRequest 与订阅者索引
订阅链路的核心数据结构是SubscriberIndex。我把它理解成一个双向索引:既能通过服务名找到所有订阅者,也能通过订阅者找到它订阅的所有服务。
客户端调用订阅接口时,发送SubscribeServiceRequest,里面携带服务名、分组、集群、订阅者信息。服务端SubscribeServiceRequestHandler会把这个订阅关系写入SubscriberIndex,并把订阅者的连接信息关联进去。
为什么要做双向索引?因为推送时的查询需求是:“服务 X 有变更,谁订阅了它?”而连接断开时的清理需求是:“连接 Y 断开了,它订阅过哪些服务?需要从哪些服务订阅集合里移除?”如果没有双向索引,这两个操作都会变成全量扫描,数据量大时性能不可控。
重连之后的上报很重要。客户端断线重连后,连接 ID 变了,服务端的旧连接还没被清理,新连接又开始注册。这个时候服务端怎么知道新连接要恢复哪些订阅关系?靠的是客户端重连完成后的主动重新订阅。客户端在连接建立并完成握手后,会把之前订阅的服务列表重新通过SubscribeServiceRequest上报一次。这也是为什么我在线上看到客户端重启后,会有一波订阅请求峰值,而不是静默恢复。
4. 订阅方推送链路:ServiceChangeEvent 触发到客户端缓存更新
4.1 数据变更后的事件广播
服务注册和订阅关系都建立好了,接下来是整条链路里最有意思的一段:服务端怎么知道服务变了,以及怎么把变更推给客户端。
以临时实例注册为例。InstanceRequestHandler完成本地存储写入后,DistroClientDataProcessor会执行数据同步,向集群其他节点广播这次变更。同步完成后,数据处理器会发布一个ServiceChangeEvent事件。
这个事件由NamingSubscriberServiceV2Impl监听。它收到事件后会调用ServiceChangeHandler.handleChange,真正的推送逻辑从这里开始。
这里我建议读源码时留意一个设计思路:数据写入和推送是解耦的,中间通过事件总线串联。优点是不管上层数据从哪来(客户端注册、控制台修改、集群同步),只要最终写入了服务存储,就会触发统一的事件通知流程,不需要每种数据来源单独写推送逻辑。
4.2 SubscriberIndex 如何找到目标连接并推送
ServiceChangeHandler拿到变更的服务名后,去SubscriberIndex里查出所有订阅了这个服务的客户端,然后调用PushService.pushDataWithCallback。
PushService会为每个订阅者构造推送内容。这里推送的并不是“只包含变更实例的 diff 数据”,而是完整的ServiceInfo对象,里面包含服务名、分组、集群列表、实例列表、最后的 MD5 值。为什么推全量而不是增量?因为增量推送需要维护每个客户端的“上一个版本”,复杂度高,而且一旦客户端漏掉某个中间版本就永远无法自愈。全量推送虽然单次数据量大一点,但只要推送成功,客户端本地缓存一定是最新的,实现简单,容错性也好。
推送请求类型是ServiceListUpdateRequest。服务端通过持有该客户端推送连接的 stream,直接把这个请求发过去。
如果推送失败怎么办?这里有一个重试机制。PushService在执行 push 时会注册回调,如果失败会记录失败次数,并在后续周期里重试。实际上 Nacos 2.x 还有一个兜底逻辑:客户端本身也有主动查询机制。推送失败不会导致数据永远不一致,客户端最终还是可以通过主动查询拉取到最新数据。所以我一直跟团队说,不要把 Nacos 2.x 的推送当成“消息队列”那样的强保证,它的定位是“快速通知 + 最终一致”,真正的数据一致性靠的是客户端兜底查询。
4.3 客户端收到推送后的处理
客户端收到ServiceListUpdateRequest后,反序列化出ServiceInfo,写入ServiceInfoHolder本地缓存,然后向业务监听器发布服务变更事件。这一步是客户端感知“服务上下线”的关键节点。
有一个现象可以验证这条链路是否正常:在 Nacos 控制台手动下线一个实例后,如果客户端在 1 秒内就收到通知,说明推送链路是通的;如果等了 30 秒左右才感知到,说明推送可能失败了,客户端走到了兜底轮询路径。
和 1.x 的 UDP 推送相比,最明显的改善是:服务端能确认推送是否成功。gRPC 连接是双向的,连接断开或请求超时服务端都能感知到,可以触发重试;UDP 则完全没有这个能力。这也是为什么 2.x 可以取消客户端频繁的长轮询操作,因为服务端有了足够可靠的主动通知能力。
5. 服务健康检查:临时实例的心跳续约与非临时实例的主动探测
5.1 临时实例:gRPC 链路上的心跳续约机制
Nacos 对临时实例和持久实例的健康检查是两套完全不同的机制。搞混这两者,线上排查健康状态异常时会非常难受。
临时实例默认每 5 秒发送一次心跳。在 2.x 中,这个心跳是通过 gRPC 连接的 gRPC 请求发送的,服务端对应的是ClientBeatHandler。心跳请求里携带实例标识和心跳时间信息,服务端解析后更新实例的lastBeat字段,并返回下一次心跳间隔。
服务端判定临时实例不健康的标准是:当前时间减去lastBeat超过 15 秒,标记实例为unhealthy;超过 30 秒,直接删除实例。
这里的 15 秒和 30 秒不是随意定的,它和心跳间隔 5 秒的倍数关系有关。5 秒一次心跳,连续 3 次没收到(15 秒)判定为疑似失联,连续 6 次没收到(30 秒)判定为彻底失联。在业务层面表现为:一个应用被 kill -9 之后,最迟 30 秒内会从注册中心消失,而其他客户端在推送机制下可能几秒内就已经感知到。
有个生产环境的坑:如果客户端机器发生长 GC(超过 15 秒),心跳线程可能被暂停,服务端会误判实例不健康。Nacos 客户端的心跳线程虽然是独立线程,但 JVM 完全停滞时所有线程都会暂停。这种情况下即使服务恢复了,健康状态也要等下一次心跳上报才能恢复,中间这段时间流量调度可能受影响。
5.2 非临时实例:延迟队列里的主动探测
非临时实例不会主动上报心跳,它的健康检查完全由服务端发起。服务端发现一个非临时实例注册成功后,会为它所在的服务创建一个HealthCheckTask,这个任务被放入一个延迟队列(DelayQueue),到点后执行检查。
检查的类型有三种,对应不同的HealthCheckProcessor:
| 探测类型 | 实现类 | 原理 |
|---|---|---|
| TCP | TcpSuperSenseProcessor | 与服务端和实例端口建立 TCP 连接,连接成功即健康 |
| HTTP | HttpHealthCheckProcessor | 发送 HTTP 请求,根据返回码和内容判断健康 |
| MySQL | MySQLHealthCheckProcessor | 执行一条检查 SQL,能正常查询即健康 |
TCP 检查的实现值得单独看一看。它基于 Java NIO 的 Selector,单线程可以管理大量并发探测连接,而不是为每个实例创建一个线程。这在实例数量大的时候非常关键。检查器会注册一个连接 Channel,设置超时时间,通过 Selector 统一监听读写事件,在超时时间内没有响应就判定失败。
5.3 健康检查的失败判定与状态流转
不管是临时实例还是非临时实例,健康检查的失败都不是一次就立刻下线的。
临时实例的判断阈值是 15 秒没有心跳先标记为unhealthy,30 秒后移除。unhealthy状态是有一段时间窗口的,客户端仍然可以通过心跳恢复。
非临时实例则完全不同:实例不健康只会被标记状态,不会被自动删除。这是因为非临时实例往往承载有状态服务,注册中心不应该在健康检查失败时直接把它从注册表里抹掉,而是让调用方来决定如何处理不健康实例。在 Nacos 控制台上,你会看到实例的“健康状态”变成 false,但服务列表里仍然有它。
客户端拉取服务列表时,默认情况下不健康实例还会被返回,只是在元数据里标明了健康状态。至于 RPC 框架是否把流量路由到不健康实例,取决于客户端负载均衡策略,这是业务侧和框架侧的事,不是 Nacos 的责任。
这两种健康检查机制很容易记混,我做了张简单的对照表:
| 维度 | 临时实例 | 非临时实例 |
|---|---|---|
| 心跳方式 | 客户端主动上报 | 服务端主动探测 |
| 默认间隔 | 5 秒 | 取决于配置,通常较长 |
| 不健康判定 | 15 秒无心跳 | 探测失败达到阈值 |
| 下线机制 | 30 秒无心跳则删除 | 不自动删除,仅标记状态 |
| 一致性协议 | Distro(AP) | JRaft(CP) |
| 适用场景 | 短生命周期服务、弹性扩缩容 | 有状态服务、数据库、缓存 |
6. 集群数据同步:Distro 协议与节点间校验补偿
6.1 Distro 协议:AP 模型的写入路线
集群数据同步是很多人在读 Nacos 源码时最容易卡住的地方,因为它涉及的数据流转不止一条链路。我的建议是先分清同步的对象:临时实例数据走 Distro,持久实例数据走 JRaft。
Distro 协议可以简单理解成:每个节点都写入本地,然后异步把变更同步给集群里其他所有节点。它不要求强一致,只要最终所有节点都拿到最新数据即可,是一个典型的 AP 模型。
客户端注册临时实例时,请求会到达某个 Nacos 节点。这个节点作为「接收节点」写入本地内存存储,然后通过DistroClientDataProcessor构建变更数据,交给任务引擎异步发送给其他节点。
这个异步过程不是逐个发送,而是可以批量。Nacos 的任务引擎会把多个数据变更任务合并成一次批量分发,减少网络请求次数。这在实例数量大、变更频繁的场景下能明显降低节点间通信压力。
节点间通信在 2.x 走的是 gRPC,具体实现在集群通道ClusterRpcClientProxy。每个节点启动时会和其他节点建立 gRPC 连接,之后 Distro 同步数据就是在这个连接上传输的。所以集群节点之间除了业务端口之外,还有一套 gRPC 通信通道,网络隔离时要一起放行。
6.2 周期性校验:MD5 对比与数据补偿
纯粹靠写入时同步,总会有意外:某个节点宕机期间错过了一批变更,网络抖动丢了一个包,某个节点磁盘写入延迟。Distro 为了保证最终一致,设计了周期性的校验任务DistroVerifyTask。
校验任务的核心逻辑是这样的:
- 本机定期(默认 6 秒左右,具体看版本配置)从自己的数据存储里读取所有服务的 Key 和对应的 MD5 值。
- 把这些 Key + MD5 列表发送给集群内其他节点。
- 对方拿到之后,和本地数据逐一比对 MD5 是否一致。
- 如果发现某个 Key 的 MD5 不一致,就把这个不一致的 Key 反馈给发起方。
- 发起方针对不一致的 Key,重新向对方推送全量数据。
这个机制的好处是,它不需要每个节点都定期全量交换数据,而是用很小的摘要信息做快速比对,只有当差异真的出现时才拉取全量数据,成本被控制在一个很低的水平。
从源码角度讲,我建议重点看两个位置:一个是发起校验任务的调度逻辑,另一个是接收校验请求后如何响应。理解了这两块,就理解了 Distro 最终一致的实现原理。
6.3 节点故障恢复时的数据补偿
节点重启后的数据恢复,是集群数据同步最容易出问题的阶段。
当一个节点启动并加入集群后,它会向集群内的其他节点发起全量数据同步请求,把别人那里已经存储的临时实例数据拉取到本地。这个全量同步的触发时机在节点启动流程里,具体实现在节点注册后的数据初始化阶段。
这里有个操作上的提醒:如果集群里某个节点长时间离线,它的数据已经严重过期,恢复后首先要做的是全量同步,而不是立刻接受写流量。Nacos 在这个阶段会有一个数据加载和损坏恢复的判断,如果本地存储中有不完整的数据,它会先完成远程同步后再对外提供服务。
遇到过一种情况:某个节点宕机后,没有等它完成全量同步就重启了客户端,客户端把请求打到了这个节点上,导致实例注册到了数据不完整的节点。虽然后续周期校验会把数据补齐,但短暂的时间里服务列表是不全的。线上操作要尽量避免这种「边恢复边接流量」的状态。
7. 源码阅读断点清单与线上问题定位经验
7.1 值得打断点的关键位置
和直接通读源码相比,我更喜欢按链路打断点去“跟一遍数据流转”。下面这几个位置是四个核心链路最值得观察的断点:
| 链路 | 关键类 | 断点方法 | 观察点 |
|---|---|---|---|
| gRPC 初始化 | GrpcClient | connectToServer | 端口、服务端地址、握手请求 |
| gRPC 初始化 | GrpcRequestAcceptor | grpcRequest | 请求类型、连接元数据 |
| 服务注册 | InstanceRequestHandler | handle | 实例参数、ephemeral 属性 |
| 订阅关系 | SubscribeServiceRequestHandler | handle | 订阅参数、写入索引结构 |
| 数据变更推送 | ServiceChangeHandler | handleChange | 变更服务名、订阅者数量 |
| 心跳处理 | ClientBeatHandler | handle | 心跳间隔、实例 lastBeat 更新 |
| 集群同步 | DistroVerifyTask | run | 校验数据量、不一致 Key 数量 |
| 集群同步 | DistroClientDataProcessor | processData | 写入本地、同步目标节点 |
建议按照「先跟注册链路,再跟推送链路,最后跟集群同步」的顺序去打断点。注册链路可以帮你理解数据是怎么进到内存存储的,推送链路能帮你看清数据变更后的事件流转,集群同步链路则是数据跨节点的完整路径。把这三条链路走通一遍,Nacos 2.x 的主干逻辑你就基本吃透了。
7.2 端口、线程与日志:排障三件套
线上排查 Nacos 2.x 问题时,我总结了一套固定打法,供参考。
第一步,确认端口联通情况。如果客户端始终连不上注册中心,先确认 9848 和 9849 端口是否在防火墙和安全组里放开。很多环境只放行了 8848,导致控制台能登录,但客户端拿不到服务列表。客户端日志里的错误会指向“connect to server failed”,反复重试。
第二步,看线程名。正常运行时,客户端会有nacos-grpc-client开头的线程,里面能看到连接 ID、远程地址。如果线程频繁退出重建,说明连接不稳定,要去查网络问题或服务端连接空闲超时配置。
第三步,看服务端日志里的ConnectionManager日志。如果一个客户端连接创建后很快又被移除,大概率是连接超过空闲阈值,或者订阅关系上报失败。这种情况往往表现为客户端日志一切正常,但控制台看不到这个客户端的连接。
7.3 兼容性:老客户端和新服务端相遇会发生什么
Nacos 2.x 服务端是向下兼容 1.x 客户端的。老客户端不建立 gRPC 连接,仍然走 HTTP 长轮询和 UDP 推送。新客户端优先走 gRPC,如果初始化失败,也会有降级逻辑回到 HTTP。
两套客户端共存在同一个集群里,就会产生两套连接模型和两套推送模型。这解释了为什么升级服务端后,控制台上看到的连接数可能没有明显减少——那些是存量 1.x 客户端的 HTTP/长轮询连接;也解释了为什么推送抖动时,老客户端的表现和新客户端不一致。排查问题前先确认客户端的版本,这一点被很多人忽略。
从我个人的经验看,读完上面这些内容之后,最好的源码入口不是 NacosNamingService,而是GrpcClient的connectToServer。先看一条连接怎么建立、怎么握手、怎么保活,再往下追请求分发和业务 Handler,整个源码脉络就会非常清晰。Nacos 2.x 虽然类多,但核心链路其实很克制,抓住“两条连接 + 四个 Handler + 事件驱动”这条主线,比试图看完全部代码要高效得多,也更不容易绕晕。