Spring Cloud 配置热更新与 Bean 代理机制梳理
本文梳理
@ConfigurationProperties远程配置刷新、@RefreshScope、依赖注入中的「本体 / 代理」,以及@Transactional、接口注入等常见问题,便于在实践中选对方案。
一、背景与结论先行
在 Spring Cloud(配合 Nacos / Config Server 等)场景下,远程配置变更后能否生效,取决于两套不同机制:
| 机制 | 典型注解 | 更新方式 | 注入到调用方的通常是什么 |
|---|---|---|---|
| ConfigurationProperties Rebinder | @ConfigurationProperties |
同一实例原地 rebind(调 setter) | 真实单例本体(无额外 AOP 时) |
| Refresh Scope | @RefreshScope |
销毁旧 target,下次调用再建新实例 | Scoped 代理 |
实践建议:
- 可变配置类:只用
@Component(或@EnableConfigurationProperties)+@ConfigurationProperties,交给 Rebinder。 - 不要在 Properties 上叠加
@RefreshScope。 - 需要按新配置「整实例重建」的有状态 Bean(如第三方 SDK Client),单独加
@RefreshScope。 - 调用方用
final注入没问题,但应通过 getter / 方法访问,不要在构造期把配置值拷出来缓存。
二、@ConfigurationProperties 远程修改后会不会自动更新?
2.1 仅 @Component + @ConfigurationProperties
会更新(在满足前提时)。
刷新链路大致为:
text
远程配置变更
→ 更新 Environment
→ 发布 EnvironmentChangeEvent
→ ConfigurationPropertiesRebinder
→ 对已注册的 @ConfigurationProperties Bean 重新绑定(调用 setter)
官方 Spring Cloud Commons 文档明确:观察到 EnvironmentChangeEvent 后,会 Re-bind any @ConfigurationProperties beans。
前提条件:
- 引入了
spring-cloud-context(Config Client / Nacos 等通常会带上)。 - 变更确实进入了
Environment(配置中心推送、/actuator/refresh、Spring Cloud Bus 等)。 - Properties 是可变 对象(具备 setter;如 Lombok
@Data)。 - 不是 Java
record,也不是仅构造器绑定的不可变@ConfigurationProperties(这类无法原地 rebind;文档写明 Record 不可 refresh)。
2.2 再叠加 @RefreshScope 会不会有问题?
不推荐在 Properties 上叠加 @RefreshScope。
原因简要说明:
-
两套机制职责不同,容易打架
- Rebinder:改当前实例字段。
- RefreshScope:清缓存、换新 target 。
同时作用在同一配置类上,行为依赖注册与刷新顺序,难以预期。
-
与扫描注册方式配合不稳定
Spring Cloud 相关讨论(如 commons#846,结论为 wontfix)指出:通过
@EnableConfigurationProperties/@ConfigurationPropertiesScan注册的 Bean,叠加@RefreshScope往往无法正确进入 Refresh Scope。官方态度更接近:若确实需要 Refresh Scope 的配置 Bean,用@Bean方法显式声明。 -
文档对
@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 读不到新值的常见原因
-
构造或初始化时把配置拷贝成局部字段 并长期使用:
javaprivate final long maxSize = storageProperties.getMaxSize(); // 只读一次 -
对集合 / 嵌套对象做了防御性拷贝后,一直用副本。
-
使用了不可变 Properties(record / constructor binding),Rebinder 无法原地更新。
-
错误地在 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(可被销毁并重建)
刷新时大致过程:
- 触发 Context Refresh(如
/actuator/refresh、配置中心联动刷新等)。 RefreshScope.refreshAll()清空 scope 中的 target 缓存(旧实例可走销毁回调)。- 代理对象本身不换 ,调用方
final字段无需、也不会被重新注入。 - 下一次方法调用:代理发现无可用 target → 重新创建(含依赖注入、
@PostConstruct等)→ 委托到新实例。
因此「拿到新 Bean」的含义是:经代理方法访问到的是新 target,而不是调用方字段被改成另一个引用。
4.2 使用注意
- 必须通过注入进来的引用调用方法,才会触发懒加载新 target。
- 在构造器里只拷贝配置标量,刷新后仍是旧值。
- RefreshScope Bean 默认偏懒创建:刷新后第一次调用才重建。
- 若希望「依赖方自身也被整 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
补充:
- 同类自调用 (
this.xxx())不经过代理,事务往往不生效。 - 是否生成代理,取决于该 Bean 是否被 Advisor 匹配,而不是「只要被注入就一定是代理」。
- 一般不要把「事务 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)
八、可直接落地的检查清单
- 配置类:可变 +
@ConfigurationProperties,不要 叠@RefreshScope。 - SDK / 连接类等有状态 Bean:需要整实例重建时再使用
@RefreshScope。 - 调用方可用构造器
final注入;每次使用时读 Properties 或调用 RefreshScope Bean 的方法。 - 避免在字段上缓存「启动那一刻」的配置快照(除非业务明确接受不热更新)。
- 不可变配置(record / 仅构造器绑定):不要期望 Rebinder 热更新。
- 事务:依赖接口或类上的代理拦截;避免同类内部自调用绕过代理。
- 「注入接口」≠「一定是代理」;无 AOP 时接口引用指向的就是实现类本体。
参考
- Spring Cloud Commons:Application Context Services(Environment Changes / Refresh Scope)
- Spring Cloud Commons 关于
@ConfigurationProperties与@RefreshScope组合行为的讨论(如 issue #846,官方倾向 wont fix / 用@Bean显式声明)
说明:文中示例均为通用示意代码,不涉及具体业务系统与私有配置。