Chapter 03
配置中心原理:近实时下发
上一章是服务列表怎么传播(Distro + 推送)。配置内容的传播是另一套机制—— 它用"长轮询"在"实时"和"省资源"之间取巧。这一章拆开配置从发布到生效的全链路,并解释最常见的"改了不生效"。
本章你将建立的 schema
- 长轮询:客户端发一个 30s 超时请求,服务端 hold 29.5s,变更即时返回——以及那 0.5s 的用意。
- 变更检测靠 MD5 比对,不靠"定时全量拉"。
- 一条配置从发布到客户端回调的完整链路。
@RefreshScope的刷新边界——"改了配置不生效"的头号原因。- 灰度发布、历史回滚,以及配置在单机 / 集群下分别存哪。
3.1长轮询:在"推"和"拉"之间取巧
客户端发一个超时 30s 的请求,服务端 hold 住 29.5s 不立刻回;期间配置变了就立即返回,没变到点回"无变化"。
客户端要"配置一变就知道"。最朴素是短轮询(每隔几秒拉一次)——实时性取决于间隔,且大量请求多数是"没变",纯浪费。另一极是服务端推(维护长连接主动推)——实时但要维护海量连接、且要求客户端可被连达。长轮询取两者之间:一次请求覆盖近 30s,期间一有变更立即返回,既近实时又不空转。
服务端 hold 住请求不占线程:用 Servlet 3.0 的 AsyncContext 把请求挂起,线程立刻释放去处理别的请求,到点或有变更时再唤醒响应。这就是单机能扛大量长轮询连接的原因。而"配置变没变"靠MD5 比对:客户端把自己持有内容的 MD5 一起发来,服务端拿当前内容的 MD5 一比,不一致才返回——不必传整段内容来判断。
| 方案 | 实时性 | 服务端开销 | 取舍 |
|---|---|---|---|
| 短轮询(定时拉) | 差(取决于间隔) | 高:大量空轮询 | 实时与开销两头不讨好 |
| 服务端推(长连接) | 好 | 维护海量连接 | 1.x 时代连接成本高(2.x 才转向它) |
| 长轮询 | 接近实时 | 低:hold 不占线程 | 选中:一次请求覆盖 29.5s,变更即时 |
长轮询是 1.x 的经典机制,也是面试最常考的模型,理解它是理解"配置近实时"的基础。2.x 起,配置变更通知也并入了 gRPC 长连接(与服务发现同一条连接),服务端可直接推 ConfigChangeNotify,比 HTTP 长轮询更直接;但"MD5 比对判断是否真的变了"这一思想仍然保留。下文链路对两者都成立。
3.2配置变更的全链路
发布 → 服务端落库并标记变更 → 唤醒挂起的长轮询 / 推送通知 → 客户端拉取最新 → 触发监听回调。
把一次"在控制台改了一个值"拆成五步,每一步都能对应到一个排障点:
这条链路里,前四步是 Nacos 负责的,第 ⑤ 步回调发生在你的应用进程内——也正是这一步最容易"看起来没生效"。
3.3刷新边界:为什么"改了不生效"
配置推到客户端后,只有标了 @RefreshScope 的 Bean 会被重建以读到新值;普通 @Value 字段不会。
这是 Nacos 配置最高频的"灵异事件":控制台明明改了、日志也显示收到变更,但接口返回的还是旧值。根因不在 Nacos,在 Spring 的刷新边界:
@RefreshScope 划定——它告诉 Spring"这个 Bean 在配置变更时要丢弃重建"。没标它的 Bean 持有的还是初始化时注入的旧值。| 写法 | 配置变更后是否自动读到新值 |
|---|---|
@Value(无 @RefreshScope) | 否——仍是旧值 |
@Value + @RefreshScope(同一 Bean) | 是——Bean 重建后读到新值 |
@ConfigurationProperties | 是——默认支持刷新,无需 @RefreshScope |
你用 @Value("${feature.rate}") 注入了一个字段,在 Nacos 改了 feature.rate,控制台显示已更新、客户端日志也打印"received config change",但接口行为没变。最可能缺了什么?
展开答案(先停 10 秒)
最可能缺了 @RefreshScope。变更确实推到了客户端(日志为证),但承载 @Value 的 Bean 没被标记为可刷新,于是不会重建,字段停留在启动时注入的旧值。给该 Bean 加 @RefreshScope,或改用 @ConfigurationProperties(默认可刷新)。
隔离语义复用:namespace / group
呼应 §1.5:配置的 namespace / group 和服务发现是同一套。实战里,namespace 拿来隔离 dev / prod 的配置(同一个 dataId 在两个环境互不影响),group 拿来把"同一类配置"跨应用归组。配错 namespace(填了名字而非 UUID)就读不到配置——和发现不到服务,是同一个根因的两种症状。
3.4灰度、回滚与持久化
灰度(beta)发布按指定 IP 先推;配置存历史版本可一键回滚;单机用内嵌 Derby,集群用外置 MySQL。
配置改错的代价比代码 bug 更直接——它瞬间生效到所有实例。Nacos 用三个能力降低这个风险:
- 灰度 / beta 发布:发布时指定一批 IP,变更只推给这些 IP,验证无误再全量。它和普通发布的区别就在推送对象:普通发布推给全部订阅者,灰度只推给指定 IP 列表。
- 历史版本与回滚:每次发布留存历史,出问题可一键回滚到上一版本。
- 持久化:见下表——这也决定了你的集群要不要外挂数据库。
| 部署形态 | 配置存哪 | 含义 |
|---|---|---|
| 单机 standalone | 内嵌 Derby | 开箱即用,仅适合本地 / 测试 |
| 集群 cluster | 外置 MySQL(多个节点共享) | 生产标配;DB 要做高可用,否则成单点 |
注意配置数据落在强一致这一侧:它持久化、要求各节点看到的内容一致——正是起点页脊柱图里和持久实例同侧的 CP。这也解释了为什么配置中心不像临时实例那样"分区时各写各的":一份算错钱的配置在两个节点上不一致,后果比一个实例短暂消失严重得多。下一章把这条脊柱完整收口。
§本章 self-check
先合上教程作答,再展开对照。
- 长轮询里服务端为什么 hold 29.5s 而不是 30s?这 0.5s 防的是什么?
- 服务端 hold 住请求期间为什么不占线程?变更检测靠什么判断"内容真的变了"?
- "改了配置不生效",从
@Value/@RefreshScope/@ConfigurationProperties角度,最常见的原因是什么? - 灰度发布和普通发布,在"推送对象"上的区别是什么?
- (综合 01)namespace 配错在配置侧表现为"读不到配置";同样的 namespace 配错,在服务发现侧会表现成什么症状?为什么是同一个根因?
答案(先做完再展开)
- 留 0.5s 给网络往返余量。若服务端也 hold 满 30s,客户端可能先到 30s 超时,把本该是"无变化"的正常响应误判成请求超时。
- 用 Servlet 3.0
AsyncContext挂起请求、释放线程,到点或有变更再唤醒——所以不占线程。变更检测靠 MD5 比对:客户端发来持有内容的 MD5,服务端比对当前 MD5,不一致才返回。 - 承载
@Value的 Bean 没加@RefreshScope,变更虽推到但 Bean 不重建,字段停在旧值。修复:加@RefreshScope或用@ConfigurationProperties。 - 普通发布推给全部订阅者;灰度只推给发布时指定的 IP 列表,验证后再全量。
- 服务发现侧表现为"发现不到服务 / no available server"。根因相同:namespace 是服务发现和配置中心共用的隔离边界,配错(如填名字落到 public)会同时让两边都"找不到"。
一个开关,要先在 2 台机器上验证
你要上线一个高风险开关 risk.switch=on,希望先在 2 台金丝雀机器验证 10 分钟,再推全量。用本章的能力设计这个流程,并说明:灰度期间,其余实例读到的是 on 还是 off?灰度配置和正式配置在 Nacos 里是什么关系?
提示(卡住再展开)
用灰度 / beta 发布,发布时填那 2 台金丝雀的 IP。灰度期间:只有这 2 台收到 on,其余实例仍读正式版本的 off——因为变更只推给了指定 IP。验证无误后"停止 beta / 全量发布",把灰度内容转正,推给全部。关键认知:灰度本质是"按推送对象切分",不是改了一个不同的 dataId。