在这里插入图片描述



昨天上午生产环境突然告警:OpenFeign 大面积调用失败,LoadBalancer 持续抛出 No servers available for service,大量业务链路找不到服务。

第一时间登录 Nacos 控制台查看日志,发现三条关键日志正在以惊人的速度刷屏——[AUTO-DELETE-IP] 表明有 IP 被自动剔除,client remove for service 意味着客户端实例被移除,client change for service 则说明服务实例列表发生了变更。三者在几十秒内密集涌现,指向同一个事实:Nacos 注册中心正在大量摘除服务实例,导致 OpenFeign 拉取到的实例列表为空,调用链全面断裂。

但问题排查不能只靠猜。Client 是如何连接到 Nacos 集群的?连接的是哪一台节点?这台节点如何成为 Responsible 节点?数据又是如何同步到其他节点的?[AUTO-DELETE-IP]client remove for serviceclient change for service 这三条日志背后分别对应什么触发条件?只有把这些底层机制真正吃透,才能在海量日志中快速定位根因。

本文基于 Nacos 2.4.3 源码,先梳理 Client 连接架构和 Responsible 节点数据同步机制,再沿着三条关键日志逐一分析心跳检测、实例摘除、服务推送的完整链路,最终给出系统的排查思路。

一、Client 与 Nacos 集群的连接架构

1.1 Client 单节点连接

Nacos 2.x 客户端使用 gRPC 长连接与 Nacos 服务端通信。RpcClient 是整个连接管理的核心,它维护一个 volatilecurrentConnection 字段,在任何时刻只与一台 Nacos 节点建立 gRPC 连接

com.alibaba.nacos.common.remote.client.RpcClient

// 任何时候只维护一条 gRPC 连接
protected volatile Connection currentConnection;

这个设计意味着:每个 Nacos Client 在任意时刻,其所有服务注册、心跳、配置订阅等操作,都通过同一条 gRPC 连接发往同一台 Nacos 节点。

连接的目标节点由 ServerListFactory 决定。RpcClient 通过 nextRpcServer() 调用 serverListFactory.genNextServer() 获取下一个服务器地址,默认采用轮询策略从服务器列表中选取:

com.alibaba.nacos.common.remote.client.RpcClient#nextRpcServer

// 从服务器列表中选择下一台节点
protected ServerInfo nextRpcServer() {
    String serverAddress = getServerListFactory().genNextServer();
    return resolveServerInfo(serverAddress);
}
com.alibaba.nacos.common.remote.client.RpcClient#currentRpcServer

// 获取当前连接的服务器信息
protected ServerInfo currentRpcServer() {
    String serverAddress = getServerListFactory().getCurrentServer();
    return resolveServerInfo(serverAddress);
}

在这里插入图片描述

1.2 故障转移与重连

当连接断开或健康检查失败时,RpcClientreconnect 逻辑会触发故障转移。重连线程通过 reconnectionSignal 阻塞队列等待重连信号,当健康检查失败时,状态切换为 UNHEALTHY 并创建 ReconnectContext(null, false) 触发重连——serverInfonull 表示使用轮询策略选择下一台节点:

com.alibaba.nacos.common.remote.client.RpcClient

// 重连线程核心逻辑
clientEventExecutor.submit(() -> {
    while (true) {
        ReconnectContext reconnectContext = reconnectionSignal
                .poll(rpcClientConfig.connectionKeepAlive(), TimeUnit.MILLISECONDS);
        if (reconnectContext == null) {
            // 定时健康检查:检查连接存活时间是否超过阈值
            if (System.currentTimeMillis() - lastActiveTimeStamp >= rpcClientConfig.connectionKeepAlive()) {
                boolean isHealthy = healthCheck();
                if (!isHealthy) {
                    // 状态切换为 UNHEALTHY,触发重连
                    boolean statusFlowSuccess = RpcClient.this.rpcClientStatus
                            .compareAndSet(rpcClientStatus, RpcClientStatus.UNHEALTHY);
                    if (statusFlowSuccess) {
                        // serverInfo 为 null,表示使用轮询策略选择下一台节点
                        reconnectContext = new ReconnectContext(null, false);
                    }
                }
            }
        }
        // 执行重连
        reconnect(reconnectContext.serverInfo, reconnectContext.onRequestFail);
    }
});

关键结论Client 与 Nacos 是单节点连接,节点宕机后自动轮询切换到下一台。这意味着 Client 的所有请求(包括心跳)都只发往当前连接的那一台节点。如果这台节点出问题,心跳就会丢失——这是理解后续故障链路的前提。

二、Responsible 节点的选举与数据同步

2.1 Responsible 节点是什么

在 Nacos 集群中,每个 Client 数据由一台特定的节点负责管理,这台节点被称为 Responsible 节点。所谓"负责",核心体现在三个方面:

  1. 心跳检测:只有 Responsible 节点才会检查该 Client 的心跳是否超时,并触发 [AUTO-DELETE-IP] 自动删除

  2. 数据同步:只有 Responsible 节点才会将 Client 的变更数据通过 Distro 协议同步到其他节点

  3. 对账校验Distro 定时对账时,只对比 Responsible 节点和其他节点的数据差异

    对于 Nacos 2.x 最常见的 gRPC 长连接场景,Client 的类型是 ConnectionBasedClient。它有一个 isNative 标志,直接决定了该 Client 是否属于当前节点:

com.alibaba.nacos.naming.core.v2.client.impl.ConnectionBasedClient

// true:此 Client 直接连接到当前 Nacos 节点(原生 Client)
// false:此 Client 是从其他节点同步过来的(镜像 Client)
private final boolean isNative;

2.2 连接即选举:ConnectionBasedClient 的 Responsible 判定

对于 ConnectionBasedClient(基于 gRPC 长连接的 Client),Responsible 节点的判定逻辑非常简单直接——谁创建了 isNative=trueClient,谁就是 Responsible 节点

当一个 Client 连接建立时,ConnectionBasedClientFactory 创建 isNative=trueClient 对象:

com.alibaba.nacos.naming.core.v2.client.factory.impl.ConnectionBasedClientFactory#newClient

// 创建原生 Client(isNative=true),表示当前节点是 Responsible 节点
public ConnectionBasedClient newClient(String clientId, ClientAttributes attributes) {
    long revision = attributes.getClientAttribute(REVISION, 0);
    ConnectionBasedClient connectionBasedClient = new ConnectionBasedClient(clientId, true, revision);
    connectionBasedClient.setAttributes(attributes);
    return connectionBasedClient;
}

当其他节点通过 Distro 协议同步收到这个 Client 的数据时,调用 newSyncedClient 创建 isNative=false 的镜像 Client

com.alibaba.nacos.naming.core.v2.client.factory.impl.ConnectionBasedClientFactory#newSyncedClient

// 创建同步 Client(isNative=false),只是镜像,不负责心跳检测和数据同步
public ConnectionBasedClient newSyncedClient(String clientId, ClientAttributes attributes) {
    long revision = attributes.getClientAttribute(REVISION, 0);
    ConnectionBasedClient connectionBasedClient = new ConnectionBasedClient(clientId, false, revision);
    connectionBasedClient.setAttributes(attributes);
    return connectionBasedClient;
}

ConnectionBasedClientManagerisResponsibleClient 方法直接检查 isNative

com.alibaba.nacos.naming.core.v2.client.manager.impl.ConnectionBasedClientManager#isResponsibleClient

// 判断是否为 Responsible Client:直接检查 isNative 标志
public boolean isResponsibleClient(Client client) {
    return (client instanceof ConnectionBasedClient) && ((ConnectionBasedClient) client).isNative();
}

核心结论:对于 gRPC 长连接场景,不存在"选举"过程。Client 连上哪台节点,那台节点就是它的 Responsible 节点。连接本身就是归属。

2.3 DistroMapper 哈希选举:补充路径

对于 IpPortBasedClient(基于 HTTP 心跳的临时实例 Client),Responsible 节点通过 DistroMapper 中的一致性哈希算法计算。DistroMapperClientresponsibleTag(即 ip:port)做哈希取模:

com.alibaba.nacos.naming.core.DistroMapper#responsible

// 一致性哈希判定:当前节点是否负责此 tag
public boolean responsible(String responsibleTag) {
    final List<String> servers = healthyList;
    if (!switchDomain.isDistroEnabled() || EnvUtil.getStandaloneMode()) {
        return true;
    }
    if (CollectionUtils.isEmpty(servers)) {
        return false;
    }
    String localAddress = EnvUtil.getLocalAddress();
    int index = servers.indexOf(localAddress);
    int lastIndex = servers.lastIndexOf(localAddress);
    if (lastIndex < 0 || index < 0) {
        return true;
    }
    int target = distroHash(responsibleTag) % servers.size();
    return target >= index && target <= lastIndex;
}
com.alibaba.nacos.naming.core.DistroMapper#distroHash

// 哈希计算:取 hash 的绝对值
private int distroHash(String responsibleTag) {
    return Math.abs(responsibleTag.hashCode() % Integer.MAX_VALUE);
}

以三节点集群为例,Client 192.168.2.100:8080 的哈希值为 targettarget % 3 = 1,则节点列表中的第 2 台即为 Responsible 节点。

2.4 Responsible 节点如何同步数据到其他节点

Client 的服务实例发生变更时,DistroClientDataProcessor 会收到 ClientChangedEvent 事件,然后通过 Distro 协议将数据同步到集群中所有其他节点。

com.alibaba.nacos.naming.consistency.ephemeral.distro.v2.DistroClientDataProcessor#onEvent

// 监听 Client 变更事件,触发 Distro 同步
public void onEvent(Event event) {
    if (EnvUtil.getStandaloneMode()) {
        return;
    }
    if (event instanceof ClientEvent.ClientVerifyFailedEvent) {
        syncToVerifyFailedServer((ClientEvent.ClientVerifyFailedEvent) event);
    } else {
        syncToAllServer((ClientEvent) event);
    }
}
com.alibaba.nacos.naming.consistency.ephemeral.distro.v2.DistroClientDataProcessor#syncToAllServer

// 同步到所有其他节点
private void syncToAllServer(ClientEvent event) {
    Client client = event.getClient();
    // 只同步 Responsible Client 的临时数据
    if (isInvalidClient(client)) {
        return;
    }
    if (event instanceof ClientEvent.ClientDisconnectEvent) {
        // Client 断开连接 → 同步 DELETE 操作
        DistroKey distroKey = new DistroKey(client.getClientId(), TYPE);
        distroProtocol.sync(distroKey, DataOperation.DELETE);
    } else if (event instanceof ClientEvent.ClientChangedEvent) {
        // Client 数据变更 → 同步 CHANGE 操作
        DistroKey distroKey = new DistroKey(client.getClientId(), TYPE);
        distroProtocol.sync(distroKey, DataOperation.CHANGE);
    }
}
com.alibaba.nacos.naming.consistency.ephemeral.distro.v2.DistroClientDataProcessor#isInvalidClient

// 判断是否为无效 Client(非 Responsible / 非临时 / 空)
private boolean isInvalidClient(Client client) {
    return null == client || !client.isEphemeral() || !clientManager.isResponsibleClient(client);
}

isInvalidClient 中的 !clientManager.isResponsibleClient(client) 确保只有 Responsible 节点才会发起 Distro 同步,镜像节点不会重复同步。

其他节点收到同步数据后,在 processData 中处理:CHANGE 操作创建或更新镜像 ClientDELETE 操作删除镜像 Client。同时,DistroVerifyTimedTask 会定时对所有 Responsible 数据进行对账,确保数据最终一致。

在这里插入图片描述

三、问题现象:三条关键日志的含义

3.1 三条日志的定位

生产环境中,如果同时出现以下三类日志,基本可以定位到"心跳丢失导致实例被自动摘除"的方向:

  1. [AUTO-DELETE-IP]:实例心跳超时,被 Nacos 服务端自动删除。只有 Responsible 节点才会打印这条日志,因为心跳检测和删除操作只在 Responsible 节点上执行。
  2. client remove for service:实例从 Client 的发布列表中移除,可能是心跳超时触发,也可能是 Distro 同步删除或 Client 主动注销。
  3. 大量 client change for service:实例恢复后重新注册,或健康状态反复切换。如果这三条日志交替出现,说明实例处于"被摘除 → 重新注册 → 再被摘除"的抖动状态

3.2 与 OpenFeign 调用失败的关联

OpenFeign 的服务发现依赖 Nacos 客户端的本地服务列表缓存。当实例被 Responsible 节点自动摘除后:

  • Responsible 节点发布 ClientChangedEvent,触发 Distro 同步到其他节点

  • Nacos 服务端感知到实例数量变化,向所有订阅该服务的消费者推送更新

  • 消费者(OpenFeign 调用方)的本地服务列表被刷新,可用实例减少甚至变为 0

  • 后续的 OpenFeign 调用因找不到可用实例而失败

    关键点:如果 Responsible 节点本身负载过高或网络异常,心跳检测可能误判超时,而 Distro 同步也可能延迟甚至失败,导致不同节点的数据不一致,进一步加剧问题。

四、心跳检测与 AUTO-DELETE-IP 的源码链路

4.1 ExpiredInstanceChecker 的定时检查

[AUTO-DELETE-IP] 日志的唯一来源是 ExpiredInstanceChecker。它是一个心跳检查器,定期遍历所有临时实例,判断心跳是否超时。

com.alibaba.nacos.naming.healthcheck.heartbeat.ExpiredInstanceChecker#doCheck

// 检查实例心跳是否过期,过期则删除
public void doCheck(Client client, Service service, HealthCheckInstancePublishInfo instance) {
    boolean expireInstance = ApplicationUtils.getBean(GlobalConfig.class).isExpireInstance();
    if (expireInstance && isExpireInstance(service, instance)) {
        deleteIp(client, service, instance);
    }
}
com.alibaba.nacos.naming.healthcheck.heartbeat.ExpiredInstanceChecker#isExpireInstance

// 判断心跳是否过期:当前时间 - 最后心跳时间 > 删除超时阈值
private boolean isExpireInstance(Service service, HealthCheckInstancePublishInfo instance) {
    long deleteTimeout = getTimeout(service, instance);
    return System.currentTimeMillis() - instance.getLastHeartBeatTime() > deleteTimeout;
}

心跳超时时间的获取有三级优先级(从高到低):

  1. 实例元数据中的 ipDeleteTimeout
  2. 实例 extendDatum 中的 IP_DELETE_TIMEOUT
  3. 默认值 Constants.DEFAULT_IP_DELETE_TIMEOUT(30 秒)
com.alibaba.nacos.naming.healthcheck.heartbeat.ExpiredInstanceChecker#getTimeout

// 三级优先级获取删除超时时间
private long getTimeout(Service service, InstancePublishInfo instance) {
    Optional<Object> timeout = getTimeoutFromMetadata(service, instance);
    if (!timeout.isPresent()) {
        timeout = Optional.ofNullable(instance.getExtendDatum().get(PreservedMetadataKeys.IP_DELETE_TIMEOUT));
    }
    return timeout.map(ConvertUtils::toLong).orElse(Constants.DEFAULT_IP_DELETE_TIMEOUT);
}

4.2 deleteIp 的完整副作用

心跳超时后,deleteIp 方法不仅打印日志,还会触发一系列事件:

com.alibaba.nacos.naming.healthcheck.heartbeat.ExpiredInstanceChecker#deleteIp

// 删除过期实例,触发多级事件
private void deleteIp(Client client, Service service, InstancePublishInfo instance) {
    Loggers.SRV_LOG.info("[AUTO-DELETE-IP] service: {}, ip: {}", service.toString(), JacksonUtils.toJson(instance));
    // 1. 从 Client 的发布列表中移除实例(触发 client remove for service 日志)
    client.removeServiceInstance(service);
    // 2. 发布服务注销事件(触发索引更新、推送等)
    NotifyCenter.publishEvent(new ClientOperationEvent.ClientDeregisterServiceEvent(service, client.getClientId()));
    // 3. 发布元数据删除事件
    NotifyCenter.publishEvent(new MetadataEvent.InstanceMetadataEvent(service, instance.getMetadataId(), true));
    // 4. 发布链路追踪事件
    NotifyCenter.publishEvent(new DeregisterInstanceTraceEvent(System.currentTimeMillis(), "",
            false, DeregisterInstanceReason.HEARTBEAT_EXPIRE, service.getNamespace(), service.getGroup(),
            service.getName(), instance.getIp(), instance.getPort()));
}

removeServiceInstance 内部会发布 ClientChangedEvent,进而触发 DistroClientDataProcessor 将变更同步到其他节点。这就是 Responsible 节点删除实例后,其他节点也能感知到的原因。

五、client remove 与 client change 的源码来源

5.1 client remove for service 的触发点

client remove for service 日志来自 AbstractClient#removeServiceInstance

com.alibaba.nacos.naming.core.v2.client.AbstractClient#removeServiceInstance

// 移除服务实例
public InstancePublishInfo removeServiceInstance(Service service) {
    InstancePublishInfo result = publishers.remove(service);
    if (null != result) {
        if (result instanceof BatchInstancePublishInfo) {
            MetricsMonitor.decrementIpCountWithBatchRegister(result);
        } else {
            MetricsMonitor.decrementInstanceCount();
        }
        // 只有实例真的存在并被移除时,才发布变更事件
        NotifyCenter.publishEvent(new ClientEvent.ClientChangedEvent(this));
    }
    // 注意:无论实例是否存在,都会打印这条日志
    Loggers.SRV_LOG.info("Client remove for service {}, {}", service, getClientId());
    return result;
}

触发 client remove for service 的场景:

  1. 心跳超时自动删除ExpiredInstanceChecker#deleteIp 调用(最常见)
  2. Client 主动注销:客户端调用 deregister 接口
  3. Distro 同步删除:其他节点同步过来的 DELETE 操作
  4. Client 断开连接gRPC 连接断开,Client 整体清理
  5. Distro 数据升级清理upgradeClient 中清理同步数据中不存在的实例

5.2 client change for service 的触发点

client change for service 日志来自 AbstractClient#addServiceInstance

com.alibaba.nacos.naming.core.v2.client.AbstractClient#addServiceInstance

// 添加或更新服务实例
public boolean addServiceInstance(Service service, InstancePublishInfo instancePublishInfo) {
    if (instancePublishInfo instanceof BatchInstancePublishInfo) {
        InstancePublishInfo old = publishers.put(service, instancePublishInfo);
        // ...
    } else {
        if (null == publishers.put(service, instancePublishInfo)) {
            MetricsMonitor.incrementInstanceCount();
        }
    }
    // 发布 Client 变更事件,触发 Distro 同步和服务推送
    NotifyCenter.publishEvent(new ClientEvent.ClientChangedEvent(this));
    Loggers.SRV_LOG.info("Client change for service {}, {}", service, getClientId());
    return true;
}

触发 client change for service 的场景:

  1. Client 首次注册:服务启动后首次注册实例
  2. 心跳恢复重新注册:心跳超时被删后,心跳恢复重新注册
  3. 实例元数据更新:权重、健康状态等变化
  4. Distro 同步数据更新:其他节点同步过来的 CHANGE 操作
  5. 健康状态从不健康恢复为健康:心跳恢复后健康状态翻转

5.3 健康状态翻转的额外触发

除了 addServiceInstanceremoveServiceInstance,心跳恢复时的健康状态翻转也会发布 ClientChangedEvent,但不会打印 client change for service 日志:

com.alibaba.nacos.naming.healthcheck.heartbeat.ClientBeatProcessorV2#run

// 心跳处理:刷新心跳时间,必要时翻转健康状态
public void run() {
    // ...
    HealthCheckInstancePublishInfo instance = (HealthCheckInstancePublishInfo) client.getInstancePublishInfo(service);
    if (instance.getIp().equals(ip) && instance.getPort() == port) {
        // 刷新最后心跳时间
        instance.setLastHeartBeatTime(System.currentTimeMillis());
        // 健康状态翻转:从不健康 -> 健康
        if (!instance.isHealthy()) {
            instance.setHealthy(true);
            // 发布服务变更事件,触发推送
            NotifyCenter.publishEvent(new ServiceEvent.ServiceChangedEvent(service));
            NotifyCenter.publishEvent(new ClientEvent.ClientChangedEvent(client));
        }
    }
}

六、服务发现失败的完整推理链路

6.1 从心跳丢失到 OpenFeign 失败

将三条日志和 OpenFeign 调用失败串起来,结合前面的架构知识,完整的故障链路如下:

在这里插入图片描述

6.2 为什么会有大量 client change for service

如果同时出现 [AUTO-DELETE-IP] 和大量 client change for service,说明实例处于"被删 → 恢复 → 再被删"的循环抖动状态。只有 Responsible 节点才会发起这种循环——因为只有 Responsible 节点在执行心跳检测和自动删除。

这种抖动的典型模式:

  • 心跳丢失超过 30 秒 → Responsible 节点删除实例([AUTO-DELETE-IP] + client remove
  • 心跳恢复后重新注册 → 实例重新加入(client change),然后 Distro 同步到其他节点
  • 如果心跳不稳定,这个循环会反复发生

七、根因推理与排查方向

7.1 最可能的根因排序

排名根因特征排查方法
1服务提供者 Full GC 导致心跳线程暂停某台实例周期性出现问题,GC 日志中有长时间 STW查看服务提供者 GC 日志
2Nacos 服务端 Responsible 节点负载过高所有实例集中在某台 Nacos 节点上批量超时看 Nacos 节点 CPU/内存/线程监控
3网络抖动或防火墙策略变更多台实例同时出现,且有网络层面的变更检查网络监控、防火墙策略
4客户端心跳线程池耗尽某台应用实例心跳发不出去,但业务正常jstack 看客户端线程状态
5Nacos 集群节点间网络分区Distro 同步异常,不同节点数据不一致看 Nacos 节点间通信日志

7.2 快速定位三步法

第一步:确定 Responsible 节点

根据 [AUTO-DELETE-IP] 日志所在的 Nacos 节点,这台节点就是该实例的 Responsible 节点。理解这一点后,排查范围就缩小到:Client → Responsible 节点之间的网络和心跳链路。

第二步:看时间分布

grep "\[AUTO-DELETE-IP\]" nacos-naming.log | awk '{print $1, $2}' | head -20
  • 如果集中在某几分钟爆发 → Nacos 服务端问题或网络问题
  • 如果分散在不同时间,且每台实例只有一两次 → 客户端问题

第三步:对比服务端和客户端时间线

[AUTO-DELETE-IP] 出现的时间点附近:

  • 服务提供者的 GC 日志有没有 STW
  • 服务提供者的应用日志有没有异常
  • 网络监控有没有丢包或延迟尖峰

7.3 临时缓解措施

如果问题正在发生,需要快速止损:

  1. 调大心跳超时阈值:临时增大 ipDeleteTimeout(默认 30s),给心跳恢复更多时间
  2. 扩容 Nacos 节点:如果是 Responsible 节点负载过高,先横向扩容;注意扩容后 Client 可能会重新连接,Responsible 节点也会重新分配
  3. 检查服务提供者 JVM:如果是 GC 问题,先调大堆内存或优化 GC
  4. 降级保护:消费者侧配置本地缓存兜底,即使推送失败也不立即清空

全文小结

本文聚焦 Nacos 2.4.3 生产环境中的服务发现失败问题,从 Client 连接架构和 Responsible 节点数据同步机制出发,沿着三条关键日志的完整链路,深入分析了故障发生的核心机制与排查方法。

在连接架构方面,Nacos Client 通过 RpcClient 维护单条 gRPC 连接,连接所在节点即为 Responsible 节点,通过 isNative 标志区分原生 Client 和镜像 Client。在数据同步方面,Responsible 节点通过 DistroClientDataProcessor 监听 ClientChangedEvent,将变更通过 Distro 协议同步到其他节点,确保集群数据最终一致。在故障链路方面,心跳丢失导致 Responsible 节点通过 ExpiredInstanceChecker 自动删除实例,触发 client remove for service 日志和 Distro 同步,消费者收到推送后本地缓存刷新,最终导致 OpenFeign 调用失败;心跳恢复后实例重新注册,形成抖动循环。

掌握了这条完整链路后,遇到类似问题时,就可以从 Responsible 节点定位、时间分布、客户端状态三个维度快速锁定根因,而不是在海量日志中盲目摸索。



原创不易,如果本文对您有帮助,带来了些许灵感或启发,烦请动动小手点赞、关注、转发、收藏。这是作者持续更新的动力源泉,衷心感谢您的支持。我会尽量在工作之余,为大家带来更高质量的内容,努力保持周更。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐