本节目标:把配置源按官方顺序完整排一遍,并实测回答「同一个键在五处都写了值,到底哪一个生效」,同时掌握环境变量命名、占位符与加密选型。
适用版本:Spring Boot 4.1.x(Java 21)
6.3 外部化配置与优先级
6.1 节讲了怎么按环境分配置文件,6.2 节讲了怎么把配置绑成对象。还剩一个最关键的问题没回答:同一个键可能在命令行、环境变量、多个配置文件里同时出现,谁说了算?本节先给出官方顺序,再用一个实测把它验证出来。
配置数据从哪来
Spring Boot 4.x 用统一的 Config Data API 加载配置,默认会扫描这些位置(按加载顺序排列,越靠后越晚加载):
optional:classpath:/
optional:classpath:/config/
optional:file:./
optional:file:./config/
optional:file:./config/*/
每个位置下都会找 application.properties、application.yml 以及它们的 profile 变体。optional: 表示位置不存在时不报错;config/*/ 表示 config 下的任意一级子目录,方便一个目录放一个环境。
完整的配置源优先级
官方文档给出一张从低到高的表。为便于查阅,这里倒过来从高到低排——越靠上优先级越高,越能覆盖别人。
| 优先级 | 配置源 | 说明 |
|---|---|---|
| 最高 | Devtools 全局设置 ~/.config/spring-boot/ | 仅 devtools 激活时生效 |
| ↑ | @TestPropertySource | 测试专用 |
| ↑ | 测试类上的 properties 属性 | 测试专用 |
| ↑ | 命令行参数 --key=value | 部署时最常用的最高优先级 |
| ↑ | SPRING_APPLICATION_JSON | 内联 JSON |
| ↑ | ServletConfig / ServletContext 初始化参数 | war 部署 |
| ↑ | JNDI 属性 java:comp/env | |
| ↑ | Java 系统属性 -Dkey=value | |
| ↑ | 操作系统环境变量 | |
| ↑ | random.* | 只读 |
| ↑ | Config data(application.yml 等) | 见下一小节 |
| ↑ | @PropertySource | 上下文刷新阶段才加入 |
| 最低 | 默认属性 SpringApplication.setDefaultProperties |
记忆锚点一句话:命令行 > 系统属性 > 环境变量 > 配置文件 > 代码默认值。
config data 内部也有顺序
上表里那个 “Config data” 本身还分层。从高到低:
| 优先级 | 位置 |
|---|---|
| 高 | file:./config/*/application-{profile}.yml |
| ↑ | file:./config/application-{profile}.yml |
| ↑ | file:./application-{profile}.yml |
| ↑ | classpath:/config/application-{profile}.yml |
| ↑ | classpath:/application-{profile}.yml |
| ↑ | file:./config/*/application.yml |
| ↑ | file:./config/application.yml |
| ↑ | file:./application.yml |
| ↑ | classpath:/config/application.yml |
| 低 | classpath:/application.yml |
两条规律:外部赢内部(file: 高于 classpath:),profile 赢基线。把 application-prod.yml 放到 jar 外的 ./config/ 里,它就能盖过 jar 内打包的一切配置。
环境变量的命名转换
环境变量名受 POSIX 限制(只有大写字母、数字和下划线),所以要把属性名做一次转换:
点(.)变下划线(_),连字符(-)直接删除,然后整体转大写。
| 配置键(kebab-case) | 环境变量 |
|---|---|
spring.datasource.url | SPRING_DATASOURCE_URL |
server.port | SERVER_PORT |
book.page-size | BOOK_PAGESIZE |
book.max-borrow-days | BOOK_MAXBORROWDAYS |
spring.jpa.hibernate.ddl-auto | SPRING_JPA_HIBERNATE_DDLAUTO |
最常见的错误是在连字符处补一个下划线:BOOK_MAX_BORROW_DAYS、SPRING_JPA_HIBERNATE_DDL_AUTO 都不会生效——它们会被当成 book.max.borrow.days、spring.jpa.hibernate.ddl.auto,是完全不同的键。这个坑很隐蔽,因为值不报错,只是「没绑上」。
列表类型用逗号分隔:BOOK_CATEGORIES=小说,技术 能绑到 List<String>。结构复杂的配置用 SPRING_APPLICATION_JSON 塞一段 JSON。
占位符与默认值
配置值里可以引用其他属性,语法是 ${...}:
app:
host: book.example.com
port: 8080
url: http://${app.host}:${app.port}
timeout: ${app.timeout:3000}
region: ${REGION:cn-north-1}
${app.port}引用属性,来源不限当前文件,任何配置源里的值都能引。${app.timeout:3000}里的冒号后面是默认值,属性不存在时用它。- 默认值可以嵌套:
${app.url:http://localhost:${app.port:8080}}。 - 解析不到又没有默认值,启动直接报
Could not resolve placeholder 'xxx'。 - 想输出字面量
${,在 properties 里写成\${。
@Value 和 @ConfigurationProperties 都支持占位符,区别在于前者把整个值当表达式解析,后者是在绑定阶段逐个解析。
随机值
RandomValuePropertySource 提供一组只读的 random.*:
app:
instance-id: ${random.uuid}
secret: ${random.value}
nonce: ${random.long}
port: ${random.int(20000,30000)}
${random.value}是 32 位十六进制字符串,适合当密钥种子。${random.uuid}生成 UUID;${random.int}、${random.long}是任意整数。${random.int(min,max)}在给定区间内取一个整数。
坑:random.* 每次读取都会重新生成,不是稳定值。同一个键被两个 Bean 分别注入,会拿到两个不同的值。所以别把它当「本次启动的固定 ID」——那种场景要在代码里生成一次,再放进单例 Bean。
命令行参数与 -D 的区别
两种写法看着像,来源和优先级完全不同。
| 维度 | --key=value | -Dkey=value |
|---|---|---|
| 位置 | -jar 之后,传给应用 | -jar 之前,传给 JVM |
| 归属 | Spring 的命令行属性源 | Java System properties |
| 优先级 | 高于系统属性和环境变量 | 高于环境变量,低于命令行 |
| 可见性 | 只有 Spring 看得到 | System.getProperty() 也能读 |
| 写法 | 只能 --key=value | 只能 -Dkey=value |
# 命令行参数:应用级最高优先级
java -jar book-service.jar --spring.profiles.active=prod --server.port=9090
# JVM 系统属性:必须写在 -jar 之前
java -Dserver.port=9090 -Dspring.profiles.active=prod -jar book-service.jar
# 环境变量
SERVER_PORT=9090 SPRING_PROFILES_ACTIVE=prod java -jar book-service.jar
还有两个属性只能在上述「早于环境准备」的渠道里设置:spring.config.location(替换默认配置位置)和 spring.config.additional-location(追加位置)。写进 application.yml 是无效的——因为它们决定了去哪里读 application.yml。
配置加密的思路与选型
生产环境的密码不能明文躺在 Git 里。做法很多,这里只讲思路和选型,不绑定具体库。
| 方案 | 密钥放哪 | 适用 | 注意 |
|---|---|---|---|
| 对称加密后写进配置,启动时解密 | 环境变量 / 启动脚本 | 单体、中小团队 | 主密钥不能与密文同仓 |
| 配置中心集中下发 | 配置中心自身鉴权 | 多环境多服务 | 引入中心化依赖 |
| 密钥管理服务(KMS) | 云 IAM | 云上生产 | 有网络与权限依赖 |
| 平台原生 Secret 对象 | 容器编排平台 | 容器化 | 注意存储加密与 RBAC |
| 运行时向密钥服务拉取 | 服务端 | 高合规要求 | 增加启动期依赖 |
无论选哪种,五条原则不变:
- 主密钥与密文分离存储,走不同渠道,不能进同一个仓库。
- 支持轮换,且轮换不需要重新打包。
- 有审计:谁、在什么时候、读了哪个密钥。
- 最小权限:dev 环境拿不到 prod 的密钥。
- 配置里是明文还是密文要一眼可辨,用命名约定(如
db-password-encrypted)标出来。
实测:同一个键在五处定义
光看表容易忘,我们做一次实测。给图书服务加一个 book.service.timeout,在五个地方都写值:
| 定义位置 | 值 |
|---|---|
application.yml | 1000 |
application-prod.yml | 2000 |
环境变量 BOOK_SERVICE_TIMEOUT | 3000 |
系统属性 -Dbook.service.timeout | 4000 |
命令行 --book.service.timeout | 5000 |
用一个探针 Bean 在启动后把最终值打出来:
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Component
class ConfigProbe implements ApplicationRunner {
private final Environment env;
ConfigProbe(Environment env) {
this.env = env;
}
@Override
public void run(ApplicationArguments args) {
System.out.println("book.service.timeout = " + env.getProperty("book.service.timeout"));
}
}
一次性把所有来源都打开:
BOOK_SERVICE_TIMEOUT=3000 \
java -Dbook.service.timeout=4000 -jar book-service.jar \
--spring.profiles.active=prod \
--book.service.timeout=5000
输出:
book.service.timeout = 5000
命令行赢了。 接着逐个撤掉,就能把优先级链条完整验证一遍:
| 操作 | 生效值 | 谁赢 |
|---|---|---|
| 五个都在 | 5000 | 命令行参数 |
| 去掉命令行参数 | 4000 | Java 系统属性 |
再去掉 -D | 3000 | 操作系统环境变量 |
| 再去掉环境变量 | 2000 | application-prod.yml |
把 profile 换成 dev | 1000 | 基线 application.yml |
这个顺序和本节开头的表完全吻合:命令行 > 系统属性 > 环境变量 > profile 配置 > 基线配置。
调试这类问题时,/actuator/env 会列出所有属性源及各自的取值,/actuator/configprops 展示绑定后的配置对象,用 management.endpoints.web.exposure.include=env,configprops 打开即可(生产环境务必加访问控制)。
本节常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
| 环境变量不生效 | 连字符处补了下划线 | 删掉连字符,如 BOOK_PAGESIZE |
占位符报 Could not resolve placeholder | 引用的键不存在且没给默认值 | 补键或加 ${x:default} |
spring.config.location 无效 | 写进了 application.yml | 改用命令行或环境变量 |
| 随机值前后不一致 | random.* 每次读取都重算 | 在代码里生成一次并存 Bean |
| 改了配置文件却没生效 | 命令行或环境变量覆盖了它 | 查 /actuator/env |
| 生产密码明文入库 | 没做外部化 | 见加密选型表 |
小结
- 配置源有严格顺序:命令行 > 系统属性 > 环境变量 > Config data >
@PropertySource> 默认属性。 - Config data 内部再分两层:外部赢内部(
file:高于classpath:),profile 赢基线。 - 环境变量的转换规则是「点变下划线、连字符删除、全大写」;
BOOK_MAX_BORROW_DAYS这类写法是错的。 ${x}引用属性,${x:default}给默认值,可以嵌套;random.*每次读取都会重新生成,不是稳定值。--key=value是应用级参数,优先级最高;-Dkey=value是 JVM 系统属性,必须写在-jar之前。- 配置加密的关键不在算法,而在「主密钥与密文分离、可轮换、可审计、最小权限」。
配置和绑定都讲完了,接下来的问题是:spring-boot-starter-webmvc 这样的 starter 里到底装了什么,Spring Boot 又是怎么把自动配置类挑出来的?下一章从 starter 的内部结构讲起。
阅读导航:上一节:6.2 @ConfigurationProperties 类型安全配置 · 下一节:7.1 Starter 的解剖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。