服务注册成功了但消费端就是一直调不到这个实例。这种问题我这些年排查过不少每次都得把 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 入口如何变成内存里的一个 Service3.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 信息的grouporder-service格式。ServiceManager拿到这个字符串后会把 group 和 name 拆开然后作为Service对象的两个独立属性。第二步是拿到或创建Service对象。ServiceManager内部维护了一个ConcurrentHashMapString, Service serviceMapkey 是 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处理ephemeraltrue的临时实例底层是 Distro 协议。persistentConsistencyService处理ephemeralfalse的持久实例底层是 Raft。这个拆分的依据是业务语义。临时实例是活多久算多久的注册时不一定非要落盘内存里有就能提供服务发现持久实例则要求数据不能丢就算 Nacos 节点重启实例数据也要恢复因此必须走 Raft 并落盘。那为什么注册接口返回成功这么迅速因为对临时实例来说写入本机内存后数据已经可见Distro 的异步复制不阻塞主流程。就算复制尚未完成消费者在短时间内查询也能拿到数据只是可能少一台新注册实例。等到复制完成所有节点都会一致。这就是 AP 模型的取舍用短暂的最终一致窗口换取注册请求的高吞吐和低延迟。4. Service 与 instanceMap服务端数据结构里最关键的两个容器4.1 Service 对象到底装了什么读注册源码绕不开Service这个类它是 Nacos 服务端对一个服务的完整抽象。先看它内部几个核心成员name服务名如order-service。groupName分组名默认DEFAULT_GROUP。clusters集群名列表比如DEFAULT。serviceMapConcurrentHashMapInstanceKey, 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它还要做下面这类操作检查实例是否已经存在如果存在就更新其lastBeatTime而不是覆盖。判断实例数量变化触发ServiceChangedEvent。更新一致性缓存和推送缓存。这些操作不是原子的如果多个线程同时注册实例可能出现一个实例被拆成两条记录或更新相互覆盖的问题。例如线程 A 读取实例列表线程 B 同时删除一个实例等 A 写回时B 的删除就丢了。ConcurrentHashMap只能保证单个操作的线程安全不能保证复合操作的原子性所以Service在写入口上加锁。这种泛化加锁在性能上其实可以接受因为注册请求已经按服务维度分散到了不同的Service对象上不同服务之间的锁是独立的不会形成全局竞争。5. 临时实例怎么活着心跳续约与移除机制5.1 客户端的 BeatReactor每 5 秒一次的守约服务注册不是一次性动作。ephemeraltrue的实例之所以能一直出现在服务列表里核心支撑是客户端的心跳任务。1.4.x 客户端里这个任务由BeatReactor管理每个实例对应一个BeatInfo和一个BeatTask。BeatTask是一个定时任务默认每隔 5 秒向服务端发送一次心跳请求接口是/v1/ns/instance/beat。心跳包里带的信息包括服务名、IP、端口、权重、集群名。这里有一个容易被忽略的设计心跳不只是报平安。客户端还会在心跳请求里带上最新的实例元数据也就是说如果你改了weight或某元数据字段客户端会在下一次心跳时同步给服务端。这样服务端不必每次注册都重建实例而是由心跳来维持状态的持续更新。有人会问如果客户端断网了心跳停了服务端怎么知道答案在服务端的心跳处理逻辑里。5.2 服务端 ClientBeatProcessor超时就移除服务端收到心跳请求后不会在主线程里直接更新状态而是把心跳信息封装成任务扔进任务池由ClientBeatProcessor异步处理。这个类做的事情主要有两步找到对应的Service和Instance更新实例的lastBeatTime为当前时间。检查当前时间与lastBeatTime的差值如果超过心跳超时阈值就把实例标记为不健康甚至直接从实例列表移除。默认情况下心跳间隔是 5 秒超时阈值大致是心跳间隔的若干倍社区常用的一个口径是 15 秒左右。也就是说实例连续几次没有心跳服务端就会判定它失联。这里我要强调一个面试里常被问到的点为什么临时实例不持久化因为临时实例的语义就是活着才存在死了就消失。如果服务端把临时实例持久化到磁盘那么 Nacos 节点重启后已经消失的实例可能被重新加载这完全违背了临时实例的语义。所以临时实例只存内存配合心跳续约来决定去留这也是注册中心 AP 模型的一种体现——宁可短暂不一致也要保证当前节点认为活着的实例能立刻被找到。5.3 持久实例的守护者其实是 HealthCheckTask很多文章把心跳机制描述成所有实例的统一保活方式这是不准确的。持久实例ephemeralfalse不依赖客户端心跳它靠的是服务端主动发起的健康检查也就是HealthCheckTask。HealthCheckTask会周期性地遍历服务里的持久实例根据实例配置的协议TCP 或 HTTP发起探测。TCP 探测就是尝试建立连接HTTP 探测就是发一个指定的请求并判断响应码。如果探测失败健康状态会被标记为不健康连续失败后实例会被摘除。这就是临时实例和持久实例最大差异的根源临时实例客户端主动上报服务端被动收超时不报就移除。持久实例服务端主动探测客户端不需要上报心跳数据要落盘重启不丢。两种模式服务于不同的场景。临时实例适合大多数微服务场景生命周期跟着进程走持久实例适合那些明确需要长期保留、进程退出也不该立即从注册中心消失的服务。理解这个差异后你再看ephemeral这个参数就不会再当成一个普通配置项了。6. 注册完成之后消费者是怎么感知的6.1 订阅关系的建立list 接口和 Updater 轮询服务注册进去只是第一步消费者还得知道。消费者端有一个HostReactor它负责维护本地缓存的服务实例列表并定时向服务端发起查询。查询接口是/v1/ns/instance/list。正常情况下消费者每 10 秒左右会拉取一次最新列表。但如果只靠这种固定轮询实例变更的感知就会有十几秒的延迟这在流量调度场景下是不太能接受的。所以 1.4.x 还做了订阅机制。消费者在首次查询时如果设置subscribetrue服务端就会为这个消费者建立一个订阅关系Subscriber对象里记录了订阅者身份、服务名、集群、IP 等信息。这个订阅关系由PushService维护。6.2 PushService 的 UDP 推送和客户端兜底一旦服务端有实例变更注册、注销、健康状态变化PushService就会把最新的ServiceInfo封装成PushPacket通过 UDP 通道推送给所有订阅者。UDP 的优势是轻量、不建立连接适合高频率的状态推送但它不可靠——包可能会丢。所以客户端不是只依赖 UDP 推送。即使收到了推送HostReactor仍然会定期执行全量比对任务每 10 秒左右把本地缓存和服务端结果对齐一次。这就是典型的推 拉并存模式推是为了快速感知拉是为了兜底防丢。理解了这条链路生产中那个经典现象就很好解释了注册成功后消费者偶尔要过几秒才看到新实例这不是故障而是 UDP 推送偶尔丢失后下一次轮询还没到。如果你所在团队对实例感知延迟很敏感可以考虑调整消费者端的轮询间隔或者升级到 Nacos 2.x 用 gRPC 长连接推送可靠性更高。7. 我推荐的跟踪姿势断点清单与常见概念混淆7.1 单机复现一套 Nacos 加一个最小注册方纸上谈兵到此为止真正想记住这条链路我建议你本地实操一遍。准备工作很简单下载 Nacos 1.4.x 服务端压缩包解压后以单机模式启动Linux 下执行bin/startup.sh -m standaloneWindows 执行startup.cmd -m standalone。准备一个最小的 Spring Boot 工程引入 Nacos Discovery 依赖配置spring.cloud.nacos.discovery.server-addr127.0.0.1:8848和spring.application.namedemo-service。启动后在服务端源码里打上断点重新请求一次注册。我推荐的断点位置按顺序打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_GROUPdemo-service。如果只看服务名的字面值你会以为服务名叫DEFAULT_GROUPdemo-service实际上DEFAULT_GROUP是 groupName是分隔符真正的服务名是demo-service。服务端解析时专门有个工具方法做拆分如果拆错了服务就归错组了。平时自己用控制台或 Open API 维护服务时也要留意这个格式。第二个是服务端Service对象和客户端ServiceInfo对象的关系。它们不是同一个东西只是名字像。服务端的Service是注册中心的核心数据结构持有实例集合和集群信息客户端的ServiceInfo更像是可供本地缓存的视图里面包含的是从服务端拉取或推送过来的实例列表、健康状态等。客户端拿到ServiceInfo后再做一层本地缓存供DiscoveryClient查询。理解这两个对象的边界后客户端和服务端源码就不会再混着看了。7.3 看完注册再看什么注销、订阅、健康检查任务池如果注册链路你已经摸清我建议顺势看下去。第一个方向是服务注销。注销做的事跟注册基本对称从实例列表移除实例、触发同步、通知订阅者。但注销比注册多一层安全考量比如判断实例是否真的属于这个服务、是否重复注销。第二个方向是健康检查任务池。HealthCheckTask所在的线程池很有意思服务的健康检查任务如何被分配、如何避免重复检查、检查结果如何反馈给注册中心都是一套完整的机制。你现在读案发时的源码会发现 Nacos 的处理粒度其实很细很多设计在面试和实际排障中都能直接拿来用。按这个顺序读下去你很快就能从会调 API进到看得懂机制的层面。以后遇到服务一直显示不健康“实例被莫名摘除”消费者感知延迟这类问题脑子里就有一条清晰的排查路径了。