Spring Boot 配置体系:配置优先级、Profile、绑定、动态配置与敏感信息(098)

Spring与Spring Boot约 18 分钟阅读10,033 字符
知识库文档 99 / 251

引言:为什么同一个配置项改了 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 配置文件。这意味着:

  1. 你不能在 application.yml 里设置 spring.profiles.active 来指定 profile——因为 application.yml 是在 profile 确定之后才加载的。你可以在 application.yml 里设置它,但它的效果是"下一次启动"(如果被外部化配置持久化),而不是"当前启动"。
  2. 设置 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 激活时才注册:

java
@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:

yaml
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>(逗号分隔自动拆分)

配置校验

java
@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 表达式:

java
@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 时会重新创建,从而读取到最新的配置值。

java
@RestController
@RefreshScope
public class ConfigController {
    @Value("${app.message}")
    private String message;

    @GetMapping("/message")
    public String getMessage() {
        return message;  // 配置刷新后返回新值
    }
}

动态刷新的局限性

  1. **@RefreshScope**只对**@Value**注入的值生效,对 @ConfigurationProperties 需要额外处理(Spring Cloud 支持 @ConfigurationProperties 的刷新,但需要引入 spring-cloud-starter-bootstrap)。
  2. 只刷新 Bean,不刷新已缓存的连接池、线程池等。如果你的 DataSource 是在 @RefreshScope Bean 中创建的,刷新后确实会新建连接池——但旧的连接池不会自动关闭,可能导致连接泄漏。
  3. **@RefreshScope**是懒加载的——被标注的 Bean 不是启动时创建,而是第一次访问时创建。这意味着如果 Bean 的初始化逻辑有问题,错误不会在启动时暴露,而是在第一次请求时才暴露。

六、敏感信息处理:加密配置与外部化

文章配图
文章配图

敏感信息不应该出现在 application.yml 中

数据库密码、API 密钥、证书私钥等敏感信息,如果直接写在 application.yml 里并提交到 Git 仓库,一旦代码仓库泄露,敏感信息就暴露了。Spring Boot 提供了几种解决方案:

  1. 外部化配置:将敏感信息放在 jar 外部的配置文件或环境变量中,不纳入版本控制。
  2. 配置加密:使用 jasypt-spring-boot 对配置值进行加密,只有运行时通过密钥解密。
  3. 配置中心:使用 Vault、Spring Cloud Config 等配置中心,敏感信息在配置中心端加密存储,应用启动时通过 HTTPS 获取。

Jasypt 加密配置

yaml
# application.yml
spring:
  datasource:
    password: ENC(encrypted_base64_string)

启动时通过环境变量或命令行传入解密密钥:

bash
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 的宽松绑定会自动转换。

最佳实践

  1. 敏感信息永远不要出现在 application.yml 中,使用环境变量或配置中心。
  2. 在**application.yml**中写默认值,在**application-{profile}.yml**中写环境差异值。
  3. 使用**@ConfigurationProperties**而非**@Value**做结构化配置,配合 @Validated 做启动时校验。
  4. 在生产环境开启**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 不支持松散绑定——它是占位符解析,不是属性绑定。