Nacos服务注册与配置中心原理

引言

2018年之前,我维护过一套基于Eureka + Spring Cloud Config + RabbitMQ手动刷新的微服务架构。服务上线要改三处配置,配置变更要重启或者调 /actuator/bus-refresh,注册中心挂了整个调用链跟着雪崩。那会儿最怕的不是写业务代码,而是周五下午改配置。

后来阿里把内部用了多年的ConfigServer和VIPServer合并开源成Nacos(Naming and Configuration Service),一个组件同时解决服务发现和配置管理,还自带控制台、健康检查、权重路由、命名空间隔离。很多团队换过来之后,架构图直接少了一半组件。

但用得爽不代表理解得深。我见过太多项目把 Nacos 当黑盒:注册上去了不知道为什么心跳会掉,配置改了不知道客户端怎么感知的,集群部署了不知道 CP/AP 模式在哪切换。这篇文章就带你把 Nacos 的两大核心能力——服务注册发现和配置中心——从源码层面拆开看,搞清楚它到底怎么跑起来的。


核心概念:用小区物业来理解 Nacos

先建立一个直觉模型。

把整个微服务集群想象成一个大型小区:

  • 服务提供者 = 小区里的住户,搬进来要在物业登记(注册),并定期报个到证明还活着(心跳)。
  • 服务消费者 = 想找某户人家办事的人,去物业查通讯录(服务发现),拿到门牌号(IP:Port)直接上门。
  • Nacos Server = 物业公司,维护住户名册,住户搬走或失联就划掉。
  • 配置中心 = 物业公告栏,物业改了通知(配置),所有订阅的住户会主动收到更新,不用每天自己跑去看。

这个模型里有两个关键点,也是本文要深挖的:

  1. 心跳机制:住户怎么报到?物业多久没收到报到就认为他失联?——对应 Nacos 的 临时实例(ephemeral) 与健康检查。
  2. 推送机制:公告栏更新了,物业怎么通知到每一户?——对应 Nacos 配置变更的 长轮询(Long Polling) 推送。

两个核心数据模型

服务注册(Naming) 的核心结构:

Namespace(命名空间,隔离环境:dev/test/prod)
  └── Group(分组,隔离业务线)
        └── Service(服务,如 order-service)
              └── Cluster(集群,如同一机房的机器)
                    └── Instance(实例,一个 IP:Port + 元数据)

配置(Config) 的核心结构:

Namespace
  └── Group
        └── DataId(配置文件名,如 order-service-prod.yaml)
              └── Content + MD5 + Type + ...

命名空间的隔离是物理级的(不同 namespace 的数据互相看不到),而 Group 的隔离是逻辑级的(同一张表里靠字段区分)。这是设计上很实用的一点:多租户/多环境用 namespace,业务分组用 Group。


源码/原理深度分析

一、服务注册:心跳、健康检查与推空保护

#### 1.1 临时实例 vs 持久实例

Nacos 的实例分两种,这是理解整个注册发现机制的钥匙:

类型 ephemeral 存储位置 健康检查方式 客户端宕机后果
临时实例 true(默认) 内存(ClientBeatCheckTask) 客户端主动上报心跳 心跳超时后被自动剔除
持久实例 false 磁盘(Derby/MySQL) 服务端主动探活(TCP/HTTP) 需手动下线,不会被自动删

为什么默认用临时实例? 因为微服务场景下,实例本身是弹性的、可替换的,我们要的是"活的实例清单",而不是"永久档案"。心跳模式天然契合这种弹性。

#### 1.2 心跳与健康检查源码

客户端注册后,会启动一个定时任务,默认每 5 秒发一次心跳(BeatReactor):

// com.alibaba.nacos.client.naming.beat.BeatReactor
public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
    String key = buildKey(serviceName, beatInfo.getIp(), beatInfo.getPort());
    // 每个实例一个定时任务,周期默认 5000ms
    executorService.schedule(new BeatTask(beatInfo), 0, beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
}

class BeatTask implements Runnable {
    BeatInfo beatInfo;
    public void run() {
        // 发送 PUT /nacos/v1/ns/instance/beat
        JSONObject result = serverProxy.sendBeat(beatInfo, BeatInfo.INTERNAL_BEAT);
        // 服务端返回的 clientBeatInterval 可动态调整下次心跳周期
        long nextTime = result.getLongValue("clientBeatInterval");
        if (nextTime > 0) {
            beatInfo.setPeriod(nextTime);
        }
        // 递归把自己再调度一次,形成循环心跳
        executorService.schedule(new BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
    }
}

服务端收到心跳后,不是简单地"刷新时间戳",而是把它转成一个延迟任务——这才是精髓(ClientBeatCheckTask / HealthCheckTask):

// com.alibaba.nacos.naming.healthcheck.heartbeat.ClientBeatCheckTask
public void run() {
    // 遍历该实例所属服务的所有实例
    for (Instance instance : instances) {
        // 距离上次心跳超过 15 秒(默认)则标记不健康
        if (System.currentTimeMillis() - instance.getLastBeat() > clientBeatTimeout) {
            if (!instance.isHealthy()) {
                // 第一次超时,标记不健康,推送变更
                instance.setHealthy(false);
                pushService.setChanged(...);
                // 再注册一个 15 秒的延迟任务,仍无心跳就删除
                clientBeatCheckTask.scheduleDelete(instance);
            } else {
                // 已经判定不健康,直接删除实例
                serviceManager.removeInstance(...);
            }
        }
    }
}

关键参数(实际生产环境经常调):

  • nacos.naming.health.check.timeout:心跳超时,默认 15 秒。
  • 心跳间隔默认 5 秒。
  • 所以从实例真正挂掉到被剔除,最坏情况约 30 秒(15s 判不健康 + 15s 删除)。

这就是为什么"服务挂了,调用方还打了一会儿"——不是 Nacos 慢,是这套机制本身就有一段容忍窗口。要更快可以调小超时,但代价是网络抖动时误判增加。

#### 1.3 服务发现的推送模型

消费者怎么知道实例列表变了?Nacos 用的是"服务端主动推送 + 客户端定时拉取"的混合模式。

graph TD A[服务提供者实例] -->|1 注册/心跳| B[Nacos Server] B -->|2 维护实例列表| C[Service 内存数据结构] C -->|3 实例变更| D[PushService] D -->|4 UDP 推送 / gRPC 连接| E[消费者客户端] E -->|5 更新本地缓存| F[ServiceInfoHolder] E -->|6 定时拉取兜底 每10s| B D -->|推送失败降级| G[客户端下次拉取时感知]

具体来说,Nacos 2.x 之后推送从 UDP 改成了 gRPC 长连接,可靠性大幅提升。客户端这边有个 ServiceInfoUpdateService 做定时拉取兜底:

// com.alibaba.nacos.client.naming.core.ServiceInfoUpdateService
// 每个服务一个定时任务,默认 10 秒(1秒起步的退避策略)
private void scheduleUpdate(String serviceName, ServiceInfo serviceInfo) {
    // 如果服务端推送的时间戳比本地新,说明推送丢了,触发一次拉取
    if (serviceInfo.getServerTimestamp() > lastRefTime) {
        // 拉取最新实例列表
        updateServiceNow(serviceName, clusters);
    }
    // 再次调度自己
    executor.schedule(new UpdateTask(...), delay, TimeUnit.MILLISECONDS);
}

推空保护:当 Nacos 拿到的实例列表为空时(可能是服务端故障或数据异常),客户端不会把本地缓存清空,而是保留旧列表。这个机制叫"推空保护",在 ServiceInfoHolder.processServiceInfo 里实现。没有它,一次服务端抖动可能让所有消费者瞬间"发现不到任何实例",酿成全局雪崩。

二、配置中心:长轮询如何做到"准实时"

配置变更的推送,是 Nacos 最经典的设计。很多人的第一反应是"是不是用了 WebSocket?"——不是。Nacos 用的是 长轮询(Long Polling),一个在实时性和资源消耗之间取得极佳平衡的方案。

#### 2.1 长轮询 vs 短轮询 vs 推送

先对比三种方案:

方案 实时性 服务端压力 实现复杂度 代表
短轮询 差(取决于间隔) 高(大量无效请求) 低 早期版本
长轮询 好(秒级) 中(连接挂起不占CPU) 中 Nacos
WebSocket/SSE 最好 低 高(需维护连接状态) Apollo(部分)

长轮询的巧妙在于:客户端发起请求后,服务端不立即返回,而是把这个请求"挂起"(hold)一段时间(默认 29.5 秒)。如果这期间配置变了,立即返回;如果没变,29.5 秒后返回一个"无变更",客户端再发起下一轮。

这样既避免了短轮询的高频无效请求,又不用维护复杂的双向连接状态。

#### 2.2 源码级剖析:客户端长轮询

客户端(nacos-client)的实现核心在 ClientWorker 和 LongPollingRunnable:

// com.alibaba.nacos.client.config.impl.ClientWorker.LongPollingRunnable
class LongPollingRunnable implements Runnable {
    public void run() {
        // 1. 先检查本地缓存的配置,找出 MD5 与服务端不一致的
        List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList);
        
        // 2. 对变更的配置重新拉取全量内容
        for (String groupKey : changedGroupKeys) {
            String[] key = GroupKey.parseKey(groupKey);
            String dataId = key[0];
            String group = key[1];
            // 真正拉取配置内容
            String[] ct = getServerConfig(dataId, group, tenant, 3000L);
            // 写入本地缓存 + 磁盘
            CacheData cache = cacheMap.get(GroupKey.getKeyTenant(dataId, group, tenant));
            cache.setContent(ct[0]);
            // 3. 触发监听器回调(Spring 的 @RefreshScope 就在这里生效)
            for (Listener listener : cache.getListeners()) {
                listener.receiveConfigInfo(ct[0]);
            }
        }
        
        // 4. 发起长轮询请求,服务端最多挂起 30 秒
        //    注意:这一步是阻塞的,返回后才继续下一轮
        List<String> updateList = checkUpdateDataIds(...); // 内部走 /v1/cs/configs/listener
        
        // 5. 无论如何,递归调度下一轮(30秒一次)
        executorService.schedule(new LongPollingRunnable(), 30000L, TimeUnit.MILLISECONDS);
    }
}

关键点:长轮询请求本身携带的是所有关注配置的 MD5 列表,服务端只需要比对 MD5,不用传全量内容,带宽极小。有变更才返回变更的 DataId 列表,客户端再按需拉全量。

#### 2.3 源码级剖析:服务端挂起与触发

服务端收到长轮询请求后,进入 LongPollingService:

// com.alibaba.nacos.config.server.service.LongPollingService
public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp,
                                 Map<String, String> clientMd5Map, ...) {
    // 1. 先立即比对一次,如果已经有变更,直接返回
    String changedGroupStr = LongPollingService.getChangeGroups(clientMd5Map);
    if (StringUtils.isNotBlank(changedGroupStr)) {
        generateResponse(rsp, changedGroupStr);
        return;
    }
    
    // 2. 没有变更,挂起请求
    //    用 Servlet 3.0 的 AsyncContext 异步化,释放容器线程
    AsyncContext asyncContext = req.startAsync();
    asyncContext.setTimeout(0L);
    
    // 3. 计算超时时间:29.5 秒(比客户端 30 秒略短,避免客户端先超时)
    long timeout = Math.min(ConfigConstant.LONG_POLLING_NO_CHANGE, 
                            (timeoutTime - System.currentTimeMillis()));
    
    // 4. 包装成一个 ClientLongPolling 任务,丢进定时调度器
    scheduler.execute(new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout));
}

// 当配置发生变更时,发布一个 ConfigDataChangeEvent 事件
// LongPollingService 监听该事件,遍历所有挂起的 ClientLongPolling,
// 比对 MD5,命中则立刻 writeResponse 唤醒对应请求

这里有个工程细节值得学习:为什么超时是 29.5 秒而不是 30 秒? 因为客户端设置的读超时是 30 秒,服务端必须比客户端先返回,否则客户端会先抛超时异常,导致这次长轮询"白挂"了。这种"服务端超时略小于客户端超时"的设计,在所有长连接场景都是通用套路。

#### 2.4 配置变更的完整链路图

sequenceDiagram participant C as 客户端 participant S as Nacos Server participant DB as 存储(Derby/MySQL) C->>S: 长轮询请求(携带所有DataId的MD5) Note over S: 比对MD5,无变更 S-->>S: AsyncContext挂起请求(29.5s) Note over S: 管理员修改配置 S->>DB: 写入新配置+新MD5 S->>S: 发布ConfigDataChangeEvent S->>S: 遍历挂起请求,MD5命中 S-->>C: 立即返回变更的DataId列表 C->>S: 拉取变更配置的全量内容 S-->>C: 返回配置内容 C->>C: 更新本地缓存+磁盘 C->>C: 触发Listener回调(@RefreshScope刷新)

三、集群一致性:Raft 与 Distro

Nacos 集群里,配置和服务用的一致性协议是不同的,这是很多人忽略的点:

  • 配置中心:使用 Raft(自己实现的简化版),保证 CP。配置必须是强一致的,否则不同节点读到的配置不一样就出大事了。
  • 服务发现:使用 Distro 协议,保证 AP。服务列表允许短暂不一致,可用性优先。

Distro 的核心思想是"责任分片":每个服务根据名字哈希,固定由某个 Nacos 节点负责写,其他节点异步同步。这样避免了写请求在集群里广播,提升了吞吐。

// com.alibaba.nacos.naming.core.DistroMapper
public String mapSrv(String key) {
    // 根据服务名hash,决定由哪个节点负责
    if (CollectionUtils.isEmpty(healthyList)) {
        return null;
    }
    int index = Math.abs(key.hashCode()) % healthyList.size();
    return healthyList.get(index);
}

这解释了为什么 Nacos 在服务发现场景下能扛住高并发——写是分片的,读是本地内存的,几乎没有单点瓶颈。


实战代码

示例 1:服务提供者注册 + 优雅上下线

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.nacos.api.naming.NamingFactory;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.serviceregistry.Registration;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

@SpringBootApplication
public class ProviderApplication {

    // Nacos 客户端 API(非 Spring Cloud 封装,用于演示底层)
    private NamingService namingService;

    @Autowired
    private NacosDiscoveryProperties properties;

    public static void main(String[] args) {
        SpringApplication.run(ProviderApplication.class, args);
    }

    @PostConstruct
    public void register() throws Exception {
        // 直接使用 Nacos 原生 API 注册,理解底层
        namingService = NamingFactory.createNamingService(
                properties.getServerAddr());

        Instance instance = new Instance();
        instance.setIp("192.168.1.100");
        instance.setPort(8080);
        instance.setServiceName("order-service");
        instance.setWeight(1.0);           // 权重,用于灰度/负载均衡
        instance.setEphemeral(true);       // 临时实例,靠心跳保活
        instance.setHealthy(true);
        // 元数据:可以放版本号,配合灰度路由
        instance.getMetadata().put("version", "v2");
        instance.getMetadata().put("zone", "beijing");

        namingService.registerInstance("order-service", "DEFAULT_GROUP", instance);
        System.out.println("服务注册成功");
    }

    /**
     * 关键:优雅下线。
     * 直接 kill -9 会导致消费者在心跳超时前仍调用到已停实例,
     * 造成一段时间的请求失败。正确做法是先注销,再等待,再关闭。
     */
    @PreDestroy
    public void deregister() throws Exception {
        if (namingService != null) {
            namingService.deregisterInstance("order-service", "DEFAULT_GROUP",
                    "192.168.1.100", 8080);
            // 等待消费者刷新本地实例缓存(避开推空/推送延迟窗口)
            Thread.sleep(5000);
            System.out.println("服务已优雅下线");
        }
    }
}

示例 2:配置动态刷新(原生 Listener + Spring @RefreshScope)

import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.Listener;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.PostConstruct;
import java.util.Properties;
import java.util.concurrent.Executor;

@RefreshScope // Spring Cloud 层面:配置变更时重建该 Bean
@RestController
public class ConfigController {

    // 从 Nacos 读取配置项
    @Value("${app.timeout:3000}")
    private int timeout;

    @Value("${app.feature.newAlgorithm:false}")
    private boolean newAlgorithmEnabled;

    @GetMapping("/config")
    public String getConfig() {
        return "timeout=" + timeout + ", newAlgorithm=" + newAlgorithmEnabled;
    }

    /**
     * 原生 SDK 方式监听配置变更,理解底层推送机制。
     * 生产中用 Spring Cloud 的 @RefreshScope 即可,但知道底层很有必要。
     */
    @PostConstruct
    public void listenConfig() throws Exception {
        Properties props = new Properties();
        props.put("serverAddr", "127.0.0.1:8848");
        // 命名空间隔离环境
        props.put("namespace", "prod-namespace");

        ConfigService configService = NacosFactory.createConfigService(props);

        String dataId = "order-service-prod.yaml";
        String group = "DEFAULT_GROUP";

        // 1. 首次拉取
        String content = configService.getConfig(dataId, group, 5000);
        System.out.println("初始配置:\n" + content);

        // 2. 注册监听器:底层就是长轮询,配置变更后回调
        configService.addListener(dataId, group, new Listener() {
            @Override
            public Executor getExecutor() {
                // 返回 null 表示使用 Nacos 内部线程池执行回调
                return null;
            }

            @Override
            public void receiveConfigInfo(String configInfo) {
                // 这里拿到的是变更后的全量内容
                System.out.println("配置发生变更:\n" + configInfo);
                // 实际项目中在这里做:重新解析、热更新本地状态等
            }
        });
    }
}

对应的 bootstrap.yml(Spring Cloud 场景下配置要先于应用加载,所以放 bootstrap):

spring:
  application:
    name: order-service
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        namespace: prod-namespace
        group: DEFAULT_GROUP
        file-extension: yaml
        # 开启自动刷新(默认 true)
        refresh-enabled: true
      discovery:
        server-addr: 127.0.0.1:8848
        namespace: prod-namespace
        # 元数据,可用于灰度
        metadata:
          version: v2

示例 3:基于权重的灰度发布 + 自定义负载均衡

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.NacosServiceManager;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.DefaultResponse;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.client.loadbalancer.Response;
import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

/**
 * 自定义负载均衡:优先路由到 version=v2 的实例(灰度),
 * 其余流量按权重随机。这是 Nacos 元数据 + 权重的典型落地。
 */
@Component
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    @Autowired
    private NacosServiceManager nacosServiceManager;

    @Autowired
    private NacosDiscoveryProperties discoveryProperties;

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        return Mono.fromSupplier(() -> {
            try {
                NamingService naming = nacosServiceManager.getNamingService(
                        discoveryProperties.getNacosProperties());
                // 拿到所有健康实例
                List<Instance> instances = naming.selectInstances(
                        "order-service", "DEFAULT_GROUP", true);

                // 1. 优先选灰度实例(version=v2)
                List<Instance> grayList = instances.stream()
                        .filter(i -> "v2".equals(i.getMetadata().get("version")))
                        .toList();

                List<Instance> candidates = grayList.isEmpty() ? instances : grayList;
                if (candidates.isEmpty()) {
                    return new DefaultResponse(null);
                }

                // 2. 按权重随机(权重越大被选中概率越高)
                double totalWeight = candidates.stream()
                        .mapToDouble(Instance::getWeight).sum();
                double random = ThreadLocalRandom.current().nextDouble() * totalWeight;
                double cumulative = 0.0;
                for (Instance instance : candidates) {
                    cumulative += instance.getWeight();
                    if (random <= cumulative) {
                        return new DefaultResponse(
                                new NacosServiceInstance(instance));
                    }
                }
                return new DefaultResponse(new NacosServiceInstance(candidates.get(0)));
            } catch (Exception e) {
                throw new RuntimeException("负载均衡选实例失败", e);
            }
        });
    }

    // 简单的 ServiceInstance 适配器
    static class NacosServiceInstance implements ServiceInstance {
        private final Instance instance;
        NacosServiceInstance(Instance instance) { this.instance = instance; }
        public String getServiceId() { return instance.getServiceName(); }
        public String getHost() { return instance.getIp(); }
        public int getPort() { return instance.getPort(); }
        public boolean isSecure() { return false; }
        public java.net.URI getUri() {
            return java.net.URI.create("http://" + getHost() + ":" + getPort());
        }
        public java.util.Map<String, String> getMetadata() { return instance.getMetadata(); }
        public String getScheme() { return "http"; }
    }
}

这个示例展示了 Nacos 相比 Eureka 的一个明显优势:实例权重和元数据是内建能力,做灰度发布、金丝雀发布时不用自己另起一套路由框架。


方案对比

Nacos vs Eureka vs Consul vs Apollo

维度 Nacos Eureka Consul Apollo
服务发现 ✅ ✅ ✅ ❌
配置中心 ✅ ❌ ✅(较弱) ✅
一致性协议 Raft(CP)+Distro(AP) AP Raft 无(DB为主)
健康检查 心跳+服务端探活 心跳 多种(TCP/HTTP/gRPC) 无
配置实时性 长轮询(秒级) - Watch(秒级) 长轮询+定时
控制台 内置 无 有 强大
多环境隔离 namespace+group 无 namespace namespace
社区活跃度 高(阿里) 停止维护 高 中
上手成本 低 低 中 中

选型建议:

  • 新项目、Spring Cloud Alibaba 体系:直接 Nacos,一个组件解决两件事,最省心。
  • 已有 Eureka,只想解决配置:可以引入 Apollo 或 Nacos 只做配置。但 Eureka 2.x 已停止维护,长期看还是要迁移。
  • K8s 体系:其实用 K8s 原生 Service + ConfigMap 也能覆盖一部分,但 Nacos 在动态性和多语言支持上更灵活。
  • 强一致要求极高(金融核心):配置用 Nacos 的 Raft 模式,或者考虑 Apollo + 数据库事务。

Nacos 相对 Eureka 的实质提升:

  1. Eureka 只有 AP,Nacos 可以按需 CP/AP。
  2. Eureka 的自我保护模式(网络抖动时保留过期实例)经常导致调用到死实例,Nacos 的健康检查更细粒度。
  3. Nacos 自带配置中心,架构组件数更少。

最佳实践与避坑指南

坑 1:直接 kill -9 导致调用失败

现象:服务下线圈,消费者还在打请求,日志里一堆 Connection refused。

原因:心跳超时前,Nacos 还不知道实例已经死了,消费者本地缓存的实例列表还没更新。

解法:实现优雅下线——先调用 deregisterInstance,等待几秒让消费者刷新缓存,再关闭进程。Spring Boot 可以监听 ContextClosedEvent 来做。

坑 2:namespace 和 group 用混

现象:明明在控制台配了,应用就是读不到。

原因:namespace 用 ID 而不是名字!很多人填了 namespace 的显示名,结果客户端找不到。namespace 参数应填命名空间的 ID(控制台里能看到)。

坑 3:配置读到了但没生效

现象:Nacos 控制台改了配置,应用日志没有刷新。

原因:

  • 没加 @RefreshScope(对于 @Value 注入的配置,Bean 需要这个注解才能重建)。
  • 配置放在 application.yml 而不是 bootstrap.yml,导致加载顺序不对。
  • spring.cloud.nacos.config.refresh-enabled=false。

坑 4:集群部署时数据不一致

现象:集群三个节点,从不同节点读到的服务列表不一样。

原因:Distro 是最终一致的,同步有延迟;或者某个节点挂了,分片数据没转移。

解法:Nacos 2.x 已大幅优化,但仍要保证集群节点网络稳定。生产建议至少 3 节点,且节点间时钟同步(心跳依赖时间戳)。

坑 5:长轮询把 Tomcat 线程打满

现象:Nacos Server 在高并发下 Tomcat 线程耗尽。

原因:早期版本长轮询占用容器线程。Nacos 已经用 AsyncContext 异步化解决了,但如果你的客户端版本太老,可能还在用同步阻塞模式。

解法:客户端升级到 2.x,服务端也升级,使用 gRPC 长连接替代 UDP,性能更好。

最佳实践清单

  1. 生产必须集群:至少 3 节点,配置外部 MySQL(别用内置 Derby,那是单机玩具)。
  2. 临时实例 + 优雅下线:默认用临时实例,但一定要实现优雅下线。
  3. namespace 隔离环境,group 隔离业务:别把所有东西塞 DEFAULT_GROUP。
  4. 配置加版本和灰度:利用 metadata 和权重做灰度,别全量推。
  5. 监控心跳和长轮询:Nacos 自带 metrics,接入 Prometheus 监控连接数、请求延迟。
  6. 客户端加本地缓存兜底:Nacos 客户端本身有磁盘缓存(~/nacos/config),确保 Nacos 挂了应用还能用旧配置启动。别把 fail-fast 开成"连不上就启动失败"。

总结

回到开头的小区物业类比,Nacos 的核心就两件事:

  1. 服务注册发现:住户登记 + 心跳报到 + 物业维护名册 + 主动通知。临时实例靠心跳保活,健康检查有 15s 容忍窗口,推送失败有定时拉取兜底,还有推空保护防止雪崩。
  1. 配置中心:公告栏 + 长轮询通知。客户端携带 MD5 列表挂起请求,服务端有变更就立即唤醒,没变更就 29.5 秒后返回。这个设计在实时性和资源消耗之间取得了极佳的平衡。

从源码层面,你需要记住几个关键点:

  • 两套一致性协议:配置用 Raft(CP),服务发现用 Distro(AP),按需选择。
  • 长轮询的时间设计:服务端超时(29.5s)必须略小于客户端超时(30s)。
  • 推空保护:实例列表为空时不清缓存,是防止雪崩的最后一道防线。
  • gRPC 替代 UDP:Nacos 2.x 的推送可靠性大幅提升,新项目别用 1.x。

延伸思考:

Nacos 的设计哲学是"在一致性和可用性之间,把选择权交给用户"。这在微服务基础设施里是很聪明的——服务发现要 AP,配置要 CP,用同一个产品覆盖两种需求。相比之下,Eureka 死守 AP,Consul 强推 CP,都不够灵活。

如果你已经理解了这套机制,下一步可以深入研究 Nacos 2.x 的 gRPC 双向流实现,以及它如何用 ConnectionManager 管理海量长连接——那是另一个精彩的工程话题。