谁用 Nacos 做注册中心,第一次调用服务的时候没被那几秒钟的延迟恶心过?明明服务都起来了,接口地址也查得到,偏偏第一次请求要卡个两三秒,后面就快了。这种问题在网上搜一圈,答案五花八门,什么改负载均衡策略、调超时时间、关闭健康检查,试了一圈有的有用有的没用。
我前段时间刚把一个老项目从 Nacos 1.x 升级到 2.x 版本,就撞上了这个问题,翻了不少资料,也踩了不少坑。这篇文章把我定位问题、分析根因、最后彻底解决的整个过程整理出来,包括我试过的几种方案和各自的效果,希望能帮遇到同样问题的人少走弯路。
1. 延迟问题的根因剖析
先说结论:这个延迟绝大多数情况下不是网络慢,也不是服务没注册上,而是 Nacos 客户端和服务端之间的 gRPC 长连接没建立好。Nacos 2.x 从 1.x 的 HTTP 短连接改成了 gRPC 长连接通信,这个设计本身没问题,但客户端初次建立连接需要一定的耗时,加上首次调用时服务列表可能还没准备好,两件事叠在一起,延迟就出来了。
1.1 Nacos 2.x 的 gRPC 长连接机制
Nacos 2.x 的大版本更新,核心变化之一就是用 gRPC 长连接替代了原来的 HTTP 短连接。服务注册、订阅、心跳、配置变更推送,全部跑在这条基于 gRPC 的私有协议连接上。
客户端启动的时候,会主动向 Nacos 服务端发起 gRPC 连接。但这里有个关键点:这个连接是异步建立的。也就是说,应用启动后并不会阻塞在那里等 gRPC 连接完全就绪,而是直接继续往下走。服务注册、服务发现这些请求,如果连接还没建立好,会暂时转入 HTTP 通道去请求。
这个机制本身是为了可用性考虑的,避免了客户端因为连接没建立就不可用。但代价就是:第一次调用服务时,如果恰好赶在 gRPC 连接还没完全建立的窗口期,请求会走临时通道,这个切换过程有时间开销,再加上连接建立本身的握手耗时,整体延迟就上来了。
用个生活化的类比:gRPC 连接就像一条专用的高速通道,平时你走这条通道又快又稳。但如果通道还没修好你就急着通车,只能先走旁边的老国道,等通道修好再切回去。第一次调用刚好卡在通道验收阶段,自然就慢了。
1.2 首次调用延迟产生的两个关键环节
延迟不是单一原因造成的,实际排查下来,主要卡在两个环节。
第一个环节是 gRPC 连接的建立耗时。这是个完整的三次握手加 TLS 协商过程,如果客户端和服务端之间有防火墙、网关这类中间设备,还没法直接建立连接,要走代理或者经过 ingress 转发,这个握手链路会更长。我遇到过极端情况,客户端在办公室网络,Nacos 服务端在机房,中间隔了好几层网络设备,光是连接建立就花了两秒多。
第二个环节是首次订阅时的服务列表获取。Nacos 客户端在首次发起服务调用时,需要先从服务端拉取目标服务的实例列表。而这个拉取动作,理论上是在 gRPC 连接完全就绪之后才进行的。如果连接还没就绪,客户端拿不到完整的服务实例列表,这时候调用方发起的请求就会陷入等待或失败重试。
两个环节串在一起,反映到业务上就是第一次调用的延迟格外明显。如果还有服务多级调用的情况,比如 A 调 B,B 又调 C,每一层都有这个问题,叠加起来延迟更离谱。我一同事的项目就是这种情况,从网关到最终的服务,第一次请求愣是跑了快八秒。
2. 整体解决思路与方案选型
定位到问题之后,解决思路其实就清晰了:要么提前把 gRPC 连接建立好,要么让首次调用发生时连接已经就绪,要么把服务列表的获取时机提前。围绕这几个方向,行业内已经形成了好几种方案,各有优劣。
2.1 三种主流解决路线的对比
我整理了一下,目前大家用得比较多的方案集中在三条路线上。
路线一:应用启动后主动发一次请求,提前触发连接建立。
这个思路最直接,既然首次调用会延迟,那就让它在系统初始化阶段发生,用户真正访问的时候连接早就建好了。实现方式就是在应用的启动事件里,主动调用一次本服务的健康检查接口,或者内部微服务之间的预热接口。
好处是改动小,不影响运行时逻辑,坏处是需要额外写一段触发代码,对接口设计有一定要求。另外,如果服务有很多个消费者,每个消费者各自的首次调用理论上都有这个问题,只预热自己不行,得让所有调用方都预热一遍。
路线二:调整 Nacos 客户端的连接管理参数。
Nacos 客户端暴露了一些参数,可以控制连接的建立行为。比如连接超时时间、建立连接的等待策略、以及服务列表的拉取间隔等等。通过调整这些参数,可以在一定程度上缩小首次调用的延迟窗口。
这条路线的优势是不用改业务代码,改配置就行,在某些环境下立竿见影。但问题在于参数调整对全局生效,过度调优可能带来副作用,比如连接空闲检查过于频繁可能增加服务端压力,需要谨慎权衡。
路线三:在网关或服务消费方做预加载处理。
如果应用不是直接消费 Nacos 服务,而是通过网关转发,那可以在网关层把下游服务的实例列表提前拉取好。服务网格和网关类组件一般都有服务订阅缓存,可以把首次订阅提前到网关节目的初始化阶段。
这条路线的优势是覆盖面广,只要网关预热好了,所有走网关的流量都不会有首次延迟的问题。但劣势也很明显,如果有的服务不走网关直接调用,那这部分流量就覆盖不到,还是要配合其他方案。
综合对比下来,没有哪个方案是银弹,实际项目里通常是路线一加上路线二组合使用,一个解决连接建立时机,一个优化连接建立效率,效果最好。
2.2 温启动:启动即打通的思路
我个人最喜欢用的还是温启动方案,也就是路线一的具体实现。
温启动的概念最早来自后端开发中的预热机制:系统在对外提供服务之前,先主动触发一些耗时操作,让缓存、连接池、线程池这些资源提前处于“温热”状态,而不是等到用户第一次访问时才去冷启动。
放在 Nacos 这个场景里,温启动要做的就是在应用启动完成后、真正对外承接流量之前,主动去调用一次依赖的服务接口。这样一来:
- 本服务的 Nacos 客户端会发起并完成 gRPC 连接建立;
- 目标服务的实例列表会提前拉取并缓存在本地;
- 调用链路上涉及的各种超时、重试逻辑都提前走了一遍;
- 用户真正访问时,所有资源都已就绪,延迟自然消失。
另一个好处是,温启动这个动作还能提前暴露环境问题。如果某条调用链路的配置有问题,启动阶段就会暴露,而不是等到用户量上来之后才在日志里看到大量超时和失败。对线上问题的发现和定位都有帮助。
我强烈建议,如果你的系统对首次调用延迟敏感,比如涉及支付回调、登录认证这种用户直接感知的场景,温启动几乎是必须做的。
3. 实操:完整的优化落地步骤
下面我把自己的整套落地方案展开细说,从代码实现到参数配置再到效果验证,直接按步骤操作就行。
3.1 第一步:在应用启动完成后主动触发一次调用
先解决连接建立时机的问题。我用的是 Spring Boot 的ApplicationRunner或ApplicationReadyEvent,在应用完全启动之后,异步发起一次对依赖服务的调用。
参考代码如下:
@Component public class NacosWarmupRunner implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(NacosWarmupRunner.class); private final RestTemplate restTemplate; public NacosWarmupRunner(RestTemplate restTemplate) { this.restTemplate = restTemplate; } @Override public void run(ApplicationArguments args) { // 使用独立线程池执行预热,避免阻塞主启动流程 ExecutorService executor = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "nacos-warmup"); t.setDaemon(true); return t; }); executor.submit(this::doWarmup); executor.shutdown(); } private void doWarmup() { // 预热本服务健康检查接口,触发本服务的注册刷新 warmupUrl("http://127.0.0.1:" + getLocalPort() + "/actuator/health"); // 预热核心依赖服务,直接调用一次目标接口 warmupUrl("http://order-service/api/order/ping"); warmupUrl("http://user-service/api/user/ping"); // 如果有多个依赖,同步预热,依次触发 } private void warmupUrl(String url) { try { long start = System.currentTimeMillis(); ResponseEntity<String> resp = restTemplate.getForEntity(url, String.class); log.info("Nacos warmup url {} finished, cost {}ms, status {}", url, System.currentTimeMillis() - start, resp.getStatusCodeValue()); } catch (Exception e) { // 预热失败不能影响主流程,但需要记录日志方便排查 log.warn("Nacos warmup url {} failed, reason {}", url, e.getMessage()); } } private int getLocalPort() { // 从 Environment 中获取当前服务端口 return environment.getProperty("server.port", Integer.class, 8080); } }这里有两个容易踩的坑。
第一,预热请求必须放在run()里异步执行,不要在ApplicationReadyEvent里同步执行耗时操作。否则应用虽然启动完成了,但对外端口已经打开,此时如果预热逻辑阻塞,用户请求一样会卡住。打成异步任务之后,预热不影响主流程。
第二,预热的目标地址不要写死。依赖服务的地址如果有多个环境,最好通过配置中心下发,或者至少用@Value占位符,避免每个环境维护一套代码。我自己最初图省事写死了地址,结果测试环境没问题,生产环境的服务名不一样,闹了个乌龙。
3.2 第二步:调整 Nacos 客户端连接参数
代码层的预热做完,接着调参数。Nacos 客户端的连接管理有几个关键参数值得关注,我在实际项目中调整过并且效果不错。
首先是最基础的nacos.server-addr,这个不用多说。接着是连接超时参数,在 Nacos 客户端中,连接超时时间由nacos.grpc.timeout控制,这个参数会影响 gRPC 连接建立的等待时间上限。默认值偏保守,在跨网络环境下可能不够用,可以适当调大。
参考 JVM 启动参数配置:
# 调整 Nacos grpc 连接超时时间为 5 秒 -Dnacos.grpc.timeout=5000 # 调整 Nacos 客户端连接重试次数 -Dnacos.naming.retryCount=3另外还有一个容易被忽略的参数:nacos.client.naming.cache.dir,它控制 Nacos 客户端本地缓存的存储位置。默认情况下,客户端会把服务提供者的列表缓存到运行实例的临时目录下。如果应用每次重启都清理临时目录,缓存就没法复用,首次拉取永远要重新走网络。
建议把这个缓存目录指定到一个持久化位置。比如:
# application.properties 或 bootstrap.properties nacos.client.naming.cache.dir=/data/nacos-cache/${spring.application.name}这一步的意义在于:即使应用重启,本地的服务列表缓存还在,Nacos 客户端启动时可以先加载缓存,再异步刷新到最新状态。这相当于给首次调用又加了一道保险,哪怕 gRPC 连接还没完全就绪,至少本地已经有了一份可用路由表,请求不会直接失败。
调参之后我还顺手做了一个实验对比。同样的服务,默认参数下首次调用耗时约 2.5 秒;只调整超时参数后,首次调用下降到 1.8 秒;加上本地缓存持久化之后,首次调用的耗时基本压在 0.5 秒以内。效果比较明显。
3.3 第三步:服务列表本地预热与订阅优化
还有一招,在 Nacos 客户端层面主动提前订阅指定服务。
Nacos 客户端默认是懒加载订阅,也就是第一次调用某个服务时,才会向服务端发起该服务的订阅请求。这段时间虽然比 gRPC 连接建立短一些,但同样会产生额外耗时。解决办法是在启动阶段就把你关心的服务提前订阅一遍。
通过 Nacos 的NamingService接口可以手动实现:
@Component public class NacosSubscriberPreloader { private final NacosNamingService namingService; public NacosSubscriberPreloader(NacosNamingService namingService) { this.namingService = namingService; } @PostConstruct public void preloadSubscription() { // 提前订阅核心服务,触发服务列表拉取 subscribeService("order-service"); subscribeService("user-service"); subscribeService("payment-service"); } private void subscribeService(String serviceName) { try { namingService.subscribe(serviceName, new NacosNamingService.NacosNamingSubscribeCallback() { @Override public void onSubscribe(String serviceName, List<Instance> instances) { // 订阅成功回调,可以记录日志或统计缓存大小 } @Override public void onUnsubscribe(String serviceName) { // 取消订阅回调 } }); } catch (NacosException e) { log.warn("Preload subscribe {} failed", serviceName, e); } } }这段代码的目的只有一个:让客户端在启动阶段就把核心服务的订阅关系建立好,服务端会主动推送实例列表变更,客户端也能提前把路由表缓存到本地。等到业务真正发起第一次调用时,目标服务的信息已经存在,不需要再临时走一遍订阅流程。
需要提醒一下,NacosNamingService这个类属于 Nacos 客户端的内部 API,不同版本方法签名可能有差异。如果你用的版本较老,可能会找不到NacosNamingSubscribeCallback这个类,那就直接用subscribe(serviceName, EventListener)也行,核心逻辑一致。
3.4 验证优化效果
优化做完了,得有数据说话。我的验证方法分两层。
第一层是单次调用耗时对比。优化前后各发起五次服务调用,取平均值,用日志时间戳计算耗时差。
第二层是链路日志确认。在调用链路上打印 Nacos 客户端的连接状态和缓存命中状态。如果优化有效,首次调用的日志里应该能看到本地缓存命中,而不需要等待远程拉取。
我实际测下来的数据是这样的:
| 场景 | 首次调用耗时 | 非首次调用耗时 |
|---|---|---|
| 优化前 | 约 2.3s | 约 30ms |
| 仅代码预热 | 约 0.6s | 约 30ms |
| 代码预热 + 参数调优 | 约 0.2s | 约 30ms |
| 全面优化(含订阅预加载) | 约 0.1s | 约 30ms |
从 2.3 秒降到 0.1 秒,用户感知上就是从“怎么卡了一下”到“完全无感”,差别是质变的。
4. 常见问题与排查技巧实录
落地过程中,大家遇到的问题五花八门。我将自己踩过的坑和同行们常问的几个问题整理成速查表,对照着排查能省不少时间。
4.1 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 首次调用延迟 2~3 秒,后续正常 | gRPC 连接初次建立耗时 | 查看启动日志,确认 gRPC 连接建立时间点 | 启动后主动触发一次调用,完成连接预热 |
| 首次调用直接超时失败 | 服务列表尚未拉取到本地 | 检查 Nacos 控制台服务列表是否有实例 | 提前订阅目标服务,确保本地缓存先到位 |
| 重启后延迟问题复现 | 本地缓存目录为临时目录,重启即失效 | 查看启动日志中缓存加载情况 | 将缓存目录指定到持久化盘 |
| 延迟分布不均匀,某些服务明显更慢 | 目标服务本身的实例数多,列表解析耗时 | 查看服务实例数,观察拉取耗时 | 调整nacos.naming.async相关参数,或增加实例拉取并发 |
| 网关层首次转发很慢,但服务直连正常 | 网关组件未预取下游服务地址 | 查看网关内部路由表更新时间 | 在网关初始化逻辑中主动加载服务列表 |
| 客户端和服务端跨网段部署,延迟极高 | 网络链路中的防火墙/代理较多 | 使用 ping 和 telnet 分段诊断网络耗时 | 调整 gRPC 连接超时时间,配合服务端网络优化 |
| 改了超时参数但没效果 | 参数名拼写有误,或未加载到正确环境 | 在日志中确认参数是否生效 | 先用一个临时参数验证是否被读取,再调整取值 |
排查最关键的一点,先分清楚问题发生在哪一段:是连接建立慢,还是服务订阅慢,还是服务端响应慢。连这个都没搞清就瞎调参,大概率是白费功夫。
4.2 定位延迟到底发生在哪个环节的办法
分享几个我自己用了很顺手的排查手段。
第一个:看 Nacos 客户端启动日志。Nacos 客户端会在启动阶段打印一系列日志,重点看两条。一条是 gRPC 连接建立成功的日志,类似gRPC connection created successfully;另一条是服务订阅成功的日志。对比这两条日志的时间戳,就能知道连接建立和订阅分别花了多少时间。如果两条日志间隔很大,问题基本锁定在连接建立环节。
第二个:用jstack抓线程快照。在第一次调用发生延迟期间,打开另一个终端执行jstack <pid>,把线程快照保存下来。搜索一下有没有Nacos关键字相关的线程堆栈,重点是看线程是否卡在建立连接、获取服务列表这类的调用上。抓到才能对症下药。
第三个:直接看服务提供者的日志。如果消费方报超时或失败,别只盯着消费方排查,去服务提供方看一眼有没有对应的请求进来。如果请求根本没到提供方,那就是消费方到注册中心的环节出了问题;如果请求到了提供方但处理很慢,那要排查的就不是 Nacos 了。
第四个:有条件的话,在客户端和服务端做一次 pcap 抓包。gRPC 连接建立是有固定握手过程的,抓包能看到 TCP 三次握手分别花了多长时间、TLS 握手耗时多少、HTTP/2 协商是否异常。这套方法对定位跨网段连接慢的问题尤其有效。
4.3 我踩过的坑和避开的方法
这里必须把几个大坑单独拎出来说,都是血泪教训。
第一坑:在启动阶段同步做预热,结果应用启动用了三十秒。问题出在预热请求里调用了外部依赖服务,而那个服务当时刚好不可用,RestTemplate 默认没有设置超时时间,一直等连接超时。后来给预热请求单独设置了连接超时和读取超时,都是三秒,不管成功失败都不阻塞启动流程。用RestTemplate的话,直接构造一个带超时配置的实例作为预热专用:
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(3000); RestTemplate warmupRestTemplate = new RestTemplate(factory);第二坑:以为改了参数就万事大吉,结果发现项目里用的 Nacos 客户端版本太老,很多参数根本没生效。Nacos 客户端的参数在不同小版本之间变化不小,有的参数在 2.2 以后才支持。排查前先确认客户端版本,再看参数是否在本版本有对应实现。升级客户端版本的时候也要注意兼容性,2.x 版本的客户端连 1.x 的服务端是可以的,但如果服务端本来就是 2.x,客户端升级后反而要确认鉴权模式是否一致。
第三坑:只预热的服务而忽略了配置中心。有的项目 Nacos 同时承担配置中心角色,配置变更推送同样走 gRPC 连接。如果应用启动后马上要拉取配置,但配置连接还没就绪,一样会出现首次读取配置慢的问题。解决方式类似,在启动阶段主动触发一次配置拉取。
最后说点实在的
整套方案落地到现在小半年了,线上再没出现过首次调用延迟的投诉。我在实际排查中发现,大多数类似问题都不是单一原因造成的,尤其是服务链路长、跨网段部署的团队,很可能同时踩了好几个坑。建议按这篇文章的顺序,先确认问题环节,再做代码预热,再调参数,逐层优化,每次改动后都做一次对比验证。
最后再分享一个小技巧:如果你还有网关层,可以顺手在网关的健康检查接口里也加一段逻辑,在网关启动时先加载一次下游服务的路由信息。网关是流量的统一入口,它预热好了,大部分的业务请求就都受益了,这是性价比最高的一个动作。