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 推送追求快,定时拉取保证最终一致。
核心组件清单
| 层 | 类 | 职责 |
|---|---|---|
| 适配层 | NacosServiceRegistry | spring-cloud 的 ServiceRegistry 实现,桥接 Registration 与 NamingService |
| 适配层 | NacosAutoServiceRegistration | 监听 WebServerInitializedEvent,触发自动注册 |
| 客户端 | NacosNamingService | 门面,对外提供 register/subscribe/selectInstances |
| 客户端 | NamingProxy | 封装 HTTP 调用(register / beat / list / deregister) |
| 客户端 | BeatReactor | 临时实例的 5s 心跳调度器 |
| 客户端 | HostReactor | 服务列表本地缓存 + 10s 定时拉取 + 订阅管理 |
| 客户端 | PushReceiver | UDP 服务,接收服务端的变更推送 |
| 服务端 | InstanceController | /instance 系列 REST 入口,带 @CanDistro |
| 服务端 | ServiceManager | 注册表管理:serviceMap、建服务、加实例 |
| 服务端 | DistroConsistencyService | 临时实例的 AP 一致性协议(本机写 + 异步同步) |
| 服务端 | DataStore | Datum 存储,Distro 同步的数据单元 |
| 服务端 | ClientBeatCheckTask | 15s/30s 超时扫描 |
| 服务端 | ClientBeatProcessor | 处理一次心跳上报 |
| 服务端 | PushService | UDP 变更推送 + 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); // ② 发注册请求
}
两个细节值得注意:
- 心跳任务在注册请求之前就挂好了。哪怕第一次注册因为网络抖动失败,心跳任务照样在跑,而心跳收到"服务不存在"的响应码后会触发自动重新注册(第五章细讲),这是一个自愈设计。
- 服务在 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 覆盖):
| 参数 | 默认值 | 含义 |
|---|---|---|
instanceHeartBeatInterval | 5000ms | 客户端心跳间隔 |
instanceHeartBeatTimeOut | 15000ms | 服务端判不健康阈值 |
ipDeleteTimeout | 30000ms | 服务端摘除实例阈值 |
| 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);
}
}
}
几个设计点:
- 拉取间隔 10s 不是客户端写死的,而是服务端在 list 响应里返回的
cacheMillis,服务端可以统一调整推送/拉取节奏。 - 拉取出错时
delayTime退回 1s 快速重试,不会因为一次网络抖动让地址列表停更 10 秒。 - 定时任务按"服务+集群"维度存在 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 官方文档(服务注册与发现)
- Nacos 源码(GitHub - nacos-group/nacos)
- Spring Cloud Alibaba Nacos Discovery 官方文档
- Nacos 架构(Distro 协议说明)
- Nacos 2.0 升级文档(gRPC 端口与兼容性说明)
标题:Nacos 注册中心源码拆解:服务注册 + 心跳 + 拉取全链路
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/28/1790516927277.html
公众号:服务端技术精选
- 引言
- 一、全景:一张时序图看懂全链路
- 核心组件清单
- 二、客户端启动:注册动作是被谁触发的
- 2.1 不是容器启动就注册,而是 Web 端口就绪后
- 2.2 注册前还会等配置和 NamingService 初始化
- 三、NacosServiceRegistry:组装 Instance 并发起 HTTP 注册
- 3.1 register() 主干
- 3.2 NacosNamingService:先挂心跳,再发注册
- 3.3 NamingProxy:注册请求长什么样
- 四、服务端:ServiceManager 怎么存注册表
- 4.1 InstanceController:带 @CanDistro 的写入口
- 4.2 ServiceManager:两级 ConcurrentHashMap 注册表
- 4.3 addInstance:写一致性协议,而不是直接改 Set
- 五、心跳机制:5s、15s、30s 三个数字的源码出处
- 5.1 客户端:BeatReactor 的 BeatTask
- 5.2 服务端收心跳:只刷新 lastBeat
- 5.3 服务端扫描:ClientBeatCheckTask 的 15s 和 30s
- 5.4 把时间账算清楚
- 六、服务发现:HostReactor 的本地缓存与定时拉取
- 6.1 查询永远先走本地缓存
- 6.2 UpdateTask:动态间隔的兜底拉取
- 6.3 list 请求顺便完成"订阅登记"
- 七、订阅与推送:UDP 推送 + 拉取兜底的推拉结合
- 7.1 为什么推送用 UDP 而不是长连接
- 7.2 客户端 PushReceiver:随机端口 + 独立线程
- 7.3 服务端 PushService:ACK 超时只重试一次
- 7.4 一次完整摘除在消费端的生效路径
- 八、Nacos 2.x 的演进:心跳和 UDP 都被长连接取代了
- 九、常见问题
- 9.1 服务启动日志显示 register finished,控制台却看不到实例?
- 9.2 15 秒判不健康能不能调小?能不能摘除更快?
- 9.3 UDP 推送收不到/经常延迟到 10 秒才更新,怎么排查?
- 9.4 Nacos 集群某个节点挂了,注册和心跳会断吗?
- 9.5 同一个服务大量实例同时重启,会不会把 Nacos 打挂?
- 9.6 临时实例和持久实例能混用吗?什么时候必须用持久实例?
- 十、总结
- 全链路速查卡
- 给团队的建议
- 一句话
- 参考资料
评论