本节目标:把「环境」从一个字符串拆成正交维度,用 profile group 组合出可维护的激活策略,并说清 jar 内基线、外部覆盖、环境变量三层各自的职责边界。
适用版本:Spring Boot 4.1.x(Java 21)
3.1 多环境配置策略
入门卷讲过 application.yml 与 spring.profiles.active 的用法,也讲过外部化配置的优先级顺序。但把服务真正推到三套环境后,问题会变成另一类:不是「怎么激活一个 profile」,而是「dev / staging / prod 这三个词到底该承载什么信息,哪些东西不该塞进 profile」。本节统一用图书借阅服务(book-loan service,领域模型 Book / Member / Loan)的配置演进过程来讲。
环境不是一个维度
大多数人一开始把 dev / staging / prod 当成一个枚举,用一个 spring.profiles.active 切换。这在只有一台机器的阶段没问题,一旦出现下面两种情况就会崩:
- 多地域:book-loan 服务要同时部署在
cn与eu两个地域,数据库、缓存、对象存储的地址都不同,但业务逻辑完全一样。 - 多租户 / 多规格:同一套镜像要给「标准版」与「机构版」两种客户使用,能力开关不同。
如果把这些都编码成 prod-cn-standard、prod-eu-institution 这样的组合 profile,组合数会指数爆炸。正确做法是把它们看成正交维度:每个维度一个 profile 片段,运行时把它们叠加。
维度 取值示例 承载的信息
----------------------------------------------------------------
环境 stage dev / staging / prod 日志级别、外部依赖地址、限流阈值
地域 region cn / eu 数据库、缓存、对象存储的端点
规格 tier standard / institution 功能开关、配额上限
一次生产部署的激活参数因此长这样:
java -jar book-loan-1.0.0.jar \
--spring.profiles.active=prod,region-cn,tier-standard
关键在于:同一个维度内的片段互斥,不同维度的片段必须能任意组合。这就要求配置里任何一处都不能假设「我一定是 prod」,比如不能出现「如果是 prod 就一定是 cn」这种硬编码判断。
profile 组合与 group
上一步解决了维度划分,但每次启动都要手写三个 profile 很啰嗦,也容易漏。Spring Boot 的 profile group 用来把「一组总是同时出现的 profile」打包:
# application.yml(jar 内基线)
spring:
profiles:
group:
prod: "prod-base,region-cn,tier-standard"
staging: "staging-base,region-cn,tier-standard"
local: "local-base,region-cn,tier-standard"
之后只要 --spring.profiles.active=prod,就会自动展开成四个 profile。这里有几个必须记住的约束:
- profile group 只能写在非 profile 专属文档里。你不能在带
spring.config.activate.on-profile的文档中定义 group,否则会被忽略。 - group 是「展开」不是「替换」:
prod展开出的 profile 仍然各自加载自己的application-<profile>.yml。 - 被 group 展开的 profile 本身也可以再是 group 的 key,但不要嵌套,排查激活结果会非常痛苦。
排查时最实用的手段是打开启动日志里的 active profiles 行,或直接看 Actuator 的 /actuator/env 端点(本地调试用,生产要鉴权):
2026-09-22T10:00:01.112+08:00 INFO 21874 --- [main] c.e.bookloan.BookLoanApplication :
The following 4 profiles are active: "prod", "prod-base", "region-cn", "tier-standard"
配置分层:jar 内基线 + 外部覆盖 + 环境变量
profile 只解决「按维度选文件」,不解决「文件从哪来」。生产上应该把配置明确分成三层,每一层的职责和变更频率都不同:
| 层 | 位置 | 谁维护 | 变更频率 | 适合放什么 |
|---|---|---|---|---|
| 基线层 | jar 内 application.yml + application-<profile>.yml | 开发 | 随代码发布 | 默认值、连接池上限、超时、日志格式 |
| 覆盖层 | 外部 file:./config/application.yml | 运维 / SRE | 不动镜像 | 环境端点、副本数相关参数 |
| 凭据层 | 环境变量 / 挂载文件 / 配置中心 | 平台 | 随时 | 数据库口令、Token、证书 |
Spring Boot 的加载顺序保证了「越靠后越优先」:jar 内的 classpath:/ 最先加载,外部 file:./config/ 覆盖它,环境变量与命令行参数再覆盖前者。所以基线层必须写成能安全兜底的默认值,而不是靠外部一定补上。
一个具体的基线文件:
# src/main/resources/application.yml —— jar 内基线
spring:
application:
name: book-loan
datasource:
hikari:
maximum-pool-size: 10
connection-timeout: 3000
jpa:
open-in-view: false
server:
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,metrics
对应的 profile 片段只放「这个环境才不同」的东西:
# src/main/resources/application-prod.yml
logging:
level:
root: warn
com.example.bookloan: info
bookloan:
loan:
max-concurrent-per-member: 5
overdue-reminder-cron: "0 0 8 * * *"
注意这里刻意没有写数据库 URL 和口令——它们属于覆盖层和凭据层。把端点写死在 jar 里,等于每次换环境都要重新打包。
12-Factor 视角:哪些该进环境变量
12-Factor 的第三条「Config」主张把配置放进环境变量,理由是环境变量天然按部署隔离、不会被误提交。但把这条教条地套到 Spring Boot 上会翻车,因为环境变量有两个硬限制:
- 只能表达扁平字符串。像
spring.datasource.hikari这种嵌套结构,用环境变量表达要靠SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE这类超长名字,可读性和可维护性都很差。 - 无法表达列表与复杂对象。数组、Map、多值列表在环境变量里没有自然表示。
因此生产上更稳妥的划分是:
| 内容类型 | 放哪 | 理由 |
|---|---|---|
| 数据库 / Redis / 消息队列的口令、Token、证书 | 环境变量或挂载文件 | 按部署隔离、绝不进 Git、可被平台轮换 |
| 端点地址、地域标识 | 外部覆盖文件 | 结构化、需要评审、随环境固定 |
| 连接池大小、超时、重试次数 | jar 内基线(可被覆盖) | 有合理默认值,多数环境不需要改 |
| 功能开关、限流阈值 | 配置中心(见 3.3) | 需要不改包动态调整 |
| 日志级别 | 环境变量 LOGGING_LEVEL_* 或覆盖文件 | 排障时要临时调 |
判断标准可以简化成一句话:「这个值会不会因为换一台机器而变,且变了不该重新打包?」 会,就往凭据层或覆盖层放;不会,就留在基线层给个默认值。
Spring Boot 的 relaxed binding 让环境变量名自动映射到属性:BOOKLOAN_LOAN_MAX_CONCURRENT_PER_MEMBER=5 等价于 bookloan.loan.max-concurrent-per-member=5。这条规则是双刃剑——它让你能用环境变量覆盖任意属性,也意味着任何属性都可能被一个你没注意到的环境变量悄悄覆盖。生产排障时,/actuator/env 会显示每个属性的最终值和它的来源(propertySources 里的 origin),这是定位「谁覆盖了它」最快的办法。
spring.config.import 的组合用法
当配置来源超过「classpath + 当前目录」时,用 spring.config.import 显式声明依赖的配置源,比依赖隐式扫描更可控。它支持多种前缀:
# application.yml
spring:
config:
import:
# 1. classpath 里的公共片段
- "classpath:defaults/bookloan-defaults.yml"
# 2. 可选的外部文件(不存在不报错,生产强烈建议带 optional:)
- "optional:file:/etc/bookloan/application.yml"
# 3. Kubernetes / Docker Secret 挂载的目录树
- "optional:configtree:/run/secrets/"
# 4. 从环境变量导入一组属性(3.5 起支持)
- "optional:env:BOOKLOAN_EXTRA_CONFIG"
各前缀的语义差异很大,必须分清:
| 前缀 | 来源 | 典型用途 | 缺文件时行为 |
|---|---|---|---|
classpath: | jar 内 | 公共默认片段 | 报错 |
file: | 文件系统 | 外部覆盖 | 报错,除非加 optional: |
configtree: | 目录(每个文件是一个属性) | K8s Secret / Docker Secret 挂载 | 报错,除非加 optional: |
env: | 环境变量的值(内容是 properties/yaml 文本) | 平台注入整块配置 | 报错,除非加 optional: |
configtree: 是容器化场景里最值得用的一种。Kubernetes 把 Secret 挂载成目录后,目录下每个文件名就是键名,文件内容就是值,Spring Boot 会把它当作配置树读进来:
/run/secrets/
├── spring.datasource.password # 文件内容即口令
└── bookloan.payment.api-key # 文件名含点,即属性名
这样口令不进环境变量、不进命令行,只以文件形式存在于容器内,权限可以收到 0400。3.2 节会详细比较这几种凭据传递方式的泄露面。
4.1 新增:导入时指定编码
一个容易被忽略的历史包袱是:properties 文件长期以 ISO-8859-1 读取。如果你的导入文件里含中文注释或中文值,之前必须转成 \uXXXX 转义,否则乱码。
Spring Boot 4.1 给 spring.config.import 增加了编码参数,语法是方括号紧跟资源路径:
spring.config.import=classpath:import.properties[encoding=utf-8]
# YAML 写法同理
spring:
config:
import:
- "classpath:defaults/bookloan-defaults.yml[encoding=utf-8]"
注意两点:
- 默认仍是 ISO-8859-1,不是 UTF-8。不写
[encoding=utf-8]时行为与旧版本完全一致,所以升级不会自动修复乱码,需要显式加参数。 - 这个参数只作用于通过
spring.config.import导入的文件;直接由 Spring Boot 默认位置加载的application.yml走的是 YAML 解析器自己的编码规则,不受影响。
一套完整的三环境配置长什么样
把前面的原则落到 book-loan 服务上,目录结构与职责如下:
book-loan/
├── src/main/resources/
│ ├── application.yml # 基线:所有环境共享的默认值
│ ├── application-dev.yml # 本地:连本机 H2 / Testcontainers
│ ├── application-staging.yml # 预发:连 staging 库,日志 info
│ └── application-prod.yml # 生产:日志 warn,限流收紧
└── deploy/
└── prod/
└── application.yml # 覆盖层:镜像外挂,放 prod 端点
三个环境文档各自只写「与基线不同的部分」:
# application-dev.yml —— 本地开发,追求快反馈
spring:
datasource:
url: jdbc:h2:mem:bookloan;DB_CLOSE_DELAY=-1
username: sa
password: ""
logging:
level:
com.example.bookloan: debug
bookloan:
loan:
max-concurrent-per-member: 100 # 本地放宽,方便压测
# application-staging.yml —— 预发,尽量贴近生产
logging:
level:
root: info
com.example.bookloan: debug # 预发保留 debug 便于验证
bookloan:
loan:
max-concurrent-per-member: 10
# deploy/prod/application.yml —— 覆盖层,不进镜像
spring:
datasource:
url: jdbc:postgresql://bookloan-db.prod.svc:5432/bookloan
data:
redis:
host: bookloan-cache.prod.svc
bookloan:
loan:
max-concurrent-per-member: 5
口令不在这三个文件里的任何一处,它由凭据层注入(3.2 节展开)。启动脚本只需指定维度:
java -jar book-loan-1.0.0.jar \
--spring.profiles.active=prod \
--spring.config.additional-location=file:./deploy/prod/
--spring.config.additional-location 会在默认位置之外追加一个搜索目录,覆盖层文件放这里,既不进 jar,也不需要改镜像。它与 spring.config.import 的区别是:前者按标准文件名规则扫描目录(自动匹配 application-prod.yml),后者导入你显式指定的具体资源。
配置到底有没有按预期生效,别靠肉眼比对,写一个启动自检把关键值打出来:
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.ApplicationArguments;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Component
public class ConfigSelfCheck implements ApplicationRunner {
private final Environment env;
public ConfigSelfCheck(Environment env) {
this.env = env;
}
@Override
public void run(ApplicationArguments args) {
// 只打非敏感值;口令类属性一律不打
System.out.println("profiles=" + String.join(",", env.getActiveProfiles()));
System.out.println("db.url=" + env.getProperty("spring.datasource.url"));
System.out.println("loan.limit=" + env.getProperty("bookloan.loan.max-concurrent-per-member"));
}
}
Environment 是解析后的最终视图,能直接回答「这个键最后是什么值」,比读 yml 文件可靠得多。
常见坑
- 把 profile 当环境变量的替代:
--spring.profiles.active也是命令行参数,出现在ps输出里。如果用它传敏感信息就错了,profile 只该传「维度名」。 optional:忘记加:生产用file:导入外部文件时,若文件缺失会直接启动失败。如果这是刻意的「缺配置就不许启动」的护栏,那没问题;否则加optional:。- group 写在 profile 文档里:
spring.profiles.group放在spring.config.activate.on-profile文档中会被静默忽略,激活结果与预期不符。 - 覆盖层与基线层字段名不一致:外部文件写
spring.datasource.url,基线写spring.datasource.jdbc-url,两者不会合并,排查时容易误判「覆盖没生效」。 - 以为环境变量优先级最高:命令行参数优先级高于环境变量。CI 里残留的
-Dkey=value可能压过你在平台配的环境变量。
小结
多环境配置的核心不是「多写几个 yml」,而是把环境拆成正交维度、用 profile group 做组合、用三层结构把「随代码发布」「随环境固定」「随时轮换」三类值分开。基线层给安全默认值,覆盖层放环境端点,凭据层交给环境变量或挂载文件。spring.config.import 让来源显式化,configtree: 是容器场景读 Secret 的推荐方式,4.1 的 [encoding=utf-8] 解决了导入文件的编码问题。
阅读导航:上一节:2.3 构建提速 · 下一节:3.2 敏感信息与密钥管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。