本机 localhost:3306 跑得好好的,打包上容器就连不上库;测试环境把 feature.enabled 打开过,生产环境忘了同步,同一份代码行为不同;改了一个 application.yml 里的密码,三个月后在 Git 历史里翻出来。这三件事都属于同一类问题:配置没有边界。
配置管理的核心不是"写多少 yml",而是三件事:谁覆盖谁(优先级)、什么该放外面(外置与敏感项)、改了之后谁生效(刷新边界)。
1. 配置来源的优先级
Spring Boot 3.x 的 Environment 是一串 PropertySource 叠加,后面的覆盖前面的。工程里常用的几层,由低到高:
| 优先级 | 来源 | 典型用途 | 覆盖关系 |
|---|---|---|---|
| 1(最低) | 代码内默认值(@Value 默认、SpringApplication 默认属性) |
兜底 | 被所有外部配置覆盖 |
| 2 | application.yml / .properties(jar 内) |
通用默认 | 被 profile 文件覆盖 |
| 3 | application-{profile}.yml |
环境差异 | 被环境变量覆盖 |
| 4 | 操作系统环境变量 | 容器、CI 注入 | 被命令行覆盖 |
| 5 | Java 系统属性(-D) |
运维临时改 | 同上 |
| 6(最高) | 命令行参数(--key=value) |
覆盖单次运行 | 最终生效 |
实践上记住两条:
- 容器环境优先用环境变量:
application-prod.yml打进镜像后,改配置要重建镜像。 -D与--都覆盖文件配置,但--只对当前进程有效,适合排障时临时抬高日志级别。
环境变量命名遵循松散绑定:spring.datasource.url → SPRING_DATASOURCE_URL。带连字符的例外:spring.datasource.hikari.maximum-pool-size 要写成 SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE(连字符被去掉而非转下划线),这是"环境变量不生效"最常见的原因。
2. profile 的激活与组合
# application.yml
spring:
profiles:
active: dev
config:
activate:
on-profile: dev
server:
port: 8081
---
spring:
config:
activate:
on-profile: prod
server:
port: 8080
| 激活方式 | 写法 | 适用 |
|---|---|---|
| 配置文件 | spring.profiles.active=dev |
本地开发 |
| 命令行 | java -jar app.jar --spring.profiles.active=prod |
单次运行 |
| 环境变量 | SPRING_PROFILES_ACTIVE=prod |
容器、CI |
| 组合 | spring.profiles.group.prod=prod-db,prod-mq |
一组配置一起开 |
注意 spring.profiles.active 写在 application-{profile}.yml 里无效,会抛 InvalidConfigDataPropertyException,必须放主文档、命令行或环境变量。多文档 --- 时每个文档都要写 spring.config.activate.on-profile;没标注的文档对所有 profile 生效。
@Profile 是 Bean 级开关,与配置文件级的 profile 是两套机制:前者决定 Bean 是否注册,后者决定哪份属性文件被加载。
3. 配置外置的三种形态
| 形态 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 外部文件 | --spring.config.location=/etc/app/ 或 SPRING_CONFIG_ADDITIONAL_LOCATION |
单机部署、容器挂载 ConfigMap | 要管文件分发与权限 |
| 环境变量 | SPRING_* 或自定义前缀 |
容器、K8s、Serverless | 变量多时难维护,敏感值会出现在 docker inspect |
| 配置中心 | Nacos / Apollo / Consul + spring.config.import |
多实例、需要动态改 | 引入新依赖与可用性风险 |
Boot 3.x 引入了统一的 spring.config.import,把"从别处拉配置"变成配置本身的一部分:
spring:
config:
import:
- optional:file:/etc/clarity/application.yml
- optional:configserver:http://config:8888
- vault://secret/clarity
optional: 表示"拉不到就跳过",不写则拉不到直接启动失败——生产宁可起不来,也不带错配置上线。同文件里 import 来的配置优先级高于文件自身,低于命令行。
4. 敏感配置怎么放
| 做法 | 效果 | 残留风险 |
|---|---|---|
写进 application.yml 提交 Git |
最省事 | 密码进历史,永久可查 |
| 环境变量注入 | 不进仓库 | docker inspect、/proc/<pid>/environ 可见 |
| 启动参数 | 不进仓库 | ps aux 可见 |
密钥管理(Vault/KMS)+ spring.config.import |
有审计、可轮换 | 需要网络与权限体系 |
| 加密属性(Jasypt 等) | 仓库里是密文 | 主密钥仍要有地方放 |
底线:仓库里不出现真实凭据。已提交过的按"已泄露"处理——删文件没用,历史还在,必须轮换。
# 检查工作区是否混入凭据
$ git log -p --all -S 'password' -- '*.yml' '*.properties' | head -50
$ grep -rn "password\|secret\|token" src/main/resources/ | grep -v '\${'
允许出现的是占位符 password: ${DB_PASSWORD},且不写默认值:缺失即启动失败,比 ${DB_PASSWORD:root} 静默连上本地库好。
5. 刷新边界
动态刷新常被高估。@RefreshScope 的语义是该 Bean 被重新创建,只有被作用域代理包住的 Bean 才拿到新值。
| 对象 | 能否刷新 | 原因 |
|---|---|---|
@RefreshScope 标注的 Bean |
能(重建实例) | 作用域代理在下次访问时重建 |
普通单例 Bean 里的 @Value 字段 |
不能 | 值在创建时注入,之后不再写 |
@ConfigurationProperties Bean |
能(重新绑定) | 但需重新绑定触发,且旧值可能已被缓存 |
| 数据源、连接池 | 风险高 | 重建会中断现有连接,需评估连接泄漏 |
| 静态字段 / 常量 | 不能 | 编译期内联,无运行期入口 |
@RefreshScope
@Component
public class FeatureGateway {
@Value("${feature.gray.ratio:0}") private int ratio; // 重建后拿到新值
}
只把"读多写少、重建无副作用"的开关类 Bean 放进 @RefreshScope;连接池改配置走滚动重启。@Value 写在 @RefreshScope 之外的类里不刷新——这个组合错误几乎每个项目犯一次。
6. 多环境一致性与配置校验
| 漂移类型 | 表现 | 治理 |
|---|---|---|
| 同 key 不同值 | 测试环境 timeout=30,生产 timeout=3 |
差异项集中在一个文件,CI 做 diff |
| 缺 key | 生产少了 mq.topic,启动才报 |
@ConfigurationProperties + @Validated 强制必填 |
| 拼写错误 | feature.enable vs feature.enabled |
同上,绑定失败即启动失败 |
@ConfigurationProperties(prefix = "clarity.mq")
@Validated
public record MqProperties(
@NotBlank String topic,
@Min(1) @Max(60) int timeoutSeconds,
@NotNull Duration ackTimeout) { }
record + @Validated 的价值是配置有问题时启动就失败,而不是首次发消息时抛 NullPointerException。
7. 本地与容器环境的差异
| 差异点 | 本地 | 容器内 | 处理 |
|---|---|---|---|
| 主机名 | localhost / 127.0.0.1 |
Service 名(如 mysql) |
用 profile 分开,或统一走环境变量 |
| 端口 | 直接映射 | 容器端口 vs Service 端口 | server.port 只影响容器内监听 |
| 时区 | 宿主机时区 | 多为 UTC | 显式 TZ=Asia/Shanghai 与 -Duser.timezone |
| 文件路径 | 相对路径 | 挂载卷路径 | 路径走配置,不写死 |
时区最容易出隐蔽 bug:容器默认 UTC,LocalDateTime.now() 与库里的 NOW() 差 8 小时。要么统一全 UTC(推荐),要么镜像里显式设 TZ,不要一半 UTC 一半本地。
8. 配置审计
| 需求 | 手段 |
|---|---|
| 谁改的、何时改 | Git 提交历史 + CODEOWNERS 审批 |
| 改了什么 | 配置中心的版本对比 / git diff |
| 线上当前值 | GET /actuator/env(需鉴权)、/actuator/configprops |
| 敏感项是否外泄 | 提交前 gitleaks 扫描 + CI 卡口 |
# 对比两个环境的配置差异(键级)
$ curl -s localhost:8081/actuator/env | jq -r '.propertySources[].properties | keys[]' | sort > /tmp/dev.txt
$ curl -s localhost:8080/actuator/env | jq -r '.propertySources[].properties | keys[]' | sort > /tmp/prod.txt
$ diff /tmp/dev.txt /tmp/prod.txt
9. 排错
| 现象 | 根因 | 处理 |
|---|---|---|
jar 外的 application.yml 不生效 |
用了 spring.config.location 但只给了目录,覆盖了默认位置 |
用 spring.config.additional-location 追加而非替换 |
| profile 文件被 jar 内同名文件覆盖 | 打包时把外部配置也打进了 jar | 外置配置目录不参与打包,或在启动脚本里显式指定 |
| 环境变量没生效 | 命名不符合松散绑定(连字符未去掉) | 按 SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE 这类规则改 |
@Value 刷新后仍是旧值 |
Bean 不在 @RefreshScope 内 |
加 @RefreshScope,或改用 @ConfigurationProperties |
| 配置中心默认值不生效 | 中心里存的是空字符串,覆盖了本地默认值 | 空值视为未配置,在客户端统一处理 |
启动报 InvalidConfigDataPropertyException |
在 profile 文档里写了 spring.profiles.active |
移到主文档、命令行或环境变量 |
| 密钥进了 Git 历史 | 早期直接提交过 | 轮换凭据,历史用 git filter-repo 清理,加 CI 扫描 |
| 多实例配置不一致 | 某实例没挂到同一配置中心命名空间 | 用 /actuator/env 逐实例比对键集合 |
10. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 单机小服务 | 一个 application.yml + 环境变量 |
上配置中心是负担 |
| 需要秒级生效的开关 | 配置中心 + @RefreshScope 的窄 Bean |
全量刷新风险大 |
| 多租户差异化配置 | 数据库表 + 运行时查询 | 配置文件是部署级,不是租户级 |
| 强合规环境 | 密钥管理服务 + 短期凭据 | 环境变量有泄露面 |
一句话:配置文件管"部署差异",配置中心管"运行期开关",密钥管理管"凭据"。三者混在一处,就是事故的起点。
11. 自测
- 命令行参数、环境变量、
application-prod.yml、application.yml,覆盖顺序是什么? spring.config.location与additional-location的区别?哪个会顶掉默认配置?- 为什么在
application-prod.yml里写spring.profiles.active会报错? @RefreshScope加在类上,类里的@Value就会刷新吗?什么情况下不会?- 容器里
SPRING_DATASOURCE_HIKARI_MAXIMUM-POOL-SIZE为什么无效?正确写法是什么? - 怎么在不暴露敏感值的前提下,比对两个环境的配置差异?