最近有位做配置中心的同行找我,说他本地起的单机 Nacos,8848 端口打开控制台一切正常,可客户端一连就报连接超时,翻日志只看到一行GrpcSdkServer starting, port: 9848。这个现象背后其实藏着 Nacos 2.x 最容易被忽略的一块——gRPC 服务端通信渠道的启动流程。它不是一个端口在干活,而是三套通道同时在跑;也不是 Spring 容器一 refresh 就立刻起来,而是被刻意安排在特定的时间点上。我把 2.x 的源码从头到尾捋了一遍,从主启动类一路追到ServerBuilder,再追到连接注册和心跳剔除,中间踩过的坑一并记下来。这篇东西适合三类人看:正在读 Nacos 源码、想搞清楚长连接模型怎么落地的后端同学;线上遇到 9848/9849 连不上、连接数被打满、连接悄悄消失的运维和 SRE;以及打算在自家项目里用 gRPC 做一套长连接推送通道、想找个成熟参考实现的开发者。看完你至少能搞清楚:服务端为什么要在 8848 之外再开两个端口,端口号是怎么算出来的,ServerBuilder上都设了哪些参数,一条连接从 TCP 握手到进入ConnectionManager中间经过谁,以及空闲连接到底是谁、按什么规则清掉的。
1. 先搞清楚这套 gRPC 服务端到底解决什么问题
1.1 从高频轮询到一条长连接:2.x 为什么要重做通信层
1.x 的客户端模型说白了就是"勤快地问"。配置监听靠定时长轮询,服务发现靠定时拉全量列表,每个客户端每隔几秒就来一次 HTTP 请求,注册中心本身无状态、也不记录谁在听什么。这套模型在集群规模小的时候非常省心,服务端不用维护任何会话,扩容缩容随便搞。但客户端数量一上来,问题就很直白:几十万台客户端乘以几秒一次的轮询频率,落到服务端就是海量的短连接和线程切换,绝大部分请求的返回值还是"没变化"。
2.x 换成长连接之后,语义反过来了:客户端建一条连接挂着,服务端记住"这个连接关心哪些配置、哪些服务",一有变更主动往下推。请求模型从"客户端问"变成"服务端推",服务端要维护会话状态,换来的是推送延迟从秒级抖动降到毫秒级、连接数从"请求数"降到"客户端数"。代价也很明显——服务端从此有了状态,需要连接管理、需要心跳、需要处理半死不活的连接。这套东西全压在 gRPC 这一层上,所以 gRPC 服务端的启动流程里,一大半代码不是在"起服务",而是在为后面的会话管理做准备。
理解了这个前提,再看BaseGrpcServer里那些看起来啰嗦的初始化步骤,就不会觉得是过度设计了。
1.2 三条通道的分工:SDK 通道、集群通道和遗留的 HTTP 通道
Nacos 2.x 起来之后,一个进程上实际会监听好几个端口,这里必须掰清楚,否则排查问题时方向一定跑偏:
| 通道 | 典型端口 | 谁在用 | 承载的请求 |
|---|---|---|---|
| HTTP 主端口 | 8848 | 控制台、OpenAPI、1.x 老客户端 | 配置 CRUD、服务查询、老版本长轮询 |
| SDK gRPC 通道 | 主端口 + 1000,默认 9848 | 2.x 的客户端 SDK | 配置监听、服务订阅、连接协商、心跳 |
| 集群 gRPC 通道 | 主端口 + 1001,默认 9849 | 集群内其他节点 | 节点间数据同步、服务列表转发 |
三条通道对应三个服务端实现,前两个是本文的主角。这种"一个进程多端口"的设计有个很实际的考虑:不同通道的流量特征完全不同,SDK 通道是海量客户端长连接、单连接流量小但连接数极多;集群通道是少量节点间连接、单连接流量大且对延迟敏感。把它们拆到不同端口上,可以分别设置线程池、消息大小上限和连接数阈值,互不干扰。如果全挤在一个端口上,一台机器被客户端连接打满的时候,集群同步也会跟着遭殃。
注意:1000 和 1001 这两个偏移量是写死在源码里的常量,不是配置项。你改了主端口,gRPC 端口会跟着平移,但偏移量本身改不了,所以规划防火墙策略时要按"主端口 + 主端口 + 1000 + 主端口 + 1001"三个一起开。
1.3 启动时机为什么必须卡在 Spring 容器就绪之后
BaseGrpcServer的子类里有大量@Autowired字段:请求处理器、连接管理器、线程池管理器,全都是 Spring Bean。这就决定了一件事——gRPC 服务端绝不能在自己构造的时候就把 Server 起起来,那时候依赖注入还没完成,拿到的全是 null。
源码里的处理方式是把"启动"这个动作延迟到主启动类Nacos的start()方法里,那时ApplicationContext已经 refresh 完毕,所有 Bean 就位。这也解释了一个新手常见的困惑:为什么在 IDE 里断点打在BaseGrpcServer的构造器上,看到端口号是 0 或者还没赋值——真正的端口计算发生在startServer()里,而不是构造阶段。
反过来看,这个设计也带来一个副作用:如果某个 Bean 初始化失败导致容器起不来,gRPC 端口就永远不会被监听,但 8848 的 HTTP 端口可能已经起来了。表现就是"控制台能开、客户端连不上",跟开头那位同行遇到的现象一模一样。所以遇到这类问题,第一件事不是怀疑网络,而是去翻启动日志里有没有 gRPC 相关的启动记录。
2. 启动入口追踪:从主类到 ServerBuilder 的完整链路
2.1 启动时序拆解:谁在什么时候调用了谁
从最外层往里剥,一次完整的启动大致经过这么几层:
- 主启动类
Nacos完成init(),装配 Spring 上下文,加载持久化、集群成员管理等模块; - 上下文就绪后,调用启动 RPC 服务端的那段逻辑,依次实例化 SDK 通道和集群通道的服务端实现;
- 进入
BaseRpcServer#start()这个模板方法,先做协议字典注册,再调用子类实现的startServer(); - 进入
BaseGrpcServer#startServer(),计算端口、构建ServerBuilder、注册服务实现、调用build().start(); - Server 启动成功后,把实例存到成员变量里,供后续
stop()时优雅关闭。
不同小版本之间这一步的方法名会有微调,有的版本把startRpcServer()抽成独立方法,有的直接在start()里 new 出两个实现类。追代码的时候不用纠结方法名,认准两个类名就行:GrpcSdkServer和GrpcClusterServer,它们是最终的落点。
有个细节值得留意:两个 Server 是串行启动的,SDK 通道先起、集群通道后起,中间没有任何并行处理。这意味着如果 SDK 通道因为端口被占用而抛异常,集群通道压根不会被尝试。日志里只会看到第一个失败,容易误判成"只起了一个"。排查端口冲突时记得两个端口都要查。
2.2 BaseRpcServer 的模板方法:三段式启动各干什么
抽象基类里那个start()方法,是典型的模板方法模式,把不变的骨架固定住,把变化的部分交给子类。它的结构大致是这么三段(下面这段代码是按源码结构重写的示意,用于说明执行顺序,不是逐字复制):
public abstract class BaseRpcServer implements RpcServer { @Override public void start() throws Exception { String serverName = getClass().getSimpleName(); LOGGER.info("{} starting...", serverName); // 第一段:协议字典准备 initPayloadRegistry(); // 第二段:交给子类构建并启动真正的 Server Server server = startServer(); // 第三段:持有引用,供优雅关闭使用 if (server != null) { this.server = server; LOGGER.info("{} started successfully, port: {}", serverName, getPort()); } } public abstract Server startServer() throws Exception; public abstract int getPort(); }第一段容易被跳过,但它其实是后面所有请求能正确分发的前提。gRPC 传输的是二进制Payload,服务端收到一堆字节之后必须知道"这是哪种请求、该交给哪个处理器"。这份映射关系就存在PayloadRegistry里,是一个从请求类型全名到RequestHandler的静态表。
第二段才是真正碰网络的部分。注意startServer()返回的是 gRPC 原生的Server对象,不是 Spring Bean,所以它必须被手动持有、手动关闭,生命周期跟容器不完全同步。
第三段看着像凑数,实际很有用。Nacos 支持运行期优雅下线,stop()时会对这个Server调shutdown()而不是shutdownNow(),让正在处理的请求有机会跑完。这一点在做滚动发布的时候差别很大——粗暴关闭会让客户端收到一串UNAVAILABLE,然后触发重连风暴。
2.3 PayloadRegistry 初始化:先把"协议字典"建好再开门
PayloadRegistry干的事很朴素:扫描 classpath 下所有实现了Request接口的类,把类名和它对应的处理器登记起来。源码里用的是 ClassGraph 做扫描,好处是不依赖 Spring 的包扫描路径,即使处理器类不在当前应用的扫描范围内也能注册上。
注册进去的信息大致是两类:一类是Payload类型本身,用于反序列化时按类型名找到对应的解析器;另一类是"这个请求该由哪个RequestHandler处理"。两个映射拼起来,就构成了服务端的路由表。
为什么非要提前建好、而不是收到请求时再反射查?因为这是每个请求都要走的热路径。一条连接上每秒可能几十个请求,每次都用Class.forName去查,开销累加起来很可观。提前建成静态 Map,收到请求时就是一次哈希查找,成本可以忽略。
这里有个实操上的坑值得记一下:如果你二次开发时新增了一个自定义请求类型,但忘了给它加注解或者放在了不被扫描的包里,表现是客户端发请求过去,服务端不回错误也不回数据,连接就干挂着。日志里只会在 DEBUG 级别打一句"payload type not found"。我第一次遇到时排查了半天网络,最后才发现是注册表里根本没这个类。定位方法很土但有效:在PayloadRegistry的注册逻辑上加个断点,看看自己那个类有没有进来。
3. 端口推导与 ServerBuilder 构建细节
3.1 主端口加偏移量:9848 和 9849 是怎么算出来的
端口计算这块代码短得可怜,但值得单独拎出来讲,因为它是最常被问到的问题之一。核心逻辑就是"读主端口配置,加上固定偏移量":
// SDK 通道 public int getPort() { return EnvUtils.getProperty("nacos.server.main.port", 8848) + Constants.SERVER_PORT_OFFSET; // 1000 } // 集群通道 public int getPort() { return EnvUtils.getProperty("nacos.server.main.port", 8848) + Constants.SERVER_PORT_OFFSET + 1; // 1001 }EnvUtils.getProperty会先读环境变量、再读 JVM 参数、最后读配置文件,所以主端口在哪个层级配置都能被感知到。这一点很重要:很多人在application.properties里改了server.port,却发现 gRPC 端口没跟着变——因为读的是nacos.server.main.port,不是 Spring 的server.port。两个键名长得像,含义完全不同。
不同主端口对应的实际监听端口如下表,规划防火墙和容器端口映射时直接照着填:
| 主端口配置 | SDK 通道 | 集群通道 |
|---|---|---|
| 8848(默认) | 9848 | 9849 |
| 8000 | 9000 | 9001 |
| 18848 | 19848 | 19849 |
| 65500 | 66500(超出范围,起不来) | 66501(超出范围) |
最后一行不是凑数,是真会踩的坑。偏移量是硬编码的加法,源码里没有任何上界校验,主端口配到 64536 以上,算出来的端口就超过 65535,ServerBuilder.build()会抛一个语焉不详的绑定异常。这种问题在测试环境通常没人发现,等到生产上为了避开端口冲突把主端口调到 65000 才炸出来。
提示:容器化部署时,
-p 8848:8848是不够的,必须把 9848 和 9849 一起映射出去,否则容器内服务起得好好的,宿主机上的客户端永远连不上。K8s 环境同理,Service 的端口列表和 NetworkPolicy 都要照顾到这两个端口。
3.2 ServerBuilder 上的关键配置逐项解读
拿到端口之后,接下来是构建ServerBuilder。这段代码是整个启动流程里信息密度最高的地方,每一行都对应一个线上可能出问题的点:
// 结构示意,按 2.2.x 的写法整理 final ServerBuilder<?> serverBuilder = Grpc.newServerBuilderForPort(getPort(), InsecureServerCredentials.create()); // 1. 业务线程池:请求真正执行的地方 serverBuilder.executor(ThreadPoolManager.getPool(RPC_EXECUTOR)); // 2. 单条消息大小上限 serverBuilder.maxInboundMessageSize(MAX_INBOUND_MESSAGE_SIZE); // 3. 保活与空闲检测 serverBuilder.keepAliveTime(KEEP_ALIVE_TIME, TimeUnit.MILLISECONDS) .keepAliveTimeout(KEEP_ALIVE_TIMEOUT, TimeUnit.MILLISECONDS) .permitKeepAliveTime(PERMIT_KEEP_ALIVE_TIME, TimeUnit.MILLISECONDS) .permitKeepAliveWithoutCalls(true); // 4. 注册业务实现 serverBuilder.addService(new RequestGrpcService(grpcRequestAcceptor, grpcBiStreamRequestAcceptor)); // 5. 起服务 return serverBuilder.build().start();线程池这一项是最容易调错的。executor()传进去的池子,是 gRPC 回调业务代码时用的线程,不是网络 IO 线程。默认情况下 gRPC 自己有一个共享的缓存线程池,Nacos 把它换成了自己的ThreadPoolManager管理的池子,目的有两个:一是统一监控,所有池子的队列长度、活跃线程数都能从同一个口子拿到;二是隔离,集群通道和 SDK 通道用不同的池子,客户端流量暴涨时不会把集群同步线程饿死。
消息大小上限直接决定了单次配置推送能带多大的内容。默认值通常是 10MB 级别,配置内容特别大的场景(比如一些超长的规则文件)会撞上这个上限,客户端收到RESOURCE_EXHAUSTED。这个报错信息很不友好,只说"资源耗尽",不说"你消息太大",很多人会往限流上想。
保活参数是长连接场景的关键。keepAliveTime是服务端主动发 ping 的间隔,permitKeepAliveTime是允许客户端发 ping 的最小间隔——客户端 ping 得太频繁会被服务端判定为"行为异常"并断开。这几个参数配得太激进,会出现大量莫名奇妙的连接被重置;配得太保守,半死不活的连接又清不掉。
注册服务实现这一步容易被误解。RequestGrpcService是一个 gRPC 生成的服务基类实现,它内部只做转发:一元请求转给GrpcRequestAcceptor,双向流转给GrpcBiStreamRequestAcceptor。真正的业务逻辑全在这两个 Acceptor 里,服务类本身薄得像张纸。这样设计的好处是序列化和业务解耦,改协议不用动业务代码。
3.3 线程池分工:网络 IO 线程与业务线程别混着用
把线程模型讲清楚,很多"为什么 QPS 上不去"的疑问会自己消失。gRPC 服务端的线程大致分三层:
第一层是Netty 的 EventLoop 线程,数量通常等于 CPU 核数乘以某个系数,负责收发字节、编解码、管理连接状态。这一层绝对不能阻塞,一旦某个回调里做了耗时操作,整个 EventLoop 上挂着的所有连接都会受影响。
第二层是gRPC 的传输线程,负责把解码后的消息投递给业务回调。Nacos 这一层基本保持默认配置。
第三层是业务执行线程,也就是serverBuilder.executor()指定的那个池子。GrpcRequestAcceptor里的请求处理逻辑跑在这里,可能涉及查数据库、读缓存、序列化大对象。
三层分开的意义在于:业务偶尔慢了,只会让业务线程池排队,不会把 EventLoop 堵死。反过来,如果为了省事直接把业务逻辑塞进 Netty 线程(比如在拦截器里同步查库),现象是 QPS 稍微一高,所有连接一起超时,看起来像网络故障,实际是线程被占满了。
我调过的一个案例:某次压测发现连接数一过两千,延迟就从 5ms 飙到 3 秒,且是全部连接一起变慢。用jstack抓了几次线程栈,发现业务线程池的队列是空的,反而 Netty 的 EventLoop 线程全卡在同一个方法上——有人在那条链路上加了个同步的日志落盘。改完立刻恢复。这个教训是:Netty 线程里的任何一次磁盘 IO,都会被放大成整条连接的故障。
4. 请求分发与连接建立:从字节流到 Connection 对象
4.1 GrpcRequestAcceptor:一元请求的统一入口
GrpcRequestAcceptor是服务端收到一元请求(一问一答,不保持流)时的总闸门。它的逻辑不复杂,但每一步都有讲究:
// 逻辑示意 Payload request = ...; // 从流里解出来的对象 String type = metadata.get(PAYLOAD_TYPE_KEY); // 请求类型,放在 header 里 if (StringUtils.isBlank(type)) { responseObserver.onNext(buildErrorResponse(...)); return; } // 1. 按类型找到处理器 RequestHandler handler = PayloadRegistry.getHandler(request); if (handler == null) { responseObserver.onNext(buildErrorResponse("no handler")); return; } // 2. 连接维度的限流与校验 Connection connection = connectionManager.getConnection(connectionId); ... // 3. 真正执行 Response response = handler.handleRequest(request, requestMeta); responseObserver.onNext(response); responseObserver.onCompleted();请求类型放在 metadata 里而不是靠反序列化后的类型判断,是因为服务端在解出对象之前就得知道用什么类去解——这是个先有鸡还是先有蛋的问题,只能靠 header 提前告诉。
第二段的连接校验很关键。一元请求虽然不建立流,但它绑定在某条已有的长连接上,服务端要能通过connectionId找到对应的Connection。如果找不到(比如连接刚被剔除掉),直接返回错误让客户端重连,比继续往下走要好。早期版本这里没做强校验,出现过连接已经断开、请求还在处理、写响应时抛异常的情况,日志里一堆StreamObserver已关闭的报错,非常干扰排查。
RequestHandler是个很薄的接口,只有handleRequest一个方法,实现类按业务领域分:配置相关的、服务发现相关的、集群同步相关的,各自一个。这种设计的好处是加新接口不用改 Acceptor,注册一张表就行;坏处是处理器散落各处,想知道总共有哪些接口,得去PayloadRegistry里数一遍。
4.2 GrpcBiStreamRequestAcceptor:双向流才是长连接的本体
真正承载长连接的是双向流。客户端建连之后会主动开一条BiRequestStream,然后在这条流上既发请求也收推送。GrpcBiStreamRequestAcceptor处理的就是这条流:
@Override public StreamObserver<Payload> biStream(StreamObserver<Payload> responseObserver) { PayloadGrpcStreamObserver observer = new PayloadGrpcStreamObserver( connectionManager, grpcCommonRequestAcceptor, responseObserver, ...); return observer; }返回的这个StreamObserver是整条流的生命周期钩子,三个方法各有分工:
onNext(Payload):收到一条消息。如果这条消息是ConnectionSetupRequest,说明是客户端建连的第一步,要在这里完成连接注册;如果是普通业务请求,转给一元请求的处理器复用逻辑。onError(Throwable):流异常断开,通常是网络抖动或客户端进程被杀,要把对应的连接从管理器里摘掉。onCompleted():客户端正常关闭流,同样要清理。
用一条双向流而不是"一元请求 + 服务端推流"两条 RPC,是为了减少连接层面的开销,同时也是为了让"建连协商"和"业务请求"复用同一条 HTTP/2 流,省一次握手。
ConnectionSetupRequest的处理是整个流程里最值得细看的一段。它做三件事:
第一件,解析客户端身份。请求里带着客户端版本、应用名、租户、标签信息,服务端把这些存进ConnectionMeta。后面做灰度、按标签路由、按版本统计连接分布,全靠这份元数据。
第二件,登记连接。调用ConnectionManager.register(connection),把连接放进一个ConcurrentHashMap<connectionId, Connection>里,同时更新连接计数。这一步之后,这条连接才算真正"可被推送"。
第三件,校验与拒绝。连接数超过上限、客户端版本过低、租户信息不合法,都可能在这里被直接拒绝。拒绝的方式是回一个带错误码的响应然后关闭流,客户端收到后不会无限重试。
有个细节特别容易被忽略:注册连接和设置元数据的顺序不能反。必须先register再setMetaInfo,因为register内部可能会触发一些基于元数据的判断(比如连接池扩容、按标签分类)。如果顺序搞反,那些判断拿到的元数据是空的,行为会很诡异。我在读源码时对这段印象最深,因为它没有注释,只能靠看调用栈推断意图。
4.3 Connection 和 ConnectionMeta 里到底存了什么
Connection这个抽象有两种实现:gRPC 的和 HTTP 的(老客户端兼容用)。gRPC 的实现底层包着一个StreamObserver引用,推送数据就是往这个流里onNext。
ConnectionMeta里的字段大致分几类:
| 分类 | 典型字段 | 用途 |
|---|---|---|
| 网络标识 | connectionId、clientIp、clientPort | 定位、日志、连接查询接口 |
| 客户端信息 | clientVersion、appName、tenant、labels | 灰度、分类统计、版本兼容 |
| 能力协商 | abilityTable(客户端支持哪些能力) | 决定推送用哪种编码、是否支持新特性 |
| 活跃信息 | lastActiveTime、createTime | 心跳更新、空闲剔除 |
能力协商这张表是 2.x 里比较巧妙的设计。客户端建连时会声明自己支持哪些能力,服务端根据这张表决定用哪种方式跟它通信。老版本客户端不支持某个推送类型,服务端会退化成"通知有变化,你自己来拉"的模式。这让 1.x 和 2.x 客户端可以共存于同一个集群,不用搞两套服务端。
lastActiveTime是所有时间戳字段里最重要的一个。它由心跳更新,是空闲剔除的唯一依据。这个字段用的是volatile加上无锁的System.currentTimeMillis()直接赋值,没有做原子性保证——因为丢一次更新的后果只是可能稍微提前剔除,可以接受。如果改成AtomicLong,每条心跳都多一次 CAS,在几十万连接的规模下反而不划算。这种"用一点点精度换吞吐"的取舍,在基础组件里非常常见。
5. 连接生命周期管理:心跳、上限与剔除
5.1 心跳是怎么更新 lastActiveTime 的
客户端连上之后,会按固定间隔(通常是几秒)发心跳。心跳请求本身极轻,不带业务数据,服务端收到后只做一件事:把这条连接的lastActiveTime刷成当前时间,然后回一个空响应。
这个设计有意做得很薄。心跳是纯开销,不产生任何业务价值,所以从解析到回包每一步都在省:不复用业务线程池、不做权限校验、不写日志(除了 DEBUG 级别)。几十万连接乘以几秒一次的心跳,累计起来是相当可观的 QPS,任何一个环节多一次字符串拼接,都会被放大成 CPU 热点。
老版本客户端用的是另一种心跳类型,字段不一样,服务端为了兼容两个都认。这也是为什么Connection要抽象出 gRPC 和 HTTP 两种实现——心跳逻辑得按类型分开走。读源码时如果看到两个名字很像的 Handler,别以为是重复代码,那是历史兼容的痕迹。
5.2 连接数上限与超限时的处理策略
ConnectionManager里有个连接总数计数器,注册时加一、注销时减一。上限从配置项读取,默认值通常是不设限或者设得非常大。
超限的处理在这几年改过几版,现在的做法比较克制:不是简单地拒掉新连接,而是先尝试挤出一些空间。挤的方式是遍历现有连接,找出最久没活动的那些,主动关掉。如果关完之后还是超限,才拒绝新连接。
这个策略背后的判断是:在连接数打满的场景下,最久没活动的连接大概率已经死了,只是服务端还没感知到。与其拒绝新来的健康客户端,不如先清掉这些僵尸。关掉一个僵尸连接的成本几乎为零,因为对端已经不在了,不需要等它优雅关闭。
不过这里有个明显的风险:如果一整批连接同时"看起来"不活跃(比如它们连上之后就没业务,只是挂着),可能被误杀。所以实际实现里会加一些保护,比如连接存活时间太短的跳过、刚有心跳的跳过。这些保护条件在源码里散落在几个 if 里,读的时候留意一下,能省很多"为什么我的连接莫名其妙断了"的排查时间。
5.3 空闲连接剔除:谁在跑、多久跑一次、按什么规则判
剔除动作由一个独立的后台任务驱动,定时跑,间隔通常是秒级。任务内容不复杂:
// 逻辑示意 public void doRuntimeEjector() { long now = System.currentTimeMillis(); for (Connection connection : connectionManager.getConnections()) { if (connection instanceof GrpcConnection) { long idle = now - connection.getMetaInfo().getLastActiveTime(); if (idle > MAX_IDLE_MILLIS) { // 约为心跳周期的若干倍 connectionManager.unregister(connection.getMetaInfo().getConnectionId()); connection.close(); } } } }阈值取心跳周期的若干倍,留出足够的容错空间。如果阈值取成"刚好两倍心跳间隔",网络稍微抖一下就误杀一片,重连风暴比不剔除还糟。
剔除任务的实现上有个取舍值得说:它是单线程遍历,不是分组并行。几十万连接的遍历,单线程跑一轮可能要几百毫秒,看起来是个瓶颈。但并行的代价是需要处理遍历过程中连接被并发注销的问题,复杂度和出错概率陡增。考虑到剔除本身不是延迟敏感的操作,慢几百毫秒完全能接受,选择简单方案是合理的。这种"优先正确性再谈性能"的判断,在基础设施代码里出现的频率比想象中高。
还有一个容易忽略的点:遍历的是快照还是实时集合。如果直接用ConcurrentHashMap的迭代器遍历,遍历期间被并发注销的元素行为是弱一致的,可能出现遍历到已删除的元素。这本身不致命(unregister是幂等的),但如果代码里对unregister的返回值做了判断,就要小心了。读这段源码时建议配合看unregister的实现,确认它在元素不存在时的行为。
6. 常见问题与排查速查
6.1 端口类问题:连不上但服务确实起来了
这是最高频的一类,几乎都是端口偏移没算对。排查顺序建议固定下来:
第一步,确认服务端实际监听了哪些端口。ss -lntp | grep java或者netstat -anp | grep 98,看 9848 和 9849 在不在。如果压根没监听,问题在服务端启动,去看启动日志里有没有GrpcSdkServer started successfully。
第二步,确认客户端连的是哪个端口。2.x 客户端会自动用主端口加 1000,但它读的主端口来自客户端的配置,跟服务端未必一致。服务端 8848、客户端配了 8849,算出来就是 9849,连到集群通道上去,行为会很奇怪——能握手但业务请求全都不认识。
第三步,确认中间有没有端口映射或安全组拦截。容器、K8s Service、云上安全组,这三层任意一层漏了 9848,现象都是连接超时。
第四步,确认防火墙对长连接的处理。有些设备的空闲连接回收时间很短,而 Nacos 的心跳间隔又比较长,中间就可能出现"连接被设备悄悄清掉,两端都还以为活着"。表现是推送延迟突然变大,然后某次请求超时后恢复。
| 现象 | 最可能的原因 | 验证方式 |
|---|---|---|
| 8848 能开,9848 超时 | 端口未映射或被拦截 | 服务端ss -lntp确认监听 |
| 客户端一直重连 | 客户端主端口配置与服务端不一致 | 对比两侧配置里的主端口 |
| 启动日志报绑定失败 | 主端口过大,加偏移后越界 | 检查主端口是否超过 64535 |
| 连上后请求全部失败 | 建议检查是否连到了集群通道 | 核对客户端连接的目标端口 |
6.2 连接类问题:连接数打满和连接泄漏
连接数打满的典型特征是:新客户端连不上、老客户端正常、服务端连接计数顶在配置上限。这时候别急着调大上限,先查是不是有连接泄漏。
泄漏的判断方法很直接:对比"客户端实际存活数"和"服务端连接计数"。如果差距随时间线性增长,基本可以确认泄漏。常见成因有三个:客户端重连时没关旧连接、服务端onError里没清理、以及客户端进程被强杀(这种情况加密连接会一直占着,因为对端不发 FIN 也不发 RST)。
第二种情况特别隐蔽,因为它在源码层面就很容易被忽略:onError里如果处理逻辑抛了异常,清理代码就执行不到。稳健的写法是把清理放在finally里,或者用onError和onCompleted都调同一个幂等的清理方法。
针对进程被强杀留下的连接,唯一有效的办法是靠剔除任务兜底。所以剔除任务的间隔和阈值不能设得太宽松,否则连接计数会长期虚高。
6.3 大包、超时和版本兼容
推送的配置内容超过消息大小上限时,客户端收到的是RESOURCE_EXHAUSTED,这个错误码极具误导性。判断方法:看服务端日志有没有对应的编码失败记录,或者直接把内容体积打出来对比上限。
超时类问题要区分是建连超时还是请求超时。建连超时通常是端口或网络问题;建连成功但请求超时,多半是业务线程池被占满或者下游慢查询。区分方法很简单:在服务端看连接数有没有涨(涨了说明建连成功),再看业务线程池的队列长度。
版本兼容问题的表现很奇特:某些客户端能收到推送,另一些收不到,但请求都正常。这时候去查ConnectionMeta里的能力表,大概率是老客户端声明了不支持某类推送,服务端自动降级成了"通知拉取"模式。这不是 bug,是设计行为。
6.4 一份可落地的排查清单
把上面这些整理成一张随手可查的表,线上出问题时按顺序过一遍,通常三分钟内能定位到方向:
| 排查项 | 命令 / 位置 | 期望结果 |
|---|---|---|
| 端口监听 | ss -lntp | grep -E '9848|9849' | 两个端口都在 LISTEN |
| 启动日志 | 搜GrpcSdkServer/GrpcClusterServer | 有 started successfully |
| 连接总数 | 控制台或连接查询接口 | 与客户端数量大致吻合 |
| 主端口一致性 | 服务端配置 vs 客户端配置 | 完全一致 |
| 消息大小 | 服务端上限 vs 最大配置体积 | 上限明显大于单条内容 |
| 剔除日志 | 搜连接 ID | 只应出现异常连接的 ID |
| 线程栈 | jstack看 EventLoop 线程 | 没有阻塞在业务方法上 |
7. 几个我在实际调试中攒下的经验
调这套东西最有效的手段其实不是读代码,而是在ConnectionManager.register和unregister上各打一个条件断点,条件设成某个特定的客户端 IP。然后完整跑一遍"客户端启动 → 建连 → 发请求 → 客户端退出"的流程,看这两个断点是不是严格配对。只要有一对配不上,泄漏的位置就找到了。这个方法比看日志快得多,因为连接建立和断开在 INFO 级别通常都不打日志。
另一个习惯是先看端口再看代码。前面提的那些连接数、心跳、剔除的问题,都建立在"端口通"的前提上。如果 9848 根本没监听,往下看一万行源码都是浪费时间。我现在的顺序固定是:端口 → 日志 → 连接计数 → 线程栈 → 源码。前面四步能解决九成问题,剩下那一成才需要翻源码。
还有一个反直觉的体会:这套启动流程里,真正需要定制的点很少。很多团队接手后第一反应是把线程池参数、消息大小、连接上限都调一遍,结果往往是把本来正常的东西调坏了。默认值是在大规模场景下磨出来的,除非你有明确的压测数据支撑,否则别动。我见过最典型的一次事故,是把业务线程池的核心线程数从默认值改成了 CPU 核数,理由是"其他框架都这么配",结果配置推送高峰期线程池疯狂创建销毁,反而拖垮了整个通道。
最后留一个后续可以自己深挖的方向:ConnectionSetupRequest里的能力表是个开放的扩展点。想给客户端加一种新的推送能力,不需要改协议,只要在客户端声明能力、服务端按能力分流就行。这条路径在源码里埋得很浅,顺着AbilityTable往下找,两三个类就能看全,比改协议加字段的方式省事得多。