Nacos 注册中心源码拆解:服务注册 + 心跳 + 拉取全链路

引言

"一个实例已经下线 20 秒了,消费者还在往它上面发请求,每隔一段时间报一批 connect timeout。" 排查时发现 Nacos 控制台里这个实例先变红(不健康)、过一会儿才彻底消失,而消费端的地址列表更新又比控制台晚了好几秒——三层时间差叠在一起,就是那批超时的全部来源。

要算清这笔时间账,光靠"知道 Nacos 有心跳、有推送"是不够的,必须回答四个源码级问题:服务实例是什么时候、被谁注册上去的?心跳 5 秒一次是谁在发,服务端凭什么 15 秒判不健康、30 秒才摘除?消费者本地的地址列表多久更新一次,UDP 推送和定时拉取到底谁兜底?

这篇文章以 spring-cloud-alibaba 2.2.x + nacos-client 1.4.x + nacos-naming 1.4.x(HTTP 心跳模式)为准,按一条实例从启动到被发现的真实路径,把 NacosServiceRegistry、NamingProxy、ServiceManager、BeatReactor、HostReactor、PushReceiver 六个核心类串起来。代码均有删减只保留主干,文末会单独讲 Nacos 2.x 用 gRPC 长连接把心跳和 UDP 干掉之后的演进。读完你会发现:注册中心最核心的设计其实就两件事——用心跳维护"我还活着"的事实,用推拉结合维护"你还有谁能调"的视图。


一、全景:一张时序图看懂全链路

Provider 应用启动(Spring 容器 refresh 完成)
  │
  │ WebServerInitializedEvent(内嵌 Tomcat 端口就绪)
  ↓
NacosAutoServiceRegistration(spring-cloud-alibaba)
  │ AbstractAutoServiceRegistration 模板方法
  ↓
NacosServiceRegistry.register(registration)
  │ 组装 Instance:ip / port / cluster / weight / metadata,ephemeral=true(临时实例)
  ↓
NacosNamingService.registerInstance()(nacos-client)
  ├─① beatReactor.addBeatInfo()  → 启动 5s 心跳定时任务(BeatTask)
  └─② serverProxy.registerService()
        │ HTTP POST /nacos/v1/ns/instance
        ↓
   Nacos Server:InstanceController.register()
        │ @CanDistro:先写本机,再异步同步给集群其他节点(Distro/AP)
        ↓
   ServiceManager.registerInstance()
        ├─ createEmptyService → Service.init():为该服务启动 5s 一次的 ClientBeatCheckTask
        └─ addInstance → DistroConsistencyService.put(key, Instances)
              ├─ DataStore 存 Datum<Instances>(注册表真相)
              └─ Notifier 异步通知 Service.onChange → 更新 Cluster 内存实例列表
                                                         → PushService.serviceChanged(触发UDP推送)

心跳维持(每 5 秒):
BeatTask.run()
  │ HTTP PUT /nacos/v1/ns/instance/beat?ip=&port=
  ↓
InstanceController.beat() → ClientBeatProcessor
  │ instance.setLastBeat(now);若之前被判不健康则恢复健康并推送
服务端定时检查(ClientBeatCheckTask,5s 扫一次):
  │ now - lastBeat > 15s → healthy=false(变红,触发推送)
  │ now - lastBeat > 30s → deleteInstance(摘除,触发推送)

服务发现(Consumer 侧):
NacosNamingService.selectInstances()
  ↓
HostReactor.getServiceInfo()
  ├─ 本地缓存 serviceInfoMap 有就直接返回
  ├─ 首次/过期:HTTP GET /nacos/v1/ns/instance/list(携带客户端 UDP 端口)
  └─ scheduleUpdateIfAbsent:UpdateTask 默认每 10s 兜底拉一次
        │ 服务端把 <服务, UDP地址> 记入 PushService 订阅表
        ↓
PushReceiver(独立线程 + 随机UDP端口)
  │ 收到服务端变更推送 → 更新本地缓存 → 回调 EventListener
  └─ 回 ACK;服务端 10s 没收到 ACK 只重推一次,之后靠 10s 轮询兜底

记住三个时间常量和一个分工:客户端心跳 5s、服务端 15s 判不健康 / 30s 摘除、客户端拉取兜底 10s;UDP 推送追求快,定时拉取保证最终一致。

核心组件清单

层类职责
适配层NacosServiceRegistryspring-cloud 的 ServiceRegistry 实现,桥接 Registration 与 NamingService
适配层NacosAutoServiceRegistration监听 WebServerInitializedEvent,触发自动注册
客户端NacosNamingService门面,对外提供 register/subscribe/selectInstances
客户端NamingProxy封装 HTTP 调用(register / beat / list / deregister)
客户端BeatReactor临时实例的 5s 心跳调度器
客户端HostReactor服务列表本地缓存 + 10s 定时拉取 + 订阅管理
客户端PushReceiverUDP 服务,接收服务端的变更推送
服务端InstanceController/instance 系列 REST 入口,带 @CanDistro
服务端ServiceManager注册表管理:serviceMap、建服务、加实例
服务端DistroConsistencyService临时实例的 AP 一致性协议(本机写 + 异步同步)
服务端DataStoreDatum 存储,Distro 同步的数据单元
服务端ClientBeatCheckTask15s/30s 超时扫描
服务端ClientBeatProcessor处理一次心跳上报
服务端PushServiceUDP 变更推送 + ACK 重试

二、客户端启动:注册动作是被谁触发的

2.1 不是容器启动就注册,而是 Web 端口就绪后

很多人以为注册发生在 ApplicationContext.refresh() 完成时,其实更晚。spring-cloud-commons 里的 AbstractAutoServiceRegistration 监听的是 WebServerInitializedEvent——内嵌 Tomcat/Undertow 端口真正绑定成功后才发布:

// spring-cloud-commons,抽象类,删减
public abstract class AbstractAutoServiceRegistration<R extends Registration>
        implements AutoServiceRegistration, ApplicationContextAware,
                   ApplicationListener<WebServerInitializedEvent> {

    private AtomicInteger port = new AtomicInteger(0);

    @Override
    public void onApplicationEvent(WebServerInitializedEvent event) {
        bind(event);
    }

    public void bind(WebServerInitializedEvent event) {
        // CAS:保证只绑定一次端口
        this.port.compareAndSet(0, event.getWebServer().getPort());
        this.start();
    }

    public void start() {
        if (!isEnabled()) return;
        if (!this.running.get()) {
            this.context.publishEvent(new InstancePreRegisteredEvent(this, getRegistration()));
            register();                       // ← 模板方法
            this.context.publishEvent(new InstanceRegisteredEvent<>(this, getConfiguration()));
            this.running.set(true);
        }
    }

    protected void register() {
        this.serviceRegistry.register(getRegistration());
    }
}

为什么要等端口?因为注册的本质就是把 ip:port 上报给注册中心,端口没绑定成功就注册,等于把一个还不能用的地址发出去。这也是为什么本地调试时偶尔能看到"注册成功但接口调不通"——事件发出和端口能正常 accept 之间还有极短的窗口。

spring-cloud-alibaba 侧的子类只做一件事:getRegistration() 返回 NacosRegistration,serviceRegistry 就是下面要讲的 NacosServiceRegistry。

2.2 注册前还会等配置和 NamingService 初始化

NacosServiceRegistry 被自动装配时,会用 nacos.discovery.* 配置构造 NacosDiscoveryProperties,并在其 init() 里创建 NamingService:

// NacosDiscoveryProperties,删减
public void init() throws Exception {
    ...
    NamingService namingService = NamingFactory.createNamingService(serverAddr);
    // 命名服务内部持有 BeatReactor / HostReactor 两个核心反应器
    this.namingService = namingService;
}

所以整个客户端在启动期会准备好三样东西:一个 HTTP 代理(NamingProxy)、一个心跳反应器(BeatReactor)、一个服务列表反应器(HostReactor)。注册只是往这套已经就绪的机器里投第一个任务。


三、NacosServiceRegistry:组装 Instance 并发起 HTTP 注册

3.1 register() 主干

// spring-cloud-alibaba,NacosServiceRegistry,删减
@Override
public void register(Registration registration) {
    if (StringUtils.isEmpty(registration.getServiceId())) {
        log.warn("No service to register for nacos client...");
        return;
    }
    NamingService namingService = namingService();
    String serviceId = registration.getServiceId();          // spring.application.name
    String group = nacosDiscoveryProperties.getGroup();      // 默认 DEFAULT_GROUP

    Instance instance = getNacosInstanceFromRegistration(registration);

    try {
        namingService.registerInstance(serviceId, group, instance);
        log.info("nacos registry, {} {}:{} register finished",
                group + "@@" + serviceId, instance.getIp(), instance.getPort());
    } catch (Exception e) {
        throw new RuntimeException(...);
    }
}

private Instance getNacosInstanceFromRegistration(Registration registration) {
    Instance instance = new Instance();
    instance.setIp(registration.getHost());
    instance.setPort(registration.getPort());
    instance.setWeight(nacosDiscoveryProperties.getWeight());     // 默认 1.0
    instance.setClusterName(nacosDiscoveryProperties.getClusterName());
    instance.setEnabled(nacosDiscoveryProperties.isInstanceEnabled());
    instance.setMetadata(registration.getMetadata());
    instance.setEphemeral(nacosDiscoveryProperties.isEphemeral()); // 默认 true!
    return instance;
}

注意 ephemeral=true 这个默认值,它决定了后面整条链路的形态:

临时实例 ephemeral=true(默认)持久实例 ephemeral=false
健康检查客户端主动发心跳服务端主动探测(TCP/HTTP,定时5次)
一致性协议Distro(AP,异步复制)Raft/JRaft(CP,强一致写)
实例下线心跳超时自动摘除永久保留,需手动删除或探测标记
适用场景微服务业务实例(随时扩缩容)数据库、DNS 这类常驻基础组件

我们 99% 的 Spring Cloud 服务走的都是左列。

3.2 NacosNamingService:先挂心跳,再发注册

// nacos-client 1.4.x,删减
@Override
public void registerInstance(String serviceName, String groupName, Instance instance)
        throws NacosException {
    NamingUtils.checkInstanceIsLegal(instance);
    String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
    // groupedServiceName = "DEFAULT_GROUP@@order-service",这是服务在Nacos内部的全名

    if (instance.isEphemeral()) {
        BeatInfo beatInfo = beatReactor.buildBeatInfo(groupedServiceName, instance);
        beatReactor.addBeatInfo(groupedServiceName, beatInfo);   // ① 心跳任务先挂上
    }

    serverProxy.registerService(groupedServiceName, groupName, instance); // ② 发注册请求
}

两个细节值得注意:

  1. 心跳任务在注册请求之前就挂好了。哪怕第一次注册因为网络抖动失败,心跳任务照样在跑,而心跳收到"服务不存在"的响应码后会触发自动重新注册(第五章细讲),这是一个自愈设计。
  2. 服务在 Nacos 内部的唯一名字是 group@@serviceName,不是单纯的 spring.application.name。排查控制台问题时按全名定位。

3.3 NamingProxy:注册请求长什么样

// NamingProxy,删减
public void registerService(String serviceName, String groupName, Instance instance)
        throws NacosException {
    final Map<String, String> params = new HashMap<>(16);
    params.put(CommonParams.NAMESPACE_ID, namespaceId);   // 默认 public
    params.put(CommonParams.SERVICE_NAME, serviceName);   // DEFAULT_GROUP@@order-service
    params.put(CommonParams.GROUP_NAME, groupName);
    params.put("clusterName", instance.getClusterName()); // 默认 DEFAULT_CLUSTER
    params.put("ip", instance.getIp());
    params.put("port", String.valueOf(instance.getPort()));
    params.put("weight", String.valueOf(instance.getWeight()));
    params.put("enable", String.valueOf(instance.isEnabled()));
    params.put("healthy", String.valueOf(instance.isHealthy()));
    params.put("ephemeral", String.valueOf(instance.isEphemeral()));
    params.put("metadata", JacksonUtils.toJson(instance.getMetadata()));

    reqApi(UtilAndComs.nacosUrlInstance, params, HttpMethod.POST);
}

即一个普通的 HTTP 请求,抓包可见:

POST /nacos/v1/ns/instance?namespaceId=public&serviceName=DEFAULT_GROUP@@order-service
     &groupName=DEFAULT_GROUP&clusterName=DEFAULT_CLUSTER
     &ip=10.0.0.21&port=8080&weight=1.0&enabled=true&healthy=true&ephemeral=true

reqApi 内部还藏着两个生产上经常救场的机制:serverAddr 可以配多个地址,调用失败自动切换下一个节点(随机起点 + 轮询);鉴权 token(accessToken)会作为参数自动带上。所以注册报 403/401 时,查的是命名空间账号权限,不是网络问题。


四、服务端:ServiceManager 怎么存注册表

4.1 InstanceController:带 @CanDistro 的写入口

// nacos-naming 1.4.x,删减
@CanDistro                                   // ← 关键注解:这个写请求可以由任意节点承接
@PostMapping
@Secured(parser = NamingResourceParser.class, action = ActionTypes.WRITE)
public String register(@RequestParam(defaultValue = Constants.DEFAULT_NAMESPACE_ID)
                       String namespaceId,
                       @RequestParam String serviceName,
                       @RequestParam(defaultValue = DEFAULT_GROUP_NAME) String groupName,
                       @RequestParam String clusterName,
                       @RequestParam String ip,
                       @RequestParam Integer port,
                       @RequestParam String weight,
                       @RequestParam String metadata,
                       @RequestParam(defaultValue = "true") Boolean ephemeral) throws Exception {

    Instance instance = parseInstance(...);
    instanceService.registerInstance(namespaceId, serviceName, ephemeral, instance);
    return "ok";
}

@CanDistro 是理解 Nacos 集群写入的钥匙:客户端可以连集群中任意一个节点注册,不要求连 leader。接到请求的节点负责写本机,然后用 Distro 协议把这次变更异步复制给其他节点。这就是 Nacos 临时实例的 AP 模型——不需要选举,任何节点都能写,代价是短时间内集群数据可能不一致。

4.2 ServiceManager:两级 ConcurrentHashMap 注册表

// ServiceManager,删减
@Component
public class ServiceManager implements RecordListener<Service>, ApplicationListener ... {

    // namespace -> (group@@serviceName -> Service)
    private final Map<String, Map<String, Service>> serviceMap = new ConcurrentHashMap<>();

    public void registerInstance(String namespaceId, String serviceName, Instance instance)
            throws NacosException {
        createEmptyService(namespaceId, serviceName, instance.isEphemeral());
        Service service = getService(namespaceId, serviceName);
        if (service == null) {
            throw new NacosException(NacosException.INVALID_PARAM,
                    "service not found, namespace: " + namespaceId + ", service: " + serviceName);
        }
        addInstance(namespaceId, serviceName, instance.isEphemeral(), instance);
    }
}

服务第一次注册时 serviceMap 里还没有这个 Service,要走 createEmptyService → createServiceIfAbsent → putServiceAndTask:

private void putServiceAndTask(Service service) {
    synchronized (putServiceLock) {
        if (putServiceIfAbsent(service)) {
            service.init();   // ← 为这个服务启动心跳检查任务
            ...
        }
    }
}

Service.init() 是服务端心跳检查的起点(第五章讲),同时 Service 对象内部持有集群和实例:

serviceMap
└─ namespaceId=public
   └─ "DEFAULT_GROUP@@order-service" → Service
        └─ clusterMap
           └─ "DEFAULT_CLUSTER" → Cluster
                ├─ ephemeralInstances : HashSet<Instance>   ← 心跳维持的临时实例
                └─ persistentInstances : HashSet<Instance>  ← 主动探测的持久实例

4.3 addInstance:写一致性协议,而不是直接改 Set

一个反直觉的点:注册请求不会直接把实例塞进 Cluster 的 Set。真正的内存更新是异步绕了一圈的:

// ServiceManager,删减
public void addInstance(String namespaceId, String serviceName, boolean ephemeral,
                        Instance... ips) throws NacosException {

    // 临时实例 key: com.alibaba.nacos.naming.iplist.ephemeral.public##DEFAULT_GROUP@@order-service
    // 持久实例 key: com.alibaba.nacos.naming.iplist.persistent....
    String key = KeyBuilder.buildInstanceListKey(namespaceId, serviceName, ephemeral);

    Service service = getService(namespaceId, serviceName);
    synchronized (service) {
        // 先读出已有实例列表,合并新实例(去重、按ip:port覆盖)
        List<Instance> instanceList = addIpAddresses(service, ephemeral, ips);
        Instances instances = new Instances();
        instances.setInstanceList(instanceList);

        consistencyService.put(key, instances);
    }
}

consistencyService 按 ephemeral 分流:

// ServiceManager#putConsistencyServiceDelegate 简化
public ConsistencyService getConsistencyService(String key) {
    return KeyBuilder.matchEphemeralKey(key) ? ephemeralConsistencyService   // Distro
                                            : persistentConsistencyService;  // Raft
}

Distro 的写入分两步:

// DistroConsistencyService,删减
@Override
public void put(String key, Record value) throws NacosException {
    onPut(key, value);                    // ① 写本机 DataStore
    // ② 异步同步给集群其他节点(带版本校验、批量合并、失败重试)
    distroProtocol.sync(new DistroKey(key, KeyBuilder.INSTANCE_LIST_KEY_PREFIX),
            DataOperation.CHANGE, globalConfig.getTaskDispatchPeriod() / 2);
}

public void onPut(String key, Record value) {
    if (KeyBuilder.matchEphemeralInstanceListKey(key)) {
        Datum<Instances> datum = new Datum<>();
        datum.value = (Instances) value;
        datum.key = key;
        datum.timestamp.incrementAndGet();      // 版本号,Distro 靠它做新旧判断
        dataStore.put(key, datum);              // DataStore 才是同步数据的"真相"
    }
    if (!listeners.containsKey(key)) {
        return;
    }
    notifier.addTask(key, DataOperation.CHANGE); // 通知本机监听者
}

Notifier 是个单线程消费者,异步回调 RecordListener.onChange,而 Service 在创建时就以 key 注册成了监听者。于是数据回流到内存注册表:

// Service implements RecordListener<Instances>,删减
@Override
public void onChange(String key, Instances value) {
    updateIPs(value.getInstanceList(), KeyBuilder.matchEphemeralInstanceListKey(key));
    recalculateChecksum();
}

// Cluster#updateIPs,删减
public synchronized void updateIps(List<Instance> ips, boolean ephemeral) {
    Set<Instance> toUpdateInstances = ephemeral ? ephemeralInstances : persistentInstances;

    // 比对新旧列表,只有实例集合真的变了才返回 changed=true
    Map<Instance, Instance> oldIpMap = toUpdateInstances.stream()
            .collect(Collectors.toMap(Instance::getInstanceId, ip -> ip));
    ...
    if (changed) {
        // 触发服务变更事件 → PushService 收到后做 UDP 推送
        pushService.serviceChanged(Service.this);
    }
}

至此一次注册完整闭环:

HTTP 请求 → ServiceManager → DistroConsistencyService
   → DataStore(同步真相)→ Notifier → Service.onChange
   → Cluster.ephemeralInstances(查询用内存视图)→ PushService(准备推送)
   → 同时 Distro 异步复制到其他节点

这个"DataStore 真相 + Cluster 内存视图"双写分离的设计,是为了让集群复制和查询/推送解耦:同步只针对 Datum 做版本合并,本机视图更新由监听者统一驱动,无论变更是来自客户端请求还是别的节点同步过来的,路径完全一样。


五、心跳机制:5s、15s、30s 三个数字的源码出处

5.1 客户端:BeatReactor 的 BeatTask

第三章提到注册前先 addBeatInfo:

// BeatReactor,删减
public BeatInfo buildBeatInfo(String groupedServiceName, Instance instance) {
    BeatInfo beatInfo = new BeatInfo();
    beatInfo.setServiceName(groupedServiceName);
    beatInfo.setIp(instance.getIp());
    beatInfo.setPort(instance.getPort());
    beatInfo.setCluster(instance.getClusterName());
    beatInfo.setWeight(instance.getWeight());
    beatInfo.setMetadata(instance.getMetadata());
    beatInfo.setScheduled(false);
    beatInfo.setPeriod(instance.getInstanceHeartBeatInterval()); // 默认 5000ms
    return beatInfo;
}

public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
    BEAT_MAP.put(serviceName, beatInfo);
    executorService.schedule(new BeatTask(beatInfo),
            beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
}

executorService 是一个核心线程数为 CPU核数/2(最少1)的 ScheduledExecutorService。心跳任务不是固定速率(scheduleAtFixedRate),而是每次执行完再 schedule 下一次——这样网络抖动时任务不会堆积:

// BeatReactor.BeatTask,删减
class BeatTask implements Runnable {
    final BeatInfo beatInfo;

    @Override
    public void run() {
        if (beatInfo.isStopped()) {
            return;
        }
        long nextTime = beatInfo.getPeriod();
        try {
            JsonNode result = serverProxy.sendBeat(beatInfo, BeatReactor.this.lightBeatEnabled);
            long interval = result.get("clientBeatInterval").asLong();
            if (interval > 0) {
                nextTime = interval;       // ← 间隔可以由服务端动态下发
            }
            int code = result.get(CommonParams.CODE).asInt();
            if (code == NamingResponseCode.RESOURCE_NOT_FOUND) {   // 20403
                // 服务端说:我没找到你的实例(可能刚重启/被同步延迟)
                // 客户端立刻补一次完整注册,然后心跳继续
                Instance instance = new Instance();
                instance.setIp(beatInfo.getIp());
                instance.setPort(beatInfo.getPort());
                instance.setWeight(beatInfo.getWeight());
                instance.setMetadata(beatInfo.getMetadata());
                instance.setClusterName(beatInfo.getCluster());
                instance.setServiceName(beatInfo.getServiceName());
                instance.setEphemeral(true);
                serverProxy.registerService(beatInfo.getServiceName(),
                        NamingUtils.getGroupName(beatInfo.getServiceName()), instance);
            }
        } catch (NacosException ex) {
            Loggers.EVT_LOG.warn("[CLIENT-BEAT] failed to send beat", ex);
        } finally {
            executorService.schedule(new BeatTask(beatInfo), nextTime, TimeUnit.MILLISECONDS);
        }
    }
}

心跳请求本身极简:

// NamingProxy#sendBeat,删减
public JsonNode sendBeat(BeatInfo beatInfo, boolean lightBeatEnabled) throws NacosException {
    Map<String, String> params = new HashMap<>(8);
    params.put(CommonParams.NAMESPACE_ID, namespaceId);
    params.put(CommonParams.SERVICE_NAME, beatInfo.getServiceName());
    params.put("ip", beatInfo.getIp());
    params.put("port", String.valueOf(beatInfo.getPort()));
    String body = StringUtils.EMPTY;
    if (!lightBeatEnabled) {
        body = JacksonUtils.toJson(beatInfo);   // 带 weight/metadata 的完整心跳
    }
    String result = reqApi(UtilAndComs.nacosUrlBeat, params, body, HttpMethod.PUT);
    return JacksonUtils.toObj(result);
}
PUT /nacos/v1/ns/instance/beat?serviceName=DEFAULT_GROUP@@order-service&ip=10.0.0.21&port=8080

这里有个容易忽略的优化 lightBeatEnabled:实例没有自定义 metadata 时心跳只发 ip+port(轻心跳),服务端能省一次 JSON 反序列化。一个大规模 Nacos 集群上心跳 QPS 轻松上万,这个优化很实在。

5.2 服务端收心跳:只刷新 lastBeat

// InstanceController,删减
@CanDistro
@PutMapping("/beat")
public ObjectNode beat(@RequestParam String serviceName, ...,
                       @RequestParam String ip, @RequestParam Integer port,
                       @RequestParam(required = false) String beat) throws Exception {
    ...
    JsonNode result = instanceService.handleClientBeat(namespaceId, serviceName,
            ip, port, clientBeat, lightBeatEnabled);
    ...
}

最终走到 ClientBeatProcessor,逻辑就两块:刷新最后心跳时间;如果实例之前被误判/超时判成不健康,借这次心跳恢复:

// ClientBeatProcessor,删减(1.4.x 实际名为 call(),逻辑等价)
public void run() {
    Service service = serviceManager.getService(namespaceId, serviceName);
    if (service == null) {
        result.put(CommonParams.CODE, NamingResponseCode.RESOURCE_NOT_FOUND); // 20403
        return result;
    }

    Cluster cluster = service.getClusterMap().get(clusterName);
    List<Instance> instances = cluster.allIPs(true);   // 临时实例

    for (Instance instance : instances) {
        if (!instance.getIp().equals(ip) || instance.getPort() != port) {
            continue;
        }
        // 心跳超时阈值(15s)内有心跳,但实例当前不健康 → 恢复并推送
        if (System.currentTimeMillis() - instance.getLastBeat() > CLIENT_BEAT_TIMEOUT) {
            if (!instance.isHealthy()) {
                instance.setHealthy(true);
                pushService.serviceChanged(service);
            }
        }
        instance.setLastBeat(System.currentTimeMillis()); // ← 心跳的全部效果
    }

    result.put("clientBeatInterval", Instance.DEFAULT_HEART_BEAT_INTERVAL); // 5000 回传给客户端
    return result;
}

注意心跳不触发 Distro 同步——lastBeat 是每个节点本地的检查依据,不参与集群复制,否则心跳量会把同步通道打满。

5.3 服务端扫描:ClientBeatCheckTask 的 15s 和 30s

第四章说过,Service 第一次被创建时 service.init() 会启动一个每 5 秒执行一次的定时任务:

// Service#init,删减
public void init() {
    // 延迟5s、周期5s
    HealthCheckReactor.scheduleCheck(clientBeatCheckTask);
    for (Map.Entry<String, Cluster> entry : clusterMap.entrySet()) {
        entry.getValue().init();
    }
}

任务本体就是开篇事故的时间账来源:

// ClientBeatCheckTask,删减
@Override
public void run() {
    try {
        // 只有"负责"这个服务的节点才执行检查
        if (!getDistroMapper().responsible(service.getName())) {
            return;
        }
        if (!getSwitchDomain().isHealthCheckEnabled()) {
            return;
        }

        List<Instance> instances = service.allIPs(true); // 只看临时实例

        // 第一轮:超过 15s 没心跳 → 标记不健康
        for (Instance instance : instances) {
            if (System.currentTimeMillis() - instance.getLastBeat()
                    > instance.getInstanceHeartBeatTimeOut()) {       // 默认 15000
                if (!instance.isMarked() && instance.isHealthy()) {
                    instance.setHealthy(false);
                    getPushService().serviceChanged(service);         // 立刻推送给订阅者
                    ApplicationUtils.publishEvent(new InstanceHeartbeatTimeoutEvent(this, instance));
                }
            }
        }

        if (!getGlobalConfig().isExpireInstance()) {
            return;
        }

        // 第二轮:超过 30s 没心跳 → 直接摘除
        for (Instance instance : instances) {
            if (instance.isMarked()) {
                continue;
            }
            if (System.currentTimeMillis() - instance.getLastBeat()
                    > instance.getIpDeleteTimeout()) {                // 默认 30000
                Loggers.SRV_LOG.info("[EXPIRE-INSTANCE] ip: {}, port: {}, service: {}, "
                        + "no beat for long.", instance.getIp(), instance.getPort(), service.getName());
                deleteIp(instance);
            }
        }
    } catch (Exception e) {
        Loggers.SRV_LOG.warn("Exception while processing client beat time out.", e);
    }
}

两个阈值都是实例级参数(可通过实例 metadata 覆盖):

参数默认值含义
instanceHeartBeatInterval5000ms客户端心跳间隔
instanceHeartBeatTimeOut15000ms服务端判不健康阈值
ipDeleteTimeout30000ms服务端摘除实例阈值
ClientBeatCheckTask 扫描周期5000ms检查任务本身的频率

getDistroMapper().responsible(serviceName) 又体现了 Distro 的分工:集群中每个节点只负责一部分服务的健康检查(对服务名做一致性 hash 取模),避免所有节点重复扫描、重复摘除。负责节点宕机了怎么办?其他节点定期重算责任分配,接管它的服务。

5.4 把时间账算清楚

以"实例 12:00:00 正常、之后宕机"为例:

12:00:00  最后一次心跳成功,lastBeat=12:00:00
12:00:05  心跳发出无响应(TCP失败/进程已没),客户端日志开始报 send beat failed
12:00:15  服务端扫描:now-lastBeat=15s+,healthy=false
          → serviceChanged → UDP 推给所有订阅者(消费者最快此时摘除)
12:00:30  服务端扫描:超过 30s,deleteIp → 再推一次,控制台实例消失

而消费端能感知的时间还要叠加推送和拉取:UDP 推送正常时约 15~16s 生效;UDP 丢包时要等 10s 兜底拉取,最坏约 25s 才摘掉。优雅停机(先从 Nacos 注销,再停流量、再关进程)能把这个窗口缩到秒级以内,这也是 Spring Boot 优雅停机要配合 spring.cloud.nacos.discovery.register-enabled 与 deregister 的原因。


六、服务发现:HostReactor 的本地缓存与定时拉取

6.1 查询永远先走本地缓存

消费端调用 selectInstances("order-service") 时:

// NacosNamingService,删减
@Override
public List<Instance> selectInstances(String serviceName, String groupName,
        List<String> clusters, boolean healthy, boolean subscribe) throws NacosException {
    ServiceInfo serviceInfo;
    if (subscribe) {
        serviceInfo = hostReactor.getServiceInfo(NamingUtils.getGroupedName(serviceName, groupName),
                StringUtils.join(clusters, ","));
    } else {
        serviceInfo = hostReactor.getServiceInfoDirectlyFromServer(...);
    }
    return selectInstances(serviceInfo, healthy);
}

默认 subscribe=true,走本地缓存:

// HostReactor,删减
public ServiceInfo getServiceInfo(final String serviceName, final String clusters) {
    final String key = ServiceInfo.getKey(serviceName, clusters);

    // ① 故障转移文件优先(运维可手工塞本地文件强制覆盖)
    ServiceInfo failoverServiceInfo = failoverReactor.getService(key);
    if (failoverServiceInfo != null) {
        return failoverServiceInfo;
    }

    // ② 本地内存缓存
    ServiceInfo serviceObj = serviceInfoMap.get(key);

    if (serviceObj == null) {
        // ③ 首次查询:同步拉一次
        serviceObj = updateServiceNow(serviceName, clusters);
    }

    // ④ 确保该服务的定时拉取任务在跑
    scheduleUpdateIfAbsent(serviceName, clusters);
    return serviceObj;
}

serviceInfoMap 是消费端注册表的本地镜像,类型是 ConcurrentHashMap<String, ServiceInfo>。三层数据来源的优先级在生产上非常关键:

FailoverReactor 本地文件(snapshot/_failover_ 目录)  优先级最高,应急切流/隔离用
   ↓ 没有
HostReactor.serviceInfoMap 内存缓存                    常态路径,读它不产生网络IO
   ↓ 没有
updateServiceNow() 同步 HTTP 拉取                      仅首次/缓存丢失时

FailoverReactor 默认每 10 秒检查一次 failover 目录,目录里放一个与服务同名的 JSON 就能让消费端强制使用指定地址列表——这是 Nacos 注册中心整体不可用时的最后一道手动降级手段,建议提前知道目录位置,出事时不用临时查。

6.2 UpdateTask:动态间隔的兜底拉取

// HostReactor#scheduleUpdateIfAbsent,删减
public void scheduleUpdateIfAbsent(String serviceName, String clusters) {
    futureMap.computeIfAbsent(ServiceInfo.getKey(serviceName, clusters), key -> {
        ScheduledFuture<?> future = addTask(new UpdateTask(serviceName, clusters));
        return future;
    });
}
// HostReactor.UpdateTask,删减
public void run() {
    long delayTime = DEFAULT_DELAY;   // 1000ms,出错时快速重试
    try {
        ServiceInfo serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
        if (serviceObj == null) {
            updateServiceNow(serviceName, clusters);          // 首次或缓存丢失:立即拉
        } else if (serviceObj.getLastRefTime() <= lastRefTime) {
            updateServiceNow(serviceName, clusters);          // 到点:全量拉
            serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
        } else {
            refreshOnly(serviceName, clusters);               // 没到点:只轻量刷新引用时间
        }
        if (serviceObj != null) {
            lastRefTime = serviceObj.getLastRefTime();
            delayTime = serviceObj.getCacheMillis();          // ← 间隔由服务端下发,默认10000
        }

        // 没人订阅了,任务自动退出,避免空转
        if (!notifier.isSubscribed(serviceName, clusters)
                && !futureMap.containsKey(ServiceInfo.getKey(serviceName, clusters))) {
            scheduleFuture.cancel(false);
            return;
        }
    } catch (Throwable e) {
        Loggers.EVT_LOG.warn("[failed to update service info]", e);
    } finally {
        if (!scheduleFuture.isCancelled()) {
            executor.schedule(this, delayTime, TimeUnit.MILLISECONDS);
        }
    }
}

几个设计点:

  1. 拉取间隔 10s 不是客户端写死的,而是服务端在 list 响应里返回的 cacheMillis,服务端可以统一调整推送/拉取节奏。
  2. 拉取出错时 delayTime 退回 1s 快速重试,不会因为一次网络抖动让地址列表停更 10 秒。
  3. 定时任务按"服务+集群"维度存在 futureMap 里,没有订阅者时自动取消——这是 Nacos 客户端不会因为调用过大量服务而泄漏定时任务的原因。

6.3 list 请求顺便完成"订阅登记"

// NamingProxy#queryList,删减
public String queryList(String serviceName, String clusters,
                        int udpPort, boolean healthyOnly, boolean notify) throws Exception {
    final Map<String, String> params = new HashMap<>(8);
    params.put(CommonParams.NAMESPACE_ID, namespaceId);
    params.put(CommonParams.SERVICE_NAME, serviceName);
    params.put("clusters", clusters);
    params.put("udpPort", String.valueOf(udpPort));   // ← 客户端UDP监听端口
    params.put("clientIP", NetUtils.localIP());
    ...
    return reqApi(UtilAndComs.nacosUrlBase + "/instance/list", params, HttpMethod.GET);
}

服务端 InstanceController.list 在返回列表的同时,把 (服务, 客户端IP:udpPort) 记到 PushService 的订阅表里,后续这个服务变更才知道推给谁。也就是说:拉取本身即订阅,不需要单独的订阅接口常驻心跳。


七、订阅与推送:UDP 推送 + 拉取兜底的推拉结合

7.1 为什么推送用 UDP 而不是长连接

1.x 年代 Nacos 服务端要支撑百万级实例订阅,给每个订阅者维持一条 TCP 长连接的成本(连接数、线程、内存、GC)不可接受。UDP 的好处是无连接、服务端只发不管,丢包了由客户端 10s 轮询兜底;坏处是不可靠,所以协议里加了 ACK 和一次重试。

7.2 客户端 PushReceiver:随机端口 + 独立线程

NacosNamingService 初始化时会启动 PushReceiver:

// PushReceiver,删减
public class PushReceiver implements Runnable, Closeable {
    private DatagramSocket udpSocket;
    private volatile boolean shutdown = false;

    public PushReceiver(HostReactor hostReactor) {
        try {
            this.hostReactor = hostReactor;
            this.udpSocket = new DatagramSocket();   // 随机可用端口,会随 list 请求上报
            ...
            Thread inThread = new Thread(this);
            inThread.setDaemon(true);
            inThread.setName("com.alibaba.nacos.naming.push.receiver");
            inThread.start();
        } catch (Exception e) {
            ...
        }
    }

    @Override
    public void run() {
        while (!shutdown) {
            byte[] buffer = new byte[UDP_MSS];      // 1024*64
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
            udpSocket.receive(packet);             // 阻塞等推送

            String json = new String(IoUtils.tryDecompress(packet.getData()), UTF_8).trim();
            PushPacket pushPacket = JacksonUtils.toObj(json, PushPacket.class);

            // ① 立刻回 ACK(让服务端知道推送成功)
            AckPacket ack = new AckPacket();
            ack.type = "ack-"+pushPacket.type;
            byte[] ackBytes = JacksonUtils.toJson(ack).getBytes(UTF_8);
            udpSocket.send(new DatagramPacket(ackBytes, ackBytes.length,
                    packet.getAddress(), packet.getPort()));

            // ② 处理推送内容
            ServiceInfo info = ...parse(pushPacket.data);
            handleServiceInfo(pushPacket.data, info);
        }
    }
}

handleServiceInfo 做严格的变更判断,防止重复/乱序推送触发无谓刷新:

// PushReceiver#handleServiceInfo,删减
public void handleServiceInfo(String serviceName, String clusters, ServiceInfo serviceInfo) {
    if (serviceInfo.getHosts() == null || !serviceInfo.validate()) {
        return;
    }
    ServiceInfo oldService = serviceInfoMap.get(serviceInfo.getKey());

    boolean changed;
    if (oldService == null) {
        changed = true;
    } else if (oldService.getHosts().size() != serviceInfo.getHosts().size()) {
        changed = true;
    } else {
        changed = !serviceInfo.hostsEquals(oldService.getHosts())
                || serviceInfo.getRefCount() != oldService.getRefCount();
    }

    if (changed) {
        serviceInfo.setLastRefTime(System.currentTimeMillis());
        serviceInfoMap.put(serviceInfo.getKey(), serviceInfo);
        // 通知所有 EventListener(最终驱动 Ribbon/LoadBalancer 更新本地实例列表)
        NotifyCenter.publishEvent(new InstancesChangeEvent(...));
    }
}

订阅入口也很薄,只是注册监听器并确保 UpdateTask 启动:

// NamingService.subscribe 的典型用法
NamingService naming = NamingFactory.createNamingService("127.0.0.1:8848");
naming.subscribe("DEFAULT_GROUP@@order-service", event -> {
    NamingEvent e = (NamingEvent) event;
    log.info("实例列表变更,当前可用实例:{}", e.getInstances());
});

Spring Cloud 侧 NacosWatch/DiscoveryClient 启动时会自动对当前应用依赖的服务完成订阅,变更事件最终更新 LoadBalancer 的 ServerList,不需要业务代码手写。

7.3 服务端 PushService:ACK 超时只重试一次

服务端的推送由第四章的 pushService.serviceChanged(service) 触发。它维护两类结构:

// PushService,删减
// 服务key -> 订阅客户端集合(来自 list 请求登记)
private final ConcurrentMap<String, ConcurrentMap<String, PushClient>> clientMap = ...;
// 待确认推送:ACK 超时检查
private final ConcurrentMap<String, AckEntry> ackMap = ...;

推送与 ACK 处理:

// 删减:发送UDP并登记ACK
private void udpSend(String data, PushClient client) throws Exception {
    byte[] bytes = data.length() > UDP_MSS ? Utils.compress(data, UTF_8) : data.getBytes(UTF_8);
    DatagramPacket packet = new DatagramPacket(bytes, bytes.length,
            client.getAddr(), client.getPort());
    udpSocket.send(packet);
}

// Receiver 线程收到客户端回的 ACK → 从 ackMap 移除
// 定时任务扫描 ackMap:超过 10s 未确认,重推一次;再失败就放弃
//     → 等这个客户端下一次 10s 轮询自己拉齐

关键取舍:ACK 超时时间 10s,只重试一次。为什么不无限重试?因为客户端 UpdateTask 本身 10s 后会拉取,推送的目标只是"快",一致性由轮询保证。这就是推拉结合的完整语义:

正常情况:服务端变更 → UDP 推送(毫秒级生效)→ ACK
推送丢失:10s ACK 超时 → 重推一次
再次丢失:放弃,客户端 UpdateTask 10s 轮询拉齐(最终一致)

另外订阅客户端条目有过期机制:客户端如果一直不发 list 请求(应用挂了),条目会被清理,避免服务端往"幽灵订阅者"一直推送。

7.4 一次完整摘除在消费端的生效路径

把第五章和本章串起来,实例宕机后消费端的视角:

T+15s  服务端 ClientBeatCheckTask 标记 healthy=false → serviceChanged
       PushService UDP 推送新列表 → PushReceiver 收到
       → serviceInfoMap 更新 → InstancesChangeEvent
       → LoadBalancer ServerList 更新,该实例被排除
T+30s  服务端摘除实例 → 再推一次 → 消费端列表移除该实例

如果消费端在 T+15s 那个瞬间正好已经选中该实例发起调用,仍可能收到一次连接错误,需要由 RPC 客户端重试(Feign/LoadBalancer 的 retry)或业务幂等重试兜底。注册中心管的是列表时效,不负责单次调用的成功,这两个层次不要混淆。


八、Nacos 2.x 的演进:心跳和 UDP 都被长连接取代了

上面整套 HTTP + 心跳 + UDP 是 1.x 的经典模型,也是线上存量最多的形态。Nacos 2.0 之后默认改用 gRPC 长连接,理解差异有助于你读懂新版日志和参数。

维度1.x(本文主线)2.x
注册通道HTTP 短连接(POST /instance)gRPC 长连接(InstanceRequest)
临时实例存活客户端 5s 心跳连接即心跳,无独立心跳请求
连接探活无(靠应用层心跳)gRPC keepalive + 连接探活,约 5s 探测、多次失败断连
变更推送UDP + 10s 轮询兜底gRPC 双向流 server push,可靠且有序
服务端状态serviceMap + Datum 为核心新增 ClientManager/连接会话,实例挂在 Client 上
客户端线程模型BeatTask + UpdateTask + PushReceiver连接事件驱动,线程数大幅减少
端口8849(gRPC,主端口偏移1000)除 8848 外还要放行 9848(gRPC,主端口+1000)

2.x 的核心思想:一个客户端与服务端只有一条长连接,连接在则这个客户端注册的所有实例都在,订阅也在。

客户端启动 → 建 gRPC 连接(9848)
  ├─ 连接上发 InstanceRequest(注册/注销,可批量)
  ├─ 连接上发 ServiceSubscribeRequest(订阅)
  └─ 连接上收 NotifySubscriberRequest(服务端主动推,TCP可靠)

连接探活失败(约15s~20s多次无响应)
  → ConnectionManager 关闭连接,发布 ClientDisconnectEvent
  → 该 Client 名下所有实例一次性摘除 → 推送给订阅者

所以在 2.x 上你看不到 /instance/beat 请求了,但要注意健康判活的语义仍在,只是载体从应用层心跳变成了连接存活:网络分区导致 gRPC 连接断开时,实例可能在旧连接未释放、新连接重连之间短暂重复/抖动。排查手段也从抓 HTTP 包变成看连接状态(nacos_server_loader/connection 相关 metrics 与 serverList 轮询日志)。迁移时防火墙、K8s NetworkPolicy 一定要放行 9848(以及集群间 9849),这是 1.x 升 2.x 最高频的踩坑点。


九、常见问题

9.1 服务启动日志显示 register finished,控制台却看不到实例?

按链路倒查:① 请求打到了哪个 serverAddr,命名空间 namespace、group、cluster 是否与控制台筛选条件一致(控制台默认 public + DEFAULT_GROUP);② 注册请求是否被拦截器/网关改写了路径(正确路径 /nacos/v1/ns/instance,带 context-path 时是 /自定义/nacos/v1/...);③ 集群模式下可能落在了其他节点,Distro 同步有毫秒级延迟,刷新即可;④ 鉴权:token 无命名空间写权限时注册会报错,看客户端是否有 403 异常被吞。

9.2 15 秒判不健康能不能调小?能不能摘除更快?

可以通过实例 metadata 参数调 instance.heart.beat.interval、instance.heart.beat.timeout、instance.ip.delete.timeout,但不建议盲目调小:心跳超时要考虑 GC、瞬时网络抖动、CPU 飙高的场景,小于 10s 容易把健康实例误摘,引发雪崩。要更快下线,正确做法是优雅停机时主动调注销接口(deregister),而不是缩短超时。消费端配合合理的 RPC 重试,比追求"秒级摘除"更稳。

9.3 UDP 推送收不到/经常延迟到 10 秒才更新,怎么排查?

现象本质是 UDP 被丢:检查客户端到服务端 8848 之外的双向 UDP 是否被安全组/防火墙拦截(UDP 无连接,两端都要放行随机端口);容器化环境下 clientIP 自动探测错误也会导致服务端往错误地址推——可配置 spring.cloud.nacos.discovery.ip 显式指定注册 IP。确认方式:更新实例后看消费端是约 1 秒内刷新还是固定 10 秒刷新,固定 10 秒说明推送完全没生效,纯靠轮询。2.x 升级后该问题自然消失。

9.4 Nacos 集群某个节点挂了,注册和心跳会断吗?

不会。NamingProxy 维护 serverList,请求失败自动切换下一个节点重试;心跳 BeatTask 每次执行都重新选节点。Distro 模式下任何节点都可承接写,负责健康检查的节点宕机会由其他节点通过责任重算接管。真正需要关注的是客户端配置了多个 server 地址(127.0.0.1:8848,127.0.0.2:8848),只配一个地址 + VIP 故障的架构才会断。

9.5 同一个服务大量实例同时重启,会不会把 Nacos 打挂?

重启风暴会造成三类压力:注册 QPS、全量列表推送放大(一个实例变更推给所有订阅者)、心跳峰值。防护手段:客户端心跳有独立线程池且错峰(schedule 初始延迟天然分散);服务端推送做了合并(短时间多次变更只推最新列表)和 UDP 压缩;业务侧发版要灰度分批,避免同服务实例同时下线。容量上 Nacos 单节点支撑万级实例主要受这三类流量影响,而不是注册表内存。

9.6 临时实例和持久实例能混用吗?什么时候必须用持久实例?

同一个服务名下不建议混用 ephemeral 类型不同的实例(key 不同:iplist.ephemeral.* 与 iplist.persistent.*,但查询视图合并,行为容易困惑)。持久实例用于地址固定、进程不归 Nacos 客户端管的组件,例如把 MySQL、Redis、DNS 域名以 IP 形式登记进 Nacos 让服务发现统一管理,健康检查由服务端发起 TCP/HTTP 探测。普通微服务一律临时实例——实例随生随灭、心跳超时自动清理才符合云原生弹性的语义。


十、总结

全链路速查卡

【注册】
WebServerInitializedEvent(端口就绪)
  → NacosServiceRegistry.register(组装Instance,ephemeral默认true)
  → NacosNamingService.registerInstance
      ├─ BeatReactor.addBeatInfo(先挂5s心跳任务)
      └─ NamingProxy.registerService → POST /nacos/v1/ns/instance
  → InstanceController.register(@CanDistro:任意节点可写)
  → ServiceManager
      ├─ createEmptyService → Service.init → 启动 ClientBeatCheckTask(5s扫描)
      └─ addInstance → DistroConsistencyService.put
            ├─ DataStore.put(Datum)(同步真相,timestamp做版本)
            ├─ Distro 异步复制到其他节点(AP)
            └─ Notifier → Service.onChange → Cluster 内存实例列表更新
                                          → PushService.serviceChanged

【心跳】
客户端 BeatTask(每5s,自调度,间隔服务端可下发)
  → PUT /nacos/v1/ns/instance/beat
  → 20403(RESOURCE_NOT_FOUND) 时自动补注册
服务端 ClientBeatProcessor:只刷 lastBeat,不健康则恢复
服务端 ClientBeatCheckTask(每5s,仅责任节点)
  → 超15s:healthy=false + 推送
  → 超30s:deleteInstance + 推送

【发现】
selectInstances → HostReactor
  → failover文件 > serviceInfoMap内存 > updateServiceNow同步拉
  → GET /nacos/v1/ns/instance/list(携带客户端UDP端口,拉取即订阅)
  → UpdateTask 每10s兜底(间隔由服务端cacheMillis下发,出错1s重试)
PushReceiver(随机UDP端口,独立线程)
  → 收推送 → 比对变更 → 更新缓存 → InstancesChangeEvent → LoadBalancer
  → 回ACK;服务端10s未收到ACK重推1次,再丢靠轮询最终一致

【2.x】
gRPC长连接(9848):连接即心跳、长连接可靠推送,UDP与/beat接口退役

给团队的建议

项建议
下线发布必须走优雅停机(先 deregister → 等流量排空 → 关进程),灰度分批,别赌15s超时
调用容错列表更新有秒级窗口,Feign/网关配少量重试 + 接口幂等,兜底单次调用失败
多地址配置serverAddr 至少配两个节点,用逗号分隔;不要只挂单点 VIP
容器环境显式配置注册 IP,避免多网卡环境 clientIP 探测错误导致 UDP 推送失败
参数调优15s/30s 默认值别动;要更快下线靠主动注销,不靠缩短超时
应急手段提前知晓 FailoverReactor 本地文件位置,注册中心故障时可手工强制地址列表
版本升级2.x 放行 9848/9849 gRPC 端口;排查思路从"抓HTTP心跳包"转为"看连接状态"
监控指标监控注册实例数、心跳超时事件数、UDP推送失败率、Distro 同步延迟四个核心指标

一句话

Nacos 注册中心的源码主干可以浓缩成两条链:注册链上,spring-cloud 在 Web 端口就绪后调 NacosServiceRegistry 组装临时实例,nacos-client 先挂 5s 心跳任务再发 POST /instance,服务端 @CanDistro 入口接请求后走 ServiceManager——Service 第一次出现时 init 出 5s 一次的健康检查任务,实例列表写进 DataStore 并通过 Distro 异步复制(AP),再由 Notifier 回流到 Cluster 内存视图并触发推送;心跳链上,客户端 BeatTask 每 5s PUT 一次 /beat(收到 20403 会自愈重注册),服务端只刷 lastBeat,ClientBeatCheckTask 每 5s 扫描,超 15s 标不健康、超 30s 摘除。服务发现则是推拉结合:HostReactor 以"failover 文件 > 内存缓存 > 同步拉取"三级返回列表,UpdateTask 每 10s 兜底拉取且间隔由服务端 cacheMillis 下发,PushReceiver 收 UDP 推送做毫秒级更新,ACK 只重试一次、最终一致性永远由轮询兜底。记住"心跳维持存在、推拉同步视图"这两个心智模型,再叠加 2.x"长连接即心跳"的演进,注册中心里 90% 的实例不刷新、摘除慢、推送达不到问题都能自己推导出答案。

互动话题:你们线上用的是 Nacos 1.x 还是 2.x?有没有遇到过实例已经下线但消费端还在调用的情况,最后是靠优雅停机、重试还是调小心跳超时解决的?评论区聊聊你的摘除时效实测数据。


参考资料


标题:Nacos 注册中心源码拆解:服务注册 + 心跳 + 拉取全链路
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/28/1790516927277.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消