ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Cloud配置刷新原理:@RefreshScope与Environment协作机制详解

Spring Cloud配置刷新原理:@RefreshScope与Environment协作机制详解 刚接手一个Spring Cloud项目的时候最容易让人迷惑的配置刷新机制就是RefreshScope和Environment这一对组合。很多人知道在配置类上加上RefreshScope就能刷新配置但真的排查问题时却发现改了Nacos或Config Server里的配置调用刷新接口也没反应最后问题不是出在RefreshScope本身而是出在Environment上。这篇文章我打算把RefreshScope和Environment的协作机制彻底讲透从底层原理、实现步骤到常见踩坑一次性说清楚。无论你是刚接触Spring Cloud的初级开发还是已经写了很长时间微服务的中级工程师只要能理解这两个东西的内外关系以后遇到配置热更新相关的诡异问题至少能少走一半弯路。1. 先搞懂这两个东西的关系Environment是仓库RefreshScope是刷新开关1.1 Environment到底存了什么在Spring框架里Environment这个接口从Spring 3.1就引入了它本质上是一个属性解析器和属性来源管理器的合体。简单理解它是整个Spring应用上下文里所有配置信息的统一汇集点。无论是application.yml里的配置、系统环境变量比如PATH、JAVA_HOME、JVM系统属性-Dxxxyyy还是来自Spring Cloud Config Server的远程配置最终都会被加载到Environment中。为什么说理解这个很重要因为RefreshScope虽然名字上看着像在管配置刷新但真正存数据的地方是Environment。RefreshScope只是优雅地销毁和重建Bean但它不会自动去变更Environment里的数据。远程配置中心推送新配置后必须先有人把新的配置值更新到Environment中然后RefreshScope才有东西可刷。Environment内部包含一个MutablePropertySources容器这个容器维护着一个有序的PropertySource列表。查找一个配置键时Spring会按照列表的倒序去匹配也就是排在前面的PropertySource优先级更高。比如系统环境变量默认排位很高所以如果环境变量里有一个名为server.port的变量它很可能覆盖application.yml里的同名配置。这个顺序问题在日常开发中经常引发为什么我改了配置文件没生效的疑问。1.2 RefreshScope为什么能刷新RefreshScope来自Spring Cloud它是Spring Cloud的Scope实现底层依靠的是GenericScope和SimpleBeanDefinitionRegistry这套机制。被它标注的Bean在Spring容器里实际注册的是ScopedProxyFactoryBean生成的代理对象真正实例存到了GenericScope的Bean生命周期缓存里。当刷新事件发生时Spring Cloud的ContextRefresher会清掉这个scope内的cache把所有标注了RefreshScope的Bean的缓存实例销毁。下次任何组件再通过代理对象访问这些Bean时会触发重新创建于是新的配置值就被绑定进新的实例里。所以RefreshScope本质上解决的是配置源已更新但Bean里的值还是旧的这个问题。它通过销毁旧Bean、创建新Bean让Bean重走一遍属性绑定流程把Environment中最新值读进去。但这里有一个前提那就是Environment里必须有新值。这就是很多人忽略的核心点只加RefreshScope不保证配置会刷新。1.3 两者配合的完整链路完整流程大概是这样的配置中心的配置发生变化比如Nacos推送config.versionv2。客户端收到变更事件触发ContextRefresher.refresh()。ContextRefresher调用PropertySourceLocator重新拉取远程配置将新的PropertySource更新到Environment中。扫出所有RefreshScope修饰的Bean清空缓存。下一次访问代理对象时Spring创建新Bean从已更新的Environment绑定新值。如果某一步断了刷新就会失败。最常见的断点在第3步和第4步。第3步要求必须配置了配置中心并且刷新逻辑确实会重新定位属性源第4步要求你访问的Bean确实被RefreshScope管理。2. 配置热更新的完整链路与底层原理2.1 从配置中心拉取配置到Environment在Spring Cloud Config Server模式下客户端通过ConfigServicePropertySourceLocator去请求远程配置中心的/env接口拿到JSON格式的配置数据然后包装成一个PropertySource添加到Spring Environment中。这个PropertySource的名通常是configServer。Nacos的机制类似但用的是Nacos-Client的长轮询或UDP推送收到变更后NacosPropertySourceLocator重新定位对应dataId的配置然后替换掉Environment里对应名称的PropertySource。Apollo也一样客户端内部维护一个Config对象配置变更后通过ApolloPropertySourceLocator把最新的配置同步到Environment。也就是说无论哪个配置中心核心套路都是一样的监听远程变更把新配置更新到Environment触发刷新事件。所以排查问题时你可以直接看Environment里某个key是否已经是新值。如果Environment里是旧值那问题根本还没到RefreshScope这一层。2.2 ContextRefresher.refresh()的内部流程调用/actuator/refresh接口后最终执行的是ContextRefresher.refresh()方法。它的关键逻辑可以概括成几点记录刷新前的Environment中涉及配置的PropertySource状态。调用PropertySourceLocator.locate()重新获取配置中心的配置并重新插入Environment。对比新增、移除、变更的配置键组成一个EnvironmentChangeEvent事件发布。清理所有RefreshScope的缓存Bean并触发ConfigurationPropertiesRebinder重新绑定ConfigurationProperties的Bean。EnvironmentChangeEvent这个事件很关键。因为Spring Boot的ConfigurationPropertiesRebinder会监听这个事件所以只要配置键发生变化ConfigurationProperties的Bean会自动重新绑定大多数情况下不需要额外加RefreshScope。但Value不一样。Value(${demo.key})注入的Bean如果不在RefreshScope内不会自动重新绑定。因为Value是Bean创建时直接解析的一旦Bean创建完成这个值就固定了。这就是为什么既要加ConfigurationProperties又要加RefreshScope时经常出现刷新后乱套的情况——部分属性重新绑定部分对象被重建容易引起状态不一致。2.3 ConfigurationProperties的自动rebind与Value的区别有一类典型问题是把ConfigurationProperties和RefreshScope用在同一类上结果刷新后对象还被重建导致一些运行时的状态丢失。比如配置类里额外维护了一个计数器重新刷新后计数器回了0。这里我建议按照这样的规则来处理纯配置载体使用ConfigurationProperties即可不需要RefreshScope。需要携带业务状态且必须跟随配置改变的类使用RefreshScope但不要用ConfigurationProperties绑定至少不要混在一起处理。使用Value注入的Bean如果希望刷新就必须加上RefreshScope没有其他替代方案。这样做的好处是职责清晰也不会出现ConfigurationProperties刷新和RefreshScope重建两个机制打架的情况。3. 实操手把手实现配置热更新3.1 基础环境搭建我用Spring Boot 2.7 Spring Cloud 2021.0.8来演示配置中心选择Nacos因为Nacos提供了配置控制台能直观地修改和发布配置适合演示刷新机制。如果你用Config Server代码差异不大核心点是一样的。先引入依赖dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-config/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-bootstrap/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency对应的bootstrap.ymlspring: application: name: demo-service cloud: nacos: config: server-addr: 127.0.0.1:8848 file-extension: yaml management: endpoints: web: exposure: include: refresh,health,info注意Nacos Config的加载发生在Spring Boot启动早期所以需要bootstrap.ymlSpring Cloud 2020.0之前的版本或者通过spring.config.import方式引入。我上面加spring-cloud-starter-bootstrap是为了兼容老写法新项目可以直接用spring.config.importnacos:demo-service.yaml。3.2 实现一个可动态刷新的配置类现在我在Nacos上新建一个dataId为demo-service.yaml的配置内容先写demo: title: 旧标题 threads: 5在服务里定义两个组件一个用Value一个用ConfigurationProperties。Component RefreshScope public class ValueConfig { Value(${demo.title}) private String title; public String getTitle() { return title; } }Component ConfigurationProperties(prefix demo) public class DemoProperties { private String title; private int threads; // getter/setter 省略 }然后写一个接口用于观察效果RestController public class DemoController { private final ValueConfig valueConfig; private final DemoProperties demoProperties; public DemoController(ValueConfig valueConfig, DemoProperties demoProperties) { this.valueConfig valueConfig; this.demoProperties demoProperties; } GetMapping(/show) public MapString, Object show() { MapString, Object map new HashMap(); map.put(valueConfig.title, valueConfig.getTitle()); map.put(demoProperties.title, demoProperties.getTitle()); map.put(demoProperties.threads, demoProperties.getThreads()); return map; } }启动服务后访问/show看到的值应该都是旧标题。接下来把Nacos里的demo.title改成新标题并发布。3.3 手动触发刷新/actuator/refresh在Nacos配置中发布新值后客户端不会自动刷新。为什么因为Nacos Config默认支持的是自动刷新但Spring Cloud的刷新链路仍然需要触发事件。实际上Nacos Config的NacosContextRefresher在检测到变更后会对配置应用refresh动作所以严格来说Nacos场景下只要变更配置/show接口就能自动拿到新值。如果你用的是传统Config Server则需要手动调用POST /actuator/refresh接口或者通过Spring Cloud Bus向所有客户端广播刷新事件。这里有个细节Nacos的自动刷新本质上是Nacos客户端感知到配置变更后主动调用RefreshEventPublisher发布RefreshEvent进而触发ContextRefresher.refresh()。所以它在机制上并没有脱离ContextRefresher.refresh()这条链路。如果你遇到Nacos配置改了但/show没变那大概率是RefreshScope没加。配置监听的dataId不对。Nacos客户端长轮询被网络隔离。刷新事件被异常吞掉。在纯Config Server场景下手动调用刷新接口后观察日志里是否出现Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext这样的输出就能判断刷新事件是否真正执行了。3.4 自定义定时刷新不依赖配置中心如果你的系统没有引入配置中心但又想让配置在运行期从某个自定义数据源比如数据库刷新该怎么写这也是一个常见的需求我提供一个最小实现思路。核心逻辑是手动修改Environment里的PropertySource然后发布RefreshScopeRefreshedEvent或者调用ContextRefresher来清空RefreshScope缓存。Component public class CustomConfigRefresher { private final ConfigurableEnvironment environment; private final ContextRefresher contextRefresher; public CustomConfigRefresher(ConfigurableEnvironment environment, ContextRefresher contextRefresher) { this.environment environment; this.contextRefresher contextRefresher; } Scheduled(fixedDelay 30000) public void refreshFromDb() { MapString, Object latest queryConfigFromDb(); MapPropertySource source new MapPropertySource(dbConfig, latest); // 移除旧的同名source再添加新的保证Environment里是最新值 environment.getPropertySources().remove(dbConfig); environment.getPropertySources().addFirst(source); // 触发刷新 contextRefresher.refresh(); } }这个方案的关键点在于一定要把新的PropertySource放到足够高的优先级或者移除旧的source否则即使refresh()了Environment里解析到的还是旧值。ContextRefresher.refresh()本身不会替你清理自定义的PropertySource它只处理配置中心相关的部分。另外频繁调用refresh()会销毁线上所有RefreshScopeBean存在瞬时重建的成本定时刷新频率不宜过高。如果是生产环境我建议30秒以上的间隔并配合异常熔断避免数据库查询失败导致空配置覆盖正常配置。4. 第一次踩坑实录环境变量与Environment的相爱相杀4.1 环境变量进不了Environmentproperty source优先级问题环境变量是Environment中优先级最高的属性来源之一。很多人以为“只要是环境变量就能覆盖配置文件里的值”其实不完全对。Spring Boot在加载环境变量时默认会执行SystemEnvironmentPropertySource的宽松绑定处理比如环境变量DEMO_TITLE可以映射到demo.title因为下划线会被转换成点号。这就带来了一个隐藏的坑如果你在服务器上设了一个DEMO_TITLE环境变量它就像一把悬在头上的剑会一直覆盖Nacos和application.yml里配置的demo.title。即使你在Nacos控制台反复修改配置RefreshScope也刷新了无数次看到的值依然是环境变量里的值。排查这种问题最好的方法就是启动时打印出所有配置来源和顺序或者临时注入一个ApplicationRunner把Environment里的PropertySources信息打印出来Component public class PropertySourcePrinter implements ApplicationRunner { private final Environment environment; public PropertySourcePrinter(Environment environment) { this.environment environment; } Override public void run(ApplicationArguments args) { if (environment instanceof ConfigurableEnvironment) { ConfigurableEnvironment env (ConfigurableEnvironment) environment; env.getPropertySources().forEach(ps - System.out.println([ ps.getName() ] class ps.getClass().getSimpleName())); } } }打印出来的顺序就是Spring查找属性的顺序。排前面的优先如果看到systemEnvironment排在nacos之前而你的配置键恰好和环境变量冲突那环境变量会胜出。4.2 常见环境变量错误JAVA_HOME、API Key缺失、externally-managed-environment结合我之前在群里看到的各种报错环境变量相关的坑有几个很典型。JAVA_HOME未定义或定义错误。这是Java开发者最常见的启动问题。报错信息可能是JAVA_HOME environment variable is not defined correctly。这类问题一般出现在安装了多个JDK的机器上或者服务器重启后环境变量没加载。排查思路很简单在命令行里执行echo $JAVA_HOMELinux/macOS或echo %JAVA_HOME%Windows确认路径里存在bin/java。openai_api_key缺失。这个报错常见于集成大模型SDK的应用比如missing environment variable: openai_api_key。在Spring应用的上下文里其实是通过Environment来读取这些key的比如Value(${openai.api.key})。如果环境变量名是OPENAI_API_KEYSpring Boot的宽松绑定会识别为openai.api.key但前提是属性的来源优先级足够。如果你在Nacos里也配了openai.api.key环境变量里也有OPENAI_API_KEY环境变量优先导致你改Nacos配置无效。遇到这种情况要么删掉环境变量要么在配置里显式指定一个不同的key并读取它。externally-managed-environment。这个报错通常出现在Python pip安装包时因为系统Python环境是外部管理的不允许pip直接安装包。它跟Spring没什么关系但如果你的Spring应用是通过Python脚本部署的这个环境错误会导致构建流程中断进而影响配置生成。解决方法是给pip加--break-system-packages参数或者改用虚拟环境venv。作为Java开发者看到这类报错不要慌它提醒你部署环境里混用了不同语言管理工具。对于微服务运维来说环境变量的管理是个需要重视的话题。我建议在CI/CD流水线里把所有环境变量集中管理并通过Environment的优先级机制做分层避免生产环境的变量污染配置中心的值。4.3 容器与Kubernetes环境下的环境变量坑如果你的服务跑在Kubernetes里环境变量问题会更隐蔽。比如kubelet.service: kubelet.service: referenced but unset environment variable evaluates to an empty string这类系统服务报错其实暴露的是在systemd服务文件中引用了未定义的环境变量。对于Spring Cloud应用容器化下的环境变量通常由Deployment的env字段注入然后通过Environment被Spring读取。这里要注意Kubernetes的envFrom.configMapRef和env字段会同时注入环境变量如果ConfigMap里定义了一个SPRING_PROFILES_ACTIVEprod那么通过Environment读取的spring.profiles.active会被覆盖成prod即使你在application.yml里写的是dev。遇到这种问题不要只盯代码要同时检查三个地方启动容器的环境变量kubectl exec进去env | grep key。Deployments YAML里的env字段。ConfigMap和Secret的配置。5. 常见问题排查与避坑指南5.1 配置刷新不生效的7个原因结合我自己的经验配置刷新不生效通常逃不出以下几个原因没有触发ContextRefresher.refresh()。只加了RefreshScope但实际调用链没有走到刷新端点或事件配置不会自动变。Environment里的值没更新。RefreshScope只负责重建Bean如果Environment里还是旧值重建一百次也没用。Bean没有被Spring管理。RefreshScope加在不是Spring Bean的类上一点效果没有。RefreshScope和ConfigurationProperties叠加导致状态丢失。前文说过两个机制同时在同一个类上会互相干扰。环境变量或系统属性优先级高于配置中心。Environment查找属性时前面的PropertySource会把后面的覆盖掉。刷新的Bean被其他单例Bean缓存了引用。常见于把RefreshScopeBean注入到一个普通单例的Service如果你通过单例Bean的方法去访问刷新Bean实际上是走代理的没问题如果你把刷新Bean的方法引用存到了静态字段或其他集合中可能就会出现旧实例问题。配置中心客户端本身没感知变更。Nacos的长轮询被网络中断、Config Server客户端缓存没失效都会导致客户端根本没拿到新值。排查时最直接的方法是先看Environment里的值再看Bean实例的哈希值。如果Environment已经是最新值但Bean实例没变化那就是RefreshScope缓存没清理如果Environment就是旧值问题在配置加载链路。5.2 问题速查表现象可能原因优先排查方向配置改了接口返回旧值刷新链路没触发查看日志是否有Refreshing输出尝试手动调用/actuator/refresh调用了refresh接口但Environment里仍是旧值配置中心属性来源没有重新加载检查Nacos/Config Server地址、dataId、group是否正确Environment里是新值但Bean还是旧值RefreshScope缓存未清理确认Bean被代理直接输出Bean的class信息看是否是ScopedProxy刷新后对象状态丢失ConfigurationProperties和RefreshScope冲突去掉RefreshScope改用纯ConfigurationProperties本地配置改了线上还是旧值环境变量覆盖检查systemEnvironment优先级执行env查看是否有同名变量容器环境下配置不一致ConfigMap/Secret注入环境变量检查Kubernetes资源配置日志显示外部环境管理错误Python等系统包管理器阻止安装在虚拟环境中运行或给pip添加--break-system-packages参数仅限可接受的场景5.3 我的几条独家建议第一在所有涉及配置的Bean里提供一个配置来源诊断接口返回当前生效的所有配置项的值同时输出它们来自哪个PropertySource。这个接口可以在线上问题排查时直接定位优先级问题。第二尽量不要在构造函数里做太多基于Value的初始化逻辑。因为RefreshScopeBean重建时构造函数也会重新执行如果构造函数里有耗时操作刷新时会影响请求性能。如果确实要初始化可以放到PostConstruct里并做幂等保护。第三对于关键业务配置建议单独拆一个配置类不要散落在各个Value里。这样既便于统一管理也方便在刷新逻辑中针对特定配置做特殊处理。第四Spring Cloud的RefreshScope是启动时就会创建代理对象的所以如果你在应用启动早期比如ApplicationRunner之前就访问了那个Bean它会提前初始化。这本身没问题但当配置中心尚未就绪时可能拿到一个空值。建议在bootstrap.yml里配置spring.cloud.config.allow-overridetrue确保配置中心优先。最后再分享一个小技巧在开发阶段我习惯把/actuator/refresh的调用封装成一个IDEA的HTTP Request文件随手就能触发刷新。而在生产环境我会更倾向于用配置中心的自动推送或者Bus批量刷新避免逐台手工调用带来的配置不一致窗口期。配置热更新这把刀用得好是便利用不好是事故核心还是彻底搞清楚Environment和RefreshScope之间的先后关系。
返回列表