Apollo 配置热更新落地:让 Spring Boot @ConfigurationProperties Bean 不重启即时生效

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. 方案:单例原地重绑 + 派生对象重建

核心三点:

  1. 配置类保持 @ConfigurationProperties 单例,不代理、不重建;
  2. 用 @ApolloConfigChangeListener 监听目标 namespace + key 前缀;
  3. 回调里用 Binder.get(environment).bind(prefix, Bindable.ofInstance(config)) 把最新值重新绑到同一个对象上。

因为注入方持有的是同一个对象引用,重绑后所有消费方立即看到新值。对于 resilience4j 熔断器这种"配置在创建时固化"的派生对象,再显式 reload() 重建。

sequenceDiagram participant A as Apollo Config participant L as Refresher participant E as Environment participant B as Binder participant C as Config Singleton participant D as Derived Object A->>L: ConfigChangeEvent (namespace + prefix match) L->>E: read latest properties L->>B: bind(prefix, ofInstance(config)) B->>C: setter / field update (same instance) L->>D: reload() if config is immutable at creation Note over C: consumers share the same reference, no restart

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 的轻量栈,这套实现依赖更少、行为更可控。
相关推荐
2601_970703831 小时前
中国除湿机与湿度控制行业研究报告
java·大数据·人工智能
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (二十一):Pipeline 事件传播 —— Inbound 与 Outbound 的双向流转
java·后端·架构
爱敲代码的TOM2 小时前
10.27 易购软件 Java开发线下一面
java·开发语言
yuzhiboyouye2 小时前
String、StringBuilder、StringBuffer 区别(面试题)
java
斯维赤2 小时前
微信步数的10点定时推送,看似简单,实则暗藏玄机
java·后端
Linux_cy2 小时前
服务器管理方案选型:面板 / SSH 客户端 / 桌面型客户端怎么选
java·linux·运维·服务器·ssh·可视化·gmssh
可乐_不渴2 小时前
【Linux】 进程虚拟地址空间
java·linux·javascript
SimonKing2 小时前
号外号外,Stream-Nexus推送项目支持集群部署了
java·后端·程序员
vipxieliang3 小时前
ValidX vs 正则表达式:可读性与维护性对比
java·spring boot