我花了大半天证明框架没 bug,最后发现 bug 在我自己手上。
那天下午同事在群里问我:job.fetch.limit 改到 1000 了,怎么调度还在按 500 跑?
我第一反应是配置中心没推过来。去 Consul 上看了一眼,值确实是 1000。
然后我开始了一轮又一轮的排查。中间做了无数个"合理"的假设,被源码一个个打脸。
这条链上一共 8 个地方能让配置改了不生效,没有一个会抛异常。
版本声明:以下结论全部来自
spring-cloud-context4.1.4 和spring-cloud-consul-config4.1.2 的源码,对应 Spring Cloud 2023.0.x(Boot 3.2 / 3.3)。2021.x 及更早的ConfigWatch实现和 bootstrap 加载方式都不一样,别直接套。文中标的类名和行号,本地 maven 仓库里解开 sources jar 就能自己对。
第一回合:我猜是 @RefreshScope 的问题
先说清现象。同一个服务里两个配置类,读同一个配置项,两个都没标 @RefreshScope:
kotlin
@Component
@ConfigurationProperties(prefix = "job.fetch")
public class FetchProps {
private int limit = 500; // getter/setter 略
}
@Component
public class JobConfig {
@Value("${job.fetch.limit:500}")
private int limit;
}
配置中心改值,上面那个立刻生效 ,下面那个纹丝不动。
我当时就纳闷了:两个都没标注解,凭啥一个行一个不行?
网上一查,都说 @Value 要配 @RefreshScope。行,我给 JobConfig 补上注解,重启,再试。还是不行。
这就开始不对劲了。
想手动刷一下确认,又发现 /actuator/refresh 压根没暴露,这个服务的 management.endpoints.web.exposure.include 里只开了 health 和 prometheus。也就是说,上线到那天为止,"配置能不能热更新"这件事我们从来没手动验证过一次,全押在 Consul 自己的 watch 上。
第二回合:翻源码,发现我连代理类型都搞错了
为了证伪"框架有 bug"这个假设,我翻了一遍它的源码。
我一直以为 @RefreshScope 的代理规则是:类有接口就 JDK 动态代理,没接口才退回 CGLIB。结果一看注解自己的定义:
less
// RefreshScope:39-52
@Scope("refresh")
public @interface RefreshScope {
@AliasFor(annotation = Scope.class)
ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;
}
TARGET_CLASS 是写死在注解默认值里 的。跟你的类有没有接口没关系,跟全局的 spring.aop.proxy-target-class 也没关系,它默认一定走 CGLIB 子类代理。
顺带说,这个是可以改的:@RefreshScope(proxyMode = ScopedProxyMode.INTERFACES) 就切成 JDK 动态代理,代价是注入点必须按接口类型来。
好,第一巴掌。
再往下翻 GenericScope 的核心逻辑,三件事让我彻底清醒。
头一条,每次通过代理调方法,都会重新解析一次目标实例 ,还带一把读锁。在 LockedScopedProxyFactoryBean.invoke:462-494,关键那行是 advised.getTargetSource().getTarget()。
再有,刷新时清缓存,逐个 bean 拿写锁再销毁。 在 GenericScope.destroy:128-145。
最要命的一条,重建是懒的 ,发生在刷新之后第一个调用者的线程上。在 BeanLifecycleWrapper.getBean:371-380,双检锁。
顺带一提,那个双检锁 synchronized (this.name) 是锁在一个 String 上的。bean 名字各不相同所以实践中没事,但看到框架代码里锁 String,还是会愣一下。
看到这里我才明白一件事: @RefreshScope 刷的是实例,不是值。
值一旦被抄到别处存着,或者调用绕过了代理,刷新就永远追不上。
这三件事画出来是下面这样,右上角标的是源码行号,可以自己去对:

第三回合:顺手纠正一个误解,它不是免费的
我之前觉得这注解标上就完事了,零成本白得一个热更新。看了源码发现成本都在暗处。
热路径上它是税。 每次调用多一次读锁加一次 target 解析,单次很小,高频调用就不小。
刷新期间写锁会堵住这个 bean 的所有调用。 配置项多、bean 多的时候,一次刷新是一串写锁。
@PreDestroy 真的会被调用。 这条最容易出事。销毁走的是标准流程,如果 bean 持有连接池、HttpClient、Kafka Consumer 这类资源,每次刷新配置都会真的销毁重建一遍。销毁逻辑不健壮,刷新配置就变成刷新故障。
重建成本压在刷新后第一个调用者身上。 因为重建是懒的。bean 初始化越重,那个倒霉请求的延迟尖刺越明显,可能就是你的线上告警。
还有一个经典的错误位置:
less
@Configuration
@RefreshScope // 标错位置了
public class ClientConfig {
@Bean
public HttpClient httpClient(@Value("${timeout:3000}") int timeout) { ... }
}
进 refresh scope 的是 ClientConfig 这个实例。它销毁重建时,@Bean 产出的 httpClient 不会跟着重建,那个对象早就作为独立 singleton 注册进容器,跟 Configuration 的生命周期脱钩了。正确位置是标在 @Bean 方法上。
但标对之后立刻撞上前面那条:HttpClient 现在每次刷新都被销毁重建,旧连接池谁关?"配置不生效"换成了"资源泄漏"。
所以它的舒适区是轻量、无资源、每次现读的数值和开关。阈值、限额、批次大小、功能开关。带资源的对象,慎入。
第四回合:破案了,为什么没标注解的反而生效
重新梳理刷新链路,ContextRefresher.refresh:93-104 的顺序是明确的:先更新 Environment 并发 EnvironmentChangeEvent,再清 refresh scope。
而 ConfigurationPropertiesRebinder 监听的正是 EnvironmentChangeEvent。它拿到事件后,对所有 @ConfigurationProperties bean 做 destroyBean 加 initializeBean(rebind:137-138),完全不看你有没有标 @RefreshScope。
@Value 没这个待遇,它只能靠所在 bean 被销毁重建时重新注入。
| 方式 | 更新机制 | 需要 @RefreshScope |
|---|---|---|
@ConfigurationProperties |
Rebinder 收到事件后重新绑定 | 不需要 |
@Value |
依赖所在 bean 销毁重建 | 必须 |
这就是开头那个反差的全部原因:一个搭的是事件的车,一个搭的是 bean 重建的车。前者不需要你做任何事,后者需要你标对注解,而且调用还得走代理。
所以真正该记的规则不是"配置类要标 @RefreshScope",而是:配置项超过三四个就用 @ConfigurationProperties,它天然少一整个失效面。 散装 @Value 每加一个字段,都是多给自己埋一个"忘标注解"的坑。
两条链路并排画出来,差别就很明显了。图最下面那三行是判据,对着自己的配置类扫一眼就知道走的哪条:

第五回合:真正的怪谈,事件压根没发出来
前面说的都是"事件到了,但没作用到你身上"。还有更阴的一整类:事件从来没发出来。 而我这个服务,因为 refresh 端点没暴露,全部身家都押在这条链上。
先看默认值(ConsulConfigProperties):watch 默认开,两轮长轮询之间 delay 1 秒,单次阻塞查询 waitTime 55 秒,failFast 默认 true。看起来挺靠谱。
阴招一:装配条件不满足,ConfigWatch 静默消失
ConfigWatch 这个 bean 本身有装配条件(ConsulConfigAutoConfiguration:47-57),要同时满足三个:RefreshEndpoint 在 classpath 上、watch.enabled 不为 false、容器里有 ConsulConfigIndexes。
缺任意一个,ConfigWatch 压根不会被创建。最阴的是第一个:少个类,整个自动刷新静默消失,而应用照常启动、照常提供服务、日志一切正常。
阴招二:异常只打 warn,从来不打 error
这里还有我另一处判断错误。我以为长轮询线程会被异常打断然后死掉,所谓"重启一下就好了"就是这么来的。翻了源码才发现不是:catch 在 for 循环体内部,加上 scheduleWithFixedDelay 的调度,线程不会死,它会一直重试。这条我猜错了。
但代价换了个形式。看它的异常处理(watchConfigKeyValues:189-203):只有首轮加 failFast 才会 rethrow,其余情况只打 warn 或 trace,从来不打 error。
Consul agent 挂了、ACL token 过期、网络断了,你日志里就是一行行 warn 在滚。有几个人的告警规则会盯 warn?
阴招三:删配置不触发刷新
真正会吞事件的是这一段:
kotlin
// ConfigWatch.watchConfigKeyValues:162-179
if (response.getValue() != null && !response.getValue().isEmpty()) {
Long newIndex = response.getConsulIndex();
if (newIndex != null && !newIndex.equals(currentIndex)) {
if (!this.consulIndexes.containsValue(newIndex) && !currentIndex.equals(-1L)) {
this.publisher.publishEvent(new RefreshEvent(this, data, data.toString()));
}
this.consulIndexes.put(context, newIndex);
}
}
第一层判断,response.getValue() 为空就整段跳过。把某个 context 下的 key 全删掉,响应是空的,于是不发事件。
也就是说:改配置会触发刷新,删配置不会。 想删掉覆盖项让它回落到默认值的人,在这里会撞上一个谁都想不到的结果:配置中心里那一项已经没了,服务还在按它跑。
阴招四:index 撞上就吞事件
同一段里的 !this.consulIndexes.containsValue(newIndex)。只要任何别的 context 已经持有这个 index 值,这次变更就被判成重复,事件直接吞掉。
多 context 是默认配置就有的(默认 context 加应用名 context 加 profile context),它们的 index 一撞就漏事件。
这两处我到现在都还没撞上过。但让我不舒服的是它们的触发条件有多平淡:删一个配置项,或者恰好几个 context 的 index 对上了。四个阴招,都不需要你做错任何事,都不打一行 error 日志。
把前面这一整条链画出来是这样,八个失效点按断在哪一层排,青色四层是你自己的代码,红色那层在 Consul 侧:

第六回合:我这个 case 的根因
查到最后,根因不在上面任何一条。
我查了那个 @Value 字段名,grep 全模块,只有声明处一处引用。零 getter,零调用。
真正在跑的值来自业务类里另一个完全不相干的来源。配置字段和业务逻辑之间从来没连过线。
刷新链路全程正确工作,把新值准确送达了一个没人读的字段。
我花了大半天证明框架没错,最后发现要证的根本不是框架。
真正拖时间的不是根因多难,是我一路都在信错的证据:
「标了 @RefreshScope」不等于在刷新覆盖面里。「identityHashCode 没变」不等于 bean 没重建,那是代理。「日志没报错」什么都不等于。
没有一个证据会主动告诉你它错了。
换回来的三截验证法
那大半天之后,我整理了一套顺序。下次遇到"配置改了不生效",不再从猜开始,按这三截往下切,能定位到断在哪一层。
第一截:值到 Environment 了吗
c
log.info("[DEBUG-REFRESH] env={}", env.getProperty("your.key"));
没到,问题在 Consul 侧或配置源,回上一节。这里别去背配置源的优先级规则,Boot 2.4 引入 ConfigData 之后排序跟老 bootstrap 时代不一样。直接看 /actuator/env 的返回,它本身就是按优先级从高到低排的,谁在前面谁生效。
第二截:bean 真的重建了吗
这一截我连着踩了两个坑。
第一次我下意识就对注入进来的对象算了 identityHashCode,值一直不变,差点得出"bean 从没重建"的结论。后来才想明白,那个对象是代理,代理本来就是稳定的,变的是它背后的目标实例。
第二次我改用 AopProxyUtils.getSingletonTarget(),拿到的是恒定的 0。因为它只认 SingletonTargetSource(AopProxyUtils.getSingletonTarget:62-70),而作用域代理用的是 SimpleBeanTargetSource,拿不到就返回 null,identityHashCode(null) 永远是 0。
一个恒定不变的探针,比没有探针更危险,因为你会拿它当免检凭证。
正确入口是 ScopedObject,框架专门给它开了口子(GenericScope:497-500):
ini
import org.springframework.aop.scope.ScopedObject;
Object target = (config instanceof ScopedObject so) ? so.getTargetObject() : config;
log.info("[DEBUG-REFRESH] target={}", System.identityHashCode(target));
刷新前后这个值变了,说明重建成功,问题在第三截。没变,回去查 @RefreshScope 标的位置对不对、调用有没有绕过代理。
第三截:业务真的读了这个字段吗
grep 字段名,看有几处引用。只有声明处一处,那就是死配置,前两截全绿也没用。
这一截最土,但它是我那次的答案,而且我是最后才查的。
三截连起来是这张,每一截的 pass / fail 分支都标了,中间那个红框是我踩的两个恒真探针:

一个补充:"改了立刻生效"未必是好事
这个 case 最后没做成实时读。而是在任务入口读一次,冻结进本次运行的上下文。后续分页循环只读上下文,不再碰配置。
看着像是把"值被抄出去"这个错误又犯了一遍,其实是刻意的。分页终止条件是 rawSize < limit,意思是"本页拿到的行数不足请求上限,说明没有更多了"。
如果任务跑到一半 limit 变了,这个判断会直接错。就拿我这次的 500 改 1000 举例。上一页用 500 拉满了 500 条。下一页做判断时 limit 已经是 1000,500 < 1000 为 true,它认为数据拉完了,提前终止。剩下的数据静静地丢了,一个报错都没有。 反方向从 1000 改回 500 轻一些,1000 < 500 为 false,它以为还有下一页,多空转一轮而已。
代价是改配置得等这一轮跑完。批处理一轮几分钟到几十分钟,完全可以接受。
"抄出去"本身不是错,无意识地抄出去才是错。 在一个明确的边界上有意识地冻结,往往正是你要的。
前面推荐了 @ConfigurationProperties,这里得补一句它的代价:和 @Validated 混用有坑。rebind 走的是 destroyBean 加 initializeBean,校验失败时 bean 处于绑定失败状态,不是保留旧值继续用 。手抖把某个数值配成负数,可能直接把配置类搞挂,所有读它的地方一起出问题。加了 @Validated 的配置类,改值前最好在灰度过一遍。
这张表值得截图
@RefreshScope 只保证一件事:这个 bean 的实例会被销毁,下次通过代理调用时重建。
它不保证的,是这 8 个失效点:
| # | 失效点 | 断在哪一层 |
|---|---|---|
| 1 | @Value 字段没有 reader,真实值另有来源 |
调用方 |
| 2 | 值被抄进只跑一次的地方或长生命周期对象 | 调用方 |
| 3 | getter 是 final 或 private,CGLIB 拦不住 | 代理 |
| 4 | @Value 所在 bean 不在 refresh scope 里 |
覆盖面 |
| 5 | @RefreshScope 标在 @Configuration 类上 |
装配位置 |
| 6 | ConfigWatch 三个装配条件缺一 |
事件源 |
| 7 | context 下 key 被删空,响应为空不发事件 | 事件源 |
| 8 | index 撞上别的 context,事件被判重复吞掉 | 事件源 |
8 个失效点,没有一个会抛异常。
后三个在 Consul 侧,连日志都只有 warn。前两个最土也最常见,是唯一两个 grep 就能查出来的。
最后说两句
如果你的服务也没暴露 /actuator/refresh,建议开一个。不是为了日常刷配置,是为了在怀疑的时候有个手动兜底,能把"自动刷新到底有没有在工作"这件事一次问清楚。我那次绕远,起点就是连这个问题都问不了。
最难查的 bug 不是抛异常的,是机制全程正确工作、只是没工作到你用的那个地方。它不报错,所以它耗掉的不是时间,是你对自己判断的信任。
你们遇到过配置改了不生效吗?最后断在哪一层?评论区聊聊。我特别想知道那三个 Consul 侧的坑有没有人真撞上过,尤其是"删配置不触发刷新"这个,我总觉得它已经在某个地方悄悄坑过人了,只是没人查到那一层。
这类"不报错但要命"的线上故障,我每次排查完都会把完整过程记成案例,攒成了一个 「经验怪谈」 系列。已经写了 DELETE 锁全表删 3344 万行、一个 Kafka topic 堵 18 小时监控全绿,后面还有几篇更邪门的。
系列都发在公众号 啫煲捞饭,同名,搜一下就能找到。都是一手生产环境的坑和真实数字,不写八股。