Nacos 2.X 把核心通信从 HTTP 短连接切换成 gRPC 长连接,这个决定看起来只是换了个 RPC 框架,实际意味着命名服务和配置中心的连接模型被彻底重写了。标题里这串字,拆开看是三件事:客户端怎么把连接建起来,服务端怎么把端口和服务拉起来,以及连接建好之后请求、推送、重连这套生命周期怎么跑。
如果你正在维护一个接入 Nacos 的生产系统,或者刚把版本从 1.X 升到 2.X,那么 gRPC 带来的端口变化、连接数变化、Push 链路变化一定会找上你。这篇文章我打算沿着源码主线,把客户端连接启动和服务端启动这条链路完整走一遍,配上我实际维护中踩过的坑,尽量让看完的人能自己定位问题。
1. 整体设计:长连接模型到底解决了什么问题
1.1 1.X 时代的三座大山
在说 2.X 之前,得先明白 Nacos 1.X 为什么非要改。1.X 的通信模型是“HTTP 为主,UDP 为辅”:配置中心靠 HTTP 长轮询,客户端拉一个请求挂 30 秒,配置变了服务端再把响应补回来;命名服务靠 UDP 推送变更通知,客户端收到通知后再主动拉一次服务列表。
这套模型在生产里最大的问题是不可控。HTTP 长轮询在四层负载均衡和云厂商的 SLB 上几乎必踩空闲连接被剪断的坑,一剪断客户端就要重连,重连高峰能把服务端线程池打满。UDP 推送更难受,NAT 网络下丢包是常态,丢一个通知客户端就只能等下个周期的主动拉取,变更敏感度完全取决于轮询间隔。服务端重启的时候,本来该推给客户端的事件全没了,客户端只能靠自己的兜底定时任务慢慢恢复。
所以 2.X 的核心思路就一句话:把“一堆散落的 HTTP 接口 + 不可靠的 UDP 通知”改造成“一套可双向通信、可感知连接状态、可主动推送的 gRPC 长连接体系”。长连接带来的最大好处是状态在服务端是可见的,连接在不在、活了多久、最后活跃时间是什么时候,都能被管理和统计,这是 1.X 那种“无状态 HTTP 请求”完全做不到的。
1.2 端口规划与通道划分
2.X 启动后默认监听的不再是单端口。以默认主端口 8848 为例:
| 端口 | 用途 |
|---|---|
| 8848 | HTTP 老接口、控制台、1.X 客户端兼容通道 |
| 9848(8848+1000) | SDK 客户端(Java、Go 等)gRPC 长连接通道 |
| 9849(8848+1001) | 服务端节点之间集群通信 gRPC 通道(Distro 同步等) |
这里最容易踩的坑就是只放通了 8848 忘了 +1000。很多线上故障表现为“客户端日志一直重连,但控制台看着是好的”,最后一看安全组和负载均衡只转发了一个端口。
两条 gRPC 通道职责完全不同。9848 是给业务 SDK 用的,客户端注册实例、订阅服务、发布配置、长轮询配置变更都走这里;9849 是服务端节点之间内部通信用的,比如集群模式下把某个服务的最新实例列表同步到其他节点。源码里对应的是两个组件,一个叫 GrpcSdkServer,一个叫 GrpcClusterServer,后面会细看。
1.3 三种通信模式并存的协议模型
gRPC 来了之后,Nacos 的通信模型从“请求-响应”变成三种模式共存。第一种是普通的一问一答,比如查询服务列表、发布配置,客户端发一个 Request,服务端回一个 Response,走 gRPC 的 unary 调用。第二种是服务端主动推送,配置变更、服务变更都是服务端主动发起,把 PushRequest 通过连接直接发给客户端。第三种是双向流,客户端和服务端建立一条 requestBiStream,服务端的推送就是在这一条流里通过 StreamObserver 下发,客户端收到后再触发本地监听器去拉最新数据。
这三种模式在源码里是同一套 Request/Response 基类体系,但走的处理器不一样。理解了这个模型,后面看客户端连接、服务端注册、请求转发才会顺。
2. 客户端连接启动的源码解剖
2.1 入口:NamingFactory 到 NacosNamingService
客户端代码一般从 NamingFactory.createNamingService 或 NacosFactory.createNamingService 进入,最终会 new 一个 NacosNamingService。这个构造过程比 1.X 复杂不少,因为里面要同时准备两条通道:一条是 gRPC 主通道,一条是 HTTP 兼容通道。
// 核心逻辑简化示意,不同小版本有调整 public class NacosNamingService implements NamingService { private final NamingGrpcClientProxy grpcClientProxy; private final NamingHttpClientProxy httpClientProxy; public NacosNamingService(Properties properties) throws NacosException { // 默认走 gRPC,HTTP 作为兼容与部分运维接口保留 this.grpcClientProxy = new NamingGrpcClientProxy(properties); this.httpClientProxy = new NamingHttpClientProxy(properties); } }注意一个关键点:gRPC 客户端的连接管理并不直接在 NamingGrpcClientProxy 里,而是封装在更底层的 RpcClient 中。NamingGrpcClientProxy 只是业务层面的封装,负责把“注册实例”“获取服务列表”这些语义翻译成具体的 Request 对象,然后丢给 RpcClient 去发。这个分层很值得借鉴,业务逻辑和连接管理彻底解耦。
构造过程中还有一堆 Properties 要读,serverAddr、namespace、username、password 这些都会传给后面的 ServerListManager 和 RpcClient。如果 serverAddr 配的是域名而不是 IP 列表,ServerListManager 还要负责按一定间隔去解析域名,把最新的节点列表刷新到内存里。
2.2 RpcClientFactory 与 GrpcClient 的创建
接着往底层走,会看到一个很重要的工厂类 RpcClientFactory。它内部维护一个缓存 Map,相同标识的客户端只会创建一个实例,避免同一个进程里多次创建连接造成连接数翻倍。
// 简化示意 public static RpcClient getClient(RpcClientType type, String label) { return CLIENT_MAP.computeIfAbsent(label, key -> createClient(type, key)); }创建出来的具体实现叫 GrpcClient,它继承了 RpcClient。RpcClient 是连接状态机的核心,这个状态机我画出来大概是这样:
WAIT_START -> STARTING -> STARTED | v UNHEALTHY | v SHUTDOWNGrpcClient 在 start 时会先检查状态,之后做几件事:构建一个固定线程池用来跑连接任务,创建一个 ServerListManager 管理服务端地址列表,注册连接事件监听器,最后真正去连第一个服务端。
ServerListManager 这里有个细节值得注意。它在初始化时并不是“选一个地址就完事”,而是维护了一个可用列表,每次连接失败会把当前节点标记为不可用,然后尝试下一个。等所有节点都失败,它会进入固定间隔的重试循环。这个设计保证客户端在服务端滚动发布的时候能自动切到没挂的节点,不会因为固定连一台而把故障放大。
2.3 连接建立:ServerCheckRequest 与 ConnectionEvent
连接真正建立是在 GrpcClient 的 connectToServer 方法里。每个待连接的服务端节点会对应创建一个 GrpcClientConnection,这个对象内部持有 gRPC 的 ManagedChannel 和两个 stub:blockingStub 和 asyncStub。
// 简化示意:GrpcClient 连接服务端 public Connection connectToServer(ServerInfo serverInfo) throws NacosException { ManagedChannel channel = NettyChannelBuilder .forAddress(serverInfo.getIp(), serverInfo.getPort() + 1000) .usePlaintext() .keepAliveTime(配置值, TimeUnit.MILLISECONDS) .build(); GrpcClientConnection connection = new GrpcClientConnection(serverInfo, channel); ServerCheckResponse checkResponse = (ServerCheckResponse) connection.request( new ServerCheckRequest(), 3000L); if (checkResponse == null) { throw new NacosException("server check failed"); } return connection; }注意这里的端口:客户端配置的 serverAddr 里写的是 8848,但真正连接的是 9848,也就是自动加 1000。所以如果你的 serverAddr 配的是某个域名,而这个域名在 DNS 解析后只能访问 8848,那么连接照样失败。我见过太多人把 8848 通了就以为 Nacos 没问题,结果客户端全在报“connect to server failed”。
服务端收到连接后回一个 ServerCheckResponse,这个握手很重要。它一方面告诉客户端“我这边服务端是真的活着且愿意接受你”,另一方面也让客户端确认协议版本兼容。握手成功后,RpcClient 把状态改成 STARTED,然后触发所有 ConnectionEventListener 的 onConnected 回调。
onConnected 回调里才是客户端真正恢复业务的起点。NamingGrpcClientProxy 会在回调里重新做一件事:把本地缓存的注册实例重新注册一遍,把订阅过的服务重新订阅一遍。这个逻辑在 1.X 里完全没有,因为 HTTP 无状态,断线没有“恢复”一说。有了长连接,就必须在重连成功后补偿所有丢失的状态,否则就会出现“客户端连上了,但服务端不知道它关注哪些服务”的问题。
2.4 请求发送的源码链路
连接建立之后,客户端的每一次业务请求都会走 RpcClient.request 到 GrpcClientConnection.request。源码里这里做得比较巧妙,它把 gRPC 的 StreamObserver 回调包装成 CompletableFuture,业务层拿到的就是一个带超时控制的 Future。
// 简化示意:GrpcClientConnection 发送请求 public Response request(Request request, long timeoutMills) { CompletableFuture<Response> future = new CompletableFuture<>(); StreamObserver<Response> responseObserver = new StreamObserver<Response>() { @Override public void onNext(Response value) { future.complete(value); } @Override public void onError(Throwable t) { future.completeExceptionally(t); } @Override public void onCompleted() { // 一般不会走到这里 } }; requestStub.withDeadlineAfter(timeoutMills, TimeUnit.MILLISECONDS) .request(toPayload(request), responseObserver); return future.get(timeoutMills, TimeUnit.MILLISECONDS); }这里有两个性能细节。第一,gRPC 请求默认会带超时时间戳,服务端响应慢时客户端这边是能提前感知 deadline 的,不会傻等。第二,客户端默认请求超时一般是 3 秒,如果你在高延迟网络或服务端压力大的环境里用,需要根据业务情况调整,否则服务端 GC 抖动一下,客户端就批量报超时。
整个客户端链路到这就闭环了:入口创建代理,代理持有 RpcClient,RpcClient 管理状态机和重连,GrpcClientConnection 负责真正的 gRPC 收发。排查客户端问题时的第一反应应该是看 RpcClient 的状态是 STARTED 还是 UNHEALTHY,这个状态日志里会直接打出来。
3. 服务端启动与监听全流程
3.1 启动入口与两个 gRPC Server 的创建
服务端启动入口是 Nacos 的主类,内部还是走 Spring Boot 那套容器初始化。关键不在启动类本身,而在 Spring 容器初始化完成后,会触发两个 gRPC Server 的启动逻辑,分别对应前面的 9848 和 9849。
这两个组件在源码里叫 GrpcSdkServer 和 GrpcClusterServer,它们都继承自 BaseGrpcServer。BaseGrpcServer 把 Netty Server 的构建、端口绑定、线程池初始化都抽出来了,子类只需要告诉它“我监听哪个端口”“我用什么拦截器”。这样设计的好处是 SDK 通道和集群通道可以共享一套启动和停机逻辑,但业务处理各自隔离。
BaseGrpcServer 启动的大致流程是这样:
// 简化示意:BaseGrpcServer 启动 protected void start() throws Exception { NettyServerBuilder builder = NettyServerBuilder .forAddress(new InetSocketAddress(port)) .addService(new GrpcRequestAcceptor(connectionManager, requestHandlerRegistry)) .executor(grpcExecutor) .maxInboundMessageSize(配置值); server = builder.build().start(); }启动时机一般在 Spring 容器完全 ready 之后。如果你在日志里看到先打印了“Nacos started successfully”,再去查 9848,此时监听应该已经建立。如果端口被占用,启动会直接失败,日志里有对应的 BindException,这类问题很好定位,跑一下lsof -i :9848就知道谁占了。
3.2 GrpcRequestAcceptor 与请求处理链路
客户端发来的 gRPC 请求,服务端最终落在 GrpcRequestAcceptor 里,它是 protobuf 生成的服务实现类。它主要做了三件事:第一,从 gRPC 上下文中解析出请求元信息;第二,根据请求类型找到对应的 RequestHandler;第三,把 Request 交给 handler 处理,并返回 Response。
// 简化示意:服务端处理请求主流程 public void request(Request request, StreamObserver<Response> observer) { RequestMeta requestMeta = parseRequestMeta(request); Connection connection = connectionManager.getConnection(requestMeta.getConnectionId()); RequestHandler handler = requestHandlerRegistry.getHandler(request.getType()); Response response = handler.handleRequest(request, requestMeta, connection); observer.onNext(response); observer.onCompleted(); }这里有个容易被忽略的点:服务端几乎每个请求都要先通过 connectionId 找到 Connection。也就是说,如果这个请求对应的连接已经被服务端回收了,请求会被直接拒绝,返回连接失效错误。客户端收到这种错误后会自动触发重连,重连完成后重新补偿订阅状态。理解这一点,就能理解为什么连接生命周期的一致性那么重要了。
RequestHandlerRegistry 的设计也很直白,本质就是一个 Map,key 是请求类型字符串,value 是具体的 handler。服务端在启动阶段会扫描所有实现 RequestHandler 的组件,自动注册进去。新增一种请求类型,只要写好 handler 并标成 Spring Bean,就能被自动识别,不需要改注册中心代码。这种“按类型路由”的模式在中间件源码里非常常见,简单、可扩展、好排查。
3.3 ConnectionManager:连接就是一等公民
ConnectionManager 是服务端管理长连接的核心,它维护了一个 connectionId 到 Connection 的映射。每次客户端建连成功,服务端会生成一个唯一的 connectionId,格式一般是“服务端 IP:端口#客户端标识#随机串”,这能保证即使客户端在同一台机器上创建多个连接,也不会撞 ID。
Connection 对象内部不只有网络通道,还保存了 ConnectionMeta,里面记录了客户端 IP、客户端版本、连接类型(SDK 还是集群)、建立时间、最后活跃时间等。这些元数据的作用在排查问题时特别大,比如你怀疑某个老版本客户端有问题,直接在服务端内存里就能查到它的版本信息,不需要去客户端翻日志。
ConnectionManager 还负责连接的清理与监听。客户端异常退出时,服务端不会立刻知道,TCP 断开需要时间。所以它会定期扫描连接列表,把超过最大空闲时间还没有活跃请求的连接主动关闭。这个机制我后面在健康检查里细讲。
3.4 服务端如何识别客户端类型与版本
服务端区分 SDK 连接和集群连接很简单,因为它们进来的是不同的 gRPC Server,9848 只收 SDK,9849 只收集群。但在同一条通道里,服务端还需要知道具体是谁在连。
这个信息来自 gRPC 的 metadata。客户端在建连时会把 clientIp、clientVersion、labels 这些放在 Header 里带过来,服务端通过拦截器统一解析。以 SDK 通道为例,客户端构造连接时写入的 metadata 里通常包含:
ClientIp: 应用所在机器 IP ClientVersion: Nacos-Client:v2.x.x ConnectionType: SDK服务端拿到这些信息后,不只是用来展示。它还涉及兼容性判断,比如某些新接口只对特定版本以上的客户端开放,服务端会结合 clientVersion 决定要不要拒绝或降级处理。所以生产上看到“客户端版本过低”之类的兼容日志,优先去升级客户端版本,而不是想着绕过去。
4. 关键机制深度拆解
4.1 长连接的健康检查与回收
长连接最怕的是“半开连接”,也就是客户端进程还在,网络上连接已经断了,但两端都没有及时感知。gRPC 的 keepalive 机制能缓解一部分,Nacos 服务端也做了自己的兜底:ConnectionManager 会启动一个定时任务,周期性遍历所有连接,检查 ConnectionMeta 里的最后活跃时间。
如果当前时间减去最后活跃时间超过阈值,服务端就认为这个连接已经不可信,主动把它关闭并从连接表移除。这个设计很好地处理了“客户端突然断电”“负载均衡静默剪断连接”这些场景。不过反过来说,如果你在客户端那边有业务请求频率极低、又恰好没配 keepalive,连接可能被服务端当成僵尸回收掉。客户端收到连接失效错误后会重连,但服务端主动推送到这个连接上的事件会丢,只能靠客户端重新订阅补偿,中间有一个时间窗口。
我的建议是,如果你的应用里 Nacos 客户端本身的请求频率很低(比如只有配置监听、没有命名服务),记得确认客户端的 keepalive 相关配置和服务端的回收阈值匹配。不要只调一边,不然要么是服务端永远收不到活跃信号,要么是客户端频繁重连。
4.2 服务端主动推送的实现链路
推送是 2.X 最有价值的特性,因为 1.X 的 UDP 推送在跨网段、NAT 环境下太脆弱了。服务端推送的链路大致是:某个数据变更(比如配置发布、服务实例上下线)先触发对应的变更事件,事件处理器拿到变更内容后,组装成一个 PushRequest,然后通过连接管理器找到所有关注这个数据的连接,再通过这个连接的双向流 Observer 把请求推过去。
// 简化示意:服务端通过双向流推送 Connection connection = connectionManager.getConnection(connectionId); if (connection != null) { StreamObserver<Response> pushObserver = connection.getPushObserver(); pushObserver.onNext(buildPushResponse(pushRequest)); }客户端那边收到推送后,不是直接更新本地缓存,而是触发一个本地事件。比如配置推送就是通知配置监听器“你关注的 key 有变化”,监听器再主动发起一次查询,拿到最新内容。这样做的好处是客户端对数据获取仍然可控,避免服务端把完整大对象直接塞过来,也方便客户端做本地校验和容错。
推送链路里有一个很常见的排查点:如果业务反馈“配置变更总要等很久才生效”,优先看服务端推送线程池是否被打满。这种场景通常是某个业务方的监听器处理太慢,拖慢了整个推送线程,连累其他应用。服务端日志里一般会有执行力不足的线索,配合 jstack 看线程状态就能定位。
4.3 超时、重连与状态补偿
客户端请求超时分为两种,一种是等待服务端响应超时,一种是连接建立超时。前者由请求的 deadline 控制,后者由 RpcClient 的重试逻辑接管。RpcClient 在连接失败后会遍历 ServerListManager 里的节点,全部失败就进入周期重试,这段时间连接状态会从 STARTED 变成 UNHEALTHY,日志里能看到明显的 reconnect 输出。
重连成功那一刻,不能只把状态切换回去就完事,还得做状态补偿。这就是前面说的 onConnected 回调里重新注册实例、重新订阅服务。这里有个细节我踩过坑:如果应用在重连期间发生了本地缓存变更(比如实例被下线),这些变更不能依赖重连补偿去覆盖,因为补偿逻辑一般只负责把“当前内存里的状态”上报上去,而不会合并断线期间的增量。所以如果你的业务在 Nacos 客户端断线期间还允许修改注册状态,要考虑自己做一个本地变更队列,重连后先入队再统一上报,避免丢变更。
超时值的设置也要讲策略。注册实例这种高频小请求,超时短一点没关系,失败了很快重试;查询服务列表这种稍重的请求,如果服务端数据量大,默认 3 秒在某些极端情况下可能不够。具体调多少,要结合实际 P99 耗时和服务端吞吐来决定,不要盲目拉到 30 秒,否则客户端线程会大量堆积在等待上。
4.4 序列化与协议封装的细节
gRPC 本身用 protobuf 传输,但 Nacos 并没有把每个业务请求都定义成独立的 proto message,而是封装了一个统一的 Payload。Payload 里有 payloadType 字段标识具体业务类型,body 字段放序列化后的二进制数据。收到请求后,服务端先解析 Payload,再根据 payloadType 反序列化成真正的 Request 对象。
这种做法的好处是协议扩展不用改 proto 文件,加一种请求类型只加代码就行;坏处是在排查问题时不能直接看明文请求体,需要做一层反序列化才能看懂内容。如果你要抓包分析,建议直接看 Nacos 自带日志里的 payloadType 字段,而不是去看原始字节。
另外注意默认是明文传输(usePlaintext)。内网部署没问题,但如果你跨公网或对安全性有要求,需要确认是否启用了 TLS。启用 TLS 后端口监听和握手流程都会有变化,排查网络问题时别再按明文那套思路去分析。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 客户端日志反复 connect failed | 9848 端口没放通,或 serverAddr 解析到了不可达地址 | telnet <ip> 9848,检查安全组和负载均衡四层转发 |
| 服务端启动失败 Address already in use | 9848 或 9849 被其他进程占用 | lsof -i :9848,lsof -i :9849 |
| 客户端频繁 onDisconnected / onConnected | 网络闪断、负载均衡空闲剪断、客户端 keepalive 与服务端回收阈值不匹配 | 看服务端连接日志,检查网络丢包,调整 keepalive 参数 |
| 配置变更推送延迟大 | 服务端推送线程池被打满,或客户端监听器处理慢 | `jstack |
| 老版本 1.X 客户端连不上 | 兼容通道被关闭,或只部署了 gRPC 端口 | 确认 8848 HTTP 通道正常,确认服务端兼容开关未强制关闭 |
| 客户端请求大量超时 | 服务端 GC 抖动、线程池不足、网络带宽打满 | jstat -gcutil <pid> 1s,看 Full GC 频率;配合耗时监控定位 |
5.2 一次“客户端重连但服务端无日志”的实录
我之前维护的一个系统,某天应用反馈 Nacos 客户端日志里一直在重连,但打开服务端控制台看,节点状态正常,服务端日志里也没有异常。最初以为是服务端出问题,结果排查半天发现是负载均衡只把 8848 放到了后端,9848 的 TCP 转发规则根本没配。
这类问题最迷惑人的地方在于:客户端日志里的 serverAddr 写的是 8848 端口,看起来好像是通的,但底层 gRPC 实际连的是 9848。如果网络层只在 8848 放行,客户端连接必然失败,而服务端因为压根没收到连接,自然也不会有任何日志。从那以后,我把端口检查直接固化成了上线脚本,任何环境部署 Nacos 后第一步先扫一遍 8848、9848、9849 三个端口,全通了再接入业务。
5.3 连接数暴涨的定位方法
长连接模型下服务端连接数会比 1.X 明显高一截,因为每个客户端都保持一条长连接而不是用完即断。但连接数涨到异常时,要能快速判断是正常扩容还是客户端连接泄漏。
先用netstat -anp | grep 9848 | wc -l看总数,再按客户端 IP 聚合一下:netstat -anp | grep 9848 | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn。如果发现某个应用机器 IP 的连接数远超它的进程数,多半是那个应用里创建了多个 Nacos 客户端实例而没有复用。RpcClientFactory 里的缓存 Map 就是为复用设计的,前提是你不要每次业务请求都 new 一个 NacosNamingService。
另外注意,同一个应用如果既用了 conf 也用了 naming,并且分别初始化了客户端,连接数会翻倍。这是正常现象,不算泄漏,但你要在容量规划时算进去。
5.4 客户端版本兼容引发的“玄学问题”
还有一类问题特别隐蔽:客户端版本和服务端版本差距过大。服务端有个特性开关,客户端版本太旧时,某些新的请求类型不会被识别,服务端日志会打 unknown request type。但业务侧看到的往往只是“这次注册失败”“这次查询结果为空”,原因不明显。
遇到这种就先去比对版本矩阵,把客户端升级到与服务端大版本一致再观察。不要觉得 2.0 和 2.2 都是 2.X 就一定兼容,中间有一些交互细节(比如 payloadType 的枚举值、握手时的版本校验字段)有过变化。升级客户端是最低成本的解决方式,比在服务端开各种兼容开关省心得多。
写在最后
这套链路我断断续续追了挺久,从最开始只会看表面日志,到后来能顺着客户端状态机、服务端 ConnectionManager 一路定位,中间踩过的坑基本都是端口、版本、超时这三类。个人感受是,2.X 的 gRPC 通信模型一旦吃透了,比 1.X 那种“接口对接口”的排查舒服很多,因为连接状态、元数据、健康检查全在服务端可见,问题是有迹可循的。
最后分享一个小技巧:排查 Nacos 连接问题时,不要一上来就翻业务日志,先做三件事——查三个端口通不通,看客户端 RpcClient 状态日志,再看服务端 ConnectionManager 的连接数变化。这三步能过滤掉八成常见故障。后面我会再单独写一篇双向流推送和连接补偿机制的文章,把这次没展开的部分补完。