跳到正文

服务注册与发现 ​

一句话:注册中心维护「服务名 → 可用实例列表」,让调用方在扩缩容、重启、故障时不用改配置;代价是调用方拿到的是一份可能短暂过期的视图,必须自己承担选错实例的风险。

适用版本: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: true
text
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=35s
java
// 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 的负载均衡缓存,所以注册成功不等于立刻全量可见。推空保护默认关闭,开之前要想清楚「空列表是故障还是合法缩零」。它的边界是:注册中心只解决「找谁」,不解决「调用可靠」;超时、重试、熔断必须在调用方做。

把一门技术讲透到既懂也会用