Apollo 配置热更新落地:让 Spring Boot @ConfigurationProperties Bean 不重启即时生效
技术栈:Spring Boot 2.6.11 / Java 8 / Apollo Client 1.9.0(
apollo-client-config-data)场景:业务开关、降级、熔断等配置需要在运行中调整,要求多实例、无重启、推送后即时生效。
1. 问题:Apollo 推送成功,Bean 里还是旧值
Apollo 的配置推送本质上只做一件事:把 namespace 的最新配置写进客户端的 Config。在 Spring 侧表现为 Environment 里的 PropertySource 内容变了。
但 Bean 不会自动跟着变:
@Value("${x}"):Apollo 的SpringValueProcessor会通过反射更新字段上的@Value(前提是 Apollo 的注解处理器已注册)。@ConfigurationProperties:Spring Boot 只在 Bean 初始化时绑定一次。之后Environment再变,也不会重新绑定。@RefreshScope:属于 Spring Cloud Context。它依赖ContextRefresher/RefreshScope.refreshAll()触发,并且会把 Bean 变成 scoped proxy,销毁后重建。当前项目没有引入 Spring Cloud Context,Apollo 的 config-data 也不会自动发EnvironmentChangeEvent,所以@RefreshScope在这里不生效。
即使引入 Spring Cloud,@RefreshScope 也有代价:代理身份、依赖顺序、构造器注入仍拿旧值、派生对象不会自动重建。因此需要一个显式的"重新绑定"动作。
2. 方案:单例原地重绑 + 派生对象重建
核心三点:
- 配置类保持
@ConfigurationProperties单例,不代理、不重建; - 用
@ApolloConfigChangeListener监听目标 namespace + key 前缀; - 回调里用
Binder.get(environment).bind(prefix, Bindable.ofInstance(config))把最新值重新绑到同一个对象上。
因为注入方持有的是同一个对象引用,重绑后所有消费方立即看到新值。对于 resilience4j 熔断器这种"配置在创建时固化"的派生对象,再显式 reload() 重建。
3. 代码落地
3.1 配置类:标准的 @ConfigurationProperties
以 FallbackConfig 为例(删节):
java
@Component
@ConfigurationProperties(prefix = "demo.fallback")
public class FallbackConfig {
/** 可用降级策略清单及各自参数 */
private volatile List<Policy> policy = new ArrayList<>();
/** 各业务类型映射到的降级策略,key 为 BusinessType#getName() */
private volatile Map<String, FallbackPolicy> bizType = new LinkedHashMap<>();
/** 渠道熔断器参数 */
private Circuit circuit = new Circuit();
@Resource
private Environment environment;
public FallbackPolicy fallbackPolicyOf(BusinessType type) {
String bizTypeKey = bizTypeKeyOf(type);
FallbackPolicy policyValue = bizTypeKey == null ? null : bizType.get(bizTypeKey);
return policyValue == null ? FallbackPolicy.DISABLED : policyValue;
}
/**
* 结果码降级集每次实时读取 Environment,不依赖重绑
*/
private Set<String> resolveFallbackCodes(String channelCode) {
String configKey = RESULT_CODE_CONFIG_KEYS.get(channelCode);
if (configKey == null) {
return DEFAULT_FALLBACK_CODES.getOrDefault(channelCode, Collections.<String>emptySet());
}
String value = environment.getProperty(configKey, (String) null);
if (value == null) {
return DEFAULT_FALLBACK_CODES.getOrDefault(channelCode, Collections.<String>emptySet());
}
Set<String> codes = new HashSet<>();
for (String item : value.split(",")) {
if (StringUtils.isNotBlank(item)) {
codes.add(item.trim());
}
}
return codes;
}
}
几个设计点:
policy/bizType用volatile:刷新线程写,业务线程读;result.code.*不绑定到字段,而是每次从Environment实时读取,所以这类配置天然热更新,不需要重新绑定;- 字段自带默认值,配置中心缺 key 时保持默认。
3.2 Refresher:监听 + Binder 重绑
FallbackRefresher:
java
@Component
public class FallbackRefresher {
private static final Logger log = LoggerFactory.getLogger(FallbackRefresher.class);
@Resource
private FallbackConfig fallbackConfig;
@Resource
private CircuitBreakerManager circuitBreakerManager;
@Resource
private Environment environment;
@ApolloConfigChangeListener(
value = {"common.fallback"},
interestedKeyPrefixes = {"demo.fallback"})
public void onFallbackConfigChange(ConfigChangeEvent changeEvent) {
Binder.get(environment).bind("demo.fallback", Bindable.ofInstance(fallbackConfig));
circuitBreakerManager.reload();
log.info("demo.fallback.* 配置热刷新完成, biz-type={}, policy={}, circuit={}",
fallbackConfig.getBizType(), fallbackConfig.getPolicy(), fallbackConfig.getCircuit());
}
}
另一个业务配置的 Refresher 是同样的套路:
java
@ApolloConfigChangeListener(value = {"application"}, interestedKeyPrefixes = {"demo.biz"})
public void onBizConfigChange(ConfigChangeEvent changeEvent) {
Binder.get(environment).bind("demo.biz", Bindable.ofInstance(bizConfig));
bizCircuitBreaker.reload();
log.info("demo.biz.* 配置热刷新完成: {}", bizConfig.describe());
}
关键点:
value是 namespace ;interestedKeyPrefixes才是 key 前缀;- 绑定整个前缀,而不是逐个 key:嵌套对象、默认值、Map/List 一次性处理,避免多字段只更新一半;
- 回调触发时 Apollo 已经更新完
Config,Environment已是新值; - 先
bind再reload(),保证熔断器读到的是新参数。
3.3 为什么 Binder 能读到新值
Apollo 的 ConfigPropertySource 不是启动时拷贝的快照,而是把 getProperty 委托给 Config:
java
public Object getProperty(String name) {
return source.getProperty(name, null);
}
所以监听回调执行时,Binder.get(environment) 读到的就是 Apollo 当前最新值。这是整套方案成立的前提。
3.4 派生对象:resilience4j 熔断器必须重建
CircuitBreakerConfig 是不可变对象,在熔断器创建时固化。只重绑 FallbackConfig 不会改变已有熔断器,所以 CircuitBreakerManager.reload() 按新参数重建:
java
public void reload() {
int rebuilt = 0;
for (CircuitBreaker breaker : registry.getAllCircuitBreakers()) {
String name = breaker.getName();
registry.replace(name, CircuitBreaker.of(name,
configOf(BreakerKey.bizTypeOf(name))));
rebuilt++;
}
// 重建后是全新实例,旧的挂钩标记失效;清空后下次取用时重新挂钩
wiredBreakerKeys.clear();
log.info("渠道熔断器配置热刷新完成,重建熔断器 {} 个", rebuilt);
}
这里有一个容易漏掉的细节:熔断器第一次创建时会往 wiredBreakerKeys 里登记并挂状态迁移监听。registry.replace 之后是新实例,旧标记必须清掉,否则新熔断器的 OPEN/CLOSED 事件不会再发布。
代价也要写清楚:重建会清空熔断状态和失败率统计,已跳闸的渠道会重新放行直连。这是"配置立即生效"换来的语义,适合低频配置变更。
3.5 多个刷新器全景
| 配置域 | namespace | 监听前缀 | 额外动作 |
|---|---|---|---|
demo.biz.* |
application |
demo.biz |
BizCircuitBreaker.reload() |
demo.fallback.* |
common.fallback |
demo.fallback |
CircuitBreakerManager.reload() |
demo.cache.* |
application |
demo.cache |
无 |
demo.business-type.* |
common.monitor |
demo.business-type / demo.businessType |
无 |
demo.business-type 同时订阅历史驼峰 key:Spring Boot 的 Binder 支持 relaxed binding,demo.businessType.map.* 也能绑定到 demo.business-type 前缀。
4. 为什么不需要 @EnableApolloConfig
项目使用的是 apollo-client-config-data + spring.config.import:
yaml
spring:
config:
import:
- apollo://application
- apollo://common.monitor
- apollo://common.fallback
apollo:
meta: http://apollo-meta.example.com:8080
启动时:
ApolloClientConfigDataAutoConfiguration注册ConfigPropertySourcesProcessor;- 它的 helper 会注册
ApolloAnnotationProcessor; ApolloAnnotationProcessor负责处理@ApolloConfig、@ApolloJsonValue、@ApolloConfigChangeListener。
所以在这种模式下,@ApolloConfigChangeListener 可以直接工作,不需要 @EnableApolloConfig。但要注意:监听订阅 ≠ 配置已加载 。如果 namespace 没出现在 spring.config.import 里,回调可能触发,但 Binder 在 Environment 里读不到这个 namespace 的 key。上线前要把 import 列表和监听器 namespace 对齐。
5. 踩坑与边界
5.1 删除 key 不会恢复默认值
Binder 只覆盖"存在的属性",不会把缺失属性重置成字段默认值。在 Spring Boot 2.6.11 上用 StandardEnvironment 实测:
text
first: a=1 map={x=1, y=2} nested={x=1,y=dy}
remove: a=1 map={x=1, y=2} nested={x=1,y=dy}
add z: a=1 map={x=1, y=2, z=9} nested={x=1,y=dy}
结论:
- 标量 key 删除 → 保留上一次的值;
- Map 的某个 key 删除 → 旧条目仍然保留(
Binder会基于旧 Map 的条目构造新 Map 再覆盖); - 嵌套对象字段删除 → 保留上一次的值。
对应到实际配置:
- 把
demo.fallback.biz-type.biz-a从 Apollo 删掉,不会 关闭 biz-a 的降级,必须显式配成disabled; demo.cache.biz-type.*、demo.business-type.map同理。
如果业务语义要求"删除即恢复默认",需要绑定到新实例再交换:
java
FallbackConfig fresh = new FallbackConfig();
Binder.get(env).bind("demo.fallback", Bindable.ofInstance(fresh));
// 再将 fresh 的关键字段 copy 到单例,或直接把单例改成 AtomicReference<Snapshot>
但 FallbackConfig 依赖注入的 Environment 来读结果码配置,新实例需要补齐这个依赖。更干净的做法是拆成"纯数据配置"和"运行时查询",或者使用 copy-on-write + AtomicReference。
5.2 多字段不是原子的
Binder 是逐字段写入的,嵌套对象是原地修改,不是整体替换。刷新过程中,其他线程可能读到新旧混合值。
volatile 能保证 Map/List 新引用的可见性,但保证不了多个字段之间的原子性。
建议:
- 独立开关、阈值类配置,接受这种弱一致;
- 有联动关系的字段,用不可变快照对象 + 单个
volatile引用整体替换; - 或者在 getter / 绑定后做范围校验(现有熔断器就对阈值和窗口做了 clamp)。
5.3 List 下标不要留空洞
如果 Apollo 里删掉 demo.fallback.policy[0] 但保留 policy[1],Binder 会抛 UnboundConfigurationPropertiesException。List 配置要么整组替换,要么改用带业务 key 的 Map,避免依赖下标。
5.4 回调线程与异常
- 回调运行在 Apollo 的通知线程池,不是请求线程。保持轻量,不要做远程调用;
- Apollo 会
catch Throwable并记录Failed to invoke config change listener ...,不会拖垮应用,但配置可能部分生效; - 关键配置建议加 try/catch + 告警:
java
try {
Binder.get(environment).bind("demo.fallback", Bindable.ofInstance(fallbackConfig));
circuitBreakerManager.reload();
} catch (Exception e) {
log.error("配置热刷新失败,请检查配置格式", e);
// 触发告警
return;
}
5.5 其它注意点
interestedKeyPrefixes是startsWith匹配,不是按.分段;- 同一个前缀散落在多个 namespace 时,
value里要全部列出; @ConfigurationProperties的@Validated校验和@PostConstruct不会在重绑时执行,需要显式校验;- 多实例各自收到 Apollo 通知,没有分布式协调;重绑是幂等的,重复执行没问题;
- 不要再给同一个配置类叠加
@RefreshScope,否则会出现双重刷新和代理身份问题。
6. 怎么验证
6.1 单测验证绑定语义
用 StandardEnvironment + MapPropertySource 就能覆盖:
java
StandardEnvironment env = new StandardEnvironment();
Map<String, Object> map = new HashMap<>();
map.put("demo.fallback.biz-type.biz-a", "failure-rate");
env.getPropertySources().addFirst(new MapPropertySource("test", map));
FallbackConfig config = new FallbackConfig();
Binder.get(env).bind("demo.fallback", Bindable.ofInstance(config));
assertEquals(FallbackPolicy.FAILURE_RATE,
config.fallbackPolicyOf(BusinessType.BUSINESS_A));
// 模拟配置变更后再次绑定
map.put("demo.fallback.biz-type.biz-a", "disabled");
Binder.get(env).bind("demo.fallback", Bindable.ofInstance(config));
assertFalse(config.isEnabled(BusinessType.BUSINESS_A));
注意:这类单测验证的是 Binder 的绑定语义,不验证监听器是否成功注册。
6.2 运行时验证
启动应用后,在 Apollo 修改:
properties
demo.fallback.circuit.cooldown-millis=10000
观察日志:
text
demo.fallback.* 配置热刷新完成, biz-type=..., policy=..., circuit=...
渠道熔断器配置热刷新完成,重建熔断器 N 个
再通过业务行为或熔断器指标确认参数已变化。日志里打印的是绑定后的实际对象,不是"收到变更"的占位文案。
7. 小结
- Apollo 推送的是配置,不是 Bean 刷新;
@ConfigurationProperties必须显式重绑。 - 最小可用方案:配置单例 +
@ApolloConfigChangeListener+Binder.ofInstance。 - 配置在创建时固化的派生对象(如 resilience4j 熔断器)需要显式重建,并明确状态清零的代价。
- 三个必须记住的边界:删除 key 不回默认、多字段非原子、namespace 没 import 就读不到值。
- 如果已经引入 Spring Cloud,可以用
ConfigurationPropertiesRebinder/@RefreshScope达到类似效果;但对于只依赖 Apollo 的轻量栈,这套实现依赖更少、行为更可控。