☰
Nacos 1.4.x 服务注册源码拆解:从实例注册到心跳续约全链路
2026/10/22 4:08:39 网站建设 项目流程

服务注册成功了,但消费端就是一直调不到这个实例。这种问题我这些年排查过不少,每次都得把 Nacos 1.4.x 服务注册源码从头到尾捋一遍,才能定位到到底是注册没进内存、心跳断了、还是推送环节出了岔子。网上讲 Nacos 源码的文章不少,但大多只画架构图、贴关键方法名,很少告诉你"这一步为什么这样设计""这个坑我当时绕了多久"。这篇我把 Nacos 1.4.x 的服务注册链路完整拆开:客户端从 Spring 容器启动到构造注册请求,服务端从 HTTP 入口到内存中的 Service 数据结构,再到临时实例的心跳续约和消费者感知,一条线走到底。适合对 Nacos 有基本使用经验、想开始读源码或正在排查注册相关问题的后端工程师。

1. 先弄清楚 1.4.x 里"服务注册"到底在解决什么问题

1.1 注册中心表面上存的是实例,实际维护的是三件事

很多人一提到服务注册,脑子里就浮现一张表格:服务名对应一堆 IP 和端口。这个理解没有错,但如果只看这张表格,源码就完全读不进去。因为 Nacos 这类注册中心要处理的其实是三件互相纠缠的事。

第一件事是数据模型。一个服务名下挂了哪些集群、每个集群里有哪几台机器、每台机器叫什么、权重多少、健康状态如何。服务端必须有一个统一的数据组织方式,把客户端上报的碎片信息整理成可查询的结构。Nacos 1.4.x 里这个结构就是Service对象和它内部维护的instanceMap,后面会细讲。

第二件事是分布式同步。生产环境永远不会只部署一个 Nacos 节点。同一个服务可能在节点 A 注册,但消费者查询时请求打到了节点 B,如果节点 B 不知道这台实例的存在,注册就白做了。所以注册请求不能只改本机内存,还要复制给集群里的其他节点。1.4.x 用的是一致性协议,对临时实例来说主要是 Distro 协议——一种适合 AP 场景的数据同步方式,核心思路是"写入本地成功后异步复制给其他节点,不强求所有节点同时确认"。

第三件事是消费者通知。实例注册进去了,订阅这个服务的消费者得尽快知道"有一个新实例可以用了"。这里涉及订阅关系的建立,以及服务端反向推送。1.4.x 的推送机制是基于 UDP 的PushService,网上很多文章对这一块讲得很简略,但它恰恰是"注册之后消费者为什么能看到新实例"的关键。

读源码的时候,如果脑子里始终装着这三件事,你就会发现 Nacos 的代码并不是随机堆出来的——每个类、每个线程池、每条调用链,都在为这三件事中的某一件事服务。

1.2 为什么我建议从 1.4.x 入手,而不是直接啃 2.x

如果你只想知道"注册中心大概怎么工作",Nacos 2.x 其实更好用,毕竟 gRPC 长连接、负载均衡、连接管理这些特性都很现代。但如果你想理解注册中心的底层设计,1.4.x 反而是更好的学习材料。

原因很简单:1.4.x 的通信链路是 HTTP + UDP,客户端和服务端的交互是"请求一响应"模式。请求到了服务端之后,入口非常明显,打个断点就能看到完整的调用栈。而 2.x 全面切到 gRPC 双向流以后,请求先进连接管理器,再分发到各类 Service,对初学者来说,光是找到"注册请求到底是从哪个类进来的"就要花不少时间。

另外 1.4.x 的代码结构相对克制。客户端注册、服务端注册、心跳、健康检查、推送,几条链路各自独立,没有太多跨模块的抽象。虽然 1.4.x 也已经为后续版本的重构打了基础(比如InstanceOperator这类抽象已经存在),但整体上还是"看得见全过程"的版本。

还有一个很现实的原因:很多公司到现在线上还在跑 1.4.x。虽然 2.x 已经出来这么久,但 1.4.x 因为稳定、文档多、社区踩坑经验丰富,存量部署量依然很大。排查生产问题的时候,你对 1.4.x 源码的熟悉程度,直接决定你定位问题的速度。

2. 客户端注册链路:从 Spring 容器启动到 Instance 对象

2.1 谁在什么时候调用了 registerInstance

先看客户端。在 Spring Cloud 场景下,一个服务启动后 Nacos 会自动完成注册,这个过程很多同学从没打断过点,所以完全不知道入口在哪里。我第一次追这个链路的时候,也是从 Spring Boot 启动日志里的NacosRegistry字样反推回去的。

整个触发链条是这样的:Spring Boot 应用启动完成,内置 Web 容器(Tomcat 或 Undertow)就绪后,会发布一个WebServerInitializedEvent事件。Spring Cloud Common 里的AbstractAutoServiceRegistration监听这个事件,然后调用子类的start()方法。在 Nacos 集成中,这个子类就是NacosAutoServiceRegistration,它最终会调用NacosServiceRegistry.register()。

NacosServiceRegistry.register()里有一句很关键:从NacosDiscoveryProperties里读取服务名、IP、端口、命名空间等信息,组装成一个Instance对象,然后调用namingService.registerInstance(serviceId, instance)。这里的namingService就是NacosNamingService,也就是 Nacos 客户端 Naming 模块的门面。

有个细节值得注意:NacosServiceRegistry.register()被调用时,ApplicationContext里已经能拿到Registration对象,这个对象包含了spring.cloud.nacos.discovery.*配置项解析后的结果。也就是说,配置解析和实际注册是分离的:前者在属性绑定阶段完成,后者在 Web 容器就绪事件后触发。如果你自定义了注册逻辑,重写NacosServiceRegistry的某个方法并不影响属性解析,这个拆分让扩展点非常干净。

2.2 Instance 对象是怎么构造出来的

Instance是 Nacos API 模块里的一个普通 POJO,但它的字段设计直接决定了服务端能拿到什么信息。

核心字段包括:

  • ip:实例 IP。如果是 Spring Cloud 应用,默认取本机网卡地址,也可以配置spring.cloud.nacos.discovery.ip强制指定。
  • port:服务端口。
  • weight:权重,默认 1.0,范围 1 到 100。服务端在做负载均衡权重计算时会用到。
  • clusterName:集群名,默认DEFAULT。不指定的话,实例归入默认集群。
  • namespaceId:命名空间 ID,默认public。这个是逻辑隔离单元,通常用来区分环境,但注意它只是一层隔离标识,并不参与服务名组合。
  • ephemeral:是否是临时实例。Spring Cloud 集成时默认是true,也就是临时实例。

我为什么强调ephemeral这个字段?因为它决定了后续一整条链路的走向。临时实例走内存 + 心跳续约机制,服务端不落盘,实例没了就没了;持久实例则要落盘,还得靠服务端主动健康检查。两种模式在服务端的处理和一致性协议上完全不同。

看到registerInstance的方法签名,很多人会好奇:为什么还要单独传一个serviceName?因为服务端存储时用的是"服务名"作为维度,而不是"实例 ID"。同一个服务名下可以有多个实例,注册接口实际上做的是"往某个服务名的实例列表里增加一条数据"。这个视角转换很重要——注册中心不是"存实例",而是"维护服务名与实例集合的映射关系"。

下面是客户端注册的调用链抽象,忽略中间一些封装后大致如此:

// 简化自 1.4.x 客户端注册链路 NacosNamingService.registerInstance(serviceName, instance) -> NamingProxy.registerService(serviceName, instance) // HTTP POST /v1/ns/instance -> InstanceController.register(服务端入口)

NamingProxy是客户端真正发 HTTP 请求的地方。它会把Instance序列化成表单参数,POST 到服务端的/v1/ns/instance接口。如果你用抓包工具看这个请求,会看到参数里包含serviceName、ip、port、weight、clusterName、namespaceId、ephemeral等字段。

3. 服务端注册链路:HTTP 入口如何变成内存里的一个 Service

3.1 InstanceController.register 和它前面的 DistroFilter

服务端收到请求后,第一个进入的 Controller 是InstanceController。register(HttpServletRequest request)方法本身非常薄,它从 request 里解析出 namespaceId、serviceName、clusterName、ip、port、weight、ephemeral 等参数,组装成一个Instance对象,然后转手交给InstanceOperatorClientImpl.registerInstance()。

但在这个 Controller 之前,还会过一层DistroFilter。这是一个 Web Filter,拦截/v1/ns/instance等命名空间接口的写入请求。它的作用我简单概括一下:如果当前 Nacos 节点不是数据的"归属节点",DistroFilter 会把请求异步转发给那个归属节点,同时本地先放行或缓存。

这里要理解 Nacos 1.4.x 的 Distro 协议设计。每个服务名通过一致性哈希算法决定由哪个节点"负责"(也就是Service对象的最近更新者)。负责写节点会持久化和同步数据,其他节点收到请求后会转发给负责节点。DistroFilter 正是这套逻辑的 HTTP 入口拦截器——它判断当前请求涉及的服务是否归本节点管,如果不是,就通过DistroHttpRegistry把请求转发到正确的节点。

为什么要这么做?因为注册中心是 AP 模型,可用性优先于强一致性。请求落到任意一个 Nacos 节点上,就算这个节点不是负责节点,也不能直接拒绝,而是尽量转发或异步复制,达到最终一致。这也是 Nacos 注册中心与配置中心的本质区别:配置中心强调的是 CP 模型,用 Raft 保证强一致。

3.2 registerInstance 与服务端的单例 Service 获取

真正做业务逻辑的是InstanceOperatorClientImpl.registerInstance(namespaceId, serviceName, instance)。这个方法比想象中简短,因为它把复杂的部分都委托给了ServiceManager和Service。

第一步是解析服务名。这里有一个容易踩的坑:serviceName很可能不是单纯的 "order-service",而是带了 group 信息的"group@@order-service"格式。ServiceManager拿到这个字符串后,会把 group 和 name 拆开,然后作为Service对象的两个独立属性。

第二步是拿到或创建Service对象。ServiceManager内部维护了一个ConcurrentHashMap<String, Service> serviceMap,key 是 namespaceId 加 serviceName。如果服务不存在,就创建一个新的Service对象,执行service.init(),然后放入 map。init()里做了很多后面要用的准备工作,比如把服务加入健康检查任务队列、初始化数据结构。

第三步就进入了Service.addIpAddress(instance)。这是服务端注册链路里最核心的一个方法,它做的事情用一句话概括就是:把实例放进服务对应的内存缓存里,并触发后续的一致性同步和推送。

// 简化自 1.4.x 服务端注册调用链 InstanceController.register(request) -> instanceOperator.registerInstance(namespaceId, serviceName, instance) -> serviceManager.getService(namespaceId, serviceName) // 单例服务对象 -> service.addIpAddress(instance) // 写入内存 + 触发同步 -> consistencyService.onPut(instance) // Distro 异步复制 -> pushService.notifySubscriber(...) // 通知订阅者

3.3 临时实例与持久实例的两套一致性通道

consistencyService并不是一个实现。1.4.x 里它被拆成了两种:

  • ephemeralConsistencyService:处理ephemeral=true的临时实例,底层是 Distro 协议。
  • persistentConsistencyService:处理ephemeral=false的持久实例,底层是 Raft。

这个拆分的依据是业务语义。临时实例是"活多久算多久"的,注册时不一定非要落盘,内存里有就能提供服务发现;持久实例则要求数据不能丢,就算 Nacos 节点重启,实例数据也要恢复,因此必须走 Raft 并落盘。

那为什么注册接口返回成功这么迅速?因为对临时实例来说,写入本机内存后,数据已经可见,Distro 的异步复制不阻塞主流程。就算复制尚未完成,消费者在短时间内查询也能拿到数据,只是可能少一台新注册实例。等到复制完成,所有节点都会一致。这就是 AP 模型的取舍:用短暂的最终一致窗口换取注册请求的高吞吐和低延迟。

4. Service 与 instanceMap:服务端数据结构里最关键的两个容器

4.1 Service 对象到底装了什么

读注册源码绕不开Service这个类,它是 Nacos 服务端对"一个服务"的完整抽象。先看它内部几个核心成员:

  • name:服务名,如order-service。
  • groupName:分组名,默认DEFAULT_GROUP。
  • clusters:集群名列表,比如DEFAULT。
  • serviceMap:ConcurrentHashMap<InstanceKey, Instance>,这是真正存实例的地方。
  • clientBeat:客户端心跳信息缓存。
  • allIPs:一个布尔标记,表示查询时要不要返回所有集群的实例。
  • healthyInstanceCount:健康实例数量,用于负载均衡和状态统计。
  • timestamp:最后更新时间。

这些字段并不复杂,但组合在一起就形成了一条完整的数据索引链:namespaceId决定哪个环境,groupName决定哪个分组,clusters决定哪个集群,name决定哪个服务。查询的时候,Nacos 会先用 namespace 和 serviceName 找到Service对象,再根据集群过滤实例。

有个细节我提醒一下:serviceMap的 key 不是简单的字符串,而是一个InstanceKey或者由 ip、port、cluster 组合而成的 key。为什么要这样设计?因为同一个服务下允许同一个 ip:port 出现在不同集群里,如果只拿ip:port做 key,两个集群就会互相覆盖。

4.2 为什么 ConcurrentHashMap 外面还要加 synchronized

我第一次看addIpAddress的实现时有个疑问:既然serviceMap已经是ConcurrentHashMap,为什么整个方法还要用synchronized包一层?后来自己在并发场景下复现了问题才明白。

addIpAddress不只是做一次serviceMap.put,它还要做下面这类操作:

  1. 检查实例是否已经存在,如果存在就更新其lastBeatTime,而不是覆盖。
  2. 判断实例数量变化,触发ServiceChangedEvent。
  3. 更新一致性缓存和推送缓存。

这些操作不是原子的,如果多个线程同时注册实例,可能出现"一个实例被拆成两条记录"或"更新相互覆盖"的问题。例如,线程 A 读取实例列表,线程 B 同时删除一个实例,等 A 写回时,B 的删除就丢了。

ConcurrentHashMap只能保证单个操作的线程安全,不能保证复合操作的原子性,所以Service在写入口上加锁。这种"泛化加锁"在性能上其实可以接受,因为注册请求已经按服务维度分散到了不同的Service对象上,不同服务之间的锁是独立的,不会形成全局竞争。

5. 临时实例怎么"活着":心跳续约与移除机制

5.1 客户端的 BeatReactor:每 5 秒一次的守约

服务注册不是一次性动作。ephemeral=true的实例之所以能一直出现在服务列表里,核心支撑是客户端的心跳任务。1.4.x 客户端里,这个任务由BeatReactor管理,每个实例对应一个BeatInfo和一个BeatTask。

BeatTask是一个定时任务,默认每隔 5 秒向服务端发送一次心跳请求,接口是/v1/ns/instance/beat。心跳包里带的信息包括服务名、IP、端口、权重、集群名。

这里有一个容易被忽略的设计:心跳不只是"报平安"。客户端还会在心跳请求里带上最新的实例元数据,也就是说,如果你改了weight或某元数据字段,客户端会在下一次心跳时同步给服务端。这样服务端不必每次注册都重建实例,而是由心跳来维持状态的持续更新。

有人会问:如果客户端断网了,心跳停了,服务端怎么知道?答案在服务端的心跳处理逻辑里。

5.2 服务端 ClientBeatProcessor:超时就移除

服务端收到心跳请求后,不会在主线程里直接更新状态,而是把心跳信息封装成任务扔进任务池,由ClientBeatProcessor异步处理。这个类做的事情主要有两步:

  1. 找到对应的Service和Instance,更新实例的lastBeatTime为当前时间。
  2. 检查当前时间与lastBeatTime的差值,如果超过心跳超时阈值,就把实例标记为不健康,甚至直接从实例列表移除。

默认情况下,心跳间隔是 5 秒,超时阈值大致是心跳间隔的若干倍(社区常用的一个口径是 15 秒左右)。也就是说,实例连续几次没有心跳,服务端就会判定它失联。

这里我要强调一个面试里常被问到的点:为什么临时实例不持久化?因为临时实例的语义就是"活着才存在,死了就消失"。如果服务端把临时实例持久化到磁盘,那么 Nacos 节点重启后,已经消失的实例可能被重新加载,这完全违背了临时实例的语义。所以临时实例只存内存,配合心跳续约来决定去留,这也是注册中心 AP 模型的一种体现——宁可短暂不一致,也要保证"当前节点认为活着的实例能立刻被找到"。

5.3 持久实例的守护者其实是 HealthCheckTask

很多文章把心跳机制描述成所有实例的统一保活方式,这是不准确的。持久实例(ephemeral=false)不依赖客户端心跳,它靠的是服务端主动发起的健康检查,也就是HealthCheckTask。

HealthCheckTask会周期性地遍历服务里的持久实例,根据实例配置的协议(TCP 或 HTTP)发起探测。TCP 探测就是尝试建立连接,HTTP 探测就是发一个指定的请求并判断响应码。如果探测失败,健康状态会被标记为不健康,连续失败后实例会被摘除。

这就是临时实例和持久实例最大差异的根源:

  • 临时实例:客户端主动上报,服务端被动收,超时不报就移除。
  • 持久实例:服务端主动探测,客户端不需要上报心跳,数据要落盘,重启不丢。

两种模式服务于不同的场景。临时实例适合大多数微服务场景,生命周期跟着进程走;持久实例适合那些"明确需要长期保留、进程退出也不该立即从注册中心消失"的服务。理解这个差异后,你再看ephemeral这个参数就不会再当成一个普通配置项了。

6. 注册完成之后,消费者是怎么感知的

6.1 订阅关系的建立:list 接口和 Updater 轮询

服务注册进去只是第一步,消费者还得知道。消费者端有一个HostReactor,它负责维护本地缓存的服务实例列表,并定时向服务端发起查询。

查询接口是/v1/ns/instance/list。正常情况下,消费者每 10 秒左右会拉取一次最新列表。但如果只靠这种固定轮询,实例变更的感知就会有十几秒的延迟,这在流量调度场景下是不太能接受的。

所以 1.4.x 还做了订阅机制。消费者在首次查询时,如果设置subscribe=true,服务端就会为这个消费者建立一个订阅关系,Subscriber对象里记录了订阅者身份、服务名、集群、IP 等信息。这个订阅关系由PushService维护。

6.2 PushService 的 UDP 推送和客户端兜底

一旦服务端有实例变更(注册、注销、健康状态变化),PushService就会把最新的ServiceInfo封装成PushPacket,通过 UDP 通道推送给所有订阅者。UDP 的优势是轻量、不建立连接,适合高频率的状态推送,但它不可靠——包可能会丢。

所以客户端不是只依赖 UDP 推送。即使收到了推送,HostReactor仍然会定期执行全量比对任务,每 10 秒左右把本地缓存和服务端结果对齐一次。这就是典型的"推 + 拉"并存模式:推是为了快速感知,拉是为了兜底防丢。

理解了这条链路,生产中那个经典现象就很好解释了:注册成功后消费者偶尔要过几秒才看到新实例,这不是故障,而是 UDP 推送偶尔丢失后,下一次轮询还没到。如果你所在团队对实例感知延迟很敏感,可以考虑调整消费者端的轮询间隔,或者升级到 Nacos 2.x 用 gRPC 长连接,推送可靠性更高。

7. 我推荐的跟踪姿势:断点清单与常见概念混淆

7.1 单机复现:一套 Nacos 加一个最小注册方

纸上谈兵到此为止,真正想记住这条链路,我建议你本地实操一遍。准备工作很简单:

  1. 下载 Nacos 1.4.x 服务端压缩包,解压后以单机模式启动,Linux 下执行bin/startup.sh -m standalone,Windows 执行startup.cmd -m standalone。
  2. 准备一个最小的 Spring Boot 工程,引入 Nacos Discovery 依赖,配置spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848和spring.application.name=demo-service。
  3. 启动后,在服务端源码里打上断点,重新请求一次注册。

我推荐的断点位置按顺序打:

  • NacosNamingService.registerInstance:客户端入口,观察Instance对象怎么被构造出来。
  • InstanceController.register:服务端入口,观察 HTTP 参数如何被解析。
  • DistroFilter.doFilter:观察集群转发逻辑,单机模式下它会直接放行。
  • ServiceManager.getService:观察服务首次出现时Service对象的创建过程。
  • Service.addIpAddress:这里能看到实例真正进入内存缓存的那一刻。
  • ClientBeatProcessor.process:心跳异步任务执行时,观察lastBeatTime更新。

每断一处,都看看调用栈和当前类的字段,很快就能建立"请求进来 → 数据结构变化 → 后续任务被触发"的整体认知。

7.2 一定会绕晕的两个概念:serviceName 与 group、Instance 与 ServiceInfo

读这段源码时有两个混淆点,我当初踩过,后来带新人也反复被问到。

第一个是serviceName的格式。服务端收到注册请求后,serviceName可能长这样:DEFAULT_GROUP@@demo-service。如果只看服务名的字面值,你会以为服务名叫DEFAULT_GROUP@@demo-service,实际上DEFAULT_GROUP是 groupName,@@是分隔符,真正的服务名是demo-service。服务端解析时专门有个工具方法做拆分,如果拆错了,服务就归错组了。平时自己用控制台或 Open API 维护服务时,也要留意这个格式。

第二个是服务端Service对象和客户端ServiceInfo对象的关系。它们不是同一个东西,只是名字像。服务端的Service是注册中心的核心数据结构,持有实例集合和集群信息;客户端的ServiceInfo更像是"可供本地缓存的视图",里面包含的是从服务端拉取或推送过来的实例列表、健康状态等。客户端拿到ServiceInfo后,再做一层本地缓存,供DiscoveryClient查询。理解这两个对象的边界后,客户端和服务端源码就不会再混着看了。

7.3 看完注册再看什么:注销、订阅、健康检查任务池

如果注册链路你已经摸清,我建议顺势看下去。第一个方向是服务注销。注销做的事跟注册基本对称:从实例列表移除实例、触发同步、通知订阅者。但注销比注册多一层"安全"考量,比如判断实例是否真的属于这个服务、是否重复注销。

第二个方向是健康检查任务池。HealthCheckTask所在的线程池很有意思,服务的健康检查任务如何被分配、如何避免重复检查、检查结果如何反馈给注册中心,都是一套完整的机制。你现在读案发时的源码,会发现 Nacos 的处理粒度其实很细,很多设计在面试和实际排障中都能直接拿来用。

按这个顺序读下去,你很快就能从"会调 API"进到"看得懂机制"的层面。以后遇到"服务一直显示不健康"“实例被莫名摘除”"消费者感知延迟"这类问题,脑子里就有一条清晰的排查路径了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询