主题
服务注册与发现
一句话:注册中心维护「服务名 → 可用实例列表」,让调用方在扩缩容、重启、故障时不用改配置;代价是调用方拿到的是一份可能短暂过期的视图,必须自己承担选错实例的风险。
适用版本:Nacos 3.2.4;2.5.4 的行为差异单独标注。Spring 示例为 Spring Cloud Alibaba 2025.0.0.0(Boot 3.5,nacos-client 3.0.3)。默认值以官方文档和对应版本源码为准。
为什么需要
没有注册中心时,调用方只能把 ip:port 写死在配置里。实例换机器、扩副本、缩副本都要改配置并重启;实例已经挂了,列表里还在,流量继续打过去。
注册中心解决三件事:提供方启动时登记自己;注册中心判断它是否还活着;消费方订阅列表,并在变更时收到通知。注意它只解决「找谁」,不解决「调用是否可靠」——超时、重试、熔断仍在调用方。
核心机制
一次注册、发现、调用
图里有两条容易混淆的箭头:N1-->>N2 是集群内部的分片复制,客户端不参与;C->>P 是真正的业务调用,完全不经过 Nacos。所以注册中心挂了,已启动的 Consumer 仍能按缓存直连;它失去的是「看到新实例」的能力。
临时实例与持久实例:一致性协议的分界线
| 维度 | 临时实例 ephemeral=true(默认) | 持久实例 ephemeral=false |
|---|---|---|
| 一致性 | Distro,AP:每个节点可写,异步复制 | Raft(JRaft),CP:多数派确认 |
| 存活判断 | 客户端心跳 + 连接活跃度 | 服务端主动 TCP/HTTP 探测 |
| 客户端断开后 | 超时自动剔除 | 保留,只标记不健康 |
| 写入代价 | 低,允许短暂旧视图 | 高,少数派不可写 |
| 适用 | 普通无状态微服务 | 没有心跳能力、必须保留名单的服务 |
Distro 用带时间戳的后写覆盖(Last-Write-Wins)解决冲突:分区期间两边都可能接受注册,恢复后以时间戳较新的为准;无状态服务可以接受,不能调错实例的路由要额外校验。
心跳与摘除:三个时间窗
Nacos 2.x/3.x 的临时实例有两层保活:
- 连接层:gRPC 客户端靠长连接保活(约 5s 一次 keepalive),HTTP/兼容客户端则用
heartBeat=true续约;服务端约 20s 没活动判连接过期。 - 实例层:约 15s 没心跳标记不健康,约 30s 没心跳从列表剔除。
这三个值是在「误判」和「发现速度」之间取折中:心跳太密,QPS 随实例数线性上涨;太稀,故障实例停留更久。改之前先压测,不要凭感觉调到 1s/3s/6s。
持久实例由服务端主动探测,探测失败只标记不健康、不删除;所以它能保住名单,但客户端仍可能把流量打到不健康实例上,除非负载均衡显式过滤。
推送、缓存与兜底
服务端通过 gRPC 主动推送变更,客户端收到后更新内存缓存并落盘。推送不是唯一保障:客户端重连后会重新订阅并全量拉取,另有定时比对纠正丢失的推送;Spring Cloud LoadBalancer 还有一层实例缓存,默认 35s(spring.cloud.loadbalancer.cache.ttl)。
所以「注册成功」不等于「立刻全量可见」:新实例最坏要等 LB 缓存刷新,下线实例最坏 35s 内仍可能收到流量。发布和下线都要留排干窗口。
推空保护:保护的是「空」,不是「旧」
Java 客户端 1.4.1+ 提供 namingPushEmptyProtection,默认 false。开启后,服务端推来空实例列表时客户端忽略该次推送,继续用本地缓存。Spring Cloud Alibaba 对应:
yaml
spring:
cloud:
nacos:
discovery:
naming-push-empty-protection: true它不是「自我保护」的替代品。该开没开时,控制台误删服务或注册中心异常返回空列表,消费方缓存被清空,瞬间全部失败;不该开却开时,服务确实缩容到 0,消费方仍拿着旧实例调用,直到缓存过期或人工介入。开启前先明确「空列表是故障还是合法状态」;合法缩零的服务不要开。
权重、元数据与保护阈值
weight默认 1.0,供客户端负载均衡按权重分配;权重 0 的实例仍可能出现在列表里,要下线请用enabled=false或先摘除。metadata是自由键值,常用于版本号、机房、灰度标签,Nacos 不解释它,选路逻辑在调用方。protectThreshold默认 0:健康实例比例低于阈值时,返回全部实例(含不健康),避免「健康实例太少,流量把剩下的也打挂」。设为 0 等于不启用保护;阈值调高会把不健康实例也放进来,要配合熔断。
断线重连与「幽灵实例」
客户端断线后会重连并重新注册。服务端清理旧连接需要时间:连接约 20s 过期,实例约 30s 剔除。这个窗口里可能出现:
控制台可能短暂看到同一 ip:port 的旧记录或实例消失后重现;连接数大于实际客户端数,实例的 connectionId 发生变化;调用方在窗口内拿到已死实例,表现为少量连接拒绝或超时。
多数情况下连接过期后会自愈。持续不消失时,先查客户端/服务端版本是否匹配、是否有大量短生命周期客户端反复建连,再查服务端连接清理日志。不要一上来就重启 Nacos,重启会放大重连风暴。
实战场景
一、一次误删服务让全部调用瞬间失败
现象:运维在控制台清理测试残留服务时误删了生产 payment-service 的服务定义。删除后 3 秒内,所有调用方报 No instance available,下单失败率 100%;服务重新注册后约 35 秒才恢复。
排查:客户端收到空列表时默认照单全收。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 服务端是否返回过空 | 服务端操作审计、实例列表变化时间 | 与失败开始时间对齐即可确认 |
| 客户端是否清空缓存 | 客户端日志 push empty;本地缓存与服务端列表比对 | 缓存被清空 → 未开启推空保护 |
| 推空保护配置 | namingPushEmptyProtection(Java 客户端 1.4.1+,默认 false)/ SCA naming-push-empty-protection | 默认 false,即“空也接受” |
| 恢复时间 | 客户端定时全量比对周期 + LB 缓存 TTL(35s) | 恢复不是瞬时的,靠兜底拉取 |
| 是否合法缩零 | 该服务是否可能被业务合法缩到 0 | 合法则不能开保护,否则幽灵调用 |
对症做法:
yaml
# 生产核心服务开启推空保护:空列表被视为异常,继续使用本地缓存
spring:
cloud:
nacos:
discovery:
naming-push-empty-protection: truetext
1. 开启前先回答“空列表是故障还是合法状态”:
可能被合法缩零的服务不要开,否则会一直调用已下线实例直到缓存过期或人工介入。
2. 控制台权限收紧:生产服务的删除需要双人复核;命名服务操作纳入审计。
3. 消费方要有兜底:熔断 + 最后一次已知列表的短时使用,
而不是把“注册中心给了空”直接翻译成“业务失败”。复盘要点:这项配置的价值在于把“瞬时全灭”变成“按缓存 TTL 逐步退化”,但它保护的是“空”,不是“旧”——AP 模式下读到旧列表是设计取舍,不能靠它兜住。判据是演练:演练里包含“删除服务定义后消费方仍能用本地缓存调用 N 秒”,N 至少覆盖一次告警响应时间。相关配置见 配置中心,处置动作见 Nacos 生产踩坑。
二、发布时实例“消失了又重现”:连接清理窗口
现象:滚动发布期间,控制台看到同一 ip:port 的实例先消失、十几秒后又出现,且实例的 connectionId 变了;调用方在这段时间收到零星 Connection refused。有人提议重启 Nacos 节点“清一下状态”,被拦下后故障自行愈合。
排查:重连与清理之间本来就有重叠窗口。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 时间窗是否与协议一致 | 连接约 20s 过期、实例约 30s 剔除 | 消失-重现间隔落在这两个数内属正常 |
| 连接数与客户端数 | 服务端 gRPC 连接数 vs 实际客户端进程数 | 连接数偏大 → 存在未清理的旧连接 |
connectionId 变化 | 实例详情里的 connectionId | 变化说明是新连接重新注册,旧记录待清理 |
| 版本匹配 | 客户端与服务端版本组合 | 不匹配会导致重复注册/清理异常,持续不愈时优先查 |
| 短生命周期客户端 | 建连/断开速率曲线 | 反复建连(脚本、临时任务)会放大窗口 |
| 是否自愈 | 后续 2 分钟内的实例列表稳定性 | 自愈即无需干预;持续不消失才需要排障 |
对症做法:
properties
# 1. 客户端重连要有退避与 jitter,避免整批实例同时重建连接
# 2. 优雅下线:先注销,再等超过 LB 缓存 TTL(35s)停进程,避免窗口内被调用
spring.cloud.loadbalancer.cache.ttl=35sjava
// 3. 调用方兜底:连接拒绝类错误做一次“换实例”重试,仅对幂等读
@DubboReference(retries = 1, cluster = "failover", timeout = 300) // 或 Feign 层等价策略text
4. 不要重启服务端来“清理状态”:重启会让所有客户端同时重连并重新注册,
把十几秒的局部窗口放大成一次集群级重连风暴。
5. 持续不消失时按顺序查:客户端/服务端版本 → 是否有大量短生命周期客户端 → 服务端连接清理日志。复盘要点:这个窗口的存在是连接模型的必然结果,验收方式是“发布期间调用方 Connection refused 数量”而不是“控制台看起来干净”。把连接数、注册实例数、心跳超时数、推送失败数做成常态看板,实例数突降通常先于调用失败出现——这条顺序让排障从被动变主动。
三、分区期间健康实例骤降:protectThreshold 的两难
现象:一次跨机架网络抖动导致某服务 12 个实例里 7 个心跳超时被标不健康。调用方先是大面积 no instance available(当时健康实例只剩 5 个,但 LB 缓存里被清空),随后又出现“请求全部打到 5 个实例、这 5 个被打挂”的连锁失败。
排查:默认 protectThreshold=0 等于不启用保护。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 健康实例比例 | instance/list 里 healthy 字段的比例与时间曲线 | 比例骤降即触发该场景 |
| 保护阈值 | 服务的 protectThreshold 实际值 | 0 → 全挂时返回空列表,没有任何可试实例 |
| 剩余实例压力 | 5 个实例的 QPS、线程池、RT 曲线 | 流量集中 → 二次故障 |
| 心跳链路 | 抖动是否只影响心跳通道(业务端口可达) | 心跳不通但服务可用,属于误判 |
| 实例类型 | 临时(Distro/AP)还是持久(Raft/CP) | CP 在少数派节点不可写,恢复期查询也会失败 |
对症做法:
bash
# 1. 给关键服务设保护阈值:健康比例低于阈值时返回全部实例(含不健康),让调用方至少有可试目标
curl -s -X PUT "http://127.0.0.1:8848/nacos/v3/admin/ns/service" \
-d "serviceName=inventory-service" -d "namespaceId=prod" \
-d "protectThreshold=0.4" # 0.4 表示健康实例不足 40% 时把不健康实例也返回text
2. 阈值调高必然把不健康实例放进列表,所以必须配合调用方熔断与快速失败:
否则“可试”会变成“试到超时”,用户等待时间被拉长。
3. 心跳误判要从链路解决:抖动期间只丢心跳不丢业务时,
考虑让调用方对“连接被拒/超时”做实例切换,而不是完全依赖健康标记。
4. 不能接受旧视图的支付路由,用持久实例(CP)或业务层幂等校验,
但要接受少数派不可写的代价;不要为了“一致”把所有服务切成 CP。复盘要点:这条阈值的作用域要说准确——它不是 Eureka 的自我保护,但效果类似;说“Nacos 没有自我保护”是不准确的。判据是演练:杀掉 60% 实例,验证调用方既不拿到空列表、也不把全部流量压到剩余实例上;同时确认健康检查只能证明进程和连接还在,不能证明数据库连得上、线程池没满,业务级健康要由 readiness 或调用方熔断补充。
最小上手
沿用 01 起的 3.2.4 单机实例。注册临时实例,并手动续一次心跳:
bash
# 注册(ephemeral=true)
curl -s -X POST "http://127.0.0.1:8848/nacos/v3/client/ns/instance" \
-H "accessToken: $ACCESS_TOKEN" \
-d "serviceName=order-service" -d "ip=127.0.0.1" -d "port=8080" \
-d "namespaceId=public" -d "ephemeral=true"
# 心跳(同一接口,heartBeat=true;只续约,不更新元数据)
curl -s -X POST "http://127.0.0.1:8848/nacos/v3/client/ns/instance" \
-H "accessToken: $ACCESS_TOKEN" \
-d "serviceName=order-service" -d "ip=127.0.0.1" -d "port=8080" \
-d "namespaceId=public" -d "ephemeral=true" -d "heartBeat=true"
curl -s "http://127.0.0.1:8848/nacos/v3/client/ns/instance/list?serviceName=order-service&namespaceId=public" \
-H "accessToken: $ACCESS_TOKEN"停止心跳后观察列表:约 15s 变不健康,约 30s 消失。这个实验比看文档更能建立「注册成功 ≠ 一直存在」的直觉。
Spring 侧只需要 discovery 依赖和地址:
yaml
spring:
application:
name: order-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
namespace: public
naming-push-empty-protection: true生产动作
- 默认用临时实例:普通微服务不要为了「稳」改持久实例;持久实例走 Raft,少数派不可写,代价被低估得最多。
- 优雅下线:先调注销接口或
preShutdown钩子摘除实例,等超过 LB 缓存 TTL(默认 35s)再停进程。 - 命名空间与分组:Namespace 隔离环境,Group 隔离业务线;服务坐标是三者组合,不要只看服务名。
- 保护阈值:健康实例比例经常骤降的服务评估
protectThreshold;设为 0 时健康实例全挂会返回空列表。 - 监控:gRPC 连接数、注册实例数、心跳超时数、推送失败数、CP 服务 leader 切换次数。实例数突降通常先于调用失败出现。
- 不要用 curl 维持长期实例:脚本注册的临时实例没有心跳,30s 后必然消失;长期存在请用 SDK 或持久实例。
排障路径:调用方无实例 → 查 instance/list → 没有则查提供方注册日志与 9848 连通性;有实例但调用失败 → 查实例 IP 是否是容器内网 IP、端口是否映射 → 修 spring.cloud.nacos.discovery.ip/port;实例频繁消失/出现 → 查 GC 停顿、网络抖动、连接过期时间;下线后仍有流量 → 查 LB 缓存 TTL 与排干时间。
边界
- AP 会读到旧列表:分区期间两边都可能注册成功,恢复后按时间戳收敛;不能接受旧视图的路由要在业务层做幂等和校验。
- CP 牺牲可用性:持久实例的少数派节点不可写,恢复期间注册/发现可能失败;不要为了「一致」把所有服务切 CP。
- 健康检查不等于业务健康:心跳只能证明进程和连接还在,不能证明数据库连得上、线程池没满。
- 注册中心不是配置库:高频写入会放大 Distro 同步与推送压力。
- 跨数据中心无原生复制:多机房优先本地集群 + 客户端路由,或引入外部同步方案。
讲清楚
服务注册与发现解决「实例地址会变」:提供方登记,注册中心探活,消费方订阅并在本地缓存,调用时直连实例,不经过注册中心。Nacos 的临时实例走 Distro 的 AP 复制,允许短暂旧视图;持久实例走 Raft 的 CP,强一致但少数派不可写。临时实例有两层保活:连接约 5s 保活、20s 过期,实例约 15s 不健康、30s 剔除;消费方还有约 35s 的负载均衡缓存,所以注册成功不等于立刻全量可见。推空保护默认关闭,开之前要想清楚「空列表是故障还是合法缩零」。它的边界是:注册中心只解决「找谁」,不解决「调用可靠」;超时、重试、熔断必须在调用方做。