引言:为什么同一个配置项改了 application.yml 却还是不生效
一个经典的线上故障:运维在服务器环境变量里设了 SERVER_PORT=8081,但应用还是监听了 application.yml 里写的 8080。排查了半小时才发现,原来 application-prod.yml 里也写了 server.port: 8080,而环境变量的优先级......等等,不是环境变量优先级最高吗?问题出在哪个环节?这道题的答案不是"看文档"能解决的——你需要理解 Spring Boot 的 PropertySource 优先级体系、spring.profiles.active 的生效时机、以及 @ConfigurationProperties 和 @Value 在绑定时的行为差异。这篇文章把这套配置体系从优先级到绑定机制到动态刷新,完整拆开。
一、配置优先级:Spring Boot 的 17 级 PropertySource 体系



配置源不是"一个文件",而是一个有序列表
Spring Boot 的配置不是从单一来源读取的——它把所有配置源(application.yml、环境变量、命令行参数、application-{profile}.yml 等)组织成一个 PropertySource 的有序列表,每个 PropertySource 是一个 key-value 集合,查找配置时按优先级从高到低遍历,第一个匹配到的值生效。
Spring Boot 官方文档定义了 17 个优先级级别(从高到低):
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | @TestPropertySource |
测试专用,只对当前测试类生效 |
| 2 | 命令行参数 --server.port=9090 |
启动时传入,优先级最高 |
| 3 | SPRING_APPLICATION_JSON |
环境变量中的 JSON 片段 |
| 4 | ServletConfig 初始化参数 |
Web 容器传入 |
| 5 | ServletContext 初始化参数 |
Web 容器传入 |
| 6 | JNDI java:comp/env |
应用服务器 JNDI 属性 |
| 7 | System.getProperties() |
JVM 系统属性 |
| 8 | System.getenv() |
操作系统环境变量 |
| 9 | RandomValuePropertySource |
random.* 随机值 |
| 10 | application-{profile}.yml(jar 外部) |
指定 profile 的外部配置 |
| 11 | application-{profile}.yml(jar 内部) |
指定 profile 的打包配置 |
| 12 | application.yml(jar 外部) |
外部通用配置 |
| 13 | application.yml(jar 内部) |
打包的通用配置 |
| 14 | @PropertySource |
注解显式引入的配置 |
| 15 | SpringApplication.setDefaultProperties() |
程序化设置的默认值 |
两个最常见的"配置不生效"根因
根因一:混淆了"jar 外部"和"jar 内部"的优先级。 很多人以为 application-prod.yml(打包在 jar 里)的优先级高于 application.yml(也打包在 jar 里),所以在 application.yml 里写 server.port: 8080,在 application-prod.yml 里不写 server.port,期望它继承 8080。但实际上,application-prod.yml 和 application.yml 是不同的 PropertySource,前者优先级高于后者——如果 application-prod.yml 里没有某个 key,Spring 会继续向下查找,在 application.yml 里找到它。所以继承是生效的。问题的关键在于:如果你在**application-prod.yml**里显式写了**server.port: 8080**,它就会覆盖你在**application.yml**里写的**server.port: 9090**,因为 application-prod.yml 的 PropertySource 排在前面。
根因二:环境变量**SERVER_PORT**的绑定方式。 环境变量 SERVER_PORT 会被 Spring Boot 的 Environment 转换,但转换规则是:把 SERVER_PORT 按点号和大写下划线拆分为 server.port。这个转换是"宽松绑定"的一部分,但如果你在 application.yml 里写了 server.port: 8080,而环境变量里是 SERVER_PORT=8081,后者优先级更高(因为 System.getenv() 排在 application.yml 前面),所以最终生效的是 8081——这跟"环境变量优先级最高"的直觉是一致的。但如果你在命令行参数里写了 --server.port=9090,那命令行参数优先级更高,最终是 9090。
PropertySource 的添加顺序
SpringApplication 在启动时会按顺序创建 Environment 并添加 PropertySource。关键代码路径在 SpringApplication.prepareEnvironment() 方法中。理解这个顺序能帮你预判"如果我在启动脚本里加了 -Dserver.port=9090,它和 application-prod.yml 里的 server.port: 8080 谁生效"——答案是 -D 参数(System.getProperties())的优先级高于 application-prod.yml。
二、Profile:环境隔离的核心机制

spring.profiles.active 的生效时机
spring.profiles.active 是一个特殊的配置项——它决定了哪些 application-{profile}.yml 会被加载。但这里有一个"鸡生蛋蛋生鸡"的问题:spring.profiles.active 本身也是一个配置项,它从哪来?
答案是:Spring Boot 在启动的最早阶段(SpringApplication 构造函数中)会从命令行参数和环境变量中读取 spring.profiles.active,用它来决定加载哪些 profile 配置文件。这意味着:
- 你不能在
application.yml里设置spring.profiles.active来指定 profile——因为application.yml是在 profile 确定之后才加载的。你可以在application.yml里设置它,但它的效果是"下一次启动"(如果被外部化配置持久化),而不是"当前启动"。 - 设置 profile 的正确方式:命令行
--spring.profiles.active=prod、环境变量SPRING_PROFILES_ACTIVE=prod、或者SpringApplication.setAdditionalProfiles()。
多 Profile 叠加
spring.profiles.active 可以指定多个 profile,用逗号分隔:--spring.profiles.active=prod,cloud。这时 application-prod.yml 和 application-cloud.yml 都会被加载。如果两个文件里有相同的 key,最后加载的生效(即 application-cloud.yml 的值覆盖 application-prod.yml)。
@Profile 注解:条件化 Bean 注册
@Profile 注解让 Bean 只在指定 profile 激活时才注册:
@Bean
@Profile("prod")
public DataSource prodDataSource() {
// 生产环境 DataSource
}
@Bean
@Profile("dev")
public DataSource devDataSource() {
// 开发环境 DataSource(比如 H2 内嵌数据库)
}
@Profile 可以配合 ! 做取反:@Profile("!prod") 表示"非 prod 环境"。
include 与 profile 分组
Spring Boot 2.4+ 引入了 profile 分组功能,允许一个主 profile 自动激活一组子 profile:
spring:
profiles:
group:
prod:
- "db-prod"
- "mq-prod"
当 spring.profiles.active=prod 时,db-prod 和 mq-prod 也会被自动激活。这个功能在多模块项目中非常有用——每个模块可以定义自己的 profile,主配置文件通过 group 统一管理。
三、@ConfigurationProperties 绑定:类型安全的配置管理


松散绑定规则
@ConfigurationProperties 支持四种命名风格到同一个 Java 字段的自动映射:
| 配置写法 | 映射到 Java 字段 maxSize |
适用场景 |
|---|---|---|
max-size(kebab-case) |
✅ | YAML 推荐写法 |
maxSize(camelCase) |
✅ | 也支持,但不推荐 |
max_size(snake_case) |
✅ | 兼容旧系统 |
MAX_SIZE(UPPER_CASE) |
✅ | 环境变量 |
属性绑定的底层实现
ConfigurationPropertiesBindingPostProcessor 在 Bean 初始化前通过 Binder 类将 Environment 中的配置绑定到 @ConfigurationProperties Bean。Binder 内部使用 PropertySourcesPropertyResolver 遍历所有 PropertySource,按优先级查找第一个匹配的 key,然后通过 JavaBean 属性描述符(PropertyDescriptor)将字符串值转换为目标类型。
类型转换由 ConversionService 完成,Spring Boot 默认注册了大量转换器,包括:
- 字符串 → Duration(
"10s"→Duration.ofSeconds(10)) - 字符串 → DataSize(
"10MB"→DataSize.ofMegabytes(10)) - 字符串 → Enum(大小写不敏感)
- 字符串 →
List<String>(逗号分隔自动拆分)
配置校验
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {
@NotBlank
private String name;
@Min(1) @Max(65535)
private int port = 8080;
@DurationMin(seconds = 1)
private Duration timeout = Duration.ofSeconds(30);
}
@Validated 触发 JSR-380 校验,@DurationMin 等是 Spring Boot 提供的扩展校验注解。校验失败会导致启动失败,而不是运行时才发现配置错误。
四、@Value 与 @ConfigurationProperties 的对比

适用场景分离
| 特性 | @Value |
@ConfigurationProperties |
|---|---|---|
| 松散绑定 | ❌ 不支持 | ✅ 支持 |
| 嵌套对象 | ❌ 需要手动拼接 | ✅ 自动映射 |
| SpEL 表达式 | ✅ 支持 | ❌ 不支持 |
| IDE 元数据 | ❌ 不生成 | ✅ 配合 processor 生成 |
| 配置校验 | ❌ 需要手动校验 | ✅ 配合 @Validated |
| 使用场景 | 注入少量配置项 | 结构化配置 |
@Value 的 SpEL 能力
@Value 的独特优势是支持 SpEL 表达式:
@Value("#{systemProperties['user.home']}/logs")
private String logDir;
@Value("#{${app.timeout} * 1000}") // 将秒转换为毫秒
private long timeoutMillis;
这不是 @ConfigurationProperties 能做到的。但 @Value 的代价是:如果配置项不存在且没有默认值,启动会直接报错——而 @ConfigurationProperties 可以给字段设默认值,更安全。
五、动态配置刷新:@RefreshScope 与配置中心

为什么需要动态刷新
传统 Spring Boot 应用的配置在启动时加载,运行期间不会改变。但在微服务架构中,配置变更(比如切换数据库地址、调整线程池大小)不应该要求重启。@RefreshScope 注解让 Bean 在配置变更时能被重新创建,而无需重启应用。
@RefreshScope 的原理
@RefreshScope 是 Spring Cloud 提供的注解,它实际上是一个 @Scope("refresh") 的封装。被 @RefreshScope 标注的 Bean 会被放入一个特殊的缓存中,当应用收到 RefreshScopeRefreshedEvent 事件时,缓存被清空,下次访问该 Bean 时会重新创建,从而读取到最新的配置值。
@RestController
@RefreshScope
public class ConfigController {
@Value("${app.message}")
private String message;
@GetMapping("/message")
public String getMessage() {
return message; // 配置刷新后返回新值
}
}
动态刷新的局限性
**@RefreshScope**只对**@Value**注入的值生效,对@ConfigurationProperties需要额外处理(Spring Cloud 支持@ConfigurationProperties的刷新,但需要引入spring-cloud-starter-bootstrap)。- 只刷新 Bean,不刷新已缓存的连接池、线程池等。如果你的 DataSource 是在
@RefreshScopeBean 中创建的,刷新后确实会新建连接池——但旧的连接池不会自动关闭,可能导致连接泄漏。 **@RefreshScope**是懒加载的——被标注的 Bean 不是启动时创建,而是第一次访问时创建。这意味着如果 Bean 的初始化逻辑有问题,错误不会在启动时暴露,而是在第一次请求时才暴露。
六、敏感信息处理:加密配置与外部化

敏感信息不应该出现在 application.yml 中
数据库密码、API 密钥、证书私钥等敏感信息,如果直接写在 application.yml 里并提交到 Git 仓库,一旦代码仓库泄露,敏感信息就暴露了。Spring Boot 提供了几种解决方案:
- 外部化配置:将敏感信息放在 jar 外部的配置文件或环境变量中,不纳入版本控制。
- 配置加密:使用
jasypt-spring-boot对配置值进行加密,只有运行时通过密钥解密。 - 配置中心:使用 Vault、Spring Cloud Config 等配置中心,敏感信息在配置中心端加密存储,应用启动时通过 HTTPS 获取。
Jasypt 加密配置
# application.yml
spring:
datasource:
password: ENC(encrypted_base64_string)
启动时通过环境变量或命令行传入解密密钥:
java -jar app.jar --jasypt.encryptor.password=${JASYPT_KEY}
Spring Cloud Config 的加密方案
Spring Cloud Config Server 支持对配置值进行加密存储。配置在 Config Server 端用对称密钥或非对称密钥加密,Client 端通过 HTTPS 拉取后解密。密钥管理通过 Vault 或 AWS KMS 等外部密钥管理服务实现。
七、常见陷阱与最佳实践


陷阱一:application.yml 里设置 spring.profiles.active 不生效
前面已经解释过——spring.profiles.active 必须在 application.yml 加载之前被确定,所以它在 application.yml 里是"无效"的(对当前启动而言)。正确做法是命令行参数或环境变量。
陷阱二:@ConfigurationProperties 的 prefix 拼写错误不报错
如果 @ConfigurationProperties(prefix = "app.datasource") 拼成了 "app.datasorce",Spring Boot 不会报错——它只是绑定不到任何值,字段保持默认值。这个行为在排查时非常隐蔽,因为应用正常启动了,只是配置值都是默认值。建议在 @ConfigurationProperties 类上加 @Validated 并给关键字段加 @NotNull 等约束,让拼写错误在启动时暴露。
陷阱三:多 PropertySource 中的 List 合并
在 application.yml 和 application-prod.yml 中分别定义了同一个 List 类型的配置项,Spring Boot 的行为是覆盖而不是合并——后加载的 List 会完全替换先加载的 List。如果你期望的是"合并"(继承公共配置再追加环境特定配置),需要自己实现合并逻辑,或者用不同的 key 分别定义后用 Java 代码合并。
陷阱四:Docker 环境变量的大小写问题
在 docker-compose.yml 中定义的环境变量 SERVER_PORT 会被 Spring Boot 映射为 server.port,但 Docker 对环境变量名的处理在不同平台上可能不一致(某些平台只支持大写字母和下划线)。如果你的配置项是 my.app.timeout,在环境变量中需要写成 MY_APP_TIMEOUT——Spring Boot 的宽松绑定会自动转换。
最佳实践
- 敏感信息永远不要出现在 application.yml 中,使用环境变量或配置中心。
- 在
**application.yml**中写默认值,在**application-{profile}.yml**中写环境差异值。 - 使用
**@ConfigurationProperties**而非**@Value**做结构化配置,配合@Validated做启动时校验。 - 在生产环境开启
**spring.config.import**引用外部配置(Spring Boot 2.4+),集中管理配置源。
小结:配置即代码
Spring Boot 的配置体系本质上是一套"配置即代码"的实践——配置的优先级、绑定、校验、刷新,每个环节都有明确的契约和行为。理解这套体系后,你不会再被"改了配置为什么不生效"这类问题困扰——你会在看到配置的一瞬间,就知道它在 17 级 PropertySource 中处于什么位置,会被谁覆盖,以及它的变更是否会触发刷新。
(以上正文阅读时长约 40-50 分钟,以下问答部分不计入正文阅读时长)
文末答疑:面试官会怎么问
问 1:你在**application.yml**里写了**server.port: 8080**,在**application-prod.yml**里写了**server.port: 8081**,启动时加了**--server.port=9090**,最终哪个生效?为什么?
答:命令行参数 --server.port=9090 生效。原因:命令行参数被映射为 PropertySource 并添加到 Environment 的最前面,优先级高于 application-prod.yml(jar 内部)和 application.yml。即使 application-prod.yml 的优先级高于 application.yml,但在命令行参数面前,两者都是低优先级的。这三个配置源的优先级从高到低是:命令行参数 > application-prod.yml > application.yml。
问 2:**@RefreshScope**标注的 Bean 在配置刷新后,旧的 Bean 实例会被 GC 回收吗?如果旧 Bean 持有连接池,会发生什么?
答:@RefreshScope 清空缓存后,旧 Bean 实例如果没有被其他地方强引用,会被 GC 回收。但问题在于:如果旧 Bean 持有连接池(如 DataSource),@RefreshScope 的刷新机制只是创建了一个新的 Bean 并持有新的连接池,旧 Bean 的连接池不会自动关闭——因为 Spring 只知道"这个 Bean 被替换了",不知道"这个 Bean 内部持有需要显式释放的资源"。如果旧连接池没有被 @PreDestroy 或 DisposableBean.destroy() 等方法关闭,连接会一直保持,直到 GC 回收时 finalize() 被调用(但 finalize() 在 JDK 9 中已被废弃)。这会导致连接泄漏。解决方案:在 @RefreshScope Bean 中实现 DisposableBean 接口,在 destroy() 方法中显式关闭连接池,或者使用连接池自带的"空闲超时自动关闭"机制。
问 3:如果把**spring.profiles.active**写在**bootstrap.yml**里,会生效吗?为什么 Spring Cloud 有**bootstrap.yml**而普通 Spring Boot 没有?
答:会生效。bootstrap.yml 是 Spring Cloud 引入的概念,它在 Spring Boot 的 ApplicationContext 创建之前,由 BootstrapApplicationListener 通过一个独立的"bootstrap context"加载。这个 bootstrap context 的优先级高于主 ApplicationContext,所以 bootstrap.yml 中的 spring.profiles.active 会在主应用上下文加载之前被确定。普通 Spring Boot 没有 bootstrap.yml 是因为没有这个 bootstrap context 的机制——Spring Boot 2.4+ 推荐用 spring.config.import 替代 bootstrap.yml 来引入外部配置。但如果你用了 Spring Cloud,bootstrap.yml 仍然是一种有效的配置方式。
问 4:生产环境的数据库密码需要定期轮换。在不重启应用的前提下,你如何让 Spring Boot 应用在密码轮换后自动使用新密码?
答:方案取决于密码存储在哪里。如果密码在配置中心(如 Spring Cloud Config Server),配合 @RefreshScope 和 Spring Cloud Bus,密码变更后通过 Config Server 的 /monitor 端点触发 /actuator/refresh,所有实例自动刷新。如果密码在 Vault,Spring Cloud Vault 支持动态数据库凭据——每次租约到期后,Vault 自动生成新凭据,应用通过 VaultDatabaseSecretBackend 自动获取新凭据。如果密码不在上述任何配置中心,最简单的方案是用 ScheduledExecutorService 定期从外部安全存储读取密码,对比内存中的旧密码,发现变化后重建连接池——但需要保证切换过程中没有请求使用旧连接,通常需要配合连接池的"优雅关闭"机制。
问 5:**@ConfigurationProperties**和**@Value**在底层实现上有什么区别?为什么**@ConfigurationProperties**支持松散绑定而**@Value**不支持?
答:两者的底层实现路径完全不同。@ConfigurationProperties 通过 ConfigurationPropertiesBindingPostProcessor(一个 BeanPostProcessor)在 Bean 初始化前,使用 Binder 类直接从 Environment 的 PropertySources 中按 prefix 递归查找并绑定。Binder 在查找 key 时,会尝试多种命名风格(松散绑定),因为它遍历的是原始的 PropertySource 列表,可以自由尝试不同的 key 格式。@Value 通过 AutowiredAnnotationBeanPostProcessor 处理,底层调用 BeanFactory.resolveEmbeddedValue(),最终使用 PropertySourcesPropertyResolver 的 resolvePlaceholders() 方法。这个方法只做精确的 key 查找——它把 ${server.port} 中的 server.port 作为精确的 key 去 PropertySources 中查找,不会尝试 server-port 或 SERVER_PORT 这些变体。这就是为什么 @Value 不支持松散绑定——它是占位符解析,不是属性绑定。