Nacos 名字里带 "Config",但它的一半职责是服务发现。很多人把两件事混着理解:配置中心靠 ConfigService 拉配置,服务发现靠 NamingService 注册实例。后者走的是一条完全不同的链路——注册表在内存、一致性用 Distro、推送在 2.x 换成了 gRPC 长连接。弄不清这条链路,"服务列表为空但实例在跑"这类问题就只能靠重启碰运气。
1. 角色分工:NameServer 与 Config
| 模块 | 职责 | 客户端 | 存储 |
|---|---|---|---|
命名服务(naming) |
服务注册、发现、健康检查 | NamingService |
内存注册表 + data/ 下持久化 |
配置服务(config) |
配置发布、订阅、灰度 | ConfigService |
MySQL(外置)或 Derby(内嵌) |
| 一致性协议 | naming 用 Distro(AP),config 用 Raft(CP) | — | — |
一个 Nacos 进程里两个模块共用一个端口(默认 8848),但内部是两套数据、两套一致性。配错 namespace 时,通常是 naming 那边注册到了默认 public,而 config 那边读的是另一个空间,两边各错一半。
2. 1.x 与 2.x:推送链路的换代
| 维度 | Nacos 1.x | Nacos 2.x |
|---|---|---|
| 客户端长连接 | UDP 推送 + HTTP 心跳 | gRPC 双向流(端口 = 主端口 + 1000,即 9848) |
| 心跳 | 客户端每 5 秒 HTTP PUT | 连接级健康检查,无显式心跳包 |
| 推送 | UDP 包,服务端主动推 | 服务端沿长连接下发 |
| 连接数 | 每实例每 5 秒一个 HTTP 请求 | 每客户端一条 gRPC 连接 |
| 服务端负载 | 大量短连接 + UDP 重试 | 长连接 + 连接数上限 |
| 兼容性 | — | 2.x 服务端需同时开 8848 与 9848(+9849 集群间) |
2.x 最大的收益是把"每 5 秒一次 HTTP 心跳"变成"一条长连接",实例上到几万时差别是数量级的。代价是端口必须多开两个:只开 8848,客户端能连上却一直报"注册失败 / 获取不到实例",原因是 gRPC 端口不通。
# 2.x 端口自查:8848 HTTP / 9848 客户端 gRPC / 9849 集群间 gRPC
$ ss -ltnp | grep -E ':8848|:984[89]'
LISTEN 0 4096 *:8848 *:* users:(("java",pid=1201,fd=98))
LISTEN 0 4096 *:9848 *:* users:(("java",pid=1201,fd=112))
LISTEN 0 4096 *:9849 *:* users:(("java",pid=1201,fd=113))
三条都在才算端口齐;只有 8848 时,客户端日志会停在 Client not connected 反复重连。
3. 注册流程:内存 + 磁盘 + 异步通知
NamingService naming = NacosFactory.createNamingService(properties);
naming.registerInstance("order-service",
new Instance("10.2.3.4", 20880, "prod"));
naming.subscribe("order-service", event -> {
// event.getInstances() 是本次变化后的全量列表
});
服务端收到注册后按顺序做三件事:
| 步骤 | 动作 | 失败后果 |
|---|---|---|
| 1 | 写入内存注册表(Service → Cluster → Instance) |
内存失败极少,OOM 除外 |
| 2 | 写磁盘(持久实例写文件,临时实例只写内存 + 异步落盘) | 服务端重启丢临时实例(预期行为) |
| 3 | 异步通知订阅者 | 客户端延迟感知,靠定时拉取兜底 |
"异步"是关键。通知不是同步调用,而是投递到推送队列,由专门的线程批量推送。所以注册成功的响应不代表订阅者已经收到——这一点决定了客户端的"双保险"设计。
4. 临时实例与持久实例
| 类型 | ephemeral |
健康检查 | 服务端重启后 |
|---|---|---|---|
| 临时实例 | true(默认) |
客户端心跳 / 连接存活 | 消失,需重新注册 |
| 持久实例 | false |
服务端主动探测(TCP/HTTP/MySQL) | 保留,磁盘恢复 |
# 注册一个持久实例
curl -X POST 'http://127.0.0.1:8848/nacos/v1/ns/instance' \
-d 'serviceName=order-service&ip=10.2.3.4&port=20880&ephemeral=false'
临时实例的语义是"我活着我就心跳",天然适合 K8s 这种随时重建的环境;持久实例适合"物理机上的中间件,我要它即使挂了也留在列表里方便排查"。选错的典型后果:把数据库主机的实例注册成临时实例,DB 重启期间实例被彻底移除,监控里看不到这台目标。
5. 健康检查的两种模式
| 模式 | 谁发起 | 判定 | 适用 |
|---|---|---|---|
| 客户端心跳 | 实例自己 | 超过 heartbeatTimeout 未收到 → 不健康 |
临时实例(Java 客户端) |
| 服务端探测 | Nacos 服务端 | 主动连 TCP/HTTP,失败 N 次 → 不健康 | 持久实例 |
| 连接存活(2.x) | 服务端 | gRPC 连接断开即视为下线 | 临时实例(2.x 客户端) |
2.x 里 Java 客户端不再发心跳,改用连接状态。这意味着:容器网络异常但进程还在时,连接先断、实例先被摘,比心跳模式快得多;反过来,如果客户端与 Nacos 之间的 gRPC 连接被中间设备静默断开而客户端未察觉,注册状态可能与实际不符,直到连接保活失败。
健康≠可用。NamingService 返回的实例可能仍是 healthy=true,但业务侧数据库连不上——健康检查只覆盖到 Nacos 与实例之间这一跳。
6. 推送与拉取的双保险
| 方向 | 机制 | 周期 | 作用 |
|---|---|---|---|
| 服务端 → 客户端 | 长连接推送(2.x)/ UDP(1.x) | 事件触发 | 秒级感知变化 |
| 客户端 → 服务端 | 定时拉取 | 10 秒(namingLoadCacheAtStart 之外) |
补偿丢失的推送 |
| 客户端 | 本地缓存文件 | 启动时加载 | Nacos 不可用时仍能启动 |
推送丢失在 UDP 时代很常见(包被丢、客户端不在线),所以必须有定时拉取。把推送当成"加速",把拉取当成"正确性保证",设计思考才对。本地缓存是第三层兜底:Nacos 全挂时,客户端至少能用上一次的列表,而不是空列表启动。
$ ls ~/nacos/naming/prod/
DEFAULT_GROUP@@order-service DEFAULT_GROUP@@user-service
$ head -c 200 '~/nacos/naming/prod/DEFAULT_GROUP@@order-service'
[{"clusterName":"DEFAULT","ip":"10.2.3.4","port":20880,"healthy":true}]
缓存文件名是 group@@serviceName,内容就是上次拿到的实例 JSON。排查"服务列表为空"时,先看这个目录:文件存在但为空,说明问题在服务端;文件都没有,说明客户端压根没连上过。
7. 常用参数
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.cloud.nacos.discovery.namespace=prod
spring.cloud.nacos.discovery.group=DEFAULT_GROUP
spring.cloud.nacos.discovery.naming-load-cache-at-start=true
spring.cloud.nacos.discovery.failure-tolerance-enabled=true
| 参数 | 默认 | 说明 |
|---|---|---|
nacos.naming.clean.interval |
30000ms | 清理过期实例的周期 |
nacos.naming.clean.expired.seconds |
15000ms(临时实例不健康判定) | 超过即标记不健康 |
nacos.naming.push.batchSize |
1000 | 单批推送数量 |
nacos.naming.push.udpPushThreadCount |
1.x 相关 | 2.x 已不适用,按版本核对 |
namingLoadCacheAtStart |
false | 启动时先读本地缓存 |
failure-tolerance-enabled |
视版本 | Nacos 不可用时用缓存列表降级 |
clean.expired.seconds 与 K8s 的 terminationGracePeriodSeconds 不匹配时,会出现"实例已经死了但列表里还有"的窗口。把它压到 15 秒以内、同时保证规则合理,比事后加监控实在。
8. 集群一致性:Distro 是 AP
| 特性 | Distro | Raft(config 用) |
|---|---|---|
| 一致性 | 最终一致(AP) | 强一致(CP) |
| 数据分布 | 每个节点全量 + 按责任节点分片 | 全量复制 |
| 写路径 | 写到责任节点,异步同步到其他节点 | 多数派确认后返回 |
| 网络分区 | 分片内可写,可能短暂不一致 | 少数派不可写 |
| 适用 | 频繁变化的实例列表 | 配置数据 |
Distro 的"责任节点"按实例 IP 的哈希分配,服务端只负责自己的分片,修改后推给其他节点。所以刚注册完立刻在另一个节点上查,可能查不到——这是设计选择,不是 bug。跨节点查询出现短暂不一致时,客户端定时拉取会在一轮内修正。
这也是 Nacos 常被吐槽"服务列表偶尔少几个"的根因:AP 系统默认接受短暂不一致,用最终一致换可用性。
9. 三个高频坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 服务列表为空但实例在跑 | namespace 不一致(注册在 public,查的是 prod) |
两边都打日志打印 namespace,逐字比对 |
| 列表有实例但 group 不对 | spring.cloud.nacos.discovery.group 与查询的 group 不一致 |
group 参与服务唯一键,必须精确匹配 |
| Nacos 连不上时启动失败 | 未开 namingLoadCacheAtStart |
打开缓存加载 + 失败容忍 |
| 2.x 客户端连 1.x 服务端 | gRPC 端口不存在 | 升级服务端,或客户端显式降级 |
| 只开了 8848 端口 | 9848/9849 未放通 | 防火墙与 K8s Service 都要放行 |
namespace 那条最隐蔽:namespace 在 Nacos 里其实是 namespace ID,不是显示名。控制台新建命名空间时自动生成的 UUID 才是 ID,把"prod"这种名字填进配置,客户端就会新建一个同名空间并注册进去——看起来一切正常,只是查的人不知道要去哪找。
# 查实例,namespace 用 ID
curl -s "http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=order-service&namespaceId=3f1c2d9a-...-e7&groupName=DEFAULT_GROUP" | jq '.hosts | length'
10. 边界:什么情况别指望 Nacos 服务发现
| 需求 | 更合适的方案 | 理由 |
|---|---|---|
| 跨集群、跨云的服务发现 | DNS + Service Mesh | Nacos 单集群能力有限 |
| 需要强一致的服务列表 | ZooKeeper / etcd | Distro 是最终一致 |
| 极高频变动的端点 | 客户端负载均衡 + 长连接 | 推送风暴会拖垮服务端 |
| 只做配置 | ConfigService |
naming 与 config 是两条链路 |
| 需要流量权重与灰度 | Nacos 2.x 权重 + 元数据 | 权重只在选择时生效,不做流量治理 |
11. 自测
- Nacos 1.x 与 2.x 的推送链路各是什么,端口差异在哪?
- 临时实例在服务端重启后会怎样,为什么这是预期行为?
- 2.x Java 客户端为什么不再发心跳,靠什么判定下线?
- 推送和定时拉取分别承担什么职责,缺一个会怎样?
- Distro 属于 AP 还是 CP,为什么会"刚注册完在别的节点查不到"?
namespace配置里该填显示名还是 ID,填错会发生什么?