配置刷新明明成功了,为什么 Bean 状态还是旧的?
refresh 接口返回成功、`/actuator/env` 也能看到新值,但业务行为还是老样子,这通常不是“配置没推到”,而是值只更新到了链路中的前半段:source 变了,Environment 变了,真正干活的对象却没切过去。
这类问题我第一次遇到时,现场特别有迷惑性。
配置中心里已经是新值,调用 /actuator/refresh 返回成功,/actuator/env 里也能搜到新值,但接口行为还是旧的。那一刻大家最容易下两个结论:
- 要么刷新没生效
- 要么 Spring 有鬼
后来这种现场见得多了,我反而更愿意先把判断说得保守一点:不是“配置没变”,而是“变化只走到了一半”。
也就是说,配置链路里可能有一层已经更新了,但真正参与业务行为的那一层还停在旧状态。
我会先把这件事拆成四层
我一般把“刷新成功但状态还是旧的”拆成四层来看:
- 配置源是不是已经变了
Environment里的最终值是不是已经变了- 绑定到配置对象上的值是不是已经变了
- 真正干活的资源对象是不是已经按新值重建或切换了
这四层如果不拆开,现场就很容易只会重复一句“明明 refresh 成功了”。但 refresh 成功,最多只能证明其中某一段链路工作了,不代表四层都通了。
第一层:source 变了,不代表 Environment 最终值就一定变了
这个最常见在覆盖顺序复杂的项目里。
表面上你在配置中心把值改了,但运行时真正生效的顺序可能是:
command line args > systemEnvironment > application-prod.yml > remote config
于是你看到远端 source 已经是新值,Environment 里最终值还是旧的。这个时候如果继续纠结 Bean,方向就已经偏了。
所以第一步我会先核对:
curl -s http://host:8080/actuator/env | rg 'your.key'
看的重点不是“远端系统里写了什么”,而是当前实例最后到底拿到了什么值,以及它来自哪个 property source。
第二层:Environment 是新的,不代表绑定对象已经是新的
很多现场会卡在这一步。
/actuator/env 已经看到新值,但 @ConfigurationProperties 对象还是旧的,或者部分字段新、部分字段旧。这时要继续看:
curl -s http://host:8080/actuator/configprops | rg 'your.prefix'
如果 env 和 configprops 不一致,问题通常落在绑定、作用域或者刷新机制本身,而不是落在远端配置源。
第三层:配置对象是新的,不代表业务一定用的是它
这是最容易被低估的一层。
我见过很多代码,配置对象虽然刷新了,但业务在 Bean 初始化时早就把值复制走了。
例如:
@ConfigurationProperties(prefix = "demo.client")
public record ClientProps(Duration timeout, int maxConn) {}
@Service
public class OrderClient {
private final int timeoutMs;
public OrderClient(ClientProps props) {
this.timeoutMs = (int) props.timeout().toMillis();
}
}
这里就算 ClientProps 后面刷新成了新值,OrderClient 构造函数里那份 timeoutMs 也还是旧的。
所以我一旦看到“configprops 已经是新值,但行为还是旧的”,就会立刻去代码里找下面这些痕迹:
@Value注入后复制到字段- 构造函数里把配置值转存成基本类型
@PostConstruct里基于旧值做了一次初始化- 单例 Bean 启动时按旧配置构建了客户端或线程池
第四层:配置对象变了,资源对象未必会自动重建
这层是很多 refresh 现场真正的根因。
比如你改的是:
- Hikari 连接池大小
- 线程池核心线程数
- HTTP client timeout
- Redis 客户端连接参数
这些值就算已经在配置对象里变了,也不代表底层资源对象会自己重建。很多资源对象是在应用启动时按当时的配置 new 出来的,后面配置刷新只能更新“描述值”,不会自动替你换掉已经在工作的对象。
下面这种代码就很典型:
@Bean
public OkHttpClient orderHttpClient(ClientProps props) {
return new OkHttpClient.Builder()
.connectTimeout(props.timeout())
.build();
}
如果这个 Bean 没有合适的刷新或重建策略,那你后面看到的新值,很可能只存在于配置对象里,真正发请求的客户端还是旧实例。
所以我在线上更关心“旧状态卡在哪一层”
这件事一旦这么问,现场会清楚很多。
如果 Environment 还是旧值
先别谈 Bean,回去查 source 覆盖顺序、实例是否真正拿到新配置、是否还有环境变量或启动参数把它盖掉了。
如果 Environment 新、configprops 旧
先查绑定和刷新机制,别急着怀疑业务代码。
如果 configprops 新、业务行为旧
重点就不在 refresh 接口了,而在“业务实际使用的对象”是不是那份新配置,或者底层资源有没有重建。
如果同一实例里有些请求像新值、有些像旧值
这通常提示你:
- 有对象被局部复制了
- 有缓存或线程本地状态残留
- 有部分资源重建了,部分没重建
- 请求流量在新旧对象之间混跑
我自己会怎么排
第一步:先把三层状态摆平
先把远端 source、/actuator/env、/actuator/configprops 摆在一起。只要三层里有一层对不上,先把那一层说清楚。
第二步:确认业务真正读的是谁
不是“应该读谁”,而是“现在真的读的是谁”。很多项目最后会发现,业务读的不是那份刚刚刷新的配置对象,而是初始化时复制出去的一份旧值。
第三步:看改动的是读取型配置,还是资源构造型配置
如果只是某个普通阈值字段,刷新后直接读新值就可能生效;但如果改的是线程池、连接池、客户端之类的构造参数,就必须认真看资源是否重建。
第四步:再决定该不该重启,而不是把重启当成羞耻操作
有些配置天生就不适合靠在线 refresh 完成状态切换。如果资源对象没设计成可平滑重建,重启实例反而是更诚实、更可控的做法。
最容易误判的几个地方
误判一:/actuator/env 是新值,就说明业务已经完全生效
不对。那通常只证明第二层通了。
误判二:configprops 是新值,就说明运行态一定是新的
也不对。真正工作的资源对象可能还是旧的。
误判三:refresh 成功,就不该再重启
这是一种很常见但不太健康的执念。不是所有配置都适合靠 refresh 在线切换。
最后一句
“刷新成功但 Bean 状态还是旧的”,真正有用的问题不是“Spring 到底有没有刷新”,而是:新值到底停在了哪一层。
只要把 source、Environment、绑定对象、资源对象这四层拆开,你通常很快就能知道,自己现在该去查覆盖顺序、刷新机制、对象复制,还是资源重建。