Skip to content

Nacos内部原理与生产治理

Nacos同时提供Naming与Config,但这两条链的对象、通信、持久化和一致性机制并不相同。把“Nacos是AP”或“Nacos用Raft”当成全局结论都不准确:临时Naming数据主要走Distro,持久Naming数据走CP协议与JRaft,配置数据则以持久化存储、服务端缓存、MD5监听和客户端本地快照为主线。

本文的核心类与执行链已按Nacos 2.3.2源码核对,同时保留Nacos 1.x历史通信模型,帮助维护JDK 8/Boot 2存量系统,也给出Java 17+/Boot 3现代接入边界。实际项目必须以当前Spring Cloud Alibaba BOM和Nacos版本文档为准。

一、学完必须回答什么

  1. Naming和Config为什么不能用同一套原理解释。
  2. NacosNamingServiceNamingClientProxyDelegateNamingGrpcClientProxy怎样完成启动和注册。
  3. Nacos 2.x临时实例为什么更接近“连接生命周期”,而不是只靠旧版定时心跳。
  4. 订阅结果怎样进入 ServiceInfoHolder,为什么业务调用不需要每次访问Nacos。
  5. gRPC断线后注册和订阅怎样通过Redo恢复。
  6. Distro和JRaft分别处理哪类数据,网络分区时会怎样。
  7. ClientWorkerCacheData、MD5和Listener怎样完成配置监听。
  8. Config通知丢失、Nacos不可用或本地快照过旧时应用会读到什么。
  9. 8848、9848、9849、7848分别做什么,为什么只放通8848会出现“控制台正常、客户端失败”。
  10. 线上服务找不到、调用旧IP、配置不刷新时从哪里取得证据并恢复。

二、Nacos 1.x、2.x与Java版本线

技术线常见环境主要通信特征
历史存量线JDK 8、Boot 2.x、较早SCA/Nacos ClientNaming HTTP注册查询、UDP推送、客户端Beat;Config HTTP长轮询
Nacos 2.x线JDK 8或更高、匹配的SCAgRPC长连接、双向推送、连接事件、注册订阅Redo;保留部分HTTP兼容接口
现代应用线Java 17+、Boot 3.x、匹配的SCANacos 2.x通信模型、Config Data导入、Micrometer/Tracing和容器化治理

不能根据一篇Nacos 1.x文章断言2.x仍然只用HTTP、UDP和Beat;也不能看到2.x使用gRPC就断言所有HTTP接口都被删除。升级时要同时核对客户端、服务端、Spring Cloud Alibaba和代理端口。

三、Nacos 2.x端口模型

默认Web端口为8848时,常见端口关系为:

端口计算方式主要用途谁访问谁
8848server.port控制台、OpenAPI、兼容HTTP请求用户、客户端、代理到Server
9848server.port + 1000SDK客户端gRPCNacos Client到Server
9849server.port + 1001Server间gRPC通信Nacos Server节点之间
7848server.port - 1000JRaft/CP协议常见默认端口Nacos Server节点之间
mermaid
flowchart TD
    A["客户端配置8848地址"] --> B["客户端计算gRPC偏移"]
    B --> C["连接9848"]
    C --> D["Server间使用9849"]
    D --> E["CP协议使用7848"]

客户端通常仍配置 server-addr=host:8848,SDK内部按偏移连接9848,而不是要求业务直接把地址写成9848。若经过SLB、Ingress、Service Mesh或防火墙,必须同时验证HTTP与gRPC长连接;只代理8848会导致控制台可打开、旧HTTP接口可访问,但2.x注册、订阅或配置监听失败。

四、Naming客户端核心对象

类或对象作用关键边界
NacosNamingService对外NamingService实现组织注册、查询、订阅API
NamingClientProxyDelegateHTTP与gRPC代理门面按实例类型和Server能力选择协议
NamingGrpcClientProxy2.x Naming RPC实现管理RpcClient、注册、订阅和Server推送
NamingGrpcRedoService缓存期望的注册与订阅状态断线重连后重新注册和订阅
ServiceInfoHolder内存实例列表与磁盘缓存业务发现主要读本地快照
ServiceInfoUpdateService查询补偿与缓存更新是否启用和周期受客户端版本/属性影响
NamingPushRequestHandler处理服务端实例变更推送把新ServiceInfo交给Holder
InstancesChangeNotifier发布本地实例变化事件通知Spring Cloud等监听者

NamingClientProxyDelegate 同时持有HTTP与gRPC代理。Nacos 2.3.2中,临时实例走gRPC;持久实例在Server声明支持对应gRPC能力时也走gRPC,否则可回退HTTP。这说明协议选择不是简单的“2.x永远不用HTTP”。

五、Naming客户端启动全过程

mermaid
flowchart TD
    A["创建NacosNamingService"] --> B["解析地址与Namespace"]
    B --> C["创建ServiceInfoHolder"]
    C --> D["创建NamingClientProxyDelegate"]
    D --> E["创建HTTP与gRPC代理"]
    E --> F["RpcClient选择Server"]
    F --> G["建立gRPC长连接"]
    G --> H["注册推送与连接监听器"]

启动链要关注四类失败:

  1. 地址解析失败:域名、Endpoint或Server列表错误。
  2. 鉴权失败:用户名、密码、Token或身份键不匹配。
  3. 8848可通但9848不可通:gRPC连接失败。
  4. 客户端和Server能力不匹配:协议协商或请求类型失败。

RpcClient不是每次业务请求重新连接Nacos,而是维护长连接和连接状态。客户端会监听Server列表变化并切换节点;长连接断开后,Naming和Config各自恢复注册、订阅或监听上下文。

六、Nacos 2.x临时实例注册链

mermaid
flowchart TD
    A["应用构造Instance"] --> B["NamingService.registerInstance"]
    B --> C["缓存期望注册状态"]
    C --> D["发送InstanceRequest"]
    D --> E["Server按connectionId找到Client"]
    E --> F["Client关联Service与Instance"]
    F --> G["发布ClientRegister事件"]
    G --> H["Distro同步到其他节点"]

Nacos 2.3.2服务端的 EphemeralClientOperationServiceImpl 会:

  1. 校验Service确实是临时Service。
  2. 根据gRPC connectionId 找到连接型Client。
  3. 把Instance转换为 InstancePublishInfo
  4. 将Service和Instance加入Client。
  5. 更新revision和lastUpdatedTime。
  6. 发布注册与元数据事件。

临时实例的生命周期与客户端连接密切相关。连接关闭时, ConnectionBasedClientManager 触发Client断开,相关临时发布数据被清理,并通过Distro同步删除。它比“每5秒发一个HTTP心跳”更符合Nacos 2.x主链。

但旧HTTP客户端和兼容路径仍可能使用Beat与超时检查,所以准确回答应是:

text
Nacos 1.x临时实例常以客户端Beat维持;
Nacos 2.x原生gRPC客户端主要以长连接和连接事件维护,
服务端仍保留兼容健康检查路径。

七、断线后为什么需要Redo

客户端本次注册成功,不代表换到另一台Server后新连接天然知道旧连接注册了什么。NamingGrpcRedoService 在客户端缓存:

  • 期望保持注册的实例。
  • 正在注销的实例。
  • 已订阅的Service、Group和Cluster。
  • 当前操作是否已在Server成功。
mermaid
flowchart TD
    A["gRPC连接断开"] --> B["标记注册与订阅待Redo"]
    B --> C["RpcClient连接其他Server"]
    C --> D["定时Redo任务扫描"]
    D --> E["重新注册期望实例"]
    E --> F["重新建立服务订阅"]

这里存在恢复窗口:新连接建立到Redo完成之间,Server可能暂时没有该客户端的注册或订阅上下文。业务仍可能使用旧本地ServiceInfo,但新变更推送尚未恢复。生产要监控Remote连接状态、Redo失败和本地缓存年龄,不能只看“应用进程还活着”。

八、持久实例为什么走JRaft

持久实例不应因为某条客户端连接断开而删除。Nacos 2.3.2的 PersistentClientOperationServiceImpl 会把注册、更新、注销封装为 WriteRequest,交给CP协议:

mermaid
flowchart TD
    A["持久实例变更"] --> B["构造WriteRequest"]
    B --> C["CPProtocol.write"]
    C --> D["JRaft Leader复制日志"]
    D --> E["多数节点确认"]
    E --> F["状态机onApply"]
    F --> G["更新持久Client与实例"]

核心语义:

  • 需要多数派才能提交写入,网络分区失去多数派时宁可拒绝写入。
  • 状态机按提交日志执行注册、更新和删除。
  • 快照用于压缩日志和节点恢复。
  • 连接断开不会直接删除持久实例。
  • 服务端可通过TCP、HTTP、MySQL等方式主动健康探测并改变Healthy状态。

“持久”不等于“永远健康”,也不等于“永远参与负载均衡”。它表示注册数据生命周期不依附于短连接或会话。

九、消费者订阅与实例推送全过程

mermaid
flowchart TD
    A["消费者订阅Service"] --> B["发送SubscribeServiceRequest"]
    B --> C["Server返回当前ServiceInfo"]
    C --> D["Server记录connectionId订阅关系"]
    D --> E["实例变化产生推送任务"]
    E --> F["gRPC推送NotifySubscriberRequest"]
    F --> G["客户端更新ServiceInfoHolder"]
    G --> H["发布InstancesChangeEvent"]

服务端 SubscribeServiceRequestHandler 在同一次请求中完成两件事:返回当前实例列表,并把订阅关系绑定到当前连接。之后实例变化时,Server能沿连接推送新的ServiceInfo。

客户端 NamingPushRequestHandler 收到推送后不直接调用业务Feign,而是交给 ServiceInfoHolder.processServiceInfo()

  1. 计算ServiceKey。
  2. 读取旧ServiceInfo。
  3. 根据配置处理空推送保护。
  4. 替换内存中的ServiceInfo。
  5. 比较实例列表是否变化。
  6. 发布 InstancesChangeEvent
  7. 把变化后的列表写入磁盘缓存。

十、ServiceInfoHolder、本地缓存与旧实例窗口

ServiceInfo的缓存Key包含分组服务名和Cluster条件,概念上可表示为:

text
Namespace + Group@@Service + Clusters

业务调用不是每次访问Nacos:

mermaid
flowchart TD
    A["Feign发起逻辑调用"] --> B["ServiceInstanceListSupplier"]
    B --> C["读取本地ServiceInfo"]
    C --> D["过滤健康与路由条件"]
    D --> E["LoadBalancer选择实例"]
    E --> F["HTTP客户端调用IP端口"]

本地缓存带来可用性,也带来陈旧窗口:

  • Server不可用时,消费者可能继续使用旧列表。
  • 实例已下线但推送尚未到达时,仍可能调用旧IP。
  • 本地缓存已更新,但线程中已经取得的旧快照仍可能完成一次调用。
  • HTTP连接池还可能持有旧实例连接。
  • 空列表保护若开启,可避免错误空推送清空全部实例,但也可能延长调用坏实例的时间。

ServiceInfoHolder 可以把变化后的ServiceInfo写到磁盘;是否在启动时加载磁盘缓存由客户端属性控制,并非所有版本和配置都默认加载。磁盘缓存是可用性手段,不是权威注册表,必须记录缓存年龄并配合短连接超时、重试预算和熔断。

10.1 保护阈值、空列表保护和为什么可能返回不健康实例

Nacos 服务发现里经常被问到两个“看起来反直觉”的现象:

  1. 为什么服务明明有实例不健康,调用方还可能拿到它。
  2. 为什么服务端推了空列表,客户端却没有立刻清空本地缓存。

这不是因为 Nacos 想把请求打坏,而是注册中心在“快速摘除坏实例”和“避免误摘除导致全站无实例”之间做取舍。

mermaid
flowchart TD
    A["实例健康状态变化"] --> B["Nacos计算健康实例比例"]
    B --> C{"是否触发保护或空列表保护"}
    C -- "未触发" --> D["推送当前健康实例列表"]
    C -- "触发" --> E["保留或返回更宽的实例集合"]
    D --> F["Consumer更新ServiceInfo"]
    E --> F
    F --> G["LoadBalancer继续按本地规则过滤"]

保护阈值解决什么

服务端保护阈值通常用于防止大面积误判。假设一次网络抖动让 Nacos Server 暂时探测不到大量 Provider,如果立刻把这些实例全部标成不可用并推给所有消费者,所有调用方可能瞬间认为“没有可用实例”,业务全站失败。保护思路是:当健康实例比例低到某个阈值以下时,注册中心宁可返回更宽的实例集合,让调用方继续尝试部分实例,而不是把服务发现结果直接清空。

它的本质是可用性保护,不是健康性保证。

机制保护什么代价
健康过滤避免选择明确不健康实例健康误判时可能把所有实例摘掉
保护阈值避免大面积误摘除导致无实例可能把部分不健康实例重新暴露给调用方
调用端超时熔断兜底处理被选中的慢实例或坏实例配错会拖垮线程池或产生误降级

所以面试不能把“保护阈值”答成“保证实例健康”。准确说法是:

text
保护阈值是注册中心在大面积健康误判时的可用性兜底。
它可能让消费者继续看到部分非健康实例,因此调用方仍必须有短超时、熔断、
错误率统计、慢实例降权和幂等补偿,不能把注册中心健康状态当作唯一事实。

空列表保护解决什么

空列表保护更偏客户端或推送结果保护:当客户端收到异常的空实例列表时,不一定立刻把本地服务表清空,而是保留最后一次可用快照。它能避免服务端异常、推送异常、网络分区或版本兼容问题导致所有调用方瞬间无实例。

但它也有代价:如果空列表是真的,比如服务确实全部下线,那么客户端保留旧快照会延长调用旧实例的窗口。

收到空列表的可能原因适合怎么处理
服务真的全部下线应清空或快速失败,避免继续打旧地址
网络分区或服务端异常可短时间保留最后快照,避免全站无实例
Namespace、Group、Service定位错误应暴露配置错误并告警,不能长期使用旧缓存掩盖
灰度规则把候选全过滤应返回明确无目标版本,而不是退回稳定版本造成串流量

商业项目怎么落地

生产上不要只问“要不要开启保护”,而要按业务风险设计组合策略:

场景建议
普通查询服务可接受短时间旧快照,配短超时、有限重试、熔断和降级
下单、支付、库存写链路旧快照可以支撑寻址,但写请求必须有幂等号、状态查询和补偿
灰度版本强约束如果目标版本候选为空,应明确失败或按策略回退,不能悄悄串到其他版本
全部实例计划下线先发布停服公告或切流,再摘流和等待传播,不能依赖空列表保护
Nacos异常空推送记录本地快照年龄、最终候选数量、推送版本和Server节点,触发告警

排查时要看哪些证据

如果出现“注册中心显示无健康实例,但调用方还在调用”或“服务下线后仍被打到”,按下面顺序取证:

  1. Nacos Server 控制台或OpenAPI中该 namespace + group + service + cluster 的实例总数、健康数、权重、enabled和metadata。
  2. 是否配置了服务保护阈值,健康实例比例是否低于阈值。
  3. 客户端是否启用空列表保护或磁盘快照,当前 ServiceInfo 的更新时间和来源。
  4. Spring Cloud LoadBalancer 是否还有二级缓存,TTL是多少。
  5. 本次请求最终选中的 IP、端口、版本和候选数量。
  6. HTTP连接池是否复用旧连接,尤其是长连接、HTTP/2、gRPC和代理场景。
  7. 下游实例日志和Readiness时间线,确认它是“旧地址”“旧版本”“旧连接”还是“旧规则”。

JDK 8 Demo:空列表保护不是绝对正确

下面示例模拟客户端收到空推送时保留旧快照。它能提高短故障可用性,但也可能继续调用旧实例。

java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;

public class NacosEmptyPushProtectionDemo {

    static final class InstanceSnapshot {
        private final AtomicReference<List<String>> instances =
                new AtomicReference<List<String>>(Collections.<String>emptyList());

        void updateFromPush(List<String> pushed, boolean emptyProtection) {
            if (pushed.isEmpty() && emptyProtection && !instances.get().isEmpty()) {
                System.out.println("ignore empty push, keep last snapshot: " + instances.get());
                return;
            }
            instances.set(Collections.unmodifiableList(new ArrayList<String>(pushed)));
        }

        List<String> current() {
            return instances.get();
        }
    }

    public static void main(String[] args) {
        InstanceSnapshot snapshot = new InstanceSnapshot();
        snapshot.updateFromPush(Arrays.asList("10.0.0.1:8080", "10.0.0.2:8080"), true);
        snapshot.updateFromPush(Collections.<String>emptyList(), true);
        System.out.println("candidate=" + snapshot.current());
    }
}

运行结果:

text
ignore empty push, keep last snapshot: [10.0.0.1:8080, 10.0.0.2:8080]
candidate=[10.0.0.1:8080, 10.0.0.2:8080]

这段代码要表达的不是“永远忽略空推送”,而是让你理解取舍:服务发现更倾向在控制面异常时保留最后可用视图,但数据面必须用超时、熔断、幂等和观测兜底。

十一、Nacos到底有没有做负载均衡

要区分两种API:

  • 直接调用Nacos selectOneHealthyInstance() 时,Nacos客户端可使用权重随机选择。
  • Spring Cloud OpenFeign/Gateway链路中,Nacos Discovery主要提供本地实例列表,最终选择通常由Spring Cloud LoadBalancer及其扩展策略完成。

所以“注册中心有健康实例”仍不等于“Feign一定能调用”:

位置可能过滤或失败的条件
Nacos定位Namespace、Group、Service名错误
ServiceInfoCluster条件、Healthy、Enabled、Weight、metadata
LoadBalancerZone、Hint、灰度策略、缓存尚未更新
HTTP客户端DNS、连接池、连接超时、读取超时
业务服务Readiness、线程池、数据库和限流

十二、临时与持久实例健康检查

维度临时实例持久实例
生命周期依据1.x常见Beat;2.x原生客户端主要是连接持久化状态,不依赖客户端连接
失联处理超时或连接关闭后清理保留注册数据
健康检查客户端会话/Beat为主Server主动TCP/HTTP/MySQL等探测
适用容器化微服务、弹性实例固定基础设施、非客户端托管实例

健康检查通过也只证明探测端点满足条件,不代表订单接口、数据库事务或依赖全部健康。应用应有Readiness,只有核心依赖达到可服务状态才注册或放开权重;优雅下线时先摘流量、等待传播和在途请求,再关闭进程。

十三、Distro怎样同步临时数据

Nacos 2.3.2中,连接所在节点负责该临时Client。Client注册或变化后发布事件,DistroClientDataProcessor 只同步临时Client数据:

mermaid
flowchart TD
    A["负责节点接收Client变化"] --> B["生成DistroKey"]
    B --> C["加入延迟同步任务"]
    C --> D["向其他Server发送变更"]
    D --> E["远端更新Client数据"]
    E --> F["周期校验修复差异"]

DistroProtocol 具有:

  • 延迟Change/Delete同步任务。
  • 向指定Server同步。
  • 节点启动时加载快照。
  • 周期Verify任务。
  • 校验失败后从负责节点重新获取数据。
  • 数据处理器和传输器扩展点。

Distro优先可用性和最终收敛。节点间网络分区时,各节点可能短暂看到不同临时实例列表;恢复后依靠同步与校验收敛。它不是Raft,不要求每次临时实例注册都等待多数派提交。

十四、JRaft怎样处理持久数据

JRaft链路关注Leader、日志、Term、Commit Index、状态机和快照:

mermaid
flowchart TD
    A["写请求到任意节点"] --> B["转交Leader"]
    B --> C["追加Raft日志"]
    C --> D["复制到Follower"]
    D --> E["多数派确认提交"]
    E --> F["状态机顺序应用"]
    F --> G["定期生成快照"]

若三节点只剩一节点,它仍可能提供部分本地读或临时数据能力,但不能代表CP写入可继续成功。恢复时要先恢复多数派和Leader,再确认日志、快照和状态机追平,不能强行让多个孤立节点各自对外写。

十五、怎样准确回答“Nacos是AP还是CP”

正确回答应按数据类型拆分:

数据主要机制倾向
临时Naming实例Distro、负责节点、异步同步与校验AP/最终一致方向
持久Naming实例CPProtocol/JRaft、多数派提交CP方向
Config配置数据库存储、服务端缓存与通知、客户端快照独立配置链,不能简单套Naming结论

错误回答包括:

  • “Nacos全部是AP”。忽略了持久Naming的JRaft。
  • “Nacos全部是CP”。忽略了临时数据Distro和本地缓存。
  • “临时实例永不一致”。Distro会同步和校验,只是不是每次强同步。
  • “持久实例一定可用”。丢失Raft多数派时写入会受限。

十六、Config客户端核心对象

类或对象作用
NacosConfigServiceget、publish、remove、addListener入口
ClientWorker管理监听缓存、RPC客户端和监听任务
ConfigTransportClient抽象配置传输与监听执行
CacheData保存DataId、Group、Tenant、内容、MD5和Listener
ConfigBatchListenRequest批量上报客户端监听项和MD5
ConfigChangeNotifyRequestServer通知某个配置标识发生变化
LocalConfigInfoProcessor管理Failover文件和Snapshot文件

CacheData 使用 volatile content 保存当前内容,并同步维护MD5。每个Listener包装器保存上次回调MD5;新MD5不同时才触发回调,避免相同内容反复通知。

十七、应用启动拉取配置全过程

源码中的 getConfigInner() 不是只做一次Server GET,其读取优先级可概括为:

mermaid
flowchart TD
    A["按DataId Group Namespace定位"] --> B["先检查本地Failover文件"]
    B --> C["没有Failover则请求Server"]
    C --> D["成功后保存本地Snapshot"]
    D --> E["Server失败时读取Snapshot"]
    E --> F["交给Spring Environment"]

必须区分:

  • Failover文件是显式本地接管机制,存在时可优先于Server配置。
  • Snapshot是最近成功配置的本地副本,用于Server请求失败时降级。
  • Snapshot可能过旧,只能保证“尽量有配置启动”,不能保证读到最新值。
  • 本地快照可能包含数据库密码等敏感内容,必须保护目录权限和备份边界。
  • 本地配置、环境变量、命令行参数和远程PropertySource还存在Spring优先级问题。

若关键配置必须最新,否则宁可不启动,应使用fail-fast和版本校验;不能因为存在旧Snapshot就无条件继续提供写服务。

十八、Nacos 2.x配置监听不是旧版长轮询

Nacos 1.x常见链路是HTTP长轮询:客户端携带DataId/Group和MD5,Server在窗口内等待变化,变化后返回标识,客户端再拉内容。

Nacos 2.x客户端主链使用gRPC监听上下文:

mermaid
flowchart TD
    A["Listener加入CacheData"] --> B["发送批量监听与MD5"]
    B --> C["Server记录连接监听关系"]
    C --> D["配置变化后推送标识"]
    D --> E["客户端标记缓存不一致"]
    E --> F["重新查询完整配置"]
    F --> G["更新Content与MD5"]
    G --> H["回调发生变化的Listener"]

服务端推送通常是“DataId/Group/Tenant变了”,不是把所有新配置直接塞进推送消息。客户端收到 ConfigChangeNotifyRequest 后,将对应 CacheData 标为与Server不一致,唤醒监听任务,再查询完整配置并检查MD5。

连接重建时,ClientWorker会把监听上下文重新同步;周期性的全量同步和批量MD5比较也能弥补单次通知丢失。因此“推送丢一次就永远不刷新”不准确,但会存在延迟窗口。

十九、Listener回调和Spring刷新边界

CacheData.safeNotifyListener() 会选择Listener自带Executor或客户端执行器执行回调。回调中不应直接执行长事务、批量SQL或远程级联发布,否则会阻塞配置通知线程并形成配置风暴。

Spring层还要经过:

text
Nacos Listener收到新内容
-> 更新远程PropertySource或Environment
-> 发布刷新相关事件
-> 重新绑定支持刷新的配置属性
-> RefreshScope Bean按机制重建

以下对象不能因为Environment变了就假设已安全切换:

  • 数据源连接池。
  • MQ Consumer Group和Topic。
  • 大型线程池。
  • Netty EventLoop。
  • TLS证书和密钥。
  • 正在执行的批处理参数。

复杂资源要使用“构建新资源 -> 健康验证 -> 原子切换引用 -> 排空旧资源 -> 失败回滚”的运行时安全流程,而不是只加 @RefreshScope

二十、配置发布在Server内部怎样走

mermaid
flowchart TD
    A["控制台或API提交配置"] --> B["鉴权、参数与容量校验"]
    B --> C["写入持久化存储"]
    C --> D["发布ConfigDataChangeEvent"]
    D --> E["更新Server缓存与MD5"]
    E --> F["通知已监听客户端"]
    F --> G["客户端重新拉取并回调"]

“发布接口返回成功”只证明Server接受并持久化了发布,不证明:

  • 每个Nacos节点缓存都已更新。
  • 每个应用实例都已收到通知。
  • Spring Bean都已刷新。
  • 新参数在业务上安全。
  • 使用旧参数的在途任务已经结束。

Nacos支持基于旧MD5的CAS发布API思路,可降低多人同时覆盖造成的丢失更新,但业务平台仍需版本号、审批、灰度、回滚和实例生效确认。

二十一、Config一致性与数据库边界

集群模式下,配置和相关元数据通常存入外部数据库,各Server维护本地缓存和MD5并处理变更通知。数据库是配置事实的重要持久化来源,客户端Snapshot只是降级副本。

故障窗口包括:

  1. 数据库写成功,但部分Server缓存尚未更新。
  2. Server已更新,但部分客户端连接断开。
  3. 客户端已拉取,但Listener回调排队。
  4. Environment已更新,但复杂Bean仍在使用旧资源。
  5. 部分应用实例成功刷新,部分实例类型转换失败。

因此要观测“配置版本收敛”,而不是只观测Nacos控制台中的值。

二十二、Boot 2旧加载方式与Boot 3现代方式

22.1 JDK 8、Boot 2存量项目

旧项目常把Nacos地址和远程配置定位写在 bootstrap.yml,让配置在主ApplicationContext创建前加载。Boot 2.4以后Config Data机制发生变化;若仍使用bootstrap方式,通常需要匹配版本的bootstrap支持,不能只创建文件就认为一定生效。

yaml
spring:
  application:
    name: order-service
  cloud:
    nacos:
      server-addr: 127.0.0.1:8848
      config:
        namespace: production
        group: ORDER_GROUP
        file-extension: yaml
      discovery:
        namespace: production
        group: ORDER_GROUP

22.2 Java 17+、Boot 3现代项目

现代版本通常使用与SCA版本匹配的Config Data导入方式:

yaml
spring:
  application:
    name: order-service
  config:
    import:
      - optional:nacos:order-service.yaml?group=ORDER_GROUP
  cloud:
    nacos:
      server-addr: nacos.infrastructure.svc:8848
      config:
        namespace: production
      discovery:
        namespace: production
        group: ORDER_GROUP

optional: 表示远程配置失败时可继续启动,不适合所有关键系统。订单写服务若没有数据库、幂等和安全配置就不能正确工作,应考虑去掉optional或增加启动版本校验。

Config Data URI、扩展配置和刷新属性会随SCA版本变化,必须查看当前版本配置元数据;不要把Boot 2的bootstrap写法和Boot 3的import写法混用后凭加载顺序猜结果。

二十三、Java 17+、Boot 3商业代码示例

依赖版本交给兼容的Spring Cloud与Spring Cloud Alibaba BOM:

xml
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

配置属性使用不可变record,并显式携带配置版本:

java
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "order.routing")
public record OrderRoutingProperties(
        String version,
        boolean grayEnabled,
        int maxAttempts) {

    public OrderRoutingProperties {
        if (version == null || version.isBlank()) {
            throw new IllegalArgumentException("order.routing.version must not be blank");
        }
        if (maxAttempts < 1 || maxAttempts > 3) {
            throw new IllegalArgumentException("maxAttempts must be between 1 and 3");
        }
    }
}
java
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@EnableConfigurationProperties(OrderRoutingProperties.class)
public class RoutingStateController {
    private final OrderRoutingProperties properties;

    public RoutingStateController(OrderRoutingProperties properties) {
        this.properties = properties;
    }

    @GetMapping("/internal/routing-state")
    RoutingState state() {
        return new RoutingState(
                properties.version(),
                properties.grayEnabled(),
                properties.maxAttempts());
    }

    record RoutingState(String version, boolean grayEnabled, int maxAttempts) {
    }
}

生产内部端点必须鉴权,且不能返回密码、Token或完整远程配置。它只暴露无敏感的生效版本,便于发布平台判断所有实例是否收敛。

二十四、JDK 8可运行原理Demo:本地注册表快照

下面用纯JDK 8还原“推送线程替换快照,业务线程继续使用已取得旧快照”的关键语义:

java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public final class RegistrySnapshotDemo {
    private final AtomicReference<List<Instance>> snapshot =
            new AtomicReference<List<Instance>>(Collections.<Instance>emptyList());
    private final AtomicInteger sequence = new AtomicInteger();

    public void onInstancesChanged(List<Instance> instances) {
        snapshot.set(Collections.unmodifiableList(new ArrayList<Instance>(instances)));
    }

    public List<Instance> currentSnapshot() {
        return snapshot.get();
    }

    public Instance choose() {
        List<Instance> current = snapshot.get();
        if (current.isEmpty()) {
            throw new IllegalStateException("no available instance");
        }
        int index = (sequence.getAndIncrement() & Integer.MAX_VALUE) % current.size();
        return current.get(index);
    }

    public static void main(String[] args) {
        RegistrySnapshotDemo registry = new RegistrySnapshotDemo();
        Instance a = new Instance("10.0.0.1", 8080);
        Instance b = new Instance("10.0.0.2", 8080);
        registry.onInstancesChanged(Arrays.asList(a, b));

        List<Instance> inFlightSnapshot = registry.currentSnapshot();
        System.out.println("firstCall=" + registry.choose());

        registry.onInstancesChanged(Collections.singletonList(b));
        System.out.println("inFlightSnapshotSize=" + inFlightSnapshot.size());
        System.out.println("newSnapshotSize=" + registry.currentSnapshot().size());
        System.out.println("secondCall=" + registry.choose());
    }

    static final class Instance {
        private final String host;
        private final int port;

        Instance(String host, int port) {
            this.host = host;
            this.port = port;
        }

        @Override
        public String toString() {
            return host + ":" + port;
        }
    }
}

预期输出:

text
firstCall=10.0.0.1:8080
inFlightSnapshotSize=2
newSnapshotSize=1
secondCall=10.0.0.2:8080

这说明本地缓存更新不是“让全世界所有线程瞬间忘记旧实例”。调用旧地址的窗口还受旧快照、在途请求和HTTP连接池影响。

二十五、生产集群与网络规划

至少考虑:

层面要求
Server节点通常三个或更多奇数节点,跨故障域但控制延迟
Config数据库高可用、备份、恢复演练、连接池监控
客户端入口Server列表或支持gRPC长连接的负载均衡
防火墙验证8848、9848、9849、7848的实际方向
时间NTP同步,避免过期、日志和故障时间线混乱
资源CPU、堆、Direct Memory、线程、连接和磁盘
变更Server与Client分批兼容升级,不同时大跨版本

Kubernetes中不能只创建暴露8848的普通Ingress就结束。gRPC连接需要对应Service端口和超时配置,Server间端口应在集群网络内互通,控制台和OpenAPI不应暴露公网。

二十六、安全、鉴权与敏感配置

  1. 启用与当前版本匹配的Nacos鉴权,不使用默认账号和默认Token Secret。
  2. Server身份键、Token密钥和数据库密码使用独立Secret管理并定期轮换。
  3. Namespace主要是逻辑隔离,不能单独当作强安全边界。
  4. 控制台仅允许管理网络访问,发布API经过网关、审计和最小权限。
  5. metadata不要放Token、身份证、手机号或数据库密码。
  6. 本地Config Snapshot可能包含敏感值,要限制宿主机目录权限。
  7. 高敏感密钥优先交给专业Secret系统,或使用受支持的加密插件和密钥管理系统。
  8. 轮换凭据时要考虑长连接重认证、旧客户端和灰度窗口。

二十七、商业订单平台完整场景

订单服务调用库存和支付查询:

mermaid
flowchart TD
    A["订单服务启动"] --> B["注册临时实例"]
    B --> C["订阅库存与支付Service"]
    C --> D["ServiceInfo进入本地缓存"]
    D --> E["Feign从缓存取得候选实例"]
    E --> F["LoadBalancer选择实例"]
    F --> G["HTTP客户端执行调用"]

配置中心管理:

  • order.routing.version:实例生效版本。
  • 灰度开关:只影响查询或可回滚逻辑。
  • 下游超时:有边界地动态调整并受总Deadline约束。
  • 营销开关:可降级但要返回明确状态。
  • 数据源和MQ Group:不直接热刷新,走资源切换或重启发布。

滚动发布时,旧实例先从注册表摘除,但消费者仍可能在传播窗口内读到旧IP。应先把实例标为不接新流量或主动注销,等待注册推送与连接排空,再停止进程;客户端必须有短连接超时、有限重试和幂等语义兜底。

二十八、必须认识的失败窗口

失败窗口现象风险恢复原则
8848通、9848不通控制台正常但客户端连接失败无法注册、订阅或监听检查端口偏移、代理和防火墙
Client与Server连接断开本地旧列表还能调用注册/订阅上下文尚未Redo查remote日志、重连和Redo状态
Distro同步延迟不同Server实例列表不同消费者短暂读到不同结果查负责节点、Distro任务和网络分区
Raft失去多数派持久实例写失败不能提交CP变更恢复多数派和Leader,不做双主写
Server不可用消费者继续用旧缓存调用已下线实例限制缓存降级时间、短超时和熔断
空推送保护保留旧列表没有瞬间清空实例坏实例存在更久结合错误率决定是否启用和恢复
Config通知丢失部分实例刷新较晚配置版本分裂批量MD5重同步并检查实例版本
Snapshot过旧应用以旧配置启动连接错误库或旧下游关键配置fail-fast和版本下限
Listener回调阻塞Client已拉到新配置但Bean未更新配置风暴和部分生效回调轻量化、异步治理、版本告警
数据库写后通知延迟控制台已显示新值实例仍使用旧值观察发布到全实例收敛时间
Namespace使用显示名而非ID控制台看似正确客户端定位不到数据核对真实namespace ID
注册了容器不可达IP控制台有健康实例消费者连接超时修正IP选择和网络可达性

二十九、服务找不到或持续调用旧IP Runbook

29.1 先取得调用方证据

  1. 保存异常原文:No servers available、连接拒绝、超时或503并不是同一问题。
  2. 记录调用方应用、实例、服务名、Namespace ID、Group、Cluster、traceId和时间。
  3. 从受保护的诊断端点或客户端日志确认本地ServiceInfo实际有哪些实例,不只看控制台。
  4. 记录本地缓存更新时间、gRPC连接状态和Redo日志。

29.2 再检查注册中心

mermaid
flowchart TD
    A["调用失败"] --> B["核对本地ServiceInfo"]
    B --> C["核对Server注册表"]
    C --> D["检查Namespace Group Service"]
    D --> E["检查9848连接与Redo"]
    E --> F["验证注册IP从消费者可达"]
    F --> G["检查LoadBalancer过滤与超时"]

Windows端口验证示例:

powershell
Test-NetConnection nacos.example.internal -Port 8848
Test-NetConnection nacos.example.internal -Port 9848

不要用“重启消费者后暂时恢复”作为结论。重启会清空或重建本地状态并重订阅,只证明问题可能与连接、缓存或Redo有关;还要定位为什么旧连接没有自动恢复。

29.3 恢复顺序

  1. 错误Namespace、Group、Service名先修配置并灰度重启。
  2. gRPC网络问题先修代理/防火墙,再观察连接和Redo成功。
  3. 注册IP不可达先摘除坏实例,再修正IP选择策略。
  4. Server节点分裂先恢复集群通信,再确认Distro或Raft收敛。
  5. 恢复后验证所有消费者本地实例列表,而不是只看一个控制台页面。

三十、配置不生效或只部分实例生效Runbook

  1. 固定配置身份:DataId、Group、Namespace ID、配置类型和目标版本。
  2. 查应用使用bootstrap还是Config Data import,确认当前SCA版本支持该写法。
  3. 查最终Environment属性来源和优先级;Actuator端点必须鉴权并脱敏。
  4. 查Config客户端连接、批量监听、Server推送、重新查询和Listener日志。
  5. 查是否使用本地Failover文件或旧Snapshot。
  6. @ConfigurationProperties 是否注册、类型转换是否成功。
  7. 查Bean是否支持刷新,复杂资源是否完成原子切换。
  8. 按实例汇总生效版本,找出未收敛实例。
  9. 错误配置优先回滚旧版本,不在每台实例上手工改本地文件。
  10. 回滚后确认业务指标、连接池、线程池和下游压力恢复。

三十一、生产监控与日志证据

证据回答的问题
Client gRPC连接状态是否连到可用Server
连接重建和Redo次数是否频繁抖动或恢复失败
ServiceInfo数量与更新时间本地注册表是否陈旧
InstancesChangeEvent数量推送是否到达并产生变化
Distro同步/校验失败临时数据是否分裂
Raft Leader、Term和PeerCP集群是否有多数派
Config查询、监听和推送RT配置链路在哪一段变慢
CacheData MD5与生效版本客户端是否真正更新
Snapshot/Failover命中是否在使用本地旧配置
数据库连接和慢SQLConfig持久化是否受阻

Nacos客户端日志位置受版本和 JM.LOG.PATH 等设置影响,常见日志包括naming、config和remote类别。排障时先从当前运行参数确认真实目录,不要照抄固定路径。日志和诊断端点不得输出完整配置内容、Token和密码。

三十二、常见错误与后果

错误为什么错后果
认为Nacos每次转发Feign请求Nacos只提供实例数据排错方向错误,忽略本地LB和HTTP客户端
认为2.x临时实例只靠心跳主链已是gRPC连接与连接事件只查Beat却忽略9848和Redo
只开放88482.x客户端需要gRPC端口控制台正常但注册监听失败
把Nacos整体说成AP或CP不同数据走Distro、JRaft和Config链无法解释分区行为
把Namespace当安全边界它首先是逻辑隔离越权访问与错误发布
把Snapshot当最新事实它只是最近本地副本以旧配置启动产生数据事故
所有配置都加RefreshScope复杂资源有生命周期新旧连接并存、事务和消息异常
发布成功就宣布完成客户端和Bean仍有传播窗口多实例配置分裂
注册容器内部不可达IP控制台只显示注册信息消费者无法建立连接
下线后立刻杀进程本地缓存和连接池仍有旧地址滚动发布出现短时错误

三十三、源码对象核对表

本文关键结论对应Nacos 2.3.2源码对象:

原理主要类
Naming入口NacosNamingService
协议选择NamingClientProxyDelegate
gRPC注册订阅NamingGrpcClientProxy
断线恢复NamingGrpcRedoServiceRedoScheduledTask
实例本地缓存ServiceInfoHolderDiskCache
推送处理NamingPushRequestHandlerInstancesChangeNotifier
临时实例服务端写入EphemeralClientOperationServiceImpl
Distro同步DistroProtocolDistroClientDataProcessor
持久实例CP写入PersistentClientOperationServiceImpl
Config客户端NacosConfigServiceClientWorkerCacheData
Config变更推送ConfigChangeNotifyRequestConfigChangeBatchListenRequestHandler
Config本地降级LocalConfigInfoProcessor

三十四、关联知识点

真正理解Nacos,不是记住“注册中心加配置中心”,而是能分别画出Naming和Config链路,解释本地缓存、长连接、Distro、JRaft、数据库和Snapshot各自解决什么问题,并在连接、Server、数据库和应用刷新同时出现故障时找到证据和恢复顺序。