Java

配置刷新明明成功了,为什么 Bean 状态还是旧的?

refresh 接口返回成功、`/actuator/env` 也能看到新值,但业务行为还是老样子,这通常不是“配置没推到”,而是值只更新到了链路中的前半段:source 变了,Environment 变了,真正干活的对象却没切过去。

  • Spring Boot
  • 配置刷新
  • Bean
  • Actuator
  • 故障排查
18 分钟阅读

这类问题我第一次遇到时,现场特别有迷惑性。

配置中心里已经是新值,调用 /actuator/refresh 返回成功,/actuator/env 里也能搜到新值,但接口行为还是旧的。那一刻大家最容易下两个结论:

  • 要么刷新没生效
  • 要么 Spring 有鬼

后来这种现场见得多了,我反而更愿意先把判断说得保守一点:不是“配置没变”,而是“变化只走到了一半”。

也就是说,配置链路里可能有一层已经更新了,但真正参与业务行为的那一层还停在旧状态。

我会先把这件事拆成四层

我一般把“刷新成功但状态还是旧的”拆成四层来看:

  1. 配置源是不是已经变了
  2. Environment 里的最终值是不是已经变了
  3. 绑定到配置对象上的值是不是已经变了
  4. 真正干活的资源对象是不是已经按新值重建或切换了

这四层如果不拆开,现场就很容易只会重复一句“明明 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'

如果 envconfigprops 不一致,问题通常落在绑定、作用域或者刷新机制本身,而不是落在远端配置源。

第三层:配置对象是新的,不代表业务一定用的是它

这是最容易被低估的一层。

我见过很多代码,配置对象虽然刷新了,但业务在 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、绑定对象、资源对象这四层拆开,你通常很快就能知道,自己现在该去查覆盖顺序、刷新机制、对象复制,还是资源重建。