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版本文档为准。
一、学完必须回答什么
- Naming和Config为什么不能用同一套原理解释。
NacosNamingService、NamingClientProxyDelegate、NamingGrpcClientProxy怎样完成启动和注册。- Nacos 2.x临时实例为什么更接近“连接生命周期”,而不是只靠旧版定时心跳。
- 订阅结果怎样进入
ServiceInfoHolder,为什么业务调用不需要每次访问Nacos。 - gRPC断线后注册和订阅怎样通过Redo恢复。
- Distro和JRaft分别处理哪类数据,网络分区时会怎样。
ClientWorker、CacheData、MD5和Listener怎样完成配置监听。- Config通知丢失、Nacos不可用或本地快照过旧时应用会读到什么。
- 8848、9848、9849、7848分别做什么,为什么只放通8848会出现“控制台正常、客户端失败”。
- 线上服务找不到、调用旧IP、配置不刷新时从哪里取得证据并恢复。
二、Nacos 1.x、2.x与Java版本线
| 技术线 | 常见环境 | 主要通信特征 |
|---|---|---|
| 历史存量线 | JDK 8、Boot 2.x、较早SCA/Nacos Client | Naming HTTP注册查询、UDP推送、客户端Beat;Config HTTP长轮询 |
| Nacos 2.x线 | JDK 8或更高、匹配的SCA | gRPC长连接、双向推送、连接事件、注册订阅Redo;保留部分HTTP兼容接口 |
| 现代应用线 | Java 17+、Boot 3.x、匹配的SCA | Nacos 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时,常见端口关系为:
| 端口 | 计算方式 | 主要用途 | 谁访问谁 |
|---|---|---|---|
| 8848 | server.port | 控制台、OpenAPI、兼容HTTP请求 | 用户、客户端、代理到Server |
| 9848 | server.port + 1000 | SDK客户端gRPC | Nacos Client到Server |
| 9849 | server.port + 1001 | Server间gRPC通信 | Nacos Server节点之间 |
| 7848 | server.port - 1000 | JRaft/CP协议常见默认端口 | Nacos Server节点之间 |
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 |
NamingClientProxyDelegate | HTTP与gRPC代理门面 | 按实例类型和Server能力选择协议 |
NamingGrpcClientProxy | 2.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客户端启动全过程
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["注册推送与连接监听器"]启动链要关注四类失败:
- 地址解析失败:域名、Endpoint或Server列表错误。
- 鉴权失败:用户名、密码、Token或身份键不匹配。
- 8848可通但9848不可通:gRPC连接失败。
- 客户端和Server能力不匹配:协议协商或请求类型失败。
RpcClient不是每次业务请求重新连接Nacos,而是维护长连接和连接状态。客户端会监听Server列表变化并切换节点;长连接断开后,Naming和Config各自恢复注册、订阅或监听上下文。
六、Nacos 2.x临时实例注册链
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 会:
- 校验Service确实是临时Service。
- 根据gRPC
connectionId找到连接型Client。 - 把Instance转换为
InstancePublishInfo。 - 将Service和Instance加入Client。
- 更新revision和lastUpdatedTime。
- 发布注册与元数据事件。
临时实例的生命周期与客户端连接密切相关。连接关闭时, ConnectionBasedClientManager 触发Client断开,相关临时发布数据被清理,并通过Distro同步删除。它比“每5秒发一个HTTP心跳”更符合Nacos 2.x主链。
但旧HTTP客户端和兼容路径仍可能使用Beat与超时检查,所以准确回答应是:
Nacos 1.x临时实例常以客户端Beat维持;
Nacos 2.x原生gRPC客户端主要以长连接和连接事件维护,
服务端仍保留兼容健康检查路径。七、断线后为什么需要Redo
客户端本次注册成功,不代表换到另一台Server后新连接天然知道旧连接注册了什么。NamingGrpcRedoService 在客户端缓存:
- 期望保持注册的实例。
- 正在注销的实例。
- 已订阅的Service、Group和Cluster。
- 当前操作是否已在Server成功。
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协议:
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状态。
“持久”不等于“永远健康”,也不等于“永远参与负载均衡”。它表示注册数据生命周期不依附于短连接或会话。
九、消费者订阅与实例推送全过程
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():
- 计算ServiceKey。
- 读取旧ServiceInfo。
- 根据配置处理空推送保护。
- 替换内存中的ServiceInfo。
- 比较实例列表是否变化。
- 发布
InstancesChangeEvent。 - 把变化后的列表写入磁盘缓存。
十、ServiceInfoHolder、本地缓存与旧实例窗口
ServiceInfo的缓存Key包含分组服务名和Cluster条件,概念上可表示为:
Namespace + Group@@Service + Clusters业务调用不是每次访问Nacos:
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 服务发现里经常被问到两个“看起来反直觉”的现象:
- 为什么服务明明有实例不健康,调用方还可能拿到它。
- 为什么服务端推了空列表,客户端却没有立刻清空本地缓存。
这不是因为 Nacos 想把请求打坏,而是注册中心在“快速摘除坏实例”和“避免误摘除导致全站无实例”之间做取舍。
flowchart TD
A["实例健康状态变化"] --> B["Nacos计算健康实例比例"]
B --> C{"是否触发保护或空列表保护"}
C -- "未触发" --> D["推送当前健康实例列表"]
C -- "触发" --> E["保留或返回更宽的实例集合"]
D --> F["Consumer更新ServiceInfo"]
E --> F
F --> G["LoadBalancer继续按本地规则过滤"]保护阈值解决什么
服务端保护阈值通常用于防止大面积误判。假设一次网络抖动让 Nacos Server 暂时探测不到大量 Provider,如果立刻把这些实例全部标成不可用并推给所有消费者,所有调用方可能瞬间认为“没有可用实例”,业务全站失败。保护思路是:当健康实例比例低到某个阈值以下时,注册中心宁可返回更宽的实例集合,让调用方继续尝试部分实例,而不是把服务发现结果直接清空。
它的本质是可用性保护,不是健康性保证。
| 机制 | 保护什么 | 代价 |
|---|---|---|
| 健康过滤 | 避免选择明确不健康实例 | 健康误判时可能把所有实例摘掉 |
| 保护阈值 | 避免大面积误摘除导致无实例 | 可能把部分不健康实例重新暴露给调用方 |
| 调用端超时熔断 | 兜底处理被选中的慢实例或坏实例 | 配错会拖垮线程池或产生误降级 |
所以面试不能把“保护阈值”答成“保证实例健康”。准确说法是:
保护阈值是注册中心在大面积健康误判时的可用性兜底。
它可能让消费者继续看到部分非健康实例,因此调用方仍必须有短超时、熔断、
错误率统计、慢实例降权和幂等补偿,不能把注册中心健康状态当作唯一事实。空列表保护解决什么
空列表保护更偏客户端或推送结果保护:当客户端收到异常的空实例列表时,不一定立刻把本地服务表清空,而是保留最后一次可用快照。它能避免服务端异常、推送异常、网络分区或版本兼容问题导致所有调用方瞬间无实例。
但它也有代价:如果空列表是真的,比如服务确实全部下线,那么客户端保留旧快照会延长调用旧实例的窗口。
| 收到空列表的可能原因 | 适合怎么处理 |
|---|---|
| 服务真的全部下线 | 应清空或快速失败,避免继续打旧地址 |
| 网络分区或服务端异常 | 可短时间保留最后快照,避免全站无实例 |
| Namespace、Group、Service定位错误 | 应暴露配置错误并告警,不能长期使用旧缓存掩盖 |
| 灰度规则把候选全过滤 | 应返回明确无目标版本,而不是退回稳定版本造成串流量 |
商业项目怎么落地
生产上不要只问“要不要开启保护”,而要按业务风险设计组合策略:
| 场景 | 建议 |
|---|---|
| 普通查询服务 | 可接受短时间旧快照,配短超时、有限重试、熔断和降级 |
| 下单、支付、库存写链路 | 旧快照可以支撑寻址,但写请求必须有幂等号、状态查询和补偿 |
| 灰度版本强约束 | 如果目标版本候选为空,应明确失败或按策略回退,不能悄悄串到其他版本 |
| 全部实例计划下线 | 先发布停服公告或切流,再摘流和等待传播,不能依赖空列表保护 |
| Nacos异常空推送 | 记录本地快照年龄、最终候选数量、推送版本和Server节点,触发告警 |
排查时要看哪些证据
如果出现“注册中心显示无健康实例,但调用方还在调用”或“服务下线后仍被打到”,按下面顺序取证:
- Nacos Server 控制台或OpenAPI中该
namespace + group + service + cluster的实例总数、健康数、权重、enabled和metadata。 - 是否配置了服务保护阈值,健康实例比例是否低于阈值。
- 客户端是否启用空列表保护或磁盘快照,当前
ServiceInfo的更新时间和来源。 - Spring Cloud LoadBalancer 是否还有二级缓存,TTL是多少。
- 本次请求最终选中的 IP、端口、版本和候选数量。
- HTTP连接池是否复用旧连接,尤其是长连接、HTTP/2、gRPC和代理场景。
- 下游实例日志和Readiness时间线,确认它是“旧地址”“旧版本”“旧连接”还是“旧规则”。
JDK 8 Demo:空列表保护不是绝对正确
下面示例模拟客户端收到空推送时保留旧快照。它能提高短故障可用性,但也可能继续调用旧实例。
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());
}
}运行结果:
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名错误 |
| ServiceInfo | Cluster条件、Healthy、Enabled、Weight、metadata |
| LoadBalancer | Zone、Hint、灰度策略、缓存尚未更新 |
| HTTP客户端 | DNS、连接池、连接超时、读取超时 |
| 业务服务 | Readiness、线程池、数据库和限流 |
十二、临时与持久实例健康检查
| 维度 | 临时实例 | 持久实例 |
|---|---|---|
| 生命周期依据 | 1.x常见Beat;2.x原生客户端主要是连接 | 持久化状态,不依赖客户端连接 |
| 失联处理 | 超时或连接关闭后清理 | 保留注册数据 |
| 健康检查 | 客户端会话/Beat为主 | Server主动TCP/HTTP/MySQL等探测 |
| 适用 | 容器化微服务、弹性实例 | 固定基础设施、非客户端托管实例 |
健康检查通过也只证明探测端点满足条件,不代表订单接口、数据库事务或依赖全部健康。应用应有Readiness,只有核心依赖达到可服务状态才注册或放开权重;优雅下线时先摘流量、等待传播和在途请求,再关闭进程。
十三、Distro怎样同步临时数据
Nacos 2.3.2中,连接所在节点负责该临时Client。Client注册或变化后发布事件,DistroClientDataProcessor 只同步临时Client数据:
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、状态机和快照:
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客户端核心对象
| 类或对象 | 作用 |
|---|---|
NacosConfigService | get、publish、remove、addListener入口 |
ClientWorker | 管理监听缓存、RPC客户端和监听任务 |
ConfigTransportClient | 抽象配置传输与监听执行 |
CacheData | 保存DataId、Group、Tenant、内容、MD5和Listener |
ConfigBatchListenRequest | 批量上报客户端监听项和MD5 |
ConfigChangeNotifyRequest | Server通知某个配置标识发生变化 |
LocalConfigInfoProcessor | 管理Failover文件和Snapshot文件 |
CacheData 使用 volatile content 保存当前内容,并同步维护MD5。每个Listener包装器保存上次回调MD5;新MD5不同时才触发回调,避免相同内容反复通知。
十七、应用启动拉取配置全过程
源码中的 getConfigInner() 不是只做一次Server GET,其读取优先级可概括为:
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监听上下文:
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层还要经过:
Nacos Listener收到新内容
-> 更新远程PropertySource或Environment
-> 发布刷新相关事件
-> 重新绑定支持刷新的配置属性
-> RefreshScope Bean按机制重建以下对象不能因为Environment变了就假设已安全切换:
- 数据源连接池。
- MQ Consumer Group和Topic。
- 大型线程池。
- Netty EventLoop。
- TLS证书和密钥。
- 正在执行的批处理参数。
复杂资源要使用“构建新资源 -> 健康验证 -> 原子切换引用 -> 排空旧资源 -> 失败回滚”的运行时安全流程,而不是只加 @RefreshScope。
二十、配置发布在Server内部怎样走
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只是降级副本。
故障窗口包括:
- 数据库写成功,但部分Server缓存尚未更新。
- Server已更新,但部分客户端连接断开。
- 客户端已拉取,但Listener回调排队。
- Environment已更新,但复杂Bean仍在使用旧资源。
- 部分应用实例成功刷新,部分实例类型转换失败。
因此要观测“配置版本收敛”,而不是只观测Nacos控制台中的值。
二十二、Boot 2旧加载方式与Boot 3现代方式
22.1 JDK 8、Boot 2存量项目
旧项目常把Nacos地址和远程配置定位写在 bootstrap.yml,让配置在主ApplicationContext创建前加载。Boot 2.4以后Config Data机制发生变化;若仍使用bootstrap方式,通常需要匹配版本的bootstrap支持,不能只创建文件就认为一定生效。
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_GROUP22.2 Java 17+、Boot 3现代项目
现代版本通常使用与SCA版本匹配的Config Data导入方式:
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_GROUPoptional: 表示远程配置失败时可继续启动,不适合所有关键系统。订单写服务若没有数据库、幂等和安全配置就不能正确工作,应考虑去掉optional或增加启动版本校验。
Config Data URI、扩展配置和刷新属性会随SCA版本变化,必须查看当前版本配置元数据;不要把Boot 2的bootstrap写法和Boot 3的import写法混用后凭加载顺序猜结果。
二十三、Java 17+、Boot 3商业代码示例
依赖版本交给兼容的Spring Cloud与Spring Cloud Alibaba BOM:
<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,并显式携带配置版本:
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");
}
}
}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还原“推送线程替换快照,业务线程继续使用已取得旧快照”的关键语义:
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;
}
}
}预期输出:
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不应暴露公网。
二十六、安全、鉴权与敏感配置
- 启用与当前版本匹配的Nacos鉴权,不使用默认账号和默认Token Secret。
- Server身份键、Token密钥和数据库密码使用独立Secret管理并定期轮换。
- Namespace主要是逻辑隔离,不能单独当作强安全边界。
- 控制台仅允许管理网络访问,发布API经过网关、审计和最小权限。
- metadata不要放Token、身份证、手机号或数据库密码。
- 本地Config Snapshot可能包含敏感值,要限制宿主机目录权限。
- 高敏感密钥优先交给专业Secret系统,或使用受支持的加密插件和密钥管理系统。
- 轮换凭据时要考虑长连接重认证、旧客户端和灰度窗口。
二十七、商业订单平台完整场景
订单服务调用库存和支付查询:
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 先取得调用方证据
- 保存异常原文:
No servers available、连接拒绝、超时或503并不是同一问题。 - 记录调用方应用、实例、服务名、Namespace ID、Group、Cluster、traceId和时间。
- 从受保护的诊断端点或客户端日志确认本地ServiceInfo实际有哪些实例,不只看控制台。
- 记录本地缓存更新时间、gRPC连接状态和Redo日志。
29.2 再检查注册中心
flowchart TD
A["调用失败"] --> B["核对本地ServiceInfo"]
B --> C["核对Server注册表"]
C --> D["检查Namespace Group Service"]
D --> E["检查9848连接与Redo"]
E --> F["验证注册IP从消费者可达"]
F --> G["检查LoadBalancer过滤与超时"]Windows端口验证示例:
Test-NetConnection nacos.example.internal -Port 8848
Test-NetConnection nacos.example.internal -Port 9848不要用“重启消费者后暂时恢复”作为结论。重启会清空或重建本地状态并重订阅,只证明问题可能与连接、缓存或Redo有关;还要定位为什么旧连接没有自动恢复。
29.3 恢复顺序
- 错误Namespace、Group、Service名先修配置并灰度重启。
- gRPC网络问题先修代理/防火墙,再观察连接和Redo成功。
- 注册IP不可达先摘除坏实例,再修正IP选择策略。
- Server节点分裂先恢复集群通信,再确认Distro或Raft收敛。
- 恢复后验证所有消费者本地实例列表,而不是只看一个控制台页面。
三十、配置不生效或只部分实例生效Runbook
- 固定配置身份:DataId、Group、Namespace ID、配置类型和目标版本。
- 查应用使用bootstrap还是Config Data import,确认当前SCA版本支持该写法。
- 查最终Environment属性来源和优先级;Actuator端点必须鉴权并脱敏。
- 查Config客户端连接、批量监听、Server推送、重新查询和Listener日志。
- 查是否使用本地Failover文件或旧Snapshot。
- 查
@ConfigurationProperties是否注册、类型转换是否成功。 - 查Bean是否支持刷新,复杂资源是否完成原子切换。
- 按实例汇总生效版本,找出未收敛实例。
- 错误配置优先回滚旧版本,不在每台实例上手工改本地文件。
- 回滚后确认业务指标、连接池、线程池和下游压力恢复。
三十一、生产监控与日志证据
| 证据 | 回答的问题 |
|---|---|
| Client gRPC连接状态 | 是否连到可用Server |
| 连接重建和Redo次数 | 是否频繁抖动或恢复失败 |
| ServiceInfo数量与更新时间 | 本地注册表是否陈旧 |
| InstancesChangeEvent数量 | 推送是否到达并产生变化 |
| Distro同步/校验失败 | 临时数据是否分裂 |
| Raft Leader、Term和Peer | CP集群是否有多数派 |
| Config查询、监听和推送RT | 配置链路在哪一段变慢 |
| CacheData MD5与生效版本 | 客户端是否真正更新 |
| Snapshot/Failover命中 | 是否在使用本地旧配置 |
| 数据库连接和慢SQL | Config持久化是否受阻 |
Nacos客户端日志位置受版本和 JM.LOG.PATH 等设置影响,常见日志包括naming、config和remote类别。排障时先从当前运行参数确认真实目录,不要照抄固定路径。日志和诊断端点不得输出完整配置内容、Token和密码。
三十二、常见错误与后果
| 错误 | 为什么错 | 后果 |
|---|---|---|
| 认为Nacos每次转发Feign请求 | Nacos只提供实例数据 | 排错方向错误,忽略本地LB和HTTP客户端 |
| 认为2.x临时实例只靠心跳 | 主链已是gRPC连接与连接事件 | 只查Beat却忽略9848和Redo |
| 只开放8848 | 2.x客户端需要gRPC端口 | 控制台正常但注册监听失败 |
| 把Nacos整体说成AP或CP | 不同数据走Distro、JRaft和Config链 | 无法解释分区行为 |
| 把Namespace当安全边界 | 它首先是逻辑隔离 | 越权访问与错误发布 |
| 把Snapshot当最新事实 | 它只是最近本地副本 | 以旧配置启动产生数据事故 |
| 所有配置都加RefreshScope | 复杂资源有生命周期 | 新旧连接并存、事务和消息异常 |
| 发布成功就宣布完成 | 客户端和Bean仍有传播窗口 | 多实例配置分裂 |
| 注册容器内部不可达IP | 控制台只显示注册信息 | 消费者无法建立连接 |
| 下线后立刻杀进程 | 本地缓存和连接池仍有旧地址 | 滚动发布出现短时错误 |
三十三、源码对象核对表
本文关键结论对应Nacos 2.3.2源码对象:
| 原理 | 主要类 |
|---|---|
| Naming入口 | NacosNamingService |
| 协议选择 | NamingClientProxyDelegate |
| gRPC注册订阅 | NamingGrpcClientProxy |
| 断线恢复 | NamingGrpcRedoService、RedoScheduledTask |
| 实例本地缓存 | ServiceInfoHolder、DiskCache |
| 推送处理 | NamingPushRequestHandler、InstancesChangeNotifier |
| 临时实例服务端写入 | EphemeralClientOperationServiceImpl |
| Distro同步 | DistroProtocol、DistroClientDataProcessor |
| 持久实例CP写入 | PersistentClientOperationServiceImpl |
| Config客户端 | NacosConfigService、ClientWorker、CacheData |
| Config变更推送 | ConfigChangeNotifyRequest、ConfigChangeBatchListenRequestHandler |
| Config本地降级 | LocalConfigInfoProcessor |
三十四、关联知识点
- Nacos基础、配置和入门排查
- Nacos独立面试题
- Spring Cloud Alibaba全景
- 服务调用端到端全过程
- LoadBalancer与Ribbon
- 动态配置安全与原子生效
- 分布式一致性理论
- Raft与共识
- 微服务稳定性治理
真正理解Nacos,不是记住“注册中心加配置中心”,而是能分别画出Naming和Config链路,解释本地缓存、长连接、Distro、JRaft、数据库和Snapshot各自解决什么问题,并在连接、Server、数据库和应用刷新同时出现故障时找到证据和恢复顺序。
