Nacos服务发现源码:NamingService、心跳机制、健康检查的完整链路
Nacos服务发现源码:NamingService、心跳机制、健康检查的完整链路
适读人群:想深入理解Nacos原理的后端工程师 | 阅读时长:约28分钟 | Nacos 2.3.x / Spring Cloud 2023.0
开篇故事
去年有个同事找我排查一个诡异的问题:服务明明还在运行,日志也在正常输出,但是Nacos控制台上显示这个实例已经下线了,其他服务调用它时也确实失败了。
我第一反应是网络问题,但测了一圈网络完全正常。然后看了一下服务启动参数,发现JVM堆设置得很小,GC日志里Full GC频率非常高,平均每30秒一次Full GC,每次STW时间超过15秒。
而Nacos客户端的心跳默认是每5秒发送一次,Nacos服务端默认15秒没收到心跳就认为实例不健康,30秒没收到就删除实例。Full GC期间应用完全停顿,心跳线程也停了,一次Full GC正好超过了Nacos的健康检查超时时间,所以服务被摘掉了。
这个问题要是不了解Nacos的心跳机制,根本不知道从哪里下手。那次之后我把Nacos的NamingService相关源码仔细读了一遍,今天把这套机制完整讲清楚。
一、核心问题分析
Nacos的服务发现机制涉及三个核心组件和两种实例类型,搞清楚这些是理解整个链路的基础。
两种实例类型:临时实例(Ephemeral)和永久实例(Persistent)。Spring Cloud默认注册的是临时实例,临时实例需要主动发送心跳来维持健康状态;永久实例不需要心跳,但也不会被Nacos自动删除。绝大多数Java微服务用的都是临时实例。
三个核心组件:
NamingService:客户端的核心接口,负责注册、注销、查询服务实例BeatReactor:心跳发送器,后台定时向Nacos服务端发送心跳HostReactor:本地实例缓存管理器,维护本地服务实例列表,支持Push和Pull两种更新方式
理解这三个组件的交互关系,才能真正搞清楚服务发现的完整链路,也才能在出问题时快速定位原因。
二、原理深度解析
2.1 服务注册完整流程
2.2 服务发现(Push+Pull双模式)
Nacos 2.x同时支持HTTP长轮询和gRPC Push两种方式来更新本地实例缓存。Nacos 1.x只有HTTP轮询,2.x引入了gRPC,Push延迟大幅降低:
2.3 心跳机制的详细时序
2.4 Nacos 1.x vs 2.x 架构对比
三、完整代码实现
3.1 项目依赖
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>2023.0.1.0</version>
</dependency>
<!-- Nacos客户端,用于直接操作NamingService -->
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
<version>2.3.2</version>
</dependency>
</dependencies>3.2 直接使用NamingService API
理解源码最好的方式是直接用API。下面展示NamingService的核心操作:
package com.laozhang.nacos.service;
import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.listener.Event;
import com.alibaba.nacos.api.naming.listener.EventListener;
import com.alibaba.nacos.api.naming.listener.NamingEvent;
import com.alibaba.nacos.api.naming.pojo.Instance;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.List;
import java.util.Properties;
@Slf4j
@Service
public class NacosNamingDemo {
private NamingService namingService;
@PostConstruct
public void init() throws NacosException {
Properties properties = new Properties();
properties.setProperty("serverAddr", "127.0.0.1:8848");
properties.setProperty("namespace", "dev");
// Nacos 2.x gRPC端口是HTTP端口+1000,即9848
// 客户端会自动计算,不需要手动配置
this.namingService = NacosFactory.createNamingService(properties);
}
/**
* 手动注册服务实例(演示注册逻辑)
* 实际项目中Spring Cloud自动注册,不需要手动调用
*/
public void registerInstance() throws NacosException {
Instance instance = new Instance();
instance.setIp("192.168.1.100");
instance.setPort(8080);
instance.setServiceName("order-service");
instance.setWeight(1.0);
// 临时实例,需要心跳
instance.setEphemeral(true);
// metadata:可以放任意KV,灰度路由的version标签就放这里
instance.getMetadata().put("version", "v1.0");
instance.getMetadata().put("zone", "shanghai");
namingService.registerInstance("order-service", "DEFAULT_GROUP", instance);
log.info("实例注册成功:{}", instance);
}
/**
* 查询所有健康实例
*/
public List<Instance> getHealthyInstances(String serviceName) throws NacosException {
// true表示只返回健康实例
return namingService.selectInstances(serviceName, true);
}
/**
* 根据权重选择一个实例(Nacos自带随机权重选择)
*/
public Instance selectOneInstance(String serviceName) throws NacosException {
return namingService.selectOneHealthyInstance(serviceName);
}
/**
* 订阅服务变更(实例上线/下线时回调)
* 这就是HostReactor的监听器机制
*/
public void subscribeService(String serviceName) throws NacosException {
namingService.subscribe(serviceName, new EventListener() {
@Override
public void onEvent(Event event) {
if (event instanceof NamingEvent) {
NamingEvent namingEvent = (NamingEvent) event;
List<Instance> instances = namingEvent.getInstances();
log.info("服务实例变更,serviceName={},当前实例数={}",
serviceName, instances.size());
// 这里可以触发本地缓存更新
instances.forEach(inst ->
log.info(" 实例:{}:{} healthy={} weight={}",
inst.getIp(), inst.getPort(),
inst.isHealthy(), inst.getWeight())
);
}
}
});
}
}3.3 自定义Nacos注册配置
package com.laozhang.nacos.config;
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.registry.NacosRegistration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* 自定义Nacos注册信息
* 在服务注册时往metadata里添加自定义信息
*/
@Component
public class CustomNacosRegistration implements ApplicationListener<WebServerInitializedEvent> {
private final NacosRegistration nacosRegistration;
@Value("${spring.application.version:v1.0}")
private String version;
@Value("${deploy.zone:default}")
private String zone;
public CustomNacosRegistration(NacosRegistration nacosRegistration) {
this.nacosRegistration = nacosRegistration;
}
@Override
public void onApplicationEvent(WebServerInitializedEvent event) {
// 在服务启动时动态添加metadata
Map<String, String> metadata = nacosRegistration.getMetadata();
metadata.put("version", version);
metadata.put("zone", zone);
metadata.put("startTime", String.valueOf(System.currentTimeMillis()));
// 添加Git信息,方便问题定位(可以从build.properties里读)
metadata.put("buildVersion", getBuildVersion());
}
private String getBuildVersion() {
try {
var props = new java.util.Properties();
props.load(getClass().getResourceAsStream("/build.properties"));
return props.getProperty("git.commit.id.abbrev", "unknown");
} catch (Exception e) {
return "unknown";
}
}
}3.4 自定义心跳参数与健康检查
package com.laozhang.nacos.config;
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class NacosHeartbeatConfig {
/**
* 自定义Nacos发现属性,包括心跳间隔等参数
*/
@Bean
public NacosDiscoveryProperties nacosDiscoveryProperties() {
NacosDiscoveryProperties properties = new NacosDiscoveryProperties();
// 心跳超时时间(毫秒):超过这个时间没收到心跳,实例被标记为不健康
// 默认15秒,如果你的应用有长时间GC,需要调大
properties.getMetadata().put("preserved.heart.beat.timeout", "30000");
// 心跳间隔(毫秒):默认5秒
properties.getMetadata().put("preserved.heart.beat.interval", "5000");
// 实例删除超时(毫秒):默认30秒
properties.getMetadata().put("preserved.ip.delete.timeout", "60000");
return properties;
}
}实际上,Nacos的心跳参数可以直接在application.yml里配置:
spring:
application:
name: order-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
namespace: dev
group: DEFAULT_GROUP
# 注册为临时实例(默认true)
ephemeral: true
# 实例权重,0-100,用于加权负载均衡
weight: 1.0
# 集群名称
cluster-name: SH
# 注册时携带的metadata
metadata:
version: v1.0
zone: shanghai
# 心跳相关参数(通过metadata传递给Nacos服务端)
# 心跳超时时间:15000ms
heart-beat-timeout: 15000
# 实例删除超时时间:30000ms
ip-delete-timeout: 300003.5 监听服务变更并更新本地缓存
在某些场景下,我们需要自己维护服务实例列表(比如做自定义负载均衡),就需要监听Nacos的服务变更事件:
package com.laozhang.nacos.service;
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.listener.NamingEvent;
import com.alibaba.nacos.api.naming.pojo.Instance;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* 本地服务实例缓存,配合Nacos监听器实现实时更新
*/
@Slf4j
@Service
public class LocalServiceInstanceCache {
private final ConcurrentHashMap<String, List<Instance>> cache = new ConcurrentHashMap<>();
private final NacosDiscoveryProperties discoveryProperties;
public LocalServiceInstanceCache(NacosDiscoveryProperties discoveryProperties) {
this.discoveryProperties = discoveryProperties;
}
@PostConstruct
public void init() throws NacosException {
NamingService namingService = discoveryProperties.namingServiceInstance();
// 需要监听的服务列表
List<String> services = List.of("order-service", "user-service", "payment-service");
for (String service : services) {
// 初始化时先加载一次
List<Instance> instances = namingService.selectInstances(service, true);
cache.put(service, instances);
log.info("初始化服务实例缓存,service={},实例数={}", service, instances.size());
// 订阅变更
namingService.subscribe(service, event -> {
if (event instanceof NamingEvent) {
NamingEvent namingEvent = (NamingEvent) event;
List<Instance> newInstances = namingEvent.getInstances()
.stream()
.filter(Instance::isHealthy)
.filter(Instance::isEnabled)
.toList();
cache.put(service, newInstances);
log.info("服务实例缓存更新,service={},新实例数={}", service, newInstances.size());
}
});
}
}
public List<Instance> getInstances(String serviceName) {
return cache.getOrDefault(serviceName, Collections.emptyList());
}
}3.6 Nacos健康检查Actuator端点
package com.laozhang.nacos.actuator;
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 自定义健康检查:把Nacos服务注册状态加入Actuator健康检查
*/
@Component("nacosDiscovery")
public class NacosHealthIndicator implements HealthIndicator {
private final NacosDiscoveryProperties discoveryProperties;
public NacosHealthIndicator(NacosDiscoveryProperties discoveryProperties) {
this.discoveryProperties = discoveryProperties;
}
@Override
public Health health() {
try {
NamingService namingService = discoveryProperties.namingServiceInstance();
String serverStatus = namingService.getServerStatus();
if ("UP".equalsIgnoreCase(serverStatus)) {
// 检查关键依赖服务是否有可用实例
List<String> criticalServices = List.of("user-service", "payment-service");
for (String service : criticalServices) {
List<Instance> instances = namingService.selectInstances(service, true);
if (instances.isEmpty()) {
return Health.down()
.withDetail("reason", "关键服务无可用实例:" + service)
.build();
}
}
return Health.up()
.withDetail("nacosServer", serverStatus)
.build();
} else {
return Health.down()
.withDetail("nacosServer", serverStatus)
.build();
}
} catch (NacosException e) {
return Health.down()
.withDetail("error", e.getMessage())
.build();
}
}
}四、生产配置与调优
4.1 Nacos客户端完整配置
spring:
cloud:
nacos:
discovery:
server-addr: nacos-cluster:8848
namespace: ${NACOS_NAMESPACE:prod}
username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD}
# 注册时使用指定IP(解决多网卡时IP选择错误的问题)
ip: ${POD_IP:} # K8s里用downward API获取Pod IP
# 服务分组,按业务域划分
group: PAYMENT_GROUP
# 失败重试次数
# failure-tolerance-count: 3
# 本地缓存路径(断网时从本地文件恢复)
naming-load-cache-at-start: true
# Nacos客户端日志,排查注册/心跳问题时有用
logging:
level:
com.alibaba.nacos: INFO
com.alibaba.cloud.nacos: DEBUG4.2 Nacos集群部署注意事项
生产环境Nacos必须集群部署,推荐3节点或5节点:
# Nacos服务端 cluster.conf
# 每行一个节点,格式:IP:PORT
192.168.1.10:8848
192.168.1.11:8848
192.168.1.12:8848客户端连接集群时,地址列表写逗号分隔:
spring:
cloud:
nacos:
discovery:
server-addr: 192.168.1.10:8848,192.168.1.11:8848,192.168.1.12:8848五、踩坑实录
坑一:Full GC导致心跳停止,服务被Nacos摘除。
这就是开篇故事里的情况。根本原因是JVM堆太小,Full GC时间超过了Nacos的心跳超时时间(15秒)。
解决方案有两个:一是优化JVM参数,减少Full GC频率和时长;二是调大Nacos的心跳超时时间。对于无法立即优化JVM的情况,临时调大超时是快速解决的方法:
spring:
cloud:
nacos:
discovery:
metadata:
preserved.heart.beat.timeout: "30000" # 30秒
preserved.ip.delete.timeout: "60000" # 60秒但这个只是治标,治本还是要解决GC问题。
坑二:多网卡环境下注册了错误的IP。
我们的服务器有两块网卡,一块是内网IP(10.x.x.x),一块是管理网卡(192.168.x.x)。Nacos客户端默认取第一个非回环地址,结果注册了管理网卡的IP,其他服务调用时连不上。
解决方案是在配置里明确指定IP:
spring:
cloud:
nacos:
discovery:
ip: 10.100.1.50 # 明确指定要注册的IP在K8s环境里,通过downward API获取Pod IP是标准做法:
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP坑三:Nacos命名空间配置错误,服务互相看不见。
测试环境和生产环境用的是同一个Nacos集群,通过命名空间隔离。有一次测试环境的同学配错了namespace ID(填了命名空间名称而不是命名空间ID),结果服务注册到了默认命名空间,而调用方从测试命名空间里找不到服务,一直报NoInstance错误。
Nacos的namespace参数必须填UUID格式的命名空间ID,不是命名空间名称。在Nacos控制台创建命名空间后,会生成一个UUID,必须用这个UUID。
坑四:服务下线时Nacos没有及时摘除实例。
Kill -9强制杀进程时,服务没有机会执行优雅关闭逻辑,Nacos客户端也没有机会发送注销请求。这种情况下,Nacos要等到心跳超时(30秒)才会删除实例,这段时间内其他服务可能还会路由到已经死掉的实例上。
正确的做法是使用优雅关机(Kill -15 + Spring的graceful shutdown),让应用有机会先调用namingService.deregisterInstance注销自己,然后再退出:
// 在应用关闭时主动注销
@PreDestroy
public void deregister() throws NacosException {
namingService.deregisterInstance(serviceName, ip, port);
log.info("已从Nacos注销实例");
}Spring Cloud的自动注册已经实现了这个逻辑,关键是要允许应用优雅关闭,不要直接kill -9。
坑五:本地开发时没有关闭Nacos注册,污染测试环境服务列表。
本地开发时,如果连的是测试环境的Nacos,每次启动都会把本地实例注册上去,测试环境调用时可能路由到你的本地机器,导致其他人测试异常。
解决方案是本地开发时关掉自动注册:
spring:
cloud:
nacos:
discovery:
register-enabled: false # 本地开发时关闭注册或者在本地profile里覆盖,连接专用的本地Nacos。
六、总结
Nacos服务发现的核心机制是:客户端注册临时实例后,由BeatReactor定时发送心跳维持健康状态,HostReactor通过订阅+Pull双模式维护本地实例缓存,服务端通过心跳超时检测来自动摘除不健康实例。
理解这个机制,就能解释很多诡异的问题:GC停顿导致被摘除、多网卡注册错误IP、命名空间配错导致找不到服务。Nacos 2.x相比1.x引入gRPC长连接,服务变更的感知速度大幅提升,推荐升级。
