☰
Nacos 2.x gRPC服务端启动流程、端口与连接管理解析
2026/10/26 14:57:57 网站建设 项目流程

上周有位同学在群里贴了一段启动日志,服务端明明起来了,8848 端口也通,控制台能打开,配置也能查,但客户端 SDK 一直转圈,最后抛了个Client not connected, current status: STARTING。他把日志翻了个底朝天也没找到异常,直到我让他执行了一条ss -lntp | grep 984,屏幕上一片空白——gRPC 的端口根本没在这个容器里露出来。这个问题几乎每个月都会有人踩一次,根源不在配置写错,而在于对 Nacos 2.x 服务端通信渠道的启动过程没有建立完整的心理模型。

我这次就顺着源码把这件事捋一遍:Nacos gRPC 服务端通信渠道是如何启动的。这篇内容主要写给三类人——正在排查 Nacos 2.x 连接问题的一线运维和后端开发、准备读 Nacos 源码但被 gRPC 那层拦住的同学、以及需要在自己的项目里嵌入一套长连接服务端的工程师。读完之后你至少能拿到三样东西:端口是怎么算出来的、start()之后到底发生了哪些事、以及连接从建立到被回收的完整链路。顺带我会把我自己踩过的几个坑和排查手法一并放进去,都是文档里不太写的东西。

1. 端口先搞明白:gRPC 通道在 Nacos 里到底占哪几个坑

很多人读源码第一件事是打开 IDEA 找main方法,结果在 Nacos 这种 Spring Boot 工程里绕半天找不到入口。我的建议是先别看代码,先把端口这件事搞清楚,因为端口是这套通信渠道最直观的外部表征,搞清楚端口,后面看类结构会顺很多。

1.1 8848、9848、9849 三个端口各管什么

Nacos 2.x 相比 1.x 最大的架构变化是把客户端与服务端之间那条“HTTP 短连接 + 长轮询”的通道换成了 gRPC 长连接。但注意,它并没有把 HTTP 扔掉,控制台、OpenAPI、健康检查这些还是走 8848,gRPC 是在旁边另开了两个端口。

  • 8848(主端口):HTTP 服务,控制台页面、OpenAPI 调用、1.x 客户端的兼容接口全部走这里。
  • 9848(SDK gRPC 端口):2.x 客户端 SDK 与服务端之间建立长连接用的端口,配置监听、服务订阅、注册请求的推送全靠它。
  • 9849(集群 gRPC 端口):Nacos 集群内部节点之间通信用的端口,比如 Distro 协议的数据同步、集群间的请求转发。

这两个 gRPC 端口不是拍脑袋定的,而是跟主端口绑定的偏移量关系。默认主端口 8848,9848 = 8848 + 1000,9849 = 8848 + 1001。这个设计的巧妙之处在于,客户端只要知道 HTTP 端口,就能推算出 gRPC 端口,不需要额外配置一份地址表。你在application.properties里把server.port改成 8858,那 gRPC 端口自动变成 9858 和 9859,客户端也自动跟着推,不会出现“HTTP 改了、gRPC 忘了改”的错配。

代价也很明显:一旦你把主端口改成别的值,所有防火墙规则、容器端口映射、负载均衡后端端口都得跟着改三份。我在一个项目里见过主端口被改成 8080 的场景,结果 gRPC 跑到了 9080/9081,运维只放了 8080,排查了两个小时。

注意:容器化部署时一定要把三个端口一起映射。只映射 8848 是 Nacos 2.x 最高频的部署事故,现象就是控制台一切正常,客户端永远连不上。

1.2 端口配置项与“负数偏移”机制

端口虽然可以自动推算,但总有需要显式指定的时候,比如你把 gRPC 单独放到另一个网卡或者走了独立的负载均衡。这时候就要用到配置项。

配置项作用默认值说明
nacos.remote.server.grpc.sdk.portSDK gRPC 端口-9848负值表示相对主端口的偏移
nacos.remote.server.grpc.cluster.port集群 gRPC 端口-9849同上
server.portHTTP 主端口8848偏移计算的基准

这里最容易被误解的是那个负数。我一开始也以为是写错了,后来才想明白:负数代表相对主端口的偏移量,正数代表绝对端口。写-9848,实际监听端口是8848 + 1000 = 9848;写9848,就是死磕 9848 这个值,主端口改了它也不动。

这两种写法在运维上对应完全不同的策略。用相对偏移,整个集群的端口跟着主端口走,扩容迁移时省心;用绝对端口,端口固定,便于防火墙和负载均衡配置,但主端口一改就得手工同步。我的习惯是生产环境用绝对端口,把端口当成契约固定下来,测试环境用偏移,省事。

代码层面这个逻辑落在BaseGrpcServer里,大致是一个getPort()的方法:判断配置值是正还是负,负的话从环境里取主端口加上偏移,正的话直接用。这段逻辑很短但很关键,后面NettyServerBuilder.forPort()拿到的就是这个算完的结果。

2. 类结构地图:撑起 gRPC 服务端的几个关键角色

搞清楚端口,接下来看是谁在负责把端口撑起来。Nacos 的 gRPC 服务端代码集中在com.alibaba.nacos.core.remote.grpc这个包下面,类不多,但层次设计得挺讲究,值得单独拆一节。

2.1 NacosGrpcServer:一个聚合壳子

先看最外层的NacosGrpcServer。这个类的职责非常单一,它自己不建连接、不监听端口,只做两件事:持有两个具体的 Server 实例,然后在合适的时候把它们的start()依次调一遍。

// 简化示意,省略了日志与异常处理 public class NacosGrpcServer { private final GrpcServer grpcSdkServer; private final GrpcServer grpcClusterServer; public NacosGrpcServer() { this.grpcSdkServer = new GrpcSdkServer(); this.grpcClusterServer = new GrpcClusterServer(); } public void start() { grpcSdkServer.start(); grpcClusterServer.start(); } }

为什么要把两个端口的 Server 拆成两个类?因为它们承载的业务完全不同。SDK 端口面向成千上万的客户端,连接数可能上万,重点在连接管理、限流、心跳;集群端口面向集群内的几个节点,连接数极少,重点在数据一致性和转发可靠性。虽然底层都是 gRPC,但参数调优、服务注册的内容、连接治理策略都不一样。拆开之后各自演进互不干扰,这个拆分我认为是合理的。

NacosGrpcServer本身实现的是一个启动器接口,由服务端启动流程在最后阶段回调。具体接口名叫什么、是Server还是ServerRunner,不同小版本有过变化,但调用点始终在 Spring 容器初始化完成之后、HTTP 端口已经开始监听的那个时间窗。这个时序很重要:gRPC 的启动依赖 Spring 容器里的一堆 Bean(ConnectionManager、RequestHandlerRegistry等),提前启动会拿到空引用。

提示:如果你在本地调试时打了断点发现NacosGrpcServer.start()一直不进,多半是 Spring 容器还没 ready,去看启动器里各个ServerRunner的 order 值。

2.2 BaseGrpcServer:模板方法把差异抽走了

两个具体 Server 的公共部分被抽到了BaseGrpcServer里。这是一个典型的模板方法模式:父类负责“怎么建一个 gRPC 服务端”这套骨架流程,子类只负责回答“我用哪个端口”“我注册哪些服务”“我的调优参数是多少”。

父类里的骨架大致是这么几段:

  1. 拿到端口号(子类实现);
  2. 构造NettyServerBuilder,塞参数(部分由子类覆盖);
  3. 往 Builder 上挂 gRPC Service 和拦截器;
  4. build().start()真正启动,把返回的Server对象持有住;
  5. 注册优雅停机的钩子。

这套结构的好处是,后面 Nacos 加新协议端口的成本很低,继承一下BaseGrpcServer,覆盖几个方法就完事。坏处是读源码的时候容易迷路——你在父类里看到的addService可能被子类覆盖了,实际注册的服务跟你以为的不一样。我第一次读的时候就被绕了一下,最后是靠打断点看builder里实际挂了哪些 service 才确认的。

子类GrpcSdkServer和GrpcClusterServer各自覆盖的内容主要是三块:端口来源、getSeverName()之类的标识、以及一些参数默认值的差异。SDK 端的消息大小上限、并发流数量上限给得都很宽,因为客户端请求可能携带实例元数据这种体积不定的东西;集群端则相对收敛,因为集群间的数据包结构比较固定。

3. 启动链路逐行拆解:从 start() 到 build().start()

这一节是整篇文章的核心。我把startServer()里那段代码按执行顺序拆开讲,顺便解释每个参数为什么这么设。

3.1 startServer() 里的构建顺序

BaseGrpcServer的startServer()大体是这么个流程,我把关键步骤抽出来:

// 简化示意 private void startServer() throws IOException { // 1. 初始化线程池 initThreadPool(); // 2. 构造 builder,指定端口 NettyServerBuilder builder = NettyServerBuilder.forPort(getPort()); // 3. 挂载 gRPC Service builder.addService(new GrpcRequestAcceptor()); builder.addService(new BiRequestStream()); // 4. 挂拦截器(TLS、日志、指标等) addInterceptors(builder); // 5. 调优参数 builder.maxInboundMessageSize(maxInboundMessageSize) .keepAliveTime(keepAliveTime, TimeUnit.SECONDS) .permitKeepAliveTime(permitKeepAliveTime, TimeUnit.SECONDS) .permitKeepAliveWithoutCalls(true) .maxConcurrentCallsPerConnection(maxConcurrentCalls); // 6. 启动 this.server = builder.build().start(); // 7. 注册停机钩子 Runtime.getRuntime().addShutdownHook(new Thread(this::stop)); }

第 1 步的线程池值得单说。gRPC 默认用的是共享的SharedResourcePool,所有 Server 实例混着用。Nacos 把它换成了自己管理的线程池,目的是隔离——客户端那边的请求再猛,也不能把集群间通信的线程占满。这个细节在生产上很关键,我见过一次集群间数据同步延迟飙高的故障,最后定位就是集群端和 SDK 端共用了执行器,SDK 端一波推送把线程池打满了。

第 2 步到第 5 步都是在拼装NettyServerBuilder。注意这里用的是 gRPC-Java 提供的NettyServerBuilder,不是自己写 Netty 的ServerBootstrap。gRPC-Java 在 Netty 之上做了一层封装,把 HTTP/2 的帧处理、流控、连接复用这些复杂东西都藏起来了。这也是为什么你读 Nacos 源码几乎看不到 HTTP/2 相关的代码,都被 gRPC 框架吃掉了。

第 6 步的build().start()是真正的分水岭。在这行之前,端口没有监听;在这行之后,ss命令才能看到。如果你在排查“端口为什么没起来”,只要确认这行有没有执行到就够了。

3.2 NettyServerBuilder 的参数都是从哪来的

这些参数不是写死的,绝大部分可以配。理解每个参数的作用,比记住它的默认值有用得多。

参数作用设置不当的后果
maxInboundMessageSize单条消息最大字节数太小会报RESOURCE_EXHAUSTED,太大容易被大包打爆内存
keepAliveTime服务端探活心跳间隔比中间设备空闲超时大,连接会被悄悄断开
keepAliveTimeout心跳应答超时太小会误判断连
permitKeepAliveTime允许客户端发心跳的最小间隔设得太小,客户端频繁探活会消耗服务端资源
permitKeepAliveWithoutCalls空闲时是否允许发心跳关掉之后空闲长连接会被中间设备清掉
maxConcurrentCallsPerConnection单连接最大并发流数太小会限制单客户端吞吐,太大可能被单个客户端拖垮

maxInboundMessageSize这个我建议每个人都检查一遍。默认值一般是 10MB 量级,正常业务远用不到,但 Nacos 的请求里会带实例元数据,元数据是个自由格式的 Map,有人往里塞了一整个 JSON 配置或者超长的标签串,单个请求就膨胀到几 MB,再乘以并发,内存直接告警。我处理过的案例里,有个团队在每个实例的元数据里塞了完整的版本发布日志,一个服务几百个实例,注册请求体轻轻松松过兆。

keepAlive那几个参数平时不用动,但一旦你把 Nacos 放在 Nginx、云负载均衡或者硬件防火墙后面,就必须动了。中间设备普遍有一个「空闲连接超时」的策略,常见值是 60 秒或 300 秒,超过就静默断开。而 gRPC 默认的探活间隔可能比这更大,结果是连接被中间设备砍了,两端都以为对方还活着,等到真正发请求时才发现通道废了,这时候才重连,业务上表现为周期性的几秒卡顿。

我的经验规则是:服务端心跳间隔取中间设备空闲超时的三分之一。中间设备 60 秒超时,那就设 20 秒;300 秒超时,设 60 到 90 秒。同时把permitKeepAliveWithoutCalls打开,让空闲连接也能探活。

3.3 服务与拦截器的挂载

addService这两行是整个服务端对外能力的定义。GrpcRequestAcceptor和BiRequestStream这两个名字看起来像业务类,实际上它们是 gRPC 的 Service 实现,分别对应 proto 文件里定义的两个 RPC 方法。

拦截器这块每个版本都在加东西,我没法保证你现在看到的和我写的一样。常见的有几类:

  • TLS 拦截器:双向 TLS 认证的时候用,负责证书校验;
  • 日志拦截器:把请求方法、耗时、来源 IP 打出来,排查问题全靠它;
  • 指标拦截器:往监控系统吐 QPS、延迟、错误率;
  • 限流拦截器:连接数超标时直接拒绝,不让请求进到业务层。

拦截器链的顺序是有讲究的。TLS 必须在最外层,因为证书没过就不该进业务;日志放在 TLS 之后,否则你记录下来一堆认证失败的噪音;限流放在业务处理之前,越早拒掉越省资源。这个顺序在代码里通常靠一个List<ServerInterceptor>的添加顺序控制,改的时候别随手插。

4. 请求进来之后:Acceptor、双向流与连接注册

端口起来了只是万里长征第一步,真正有意思的是请求进来之后怎么处理。Nacos 的 gRPC 服务端定义了两个 RPC 方法,一个是一元调用,一个是双向流,分工明确。

4.1 GrpcRequestAcceptor:一元调用的入口

GrpcRequestAcceptor处理的是「一问一答」型请求,服务注册、服务发现、配置查询这些操作都走这里。它的方法签名很长,核心就一个Payload进、一个Payload出。

处理流程大致这么几步:

  1. 从 gRPC 的上下文里把客户端 IP、连接信息取出来;
  2. 用连接信息去ConnectionManager里找对应的连接对象,找不到就现注册一个;
  3. 从Payload的 metadata 里取出请求类型标识,比如InstanceRequest、ConfigQueryRequest;
  4. 拿着类型标识去RequestHandlerRegistry里查对应的 Handler;
  5. Handler 处理完返回Response对象,转成Payload塞回responseObserver;
  6. 全程包在 try-catch 里,任何异常都要转成一个错误响应返回,不能让异常穿透到 gRPC 框架层。

第 6 点看着平淡,其实很重要。gRPC 框架如果收到未捕获的异常,会直接把整条流关掉,客户端看到的是一个笼统的UNKNOWN错误,什么信息都没有。Nacos 把异常包装成带错误码的响应体,客户端能拿到具体原因,排查体验完全不一样。这个做法我在自己的项目里也抄了一遍。

第 2 步的「找不到就现注册」也值得说。理论上连接应该在双向流建立的时候就注册好了,一元调用是复用的。但总有客户端先发一元请求、后建双向流的情况(比如某些版本的 SDK 初始化顺序),所以这里做了兜底。这种「正常路径 + 兜底路径」的写法在 Nacos 里到处都是,读代码的时候要留意,否则你会觉得逻辑重复。

4.2 BiRequestStream:长连接真正建立的地方

如果说一元调用是「打电话问个事」,那BiRequestStream就是「一直保持通话」。这个方法返回一个StreamObserver,客户端和服务端各自持有一端,可以随时往对方推送消息。Nacos 的服务变更推送、配置变更推送全靠它。

// 简化示意 public StreamObserver<Payload> requestBiStream(StreamObserver<Payload> responseObserver) { // 1. 从请求里解析出连接元信息 ConnectionMeta metaInfo = parseMeta(request); // 2. 在 ConnectionManager 里注册连接 Connection connection = connectionManager.register(metaInfo); // 3. 关键一步:把响应观察者挂到连接对象上 connection.setTransport(responseObserver); // 4. 给客户端回一个握手应答 responseObserver.onNext(buildAckPayload()); // 5. 返回一个观察者,处理客户端后续发来的消息 return new StreamObserver<Payload>() { public void onNext(Payload payload) { // 处理心跳、请求等 handlePayload(connection, payload); } public void onError(Throwable t) { connectionManager.unregister(connection); } public void onCompleted() { connectionManager.unregister(connection); } }; }

第 3 步是整个长连接机制的命门。connection.setTransport(responseObserver)这一行执行之后,服务端就有了一个可以主动往这个客户端推消息的句柄。之后不管是服务实例列表变了、配置内容改了,还是集群里其他节点转发过来的推送请求,最终都是通过这个StreamObserver的onNext发出去的。

这里有个技术细节容易忽略:responseObserver在实际运行时会被 gRPC 框架包装成ServerCallStreamObserver,它提供了isReady()和setOnReadyHandler()这样的方法。isReady()用来判断发送缓冲区还有没有空间——如果客户端消费慢,缓冲区满了,onNext会阻塞或者堆积内存。一个成熟的推送实现必须检查isReady(),不 ready 就缓存,等 ready 了再发。Nacos 在推送链路上做了这层判断,自己写长连接服务端的时候千万记得抄。

onError和onCompleted里做连接注销,这是长连接服务的标准收尾动作。但要注意,这两个回调不一定都会触发。网络断开时,onError通常会来,但如果是对端进程被 kill -9,TCP 连接可能处于半开状态,服务端要等到 TCP 层的 keepalive 超时才能感知,这个时间可能长达几十分钟。所以光靠回调做清理是不够的,必须有一套主动的超时检测机制,这就是下一节要讲的。

4.3 PayloadRegistry:Payload 里的 Any 怎么还原

还有一个容易被忽略但绕不开的机制:Payload这个 proto 消息里,真正的业务数据放在一个Any类型的字段里。Any是 protobuf 提供的「类型擦除」容器,序列化的时候会把真实类型的全限定名一起存进去,反序列化时再根据这个名字找对应的 Parser。

问题来了,Parser 从哪找?总不能每次反射吧。Nacos 的做法是在PayloadRegistry里做一张静态注册表,所有可能出现在Any里的消息类型,在类加载阶段就调registerPayload把名字和 Parser 实例登记进去。

这个设计带来的一个连带影响是:如果你自己扩展了 Request 类型,忘了注册 Parser,运行时就会报「找不到对应类型」。我第一次扩展自定义请求时就栽在这里,序列化一切正常,服务端收到之后解不出来,日志里是一句很含蓄的提示,找了半天才发现是少了一行注册代码。这条经验我后来写进了团队内部的扩展规范里。

5. 连接生命周期管理:心跳、超时与剔除

服务端通道起来之后,面对的是一堆长连接的生死。这部分逻辑主要在ConnectionManager里,代码量不大但坑很多。

5.1 连接注册与限流

ConnectionManager内部维护着一个Map<String, Connection>,key 是连接 ID。注册的时候要处理好几件事:

第一,连接数上限。服务端不可能无限制接收连接,每个连接都要占内存、占文件描述符。ConnectionManager维护了一个全局计数和一个按 IP 的计数,超过阈值直接抛异常拒绝。这个阈值可以配,默认值在不同版本里差别挺大,生产环境要根据实际客户端数量调整。

第二,单 IP 连接数限制。这一条挡的是「某个客户端有 bug,疯狂重连」这类问题。我遇到过的一个真实场景:某客户端因为网络抖动进入重连循环,但每次重连没有正确关闭旧连接,几分钟内在一个 IP 上堆了上千条连接,直接触发了单 IP 限制,结果这个 IP 下的所有正常客户端一起被拒。这种问题只能靠治客户端,服务端的限制只是止损。

第三,连接元信息的记录。包括连接类型(SDK 还是集群)、客户端版本、连接建立时间、最后活跃时间。客户端版本这一项在排查兼容性问题时特别好用,能直接看出来是不是老版本 SDK 在捣乱。

连接拒绝的时候抛什么异常也有讲究。抛RuntimeException客户端只能看到一句「服务端错误」,抛带错误码的NacosRuntimeException客户端就能识别出「连接超限」,从而做退避重试而不是立刻重连。这个细节决定了故障时是雪崩还是自愈。

5.2 心跳与不活跃连接清理

前面说过,TCP 半开连接靠onError是靠不住的,必须有主动检测。Nacos 的机制是双管齐下:

客户端主动心跳:客户端每隔几秒发一个HealthCheckRequest,服务端收到后更新这条连接的lastActiveTime,然后回一个HealthCheckResponse。这个心跳走的是已经建立的双向流,成本极低。

服务端定时扫描:后台起一个定时任务,定期遍历所有连接,把lastActiveTime超过阈值(通常是心跳间隔的数倍)的连接挑出来,主动关闭并注销。

这两个机制配合起来,才能保证连接表里不会堆积僵尸连接。阈值怎么定?我的一般做法是「心跳间隔 × 3」,比如客户端 5 秒一次心跳,服务端 15 秒没收到就判定失联。留 3 倍余量是为了容忍网络抖动和 GC 停顿,设成 1.5 倍很容易误杀。

还有一个容易被忽略的点:主动关闭连接时,要先把StreamObserver关掉,再清 Map。顺序反了的话,可能在清 Map 和关流之间有个窗口,推送线程拿着一个已经不存在的连接去发消息,抛空指针。这种偶发异常在压测时特别难复现,但在生产上会零星冒出来。

Nacos 在较新的版本里把这部分逻辑拆成了一个专门的连接剔除器,负责处理「连接数接近上限时主动踢掉最不活跃的连接」这类策略。这个演进方向是对的,连接治理本身足够复杂,塞在ConnectionManager里会让这个类越来越臃肿。

6. 实操验证与故障排查实录

理论讲完,来点能直接抄作业的。这一节是我这些年排查 Nacos 连接问题的固定套路,按顺序执行基本能覆盖八成场景。

6.1 本地验证 gRPC 通道是否真的起来了

第一步永远是确认端口在不在。

# Linux ss -lntp | grep -E '9848|9849' # 老系统或者 macOS netstat -anp | grep -E '9848|9849'

期望看到两行LISTEN,分别在 9848 和 9849 上。如果只有 8848 没有这两个,说明startServer()没执行到,去翻启动日志里有没有 gRPC 相关的异常,最常见的是端口被占用。

第二步,验证端口能不能连通(注意要从客户端所在的机器发起,不是服务端本机):

# 能建立 TCP 连接就说明网络链路通了 timeout 3 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/9848' && echo OK || echo FAIL

第三步,把服务端的 gRPC 日志打开,看连接建立和请求处理的细节:

logging: level: com.alibaba.nacos.core.remote: debug

启动一个客户端,日志里应该能看到连接注册、心跳、请求处理的完整链路。如果日志里一条连接记录都没有,说明请求根本没到服务端,问题在网络层;如果有连接但很快又注销,说明是认证或协议不匹配。

第四步,看线程。gRPC 服务端起来之后会有一批以grpc-default-executor或 Nacos 自定义前缀命名的线程:

jstack <pid> | grep -i grpc | head -20

一个都没看到,基本可以断定 gRPC 服务端没启动成功。

提示:本地用 Docker 起服务端时,-p 8848:8848只映射了 HTTP 端口,务必补上-p 9848:9848 -p 9849:9849。用--network host能规避这个问题,但不推荐,容易掩盖端口规划的疏漏。

6.2 常见问题速查表

现象大概率原因排查动作
客户端报Client not connected, current status: STARTING9848 端口不通从客户端机器测 9848 的 TCP 连通性,检查容器端口映射和防火墙
客户端连上后几秒断开、反复重连中间设备空闲超时或心跳参数不匹配抓包看断开时机,调整服务端心跳间隔为设备超时的三分之一
报RESOURCE_EXHAUSTED: gRPC message exceeds maximum size单条消息超过maxInboundMessageSize检查实例元数据是否过大,或临时调大该参数
集群节点间数据不同步9849 端口不通检查节点间网络策略,确认集群端口配置一致
大量connection over limit连接数超限或客户端重连泄漏看客户端是否复用连接,必要时调大上限并修客户端
报UNAVAILABLE: io exception服务端未启动或端口未监听回到第一步查ss输出
连接建立但所有请求超时业务线程池打满看线程栈,确认是否有一批线程卡在同一个 Handler 里

6.3 几个踩过的坑

第一个坑,端口偏移算错。有个环境主端口配成了8080,运维按经验放了9080和9081,但配置文件里同时显式写了nacos.remote.server.grpc.sdk.port=9848(正的,绝对端口),实际监听在 9848,两边对不上。这种「配置和运维各按各的规则来」的事故最难查,因为每一方单独看都没错。我的做法是把端口规划写成一页纸,配置项、容器映射、防火墙规则、负载均衡后端全部对齐,改的时候一起改。

第二个坑,负载均衡把长连接当短连接处理。早期版本的某些七层负载均衡不认 HTTP/2 的:method之类的新特性,把 gRPC 连接当成异常流量处理。后来换成四层透传才解决。如果你的架构里有中间设备,一定要确认它支持 HTTP/2 透传,而不是做七层解析。

第三个坑,客户端版本混杂。1.x 的客户端根本不走 gRPC,2.x 的不同小版本对Payload的字段理解也可能有差异。排查时第一件事是把客户端版本号打出来,我一般会在服务端的连接注册日志里把客户端版本一并打出来,省得挨个问。

第四个坑,优雅停机没做。服务端重启时如果直接 kill,客户端那一端的连接要等到 TCP 超时才发现,中间这段时间请求全失败。BaseGrpcServer里注册了 shutdown hook,会调server.shutdown(),但要注意顺序:先停推送、再停连接、最后停 Server。顺序错了客户端会收到一堆推送失败的异常日志。这个在滚动发布的场景下尤其明显,配好优雅停机能让发布时的错误率降一个量级。

最后一个经验:不要只盯着ConnectionManager看问题。gRPC 这一层的很多故障是底层的网络问题伪装出来的,比如 MTU 不匹配导致大包丢弃、NAT 表项老化导致连接假死。遇到「代码看起来完全正确但就是不工作」的情况,抓个包比自己跟自己较劲有效得多,看一眼前几个 TCP 包有没有正常握手、有没有 RST,很多问题三分钟就能定性。我现在的习惯是,凡是超过半小时没定位到的连接问题,直接上抓包,不猜了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询