微服务与中间件排查 列表全部文章
微服务与中间件排查

配置改了线上不生效,服务下线了流量还在来:Nacos 配置与注册中心的两类“灵异事件”

两类高频“灵异事件”:Nacos 配置改了不生效(dataId/group/namespace 对不上、缺 @RefreshScope、@Value 与 @ConfigurationProperties 的差异、本地缓存),以及服务下线后仍有流量(心跳摘除延迟、消费者缓存、优雅停机顺序)。附 curl 查询配置与实例的排查清单。

更多

周五下午四点,产品经理走过来,语气温柔得像要借钱:“订单超时时间,配置中心里已经从 1 秒改成 5 秒了,线上怎么还是 1 秒就超时?”我打开 Nacos 控制台,刷新,确实是 5。我又看了一眼日志,嗯,1 秒。配置中心和我,必有一个在说谎。

晚上十点,同一周,另一件事:发版时明明先把旧实例下线了,监控里却看到这台机器还在被调用,报了一堆 Connection refused。同事的评价是:“Nacos 的实例是不是有延迟?”——它确实有,而且这些“延迟”并不是 bug,是一堆你不知道的默认值和缓存。这篇备忘录把两类问题放在一起,因为它们的本质相同:“控制面”里的状态,和“数据面”里实际生效的状态,是两回事,中间隔着缓存、监听和刷新机制。

一、现象

现象 A:配置中心改了,线上没生效

  • 控制台里值是新的,应用里 @Value 读到的还是旧的;
  • 或者新起的实例也读不到新配置,读到的是默认值 / 本地文件里的值;
  • 或者有些实例生效、有些没生效,重启后又都好了。

现象 B:服务下线了,流量还在打过去

  • 发版停旧实例后 30~60 秒内仍有请求报 Connection refused / 502 / Dubbo 的 No provider available 或超时;
  • Nacos 控制台上实例已经“健康:false”或者已经消失,消费者却还在调用它;
  • 已经下线的实例被别的实例“拉回来”——其实是实例没停干净又在发心跳。

两类问题共同的第一原则:先看事实,别猜。配置类问题,不看控制台,直接用命令查服务端实际存的内容,再查应用里实际读到的值;注册中心类问题,直接查注册中心里的实例列表,再看消费者本地缓存。下面是路线。

二、排查路线图

先看配置的这条链路:从 Nacos 服务端到你的 Java 字段,有四个地方可以“断”:

Nacos 服务端namespace/group/dataId 客户端拉取/监听三元组要完全一致 Spring EnvironmentPropertySource 优先级 Bean 字段@Value / @CP 业务逻辑真正使用的值 断点 1:三元组不一致namespace 写了名字不是 IDgroup / 后缀 yaml≠properties 断点 2:没加载进来bootstrap / config.import 缺extension-configs 没开 refresh 断点 3:被高优先级覆盖本地 yml / 命令行 / 环境变量同名 key 谁赢 断点 4:Bean 没刷新@Value 无 @RefreshScopestatic / final / 构造器注入 排查顺序:先 curl 服务端确认“存的对”,再 actuator/env 确认“读到了”,最后看 Bean 是否刷新

再看注册中心的这条链路——“服务下线”到“流量消失”之间,隔着好几层缓存和时钟:

T0 kill 进程 T0+15s 标记不健康 T0+30s 实例被删除 消费者缓存刷新后 HTTP 心跳客户端:5s 一次 15s 无心跳 30s 无心跳 这段时间里:消费者本地缓存的实例列表里仍有旧实例 → 请求继续打过去 → Connection refused gRPC 长连接(Nacos 2.x)断开即摘除会快很多,但消费者侧 LoadBalancer 缓存仍可能滞后 正确的优雅下线顺序 ① 主动 deregister(先摘除)→ ② 等待缓存传播 ③ 处理完在途请求 → ④ 再停进程 错误姿势 kill -9 / docker stop 超时被强杀 依赖心跳超时被动摘除 → 有流量空窗期

三、动手(一):配置为什么没生效

第 1 步:绕过控制台,用 curl 查服务端实际存的内容

控制台是前端页面,它展示的是“你当前选中的 namespace / group”,很容易看着 dev 命名空间以为是 prod。最可靠的方法,是用和客户端完全相同的三元组直接请求 OpenAPI。Nacos 2.x 兼容 1.x 的 OpenAPI(3.x 则使用 /nacos/v3/client/cs/config 等新路径,先确认你的服务端版本):

# dataId + group + tenant(=namespace 的 ID,不是名称;public 命名空间可不传)
$ curl -s "http://10.0.2.5:8848/nacos/v1/cs/configs?dataId=demo-order.yaml&group=DEFAULT_GROUP&tenant=b3c1a2e4-xxxx"
order:
  timeout: 5000

# 三元组对不上时
$ curl -s -w "\nHTTP %{http_code}\n" "http://10.0.2.5:8848/nacos/v1/cs/configs?dataId=demo-order.yml&group=DEFAULT_GROUP&tenant=b3c1a2e4-xxxx"
config data not exist
HTTP 404

注意上面第二条:dataId 写成了 .yml,而配置中心里建的是 .yaml——Nacos 里 dataId 是字符串精确匹配,差一个字母就是“不存在”。(如果服务端开启了鉴权,需要先登录拿 accessToken 再带上,别把它写进脚本/文章。)

第 2 步:核对三元组——dataId / group / namespace

Nacos 官方概念里,Data ID 标识一组配置(通常按 Java 包名风格命名),Group 默认 DEFAULT_GROUP,Namespace 用来隔离环境(开发/测试/生产)。客户端要同时匹配这三个才能读到。Spring Cloud Alibaba 生成 dataId 的规则(官方文档)是:${prefix}-${spring.profiles.active}.${file-extension},prefix 默认取 spring.application.name,profile 为空时不带连字符,file-extension 支持 properties 和 yaml。这里的经典坑有四个:

  • 后缀不匹配:配置中心里建的是 demo-order.yaml,但客户端 file-extension 没配(默认 properties),它去找 demo-order.properties,找不到——不报错、静默用默认值;
  • profile 拼接:激活了 prod 环境,客户端找的是 demo-order-prod.yaml,而你改的是 demo-order.yaml;
  • namespace 用了名称:spring.cloud.nacos.config.namespace 要填命名空间 ID(一串 UUID 或你自定义的 ID),不是页面上显示的名称;
  • group 不一致:控制台上新建配置时手滑选了别的 group。
# bootstrap.yml(Spring Cloud 2020 之前 / 启用 bootstrap 的项目)
spring:
  application:
    name: demo-order
  profiles:
    active: prod
  cloud:
    nacos:
      config:
        server-addr: 10.0.2.5:8848
        namespace: b3c1a2e4-xxxx      # 命名空间 ID
        group: DEFAULT_GROUP
        file-extension: yaml          # 不写就是 properties!
        # 需要多个共享配置时:
        extension-configs:
          - data-id: common-redis.yaml
            group: DEFAULT_GROUP
            refresh: true             # 不写 refresh: true,这份配置不会动态刷新
# 新版(Spring Boot 2.4+ / Spring Cloud 2021+)常用 spring.config.import,不再依赖 bootstrap
spring:
  application:
    name: demo-order
  config:
    import:
      - optional:nacos:demo-order.yaml?group=DEFAULT_GROUP&refreshEnabled=true
  cloud:
    nacos:
      config:
        server-addr: 10.0.2.5:8848
        namespace: b3c1a2e4-xxxx

这里有一个非常坑的地方:从 bootstrap 迁移到 spring.config.import 时,如果 bootstrap.yml 还在但没引入 bootstrap starter,它就会被静默忽略,Nacos 配置根本没有被加载——而应用照样启动成功(optional: 前缀就是“找不到也不报错”)。所以遇到“完全读不到 Nacos 配置”,第一件事是看启动日志有没有出现 Nacos 加载配置的记录,或者直接查 Environment。

第 3 步:看应用里到底读到了什么

$ curl -s http://localhost:8080/actuator/env/order.timeout
{
  "property": { "source": "applicationConfig: [classpath:/application.yml]", "value": "1000" },
  "activeProfiles": ["prod"],
  "propertySources": [
    { "name": "commandLineArgs" },
    { "name": "bootstrapProperties-demo-order.yaml,DEFAULT_GROUP", "property": { "value": "5000" } },
    { "name": "applicationConfig: [classpath:/application.yml]", "property": { "value": "1000" } }
  ]
}

(输出结构随版本略有差异,重点是看propertySources里每个来源各自的值)。这个例子里 Nacos 那份是 5000,但最终生效的 property.source 是本地 application.yml 里的 1000——本地配置的优先级高于了远程配置,这就是“断点 3”。Spring Cloud 默认远程配置优先级高于本地文件,但如果有人加了 spring.cloud.config.override-none=true,或把同名 key 写进了命令行参数 / 环境变量 / JVM -D,就会反过来。配置的最终值由 Environment 里优先级最高的来源决定,不是最后修改的那个。

第 4 步:@Value 不刷新 vs @ConfigurationProperties

配置读到了,但改了之后应用里的值不变:

@Service
public class OrderService {
    @Value("${order.timeout:1000}")
    private int timeout;                 // 启动时注入一次,之后 Nacos 推送变更也不会变!
}

Nacos 客户端在收到变更后,会把新值更新到 Spring 的 Environment 里,并发布刷新事件;但 @Value 是Bean 创建时注入一次的,除非这个 Bean 在 @RefreshScope 里(变更时销毁重建),否则字段不会更新。两种正确写法:

// 写法 1:加 @RefreshScope,变更后该 Bean 被重新创建,@Value 重新注入
@Service
@RefreshScope
public class OrderService {
    @Value("${order.timeout:1000}")
    private int timeout;
}

// 写法 2:@ConfigurationProperties,变更后由 Spring Cloud 自动重新绑定
@Component
@ConfigurationProperties(prefix = "order")
public class OrderProperties {
    private int timeout = 1000;
    public int getTimeout() { return timeout; }
    public void setTimeout(int timeout) { this.timeout = timeout; }
}
// 使用处注入 OrderProperties,每次读 getTimeout(),不要把值缓存到 static/final 字段里

三个“加了注解也白搭”的场景:

  • 把 @Value 注入到 static 字段(Spring 本身就不支持注入 static,靠 setter 绕过后更不会刷新);
  • 用构造器注入 final 字段保存配置值;
  • 使用者自己缓存了值:比如在 @PostConstruct 里读一次,创建了线程池、RestTemplate、Redis 连接——参数在初始化时就固化了,后面配置再变也不会重建这些对象。这类“重型对象”要么监听 RefreshScopeRefreshedEvent 重建,要么干脆接受“改这类配置必须重启”。

另外 @RefreshScope 不是没有代价:官方及社区的经验都提到,它会“销毁 Bean 再创建”,用在有状态或重初始化的 Bean 上,生产里容易出问题。能用 @ConfigurationProperties 就别到处撒 @RefreshScope。

第 5 步:客户端本地缓存目录

Nacos 客户端会把拉到的配置写一份到本地快照,服务端不可用时可以用它启动。默认位置在用户目录下:

$ ls ~/nacos/config/
fixed-10.0.2.5_8848_nacos
$ find ~/nacos/config -name 'demo-order*'
/home/app/nacos/config/fixed-10.0.2.5_8848_nacos/snapshot-tenant/b3c1a2e4-xxxx/DEFAULT_GROUP/demo-order.yaml
$ cat /home/app/nacos/config/fixed-10.0.2.5_8848_nacos/snapshot-tenant/b3c1a2e4-xxxx/DEFAULT_GROUP/demo-order.yaml
order:
  timeout: 1000                  # 这是旧值:客户端缓存没更新

用它可以判断:这台实例有没有真正收到过新配置。如果快照是旧的,说明这台客户端没有和服务端同步——可能是网络策略把 8848 / gRPC 端口(2.x 客户端还会用 9848 等偏移端口)挡了,导致监听长连接不通,只能靠启动时拉取。同时看客户端日志:~/logs/nacos/config.log。(这些目录是 Nacos 客户端默认位置,具体路径以你使用的客户端版本和 JM.SNAPSHOT.PATH 等配置为准。)

补充:Apollo 的同类坑

Apollo 的思路类似,但概念不同,坑也不同:

  • Namespace:Apollo 中的 Namespace 是“配置文件”的概念(application、application.yml 等),不是环境隔离,环境是 Environment + Cluster。客户端只会读到apollo.bootstrap.namespaces 里声明的 namespace,没声明的读不到;
  • 发布 ≠ 保存:Apollo 页面上“修改”之后必须点“发布”才会生效,这是最常见的“没生效”;
  • 灰度:如果做了灰度发布,只有匹配灰度规则的实例(IP 或标签)才会拿到新值,其余实例仍是旧值,这就是“有的实例生效有的没生效”;
  • 客户端缓存:官方说明客户端会在本地缓存配置(默认目录 /opt/data/{appId}/config-cache,Windows 是 C:\opt\data\...),服务端不可用时回退读取;更新靠 60 秒左右的长轮询 + 定时兜底,@Value 在 Apollo 中支持自动更新,也可以用 @ApolloConfigChangeListener 监听变更;
  • 目录权限:/opt/data 没有写权限时,客户端无法落缓存,容器里尤其常见。

四、动手(二):服务下线了为什么还有流量

第 1 步:直接查注册中心里的实例列表

$ curl -s "http://10.0.2.5:8848/nacos/v1/ns/instance/list?serviceName=demo-product&groupName=DEFAULT_GROUP&namespaceId=b3c1a2e4-xxxx&healthyOnly=false" | python3 -m json.tool
{
    "name": "DEFAULT_GROUP@@demo-product",
    "clusters": "",
    "cacheMillis": 10000,
    "hosts": [
        {
            "instanceId": "10.0.1.21#8080#DEFAULT#DEFAULT_GROUP@@demo-product",
            "ip": "10.0.1.21",
            "port": 8080,
            "weight": 1.0,
            "healthy": false,
            "enabled": true,
            "ephemeral": true,
            "clusterName": "DEFAULT",
            "metadata": { "preserved.register.source": "SPRING_CLOUD" },
            "instanceHeartBeatInterval": 5000,
            "instanceHeartBeatTimeOut": 15000,
            "ipDeleteTimeout": 30000
        },
        { "ip": "10.0.1.22", "port": 8080, "healthy": true, "ephemeral": true, ... }
    ]
}

几个字段的含义要记住:

  • healthy:是否健康;healthyOnly=false 时不健康的也会列出来,true 只返回健康的(客户端订阅时默认是只要健康的);
  • ephemeral: true:临时实例,靠客户端存活状态维持。对 HTTP 心跳的客户端来说,超时未续约就摘除;对 gRPC 客户端(Nacos 2.x)来说,连接断开即摘除;false 是持久实例,由服务端主动探测,不会因为客户端下线而消失,需手动删除;
  • instanceHeartBeatInterval / TimeOut / ipDeleteTimeout:默认 5s 心跳间隔、15s 无心跳标记不健康、30s 无心跳删除——所以被动摘除最快也要 15s,删除要 30s;
  • cacheMillis:建议客户端缓存该结果的时间。

第 2 步:区分“注册中心里还在” vs “注册中心里没了但消费者还在调”

分两种情况:

  1. 注册中心里还在,healthy 还是 true:说明进程被强杀(没来得及 deregister),心跳超时还没到;或者进程没死透:比如 docker stop 后进程还在,或有另一个实例(同 IP 同端口的旧容器/守护进程)仍在发心跳;
  2. 注册中心里已经没了,但消费者仍在调:这是消费者本地缓存。Nacos 客户端自己有本地服务列表缓存并靠推送更新,Spring Cloud LoadBalancer 又有一层缓存(默认 TTL 约 35 秒,可通过 spring.cloud.loadbalancer.cache.ttl 调整),老一点的 Ribbon 则是每 30 秒刷新一次服务列表;Dubbo 消费者本地也维护 provider 列表,由注册中心推送更新,但推送到达前请求已经在飞。这些时间叠加起来,可以让一个已下线的实例在“不该”的时间内继续收到流量。

第 3 步:验证进程到底停没停干净

$ ss -lntp | grep 8080          # 端口还有没有人在听
$ ps -ef | grep demo-product | grep -v grep
$ docker ps | grep demo-product
$ journalctl -u demo-product --since "21:50" | grep -E 'Stopping|deregister|Graceful|Killed'
Sep 18 21:58:02 app-01 java[8231]: Graceful shutdown aborted with one or more requests still active
Sep 18 21:58:12 app-01 systemd[1]: demo-product.service: State 'stop-sigterm' timed out. Killing.

看到 timed out. Killing,就是优雅停机超时被 SIGKILL——这意味着 Spring 的 shutdown hook 可能没执行完,Nacos 的 deregister 也可能没发出去,注册中心只能靠心跳超时慢慢摘除。

五、根因

配置类:控制面与数据面之间隔着“客户端监听 + Spring 刷新”

Nacos 配置中心的推送并不是“服务端直接改应用变量”,而是:服务端存配置 → 客户端通过监听(2.x 是 gRPC 长连接推送通知,客户端再拉取)感知变化 → 客户端更新 Environment → 发布 Spring 事件 → 只有被 @RefreshScope 包裹的 Bean 或 @ConfigurationProperties Bean 才会被刷新。任何一步不满足,值就“看起来没变”。而三元组不匹配则更直接——你压根没读到这份配置。

注册中心类:摘除是“被动 + 缓存 + 传播”的过程

临时实例的生命周期由客户端存活决定:HTTP 心跳客户端要靠心跳超时(默认 15s 不健康、30s 删除);gRPC 客户端断开连接后服务端很快清理,但清理之后的推送到消费者也需要时间。所以“下线”是分阶段传播的:注册中心摘除 → 推送/拉取到消费者 → 消费者本地列表更新 → 负载均衡器刷新缓存 → 在途请求结束。只靠被动摘除,注定有一段“旧地址还在被使用”的空窗。要消灭空窗,得主动:先摘除、等传播、再停服务。

六、止血与修复

配置类止血与根治

  1. 止血:确认 curl 服务端的值对,再定位是哪一步没生效。最快的止血通常是滚动重启让新配置重新读取(如果只是 Bean 没刷新);或者调用 POST /actuator/refresh(需要暴露 refresh 端点)触发一次刷新,返回值会列出变更的 key,如果返回 [],说明客户端根本没感知到变更(回到三元组和网络);
  2. 根治:
    • 统一配置命名规范(dataId 用固定后缀 yaml,namespace 按环境固定,写进模板);
    • 动态配置一律用 @ConfigurationProperties,少用 @Value;确需 @Value 的用 @RefreshScope,但别包含重型初始化对象;
    • 启动时打印关键配置的来源和值(或暴露 /actuator/env,注意脱敏与鉴权);
    • 配置变更走“灰度 → 观察 → 全量”,并保留历史版本与回滚能力(Nacos 有配置历史版本,Apollo 有发布历史与回滚);
    • 发布后立刻用 curl / actuator 验证,别只看控制台。

注册中心类止血与根治

  1. 止血:确认旧实例进程确实已停止;在 Nacos 控制台把该实例下线(enabled=false)或调用 API 删除(谨慎,只对你确认已死的实例操作);消费者侧临时重试到其它节点(幂等读接口),或重启受影响的少量消费者刷新缓存;
  2. 根治:优雅停机 + 先摘除再等待。
# Spring Boot 优雅停机:收到 SIGTERM 后不再接新请求,等在途请求完成
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s     # 默认 30s
  cloud:
    nacos:
      discovery:
        server-addr: 10.0.2.5:8848
        namespace: b3c1a2e4-xxxx
# 发布脚本里的顺序(示意)
# 1) 主动从注册中心摘除(Nacos 控制台/API 将实例 enabled 置为 false,或调用应用自己的 offline 端点)
# 2) 等待消费者缓存传播(>= 消费者侧缓存 TTL,通常 20~40s)
sleep 30
# 3) 发 SIGTERM(不是 kill -9),让 Spring 优雅停机并 deregister
systemctl stop demo-product          # 确保 TimeoutStopSec 大于优雅停机超时
# 4) 启动新版本,健康检查通过后才注册/放量

要点:systemd 的 TimeoutStopSec(或 Docker 的 --stop-timeout / K8s 的 terminationGracePeriodSeconds)必须大于“摘除等待 + 优雅停机等待”,否则会被强杀;K8s 里还可以用 preStop 钩子先 sleep 一会儿,给注册中心和消费者传播的时间。Dubbo 也有类似的机制:先从注册中心注销,再等待一段时间(dubbo.service.shutdown.wait)处理在途请求,再关闭连接。

还有一层保险:消费者侧要有重试与熔断,配合前一篇 Dubbo 的“幂等才重试”原则,遇到连接被拒绝这类“请求根本没发出去”的错误重试到另一台是安全的。

七、复盘

做法为什么
用 curl 直接请求 Nacos OpenAPI(dataId/group/tenant)核对配置绕开控制台的“当前命名空间”误导,与客户端用同一个三元组
namespace 填 ID 不填名称;yaml 项目显式写 file-extension: yaml后缀和 namespace 写错都不会报错,只是静默读不到
用 /actuator/env 看各 PropertySource 的值最终生效值由优先级决定,不是“最后改的”
动态配置优先 @ConfigurationProperties;@Value 需配 @RefreshScope@Value 只注入一次;static/final/@PostConstruct 固化的对象也不会刷新
Apollo:改完要“发布”,注意灰度规则和 namespace 声明“有的实例生效有的没生效”多半是灰度
curl .../ns/instance/list 看 healthy/ephemeral 与心跳参数确认是“注册中心还在”还是“消费者缓存”,思路完全不同
发布顺序:先主动摘除 → 等缓存传播 → 优雅停机(SIGTERM)被动摘除最快 15s、删除 30s,加上消费者缓存,必然有空窗
停机超时时间 > 摘除等待 + 优雅停机等待否则被 SIGKILL,deregister 没发出,回到被动摘除
配置变更灰度 + 可回滚;发布后验证客户端读到的值配置变更和代码发布一样危险,只是更隐蔽

一句话备忘:配置不生效:curl 服务端确认三元组 → actuator/env 看优先级 → 看 Bean 有没有刷新(@ConfigurationProperties 优于 @Value+@RefreshScope);流量不停:curl 查实例 healthy/ephemeral → 想消费者缓存 → 发布走“先摘除、再等待、再停机”,别 kill -9。

我后来在发布脚本里给那句 sleep 30 起了个名字,叫“等消息传到最后一个消费者的礼貌时间”。运维同学看了半天问我:“为什么是 30?”我说:“因为 Nacos 的默认值是 5、15、30,我想顺个数。”他沉默了两秒,说:“你比 Nacos 更有仪式感。”

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人