《Spring Boot 实战》3.1 多环境配置策略

入门卷讲过 application.yml 与 profile,本节解决生产上真正棘手的问题:环境维度如何划分、profile 组合与 group 怎么用、jar 内基线与外部覆盖如何分层、12-Factor 下哪些值该进环境变量,以及 spring.config.import 的组合语法与 4.1 新增的 encoding 参数。

本节目标:把「环境」从一个字符串拆成正交维度,用 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 敏感信息与密钥管理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计