Spring Cloud 配置热更新与 Bean 代理机制梳理

Spring Cloud 配置热更新与 Bean 代理机制梳理

本文梳理 @ConfigurationProperties 远程配置刷新、@RefreshScope、依赖注入中的「本体 / 代理」,以及 @Transactional、接口注入等常见问题,便于在实践中选对方案。


一、背景与结论先行

在 Spring Cloud(配合 Nacos / Config Server 等)场景下,远程配置变更后能否生效,取决于两套不同机制:

机制 典型注解 更新方式 注入到调用方的通常是什么
ConfigurationProperties Rebinder @ConfigurationProperties 同一实例原地 rebind(调 setter) 真实单例本体(无额外 AOP 时)
Refresh Scope @RefreshScope 销毁旧 target,下次调用再建新实例 Scoped 代理

实践建议:

  1. 可变配置类:只用 @Component(或 @EnableConfigurationProperties)+ @ConfigurationProperties,交给 Rebinder。
  2. 不要在 Properties 上叠加 @RefreshScope
  3. 需要按新配置「整实例重建」的有状态 Bean(如第三方 SDK Client),单独加 @RefreshScope
  4. 调用方用 final 注入没问题,但应通过 getter / 方法访问,不要在构造期把配置值拷出来缓存。

二、@ConfigurationProperties 远程修改后会不会自动更新?

2.1 仅 @Component + @ConfigurationProperties

会更新(在满足前提时)。

刷新链路大致为:

text 复制代码
远程配置变更
  → 更新 Environment
  → 发布 EnvironmentChangeEvent
  → ConfigurationPropertiesRebinder
  → 对已注册的 @ConfigurationProperties Bean 重新绑定(调用 setter)

官方 Spring Cloud Commons 文档明确:观察到 EnvironmentChangeEvent 后,会 Re-bind any @ConfigurationProperties beans

前提条件:

  1. 引入了 spring-cloud-context(Config Client / Nacos 等通常会带上)。
  2. 变更确实进入了 Environment(配置中心推送、/actuator/refresh、Spring Cloud Bus 等)。
  3. Properties 是可变 对象(具备 setter;如 Lombok @Data)。
  4. 不是 Java record,也不是仅构造器绑定的不可变 @ConfigurationProperties(这类无法原地 rebind;文档写明 Record 不可 refresh)。

2.2 再叠加 @RefreshScope 会不会有问题?

不推荐在 Properties 上叠加 @RefreshScope

原因简要说明:

  1. 两套机制职责不同,容易打架

    • Rebinder:改当前实例字段。
    • RefreshScope:清缓存、换新 target
      同时作用在同一配置类上,行为依赖注册与刷新顺序,难以预期。
  2. 与扫描注册方式配合不稳定

    Spring Cloud 相关讨论(如 commons#846,结论为 wontfix)指出:通过 @EnableConfigurationProperties / @ConfigurationPropertiesScan 注册的 Bean,叠加 @RefreshScope 往往无法正确进入 Refresh Scope。官方态度更接近:若确实需要 Refresh Scope 的配置 Bean,用 @Bean 方法显式声明。

  3. 文档对 @RefreshScope 的定位不同

    Refresh Scope 面向「初始化时读死配置、需要原子换实例」的有状态 Bean;而可变 @ConfigurationProperties 的主路径是 Environment 变更后的 rebind

2.3 按 Spring Cloud / Boot 世代看差异

时期 大致范围 行为要点
Boot 2.x Spring Cloud Greenwich ~ 2021.x 可变 @ConfigurationProperties 靠 Rebinder;叠 RefreshScope 的坑已存在
Boot 3.0+ Spring Cloud 2022.0+ 模型延续;Record / 构造器绑定不可变对象无法 rebind
Boot 3.x 近期 2023.x ~ 2025.x 等 机制未改:Properties → Rebinder;有状态 Client → @RefreshScope

版本差异主要在「不可变绑定能不能刷」,不在「要不要给 Properties 加 RefreshScope」。

对可变 Properties:各版本都不需要、也不建议叠 RefreshScope。

2.4 推荐拆分写法(示意)

java 复制代码
// 配置类:只做绑定,交给 Rebinder
@Component
@ConfigurationProperties(prefix = "app.storage")
public class StorageProperties {
    private String endpoint;
    private String accessKey;
    // getters / setters
}

// 有状态客户端:配置变更后需要整实例重建
@Component
@RefreshScope
public class StorageClientHolder {

    private final StorageProperties properties;
    private volatile Client client;

    public StorageClientHolder(StorageProperties properties) {
        this.properties = properties;
    }

    @PostConstruct
    public void init() {
        this.client = Client.create(properties.getEndpoint(), properties.getAccessKey());
    }

    public Client getClient() {
        return client;
    }

    @PreDestroy
    public void destroy() {
        // 关闭旧客户端
    }
}

三、调用方用 final 注入 Properties,还能读到最新配置吗?

3.1 只有 Rebinder(未加 @RefreshScope)------能

final 约束的是引用不可再赋值,不是对象内部状态不可变:

java 复制代码
private final StorageProperties storageProperties;

Rebinder 对同一个单例实例 调用 setter。之后通过 storageProperties.getXxx() 即可读到新值。

3.2 读不到新值的常见原因

  1. 构造或初始化时把配置拷贝成局部字段 并长期使用:

    java 复制代码
    private final long maxSize = storageProperties.getMaxSize(); // 只读一次
  2. 对集合 / 嵌套对象做了防御性拷贝后,一直用副本。

  3. 使用了不可变 Properties(record / constructor binding),Rebinder 无法原地更新。

  4. 错误地在 Properties 上叠加 @RefreshScope 后,再配合错误用法(例如拿到非代理 target、或缓存标量),行为更易踩坑。

3.3 若 Properties 上误加了 @RefreshScope

此时注入的往往是代理final 持有代理引用;方法调用可能落到刷新后的新 target。理论上「通过方法访问」仍可能看到新值,但与 Rebinder 叠加后契约不清晰,不建议依赖这种写法


四、@RefreshScope + final 注入:重建后为何还能用到「新 Bean」?

4.1 原理:final 钉死的是代理,不是业务实例

@RefreshScope 的 Bean 注册为 scoped proxy (类似带 ScopedProxyMode 的 scope)。

text 复制代码
调用方.final 字段 ──► RefreshScope Proxy(单例,引用不变)
                           │
                           ▼ 每次方法调用时解析
                    Scope 缓存中的 target(可被销毁并重建)

刷新时大致过程:

  1. 触发 Context Refresh(如 /actuator/refresh、配置中心联动刷新等)。
  2. RefreshScope.refreshAll() 清空 scope 中的 target 缓存(旧实例可走销毁回调)。
  3. 代理对象本身不换 ,调用方 final 字段无需、也不会被重新注入。
  4. 下一次方法调用:代理发现无可用 target → 重新创建(含依赖注入、@PostConstruct 等)→ 委托到新实例。

因此「拿到新 Bean」的含义是:经代理方法访问到的是新 target,而不是调用方字段被改成另一个引用。

4.2 使用注意

  1. 必须通过注入进来的引用调用方法,才会触发懒加载新 target。
  2. 在构造器里只拷贝配置标量,刷新后仍是旧值。
  3. RefreshScope Bean 默认偏懒创建:刷新后第一次调用才重建。
  4. 若希望「依赖方自身也被整 Bean 重建并重新装配依赖」,依赖方也需要进入 Refresh Scope;多数场景下,仅对有状态 Client 使用 @RefreshScope,业务层保持普通单例 + final 注入代理即可。

4.3 与 Rebinder 对照

对比项 @RefreshScope Properties Rebinder
final 持有 代理 真实单例
更新方式 替换 target 原地修改字段
为何 final 仍能看到「新结果」 方法走代理 → 新 target 同一对象被 setter 更新

五、Properties 注入的是代理吗?事务又是怎么生效的?

5.1 不是所有 Bean 注入都是代理

容器最终注入的是 BeanFactory 中对外暴露的那个对象

Bean 情况 常见注入结果
普通组件,无 AOP 本体
存在 @Transactional / @Async / @Cacheable AOP 代理
@RefreshScope Scoped 代理
@Component + @ConfigurationProperties(无上述织入) 本体

Properties 热更新靠「不代理的单例被原地改字段」;事务靠「被事务切面匹配的 Bean 被代理」。二者机制不同,并不矛盾。

5.2 事务为何必须靠代理

text 复制代码
调用方 ──► Service 代理 ──► 真实 Service
              │
              └ 拦截:开启 / 提交 / 回滚事务,再调用 target

补充:

  1. 同类自调用this.xxx())不经过代理,事务往往不生效。
  2. 是否生成代理,取决于该 Bean 是否被 Advisor 匹配,而不是「只要被注入就一定是代理」。
  3. 一般不要把「事务 Service」和「配置 Properties」揉成一个既 @Transactional@ConfigurationProperties 的类,以免两套生命周期叠在一起难以推理。

六、Service 实现接口、Controller 注入接口时,运行时到底是什么?

注入接口只决定依赖的静态类型;运行时对象是本体还是代理,由该 Bean 是否需要拦截决定。

6.1 无 AOP

text 复制代码
Controller ──(接口类型引用)──► ServiceImpl 本体

6.2 有 @Transactional 等,且为 JDK 动态代理

(实现了接口时较常见的一种形态)

text 复制代码
Controller ──► 接口
                │
                ▼
          JDK Proxy(实现了该接口)
                │
                ▼
          ServiceImpl 本体

特点:instanceof 接口 为 true;instanceof ServiceImpl 通常为 false。

6.3 CGLIB 代理

(例如开启 spring.aop.proxy-target-class=true;Spring Boot 环境中很常见)

text 复制代码
Controller ──► 接口
                │
                ▼
          CGLIB 子类(继承 ServiceImpl,并实现接口)

仍可通过接口注入;getClass().getName() 常见带 $$SpringCGLIB$$ / 历史的 $$EnhancerBySpringCGLIB$$ 一类后缀。

6.4 如何自检

在运行时打印注入对象的运行时类名即可区分:本体实现类名、JDK Proxy 名,或 CGLIB 子类名。


七、总览图

text 复制代码
远程配置变更
        │
        ├──────────────────────────────┐
        ▼                              ▼
 Environment 更新                 (可选)RefreshScope.refreshAll
        │                              │
        ▼                              ▼
 ConfigurationPropertiesRebinder   清空 @RefreshScope 的 target 缓存
  原地 setter 更新 Properties           │
        │                              ▼
        │                     下次方法调用 → 新建 target
        ▼                              │
 业务代码通过 final 引用                 │
 调用 Properties.getXxx() 得新值        │
        │                              │
        └──── 业务 / Controller ────────┘
              │
              ├─ 注入普通 Properties     → 本体(Rebinder)
              ├─ 注入 @RefreshScope Bean → Scoped 代理
              └─ 注入 @Transactional 服务 → AOP 代理(JDK 或 CGLIB)

八、可直接落地的检查清单

  1. 配置类:可变 + @ConfigurationProperties不要@RefreshScope
  2. SDK / 连接类等有状态 Bean:需要整实例重建时再使用 @RefreshScope
  3. 调用方可用构造器 final 注入;每次使用时读 Properties 或调用 RefreshScope Bean 的方法。
  4. 避免在字段上缓存「启动那一刻」的配置快照(除非业务明确接受不热更新)。
  5. 不可变配置(record / 仅构造器绑定):不要期望 Rebinder 热更新。
  6. 事务:依赖接口或类上的代理拦截;避免同类内部自调用绕过代理。
  7. 「注入接口」≠「一定是代理」;无 AOP 时接口引用指向的就是实现类本体。

参考


说明:文中示例均为通用示意代码,不涉及具体业务系统与私有配置。

相关推荐
码事漫谈1 小时前
我用 Seed Evolving 做了个 AI 小说写作工具
后端
evans在进步2 小时前
Spring AI 从入门到实战:用 Java 实现大模型对话与 Tool Calling
java·人工智能·spring
evans在进步2 小时前
LeetCode 189 轮转数组:三次反转原地解决,图解 Java 实现
java·算法·leetcode
DantyWei2 小时前
kube-controller-manager的leader选举流程和策略
后端
妙码生花3 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十四):管理员个人资料页面、管理员日志优化
前端·后端·go
我命由我123453 小时前
Jetpack Compose - MaterialExpressiveTheme 与 MaterialTheme、ColorScheme
android·java·开发语言·java-ee·kotlin·android jetpack·android runtime
大卫陈3 小时前
PCB 拼版系统近期迭代复盘:大小拼跃迁、横直料重构与引擎打磨
后端·架构
二月龙3 小时前
JS 垃圾回收:为什么你明明释放了变量,内存还是爆了?
后端