读Nacos源码有一段时间了,如果要我挑一个最值得反复研究的模块,我一定选服务端的健康管理与剔除逻辑。心跳机制是注册中心的“生命线”——实例注册上来之后,如果没有一套机制确认它还活着,服务发现最终会退化成一份堆满僵尸IP的通讯录。而Nacos的心跳处理,恰恰是理解整个服务健康管理体系的钥匙。
这篇文章会从源码层面带你完整梳理Nacos心跳机制与服务剔除的实现链路。从客户端的BeatReactor如何发心跳,到服务端的handleBeat调用链,再到HealthCheckProcessor如何判定不健康实例、如何触发剔除,每一步我都会结合源码路径和实际运行逻辑讲清楚。适合正在研究Nacos源码、准备面试或者在生产环境遇到过“实例明明存在却调用失败”这类问题的同学。
1. 从一次心跳说起:服务健康管理的整体设计
如果只盯着某一行代码,很容易迷路。Nacos的健康管理从来不是“一个心跳接口”那么简单,它牵扯到客户端定时任务、服务端状态存储、一致性协议、健康检查线程池、保护阈值等多个模块。先看整体,再钻细节。
1.1 三个角色:客户端、服务端与一致性协议
Nacos的健康管理可以拆成三个层面来看。
第一层是客户端。正常注册进Nacos的临时实例,会通过BeatReactor内置的定时任务周期性向服务端发送心跳。心跳的本质是“告诉服务端我还活着”。客户端心跳周期默认是5秒,但服务端会根据实际配置返回期望的period,客户端会拿这个返回值修正自己的发送间隔。
第二层是服务端。服务端通过InstanceOperatorClientImpl.handleBeat方法接收心跳请求。这个方法不只是“刷新一下最后心跳时间”那么简单,它会同步处理实例元数据、更新内存中的Service数据结构、触发数据一致性同步,并且决定是否需要返回新的心跳周期给客户端。
第三层是底层一致性协议。Nacos服务端集群模式下,心跳状态的变更要通过Distro或AP协议做数据同步。临时实例的心跳数据主要维护在内存里,也就是Distro协议管理的实例列表中。每次心跳不只是改本机内存,还会把最新状态同步给其他节点。
我最早读源码的时候,只在第一个层面打转,以为看懂了BeatReactor就等于理解了心跳机制。实际上,真正决定“这个实例会不会被剔除”的逻辑几乎全部在服务端,客户端只是一个“听话的发报机”。所以这篇文章的重心也会放在服务端。
1.2 临时实例与持久实例:为什么要分成两套检查逻辑
Nacos把实例分成两类:临时实例(ephemeral=true)和持久实例(ephemeral=false)。这个属性不仅影响了注册存储方式,也直接决定了心跳机制完全不同。
临时实例是微服务架构中默认的选择。它注册后数据放在内存里,通过心跳维持生存状态,如果心跳超时,服务端会主动把它剔除。得益于内存存储和Distro协议,临时实例的支持量级很大,读写路径都很快。
持久实例则走的是另一套逻辑——数据落库到MySQL(Nacos内置的config_info或者专门的实例表在这套逻辑下会有所不同),健康状态通过服务端主动探活来维护,也就是MysqlCheckProcessor相关的逻辑。持久实例不依赖客户端心跳,而是服务端定期去检测这个实例对应的数据库记录是否还有效。
为什么要有两套逻辑?两个原因。第一,临时实例适合动态扩缩容的场景,注册、注销、故障感知都要快,牺牲一点强一致性换可用性很划算。第二,持久实例更多是兼容传统注册中心的使用方式,或者一些非Java生态、不方便集成Nacos客户端的场景,让服务端主动检测更合适。
从源码层面看,这两套逻辑对应的处理链路分别在ConsistencyService的Distro协议分支和PersistentClientOperationService分支里。我们后面讲心跳处理时会分别提到。
2. 客户端心跳机制源码:BeatReactor与双通道心跳
既然要源码层面吃透心跳机制,就先把客户端这块研究透。客户端不只是“发个HTTP请求”这么简单,里面的任务调度和通道选择都是有讲究的。
2.1 BeatReactor的启动与BeatInfo封装
在Nacos 1.x时代,客户端心跳就是单纯的HTTP POST请求,服务地址是/v1/ns/instance/beat。到了2.x版本,gRPC成为主连接,但心跳机制并没有完全抛弃HTTP,形成了双通道心跳。
BeatReactor的构造在NacosClientModule或者NamingModule中完成,核心是两个对象:一个是NamingHttpClientProxy,负责HTTP通信;另一个是gRPCClient,负责RPC通信。BeatReactor持有一个ConcurrentHashMap,key是构建出来的beatKey(格式类似namespaceId##serviceName##ip:port##cluster),value是BeatInfo对象。
我贴一段关键的数据结构帮助理解:
public class BeatInfo { private int port; private String ip; private String serviceName; private String cluster; private Map<String, String> metadata; private volatile boolean scheduled; private volatile long period; private volatile boolean stopped; }这个类有几个字段值得注意。period是心跳周期,默认5000毫秒,但服务端有权通过返回值动态调整。scheduled标记当前这个实例的心跳任务是否已经被调度起来。stopped标记是否已经停止心跳——当客户端主动调用deregister时,会把这个标志置为true,并立即取消任务。
BeatReactor对外暴露的核心方法是addBeatInfo。它会先通过scheduledExecutor定期执行BeatTask,每个BeatTask都是一个独立的定时任务。任务执行时会从beatMap里取数据,然后构建心跳请求发出去。
2.2 HTTP心跳与gRPC心跳的分工
很多同学以为Nacos 2.x全部走gRPC,实际上心跳是分场景的。
当客户端使用Spring Cloud Alibaba 2021.x以下的版本,对应的Nacos Client 1.x,心跳只走HTTP。当客户端升级到Nacos Client 2.x且服务端支持gRPC时,客户端会优先构建RpcHeartBeatTask。但我实际看源码发现,BeatReactor里的addBeatInfo,在2.x版本中依然保留了一个HTTP心跳的通道,代码里叫HTTP_OLD_BEAT或者兼容模式,具体要看配置项nacos.naming.heartbeat.old.beat。
我梳理一下当前主流版本的实际表现:
| 客户端版本 | 心跳方式 | 触发条件 |
|---|---|---|
| 1.x | HTTPScheduledBeat | 默认走HTTP,周期5秒 |
| 2.x(兼容模式) | HTTP + gRPC双发 | 服务端未开启gRPC或者SDK内配置兼容开关 |
| 2.x(新链路) | gRPC单发 | 服务端支持gRPC,默认心跳走GrpcBeatTask |
之所以保留双通道,是为了兼容和服务端版本不匹配的老客户端。Nacos 2.x服务端对老版本1.x客户端的心跳请求是完全兼容的,服务端通过UserAgent或者请求类型自动识别。但要注意的是,如果同一个服务下同时存在1.x老客户端和2.x新客户端,服务端的心跳处理逻辑也能兼容——它通过InstanceOperatorClientImpl.handleBeat统一入口,内部根据实例的ephemeral属性和连接类型分发到不同处理链。
2.3 收到服务端应答后:period的自我修正
BeatTask的run方法核心逻辑很简单:构建心跳请求,发送,等待结果。但真正有意思的是它怎么处理服务端返回的period。
服务端在handleBeat方法里,会从ClientBeat对象中解析出实例的期望心跳周期。如果实例是自己的(这里是说服务端根据配置计算出来的推荐周期),返回值可能有变化。客户端收到后,如果发现服务端返回的period和当前period不一致,会用新的period重新调度自己。
这个设计解决了什么痛点?举个例子。服务端集群在运行时,某段时间压力比较大,希望在3000毫秒到15000毫秒之间动态调整心跳频率,客户端必须跟随服务端的节奏,不能自顾自地固定5秒一跳。源码中对应这一段:
if (beatResult.getClientBeatInterval() > 0) { beatInfo.setPeriod(beatResult.getClientBeatInterval()); }实际运行时的表现是,客户端下一次心跳的间隔会被更新,不需要重启。这是一个很容易被忽略但是非常重要的细节,因为很多排查心跳问题的同学,只去看服务端日志,忽略了客户端其实一直在动态调整自己的发送周期。如果服务端返回了一个异常大的period,客户端就会“慢悠悠”地发心跳,看起来像失联了,实际是被服务端调慢了节奏。
3. 服务端心跳处理源码:从handleBeat到元数据刷新
服务端接收到心跳之后,处理链路非常长。从最外层的Controller到内部的ServiceStorage,每一步都有它存在的意义。我建议你把这条链路背下来,面试时能流畅讲出来会非常加分。
3.1 InstanceOperatorClientImpl.handleBeat调用链
以HTTP心跳为例,请求过来之后首先到InstanceController的beat方法,这个方法会被映射到PUT /v1/ns/instance/beat。老版本里这段逻辑在InstanceOperatorService里,新版本拆成了InstanceOperatorClientImpl,专门处理客户端接口。
核心方法签名是这样的:
@Override public InstanceBeatResult handleBeat(String namespaceId, String serviceName, String ip, String port, String cluster, ClientBeat clientBeat) throws NacosException { // ...省略部分代码 Service service = Service.newService(namespaceId, serviceName); Instance instance = new Instance(); instance.setIp(ip); instance.setPort(Integer.parseInt(port)); instance.setClusterName(cluster); // 关键操作:实例元数据解析 ServiceMetadataManager.getServiceMetadata(service).getInstanceMetadata(instance); // 核心调用链,实际更新心跳 ServiceManager.getInstance().updateBeatInfo(service, instance, clientBeat); // ... }这个方法做的事可以拆成三步。
第一步,根据请求参数创建一个Service对象,用于定位这个心跳归属于哪个服务。第二步,从instanceMetadata中解析出metadata信息,这样即使心跳请求里没带metadata,服务端也能从已有的元数据缓存中恢复实例的完整信息。第三步,调用ServiceManager.updateBeatInfo,这才是真正更新内存状态的地方。
ServiceManager.updateBeatInfo内部还会再往下走,最终会调用到Service的registerInstance或者类似逻辑,把实例的最后心跳时间给刷掉。这里有一个关键点:心跳不是简单地在Map里更新一个时间戳,它需要保证这个实例确实“存在”,如果实例不存在,心跳会触发一次注册。这就解释了为什么你手动用HTTP工具发心跳请求,能把一个未注册的实例给“顶”上去。
3.2 distro与mysqlStore:实例状态落库的两种路径
服务端的健康状态最终要反馈到存储层。这里我们分成临时实例和持久实例两条路径。
临时实例走的是Distro协议,代码路径在com.alibaba.nacos.naming.core.v2.service.impl.EphemeralClientOperationServiceImpl。这个类维护了一个DistroClientStore,里面是内存中的实例列表。每次心跳更新时,不仅要改本机的内存,还要把变更同步到集群的其他节点。如果你打开源码看,会发现心跳更新会被封装成一个DistroClientData,通过DistroProtocol组件异步同步给其他节点。
持久实例走的是PersistentClientOperationServiceImpl,它会依赖ConsistencyService(具体是PersistentConsistencyServiceDelegateImpl)把数据写入MySQL。注意,这里的数据结构是Client(客户端)级别的,每条client记录对应一个客户端,客户端下挂多个实例。
这两条路径有一个共性:心率更新的结果不是立刻体现在实例消失上,而是维护一个lastUpdatedTime之类的时间戳。后续的健康检查线程会拿这个时间戳和当前时间做差,一旦超时,就进入剔除流程。
3.3 心跳时间戳的更新与lastBeat记录
Nacos服务端用来判断实例是否健康,依赖的不只是心跳本身,而是心跳时间戳和当前系统时间的差值。
在具体实现里,实例的状态以“存活标记”存在。比如com.alibaba.nacos.naming.core.v2.pojo.Instance,内部有一个healthy的布尔字段和一个lastUpdatedTime字段。每次handleBeat成功,都会把lastUpdatedTime更新为当前时间,同时把healthy字段置为true。
这里有个很关键的点:为什么不能直接利用System.currentTimeMillis()判断?因为服务端和客户端可能存在时钟偏差,而且心跳更新与健康检查线程之间会有并发竞争。所以Nacos用的是相对时间来比较:健康检查线程启动时记录一个基准时间,心跳更新线程会更新实例的时间戳,最后通过TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - instance.getLastUpdatedTime())来判断超时时间。一旦超时超过阈值,实例被标记为unhealthy,再经过一段时间就会被剔除。
我特别提醒一下,源码中判断是否应该剔除的等待时间,并不是你配置的healthCheckTimeout,它会乘上一个系数,给网络抖动留出缓冲。直接看代码发现,剔除前的宽限期通常会比单纯的超时时间更长,这是为了避免瞬断误杀。
4. 服务剔除机制源码:HealthCheckProcessor与保护阈值
如果心跳机制是“维持生命”,那服务剔除就是“宣告死亡”。这一节是整篇文章的高潮,因为剔除逻辑牵涉到健康检查线程池、处理器选择、保护阈值等多个组件,读懂了这里,才算真正吃透服务健康管理。
4.1 三类HealthCheckProcessor的选择逻辑
健康检查的入口在HealthCheckReactor,它初始化时会创建三组处理器:
| 处理器 | 适用场景 | 核心策略 |
|---|---|---|
| TcpSuperSenseProcessor | 临时实例,服务端主动探活TCP端口 | 通过NIO非阻塞方式,批量检测实例端口连通性 |
| MysqlCheckProcessor | 持久实例,基于数据库心跳记录检测 | 查询数据库中的心跳时间,判断存活 |
| GrpcHealthCheckProcessor(2.x) | gRPC连接实例(2.x客户端的临时实例) | 基于连接状态判断,服务端与客户端保持长连接,不需要TCP探活 |
处理器之间是怎么选择的?核心在HealthCheckReactor.scheduleTcpSuperSenseTask或者HealthCheckCommon中。流程是先拿到当前Service,遍历它下面的所有实例,根据实例类型选处理器。具体可见AbstractHealthCheckProcessor的抽象类定义,以及两个子类各自实现的process方法。
如果你用的是Nacos 2.x,绝大多数临时实例会走GrpcHealthCheckProcessor。这个处理器不需要主动发起网络请求,而是依赖gRPC连接的健康状态。只要连接没断,就认为实例存活,不健康判定取决于远端的PUSH或者连接异常回调。因此在2.x集群里面,如果客户端异常退出,连接会自动关闭,服务端很快就知道实例不在了,比1.x的5秒心跳轮询快得多。
4.2 TcpSuperSenseProcessor探活流程
虽然gRPC处理器效率更高,但TCP探活这条老路仍然被很多老版本客户端使用,而且它也是理解Nacos健康检查思想的最佳入口。
TcpSuperSenseProcessor的核心数据结构是一个任务队列,每次把待处理的Task(比如某个服务的某个实例)先收集起来,然后交给NIO Selector统一处理。大致流程是这样的:
- 遍历待处理的Task列表,为每个Task构造一个基于SocketChannel的探测连接。
- 注册到Selector上,设置OP_CONNECT或者OP_READ事件。
- 主循环中通过selector.select()等待事件就绪。
- 如果在超时时间内事件就绪,说明实例端口可以连通,标记为健康。
- 如果超时或者发生IO异常,则记录失败次数,达到阈值后标记实例为不健康。
这里有个细节:它探测的是实例的业务端口,而不是Nacos的端口。比如你有一个订单服务注册到Nacos,地址是192.168.1.10:8080,那么健康检查线程会去连192.168.1.10:8080这个端口。如果TCP连接建立成功,就认为业务实例还活着;如果连不上,就认为挂掉了。
这个机制的好处是不依赖于客户端自己做上报,服务端有主动权,坏处是如果网络分区、防火墙拦截、或者业务端口仅对内网开放但健康检查的网段不通,都会产生误判。
4.3 保护阈值protectThreshold的计算意义
很多同学看Nacos控制台都能看到“保护阈值”这个参数,但不太清楚它的代码位置和算法逻辑。这里一定要单独讲。
保护阈值在Service类中有对应的字段,叫做protectThreshold,默认值为0。当它大于0时,健康检查的行为会发生变化。
核心逻辑在HealthCheckCommon或者AbstractHealthCheckProcessor里,大致是判断当前服务的“健康实例数量 / 总实例数量”是否小于等于这个阈值。如果小于等于阈值,即使实例被判断为不健康,也不会真正执行剔除,只会在实例状态上标记unhealthy。
为什么要这么设计?想象一个极端场景:某个服务有100个实例,因为机房网络故障,80个实例瞬间失去心跳。如果注册中心立刻把这80个实例全部剔除,剩下20个实例会承接所有流量,很快就被打垮,然后这20个也挂掉,最终导致整个服务雪崩。保护阈值的意义就是“我宁可把不健康的实例继续留在列表里,让调用方自己去做重试和熔断,也不要让可用实例数量瞬间塌缩”。
在源码里对应是这样一段计算逻辑:
double healthyCount = service.getHealthyInstanceCount(); double totalCount = service.getInstanceCount(); double healthyRatio = healthyCount / totalCount; if (healthyRatio <= service.getProtectThreshold()) { // 不触发剔除,仅标记状态 // 走 protect 逻辑 }实际使用中,我建议把保护阈值设置成0到1之间的小数,比如0.6或者0.7,而不是直接写0。默认0意味着不启用保护,一旦发生大面积故障,注册中心就会“硬剔除”,风险还是很大的。
4.4 自动注销与unregisterInstance链路的闭环
一旦实例满足了剔除条件,接下来就会进入自动注销流程。这个链路的关键类是AbstractHealthCheckProcessor中的callAutoRemove方法。
逻辑不复杂,但调用链值得关注。大致是:
- 健康检查线程判断某个实例超时,收集到待删除列表。
- 调用
ServiceManager或AbstractInstanceOperator,执行unregisterInstance。 - unregisterInstance内部根据实例类型:临时实例走DistroClientStore.removeClient,持久实例走数据库删除。
- 实例被移除后,ServiceStorage会触发ServiceEvent,推送给订阅了该服务的客户端。
源码里对应AbstractHealthCheckProcessor中的最后一段:
if (instance.isHealthy()) return; // 移除实例,触发事件 serviceManager.removeInstance(namespaceId, serviceName, instance);从外部看,整个闭环是:客户端发送心跳 → 服务端更新lastUpdatedTime → 健康检查线程轮询比较时间差 → 超时后标记unhealthy → 达到阈值后剔除 → 推送最新服务列表给订阅者。任何一个环节卡住,都会出现“服务列表里有实例但调不通”或者“实例消失了但客户端还拿着旧缓存”的问题。
5. 常见问题与排查实录
读源码的目的是为了解决生产问题。这一节我整理了平时排查中遇到的几个高频问题,结合上面的原理给出诊断思路。
5.1 实例明明在跑,却被Nacos标记为不健康
这类问题在1.x版本中特别常见。首先确认你用的是HTTP心跳还是gRPC心跳。如果是HTTP心跳,再去检查服务端到实例业务端口的TCP连通性。很多情况下是因为健康检查线程所在机器的防火墙规则或者安全组没有开放业务端口,导致TCP探活失败。
如果是2.x的gRPC连接方式,优先检查客户端和服务端之间的长连接是否正常。你可以通过Nacos控制台查看这个实例的连接状态。另外,如果客户端长时间处于Full GC停滞状态,gRPC的连接也可能发生假死,需要检查客户端GC日志。
5.2 客户端日志显示心跳发送成功,但服务端很快剔除
这类问题多半是时间戳出现了漂移。最典型的场景是服务端所在机器的时间突然往前跳,导致计算出的实例超时时长比实际短。Nacos对服务端各节点的时间一致性要求很高,生产环境务必让集群所有节点都开启NTP时间同步,否则会出现“心跳明明很快,剔除也很快”的奇怪现象。
还有一个隐蔽因素:如果实例注册时带了metadata,并且metadata里写了preserved.heart.beat.interval和preserved.heart.beat.timeout,服务端会优先用这两个值覆盖全局参数。我曾经遇到过开发同学在metadata里配置了一个极短的heart.beat.timeout,导致刚上线就被剔除。
5.3 保护阈值误开启导致流量异常
保护阈值一旦设置不当,会把不健康实例继续返回给调用方,调用方拿到一堆失败请求。排查思路是:看Nacos控制台的服务详情,确认健康实例数和总实例数,看看保护阈值是否小于健康比例。
比如总实例100个,健康实例90个,保护阈值设置成0.95,这时候健康比例是0.9,没到保护阈值,不触发保护。但如果你设置成0.8,健康比例0.9大于0.8,也不会保护。只有健康比例低于阈值时才会保留不健康实例。我见过最坑的配置是把保护阈值设成0.1,结果服务还剩10%健康实例时就开始保护,流量全部打到了那些已经失联的实例上。
5.4 数据库版本和启动环境问题导致服务端异常
虽然本文重点是源码,但顺带提醒一个和Nacos服务端环境强相关的问题:Nacos 2.x老版本对MySQL 8.0.21之后的时区处理存在兼容问题。如果你用MySQL 8.4.x版本启动Nacos,可能会出现心跳状态写库失败的现象,表面表现为“服务端日志报错,实例状态无法持久化”。
最直接的方式是找到和你MySQL版本匹配的Nacos版本。比如MySQL 8.4.x配Nacos 2.5.x以上比较稳妥,同时要注意建库脚本,Nacos从2.2之后开始引入config_info_gray、his_config_info等表,低版本脚本和高版本服务端之间不能乱用。
5.5 客户端版本与心跳通道不一致导致行为异常
假设服务端是Nacos 2.5.0,客户端是Spring Cloud Alibaba 2.2.x内置的Nacos Client 1.4.x。这时候客户端只会发HTTP心跳,服务端判定为老客户端,行为逻辑和2.x原生客户端完全不同。
如果你想验证当前客户端到底是什么心跳通道,可以查看客户端日志,搜BeatTask或者GrpcHeartBeat关键字。1.x客户端会有“PUBLIC-###-DEFAULT@@xxx sending beat to server”的日志,2.x客户端则会有gRPC连接建立日志。
生产环境最稳妥的方案:服务端升级到2.x之后,客户端也要尽量跟着升级,避免老客户端长连接和健康检查的新特性没法用上,否则你只是在“表面升级”。
Nacos的心跳机制和服务剔除,核心代码量虽然不算大,但每一行设计都建立在分布式系统的基础问题上。我读源码最大的感受是,Nacos并不是简单地在实例上打一个“健康/不健康”标签,而是通过一套“客户端上报+服务端探活+保护阈值+事件推送”的组合拳,尽量在不稳定的网络环境中保持服务发现的可用性。
如果你正在排查线上问题,建议先从健康检查线程的日志入手,确定实例是被TCP探活判定失败,还是被超时阈值判定失败,再去沿着上面的调用链逐级定位。源码读多了你会发现,注册中心的心跳机制其实是“异地多活”“故障转移”这些更高级话题的地基,把这一块打扎实了,后面理解任何分布式系统都会轻松很多。