上一节,我们终于把 TaskHub 的任务数据从内存搬进了数据库。Controller 的接口没有变,Service 仍然处理业务规则,Repository 则换成了 Spring Data JPA 生成的代理。现在应用关掉再启动,数据存取已经不再依赖一张临时的内存 Map。
但数据库一接进来,另一个问题马上浮出水面:代码可以不变,运行环境却一直在变。你的电脑上用 H2,测试环境也许有一套临时数据库,生产环境则要连接 PostgreSQL;本地调试希望多看几行日志,线上却不能把 SQL、密码和内部组件状态全部打印出去;端口在自己电脑上是 8080,到了服务器上可能要由平台统一分配。
如果把这些差异写成 Java 里的 if (prod),同一份代码很快就会长出许多环境分支。更麻烦的是,数据库密码也会跟着源码进入 Git。正确的方向不是为每个环境编译一个 TaskHub,而是让同一份可执行程序在启动时接收不同配置。
这一节我们就给 TaskHub 加上这套能力:先建立清楚的默认配置,再用 Profile 和环境变量覆盖它;把散落的字符串绑定成有类型、可校验的配置对象;看懂自动配置为什么生效;最后用 Actuator、健康检查、结构化日志和 requestId 回答两个上线后一定会遇到的问题——“它现在活着吗?”以及“刚才那次请求到底经历了什么?”
先想一个很实际的场景。TaskHub 在开发机上这样连接数据库:
spring:
datasource:
url: jdbc:h2:mem:taskhub
username: sa
password: ""到了生产环境,连接地址变成 PostgreSQL,用户名和密码也由部署平台注入。任务的创建规则、JPA 实体、事务逻辑都没有变,变的只是数据库在哪里。数据库地址因此属于配置,而不是业务代码。
同样的判断也适用于端口、日志级别、默认分页大小、功能开关和外部服务超时时间:它们会影响程序如何运行,但不应该改变程序“是谁”。把这些值从 Java 源码中拿出来,就是外部化配置。
Spring Boot 启动时会把配置文件、环境变量、Java 系统属性和命令行参数等来源整理进一个 Environment。代码里的 @Value 可以从这里取一个值,@ConfigurationProperties 则会通过 Binder 把一组有共同前缀的值转换成一个 Java 对象。后面看到的 Profile,本质上也不是启动另一套 Spring,而是决定哪些配置文档要加入这个 Environment。
这里有一个很重要的边界:外部化配置并不等于“所有东西都要变成配置”。任务标题最长 120 个字符属于稳定的业务契约,随意通过环境变量修改会让不同实例执行不同规则;数据库地址和默认页大小则天然会随环境变化。判断标准不是“能不能写进 YAML”,而是“部署时是否需要在不改代码的情况下改变它”。
很多配置问题之所以难排,是因为我们把 Spring Boot 启动想成了“先创建所有对象,再读一个 YAML”。真实顺序恰好相反:框架必须先建立配置环境,后续才知道应该创建哪些 Bean、用什么端口、采用什么日志级别。
可以把 TaskHub 的启动压缩成五个阶段:
收集配置来源
↓
合并 Environment,决定活动 Profile
↓
根据条件选择自动配置,注册 Bean 定义
↓
绑定并校验 TaskHubProperties,创建 Bean
↓
刷新 ApplicationContext,启动 Tomcat 并开放端口第一阶段只是在“找材料”。Spring Boot 会查找约定位置的 application.yml,读取启动参数和环境变量。第二阶段把它们按优先级放进 Environment,此时才得到最终的 spring.profiles.active、数据源地址和端口。Profile 文件不是在代码运行一半时动态切换,而是在配置数据加载阶段决定是否参与合并。
第三阶段开始处理自动配置条件。比如数据源配置会询问 JDBC 类是否存在、是否已有用户自定义 DataSource、相关属性是否满足条件。第四阶段 Binder 才把 taskhub.* 交给 TaskHubProperties,校验失败会中止上下文刷新。最后,只有所有必需 Bean 都能创建,嵌入式 Tomcat 才真正监听端口。
这条时间线能解释一个现象:有些错误发生时 /actuator/health 根本访问不了,因为 Web 服务器还没启动。此时不要反复 curl,而要读启动报告。也能解释为什么 logging.*、spring.main.* 这类很早就要使用的属性不适合藏在普通 @PropertySource 里;当配置类被处理时,日志系统可能已经初始化。应用级配置优先放在 Boot 的配置数据体系中,而不是另造一套加载顺序。
配置也不是“启动成功后一直去读 YAML 文件”。绝大多数属性在启动时解析并绑定,运行过程中修改源码目录下的 YAML,不会自动重建数据源或配置 Bean。需要动态配置时,要明确引入刷新机制并处理一致性问题,不能假设文件保存一下,所有实例就同时改变。对当前 TaskHub 来说,修改配置后的可靠做法是重新启动实例,让启动校验重新跑一遍。
我们统一使用 src/main/resources/application.yml 保存 TaskHub 的本地开发基线。项目只保留 YAML,不在同一目录再放一份 application.properties。两种格式 Spring Boot 都支持,但混用会让人花时间猜同名属性到底来自哪里。
spring:
application:
name: taskhub
datasource:
url: jdbc:h2:mem:taskhub;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password: ""
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
flyway:
enabled: true
logging:
pattern:
这份文件不是一张“常用属性大全”,每一项都在当前项目里有明确用途。
spring.application.name 是应用的稳定名字,它会出现在日志和观测信息里。数据源继续使用上一节的 H2,并开启 PostgreSQL 兼容模式,让开发阶段尽早避开一部分方言差异。DB_CLOSE_DELAY=-1 让内存数据库在当前 JVM 存活期间保持打开;应用进程结束后,数据仍会消失,所以它只是开发便利,不是生产持久化方案。
当前项目已经把表结构交给 Flyway 迁移脚本,Hibernate 只用 ddl-auto=validate 核对实体与数据库是否一致,不再自行创建或删除表。这样开发与生产走同一条迁移链;如果脚本没有建立所需列,应用会在启动阶段失败。open-in-view=false 则把持久化上下文收回到 Service 的事务边界内,防止 Web 层序列化响应时才偷偷补查数据库。
logging.pattern.console 把 requestId 预留在本地可读日志中;请求之外的启动日志会显示 no-request。taskhub.* 是我们自己的配置命名空间,稍后会绑定到 TaskHubProperties。最后两组 management 和 info 配置属于 Actuator:我们只允许三个管理端点通过 HTTP 暴露,不向匿名访问者展示健康详情,并开启存活、就绪两个健康组,为后面的部署探针留下稳定路径。
YAML 的缩进表达层级,但 Spring 最终看到的仍然是扁平属性。例如:
taskhub.default-page-size=10
management.endpoints.web.exposure.include=health,info,metrics所以 YAML 中不要使用 Tab,也不要因为看起来对齐就随意多缩进一层。配置文件最麻烦的错误,往往不是值写错,而是值被放到了另一个键下面。
还有一个容易被忽略的维护原则:配置要按“谁负责它”分组。spring.datasource.* 由数据源自动配置消费,management.* 由 Actuator 消费,taskhub.* 则由我们自己的配置类消费。不要把数据库地址命名成 taskhub.db-url,然后又在配置类里手动复制给 DataSource;这会形成两套含义相同的属性。框架已经定义好的能力使用框架命名空间,只有业务应用自己拥有的概念才放进 taskhub。
基础文件也应该给新加入项目的人一个可运行起点。克隆代码后不设置任何生产凭据,TaskHub 能使用 H2 启动、建表并完成开发验证;一旦激活 prod,配置要求立刻收紧。这样的默认值是“安全的开发体验”,不是偷偷替生产兜底。
配置可以来自多个位置,覆盖能力是这套体系的核心。对 TaskHub 当前阶段真正有用的顺序可以记成一条短链,越靠后优先级越高:
application.yml 中的开发默认值
↓
application-prod.yml 中的生产差异
↓
操作系统环境变量
↓
Java 系统属性(-D...)
↓
命令行参数(--...)完整的 Spring 环境还可以包含外部配置文件、JNDI、SPRING_APPLICATION_JSON 和测试专用属性源,但现在没有必要背一张十几行的清单。排错时真正要抓住的是:同名属性不是合并投票,而是高优先级来源覆盖低优先级来源。在上面这条常用链里,Java 系统属性会覆盖环境变量,命令行参数又会覆盖系统属性。
用端口做一次最直观的实验。基础配置没有写 server.port,所以嵌入式服务器采用 8080。先用环境变量改成 8090,再故意在命令行改成 8181:
./mvnw clean package
SERVER_PORT=8090 \
java -jar target/taskhub-0.0.1-SNAPSHOT.jar --server.port=8181启动日志中的关键行会是:
Tomcat started on port 8181 (http) with context path '/'
Started TaskHubApplication8090 没有“失效”,它只是被更高优先级的命令行参数盖住了。把 --server.port=8181 去掉,再启动一次,端口就会变成 8090。
环境变量的名字也不是凭感觉加下划线。把规范属性名转换成环境变量时,规则是:点号变下划线、短横线删除、最后转成大写。因此:
server.port → SERVER_PORT
spring.profiles.active → SPRING_PROFILES_ACTIVE
taskhub.default-page-size → TASKHUB_DEFAULTPAGESIZE最后一个尤其容易写错。default-page-size 中的两个短横线会被删除,所以不是 TASKHUB_DEFAULT_PAGE_SIZE。遇到“环境变量明明设置了却没生效”时,先核对规范属性名和转换规则,再去怀疑 Spring。

命令行参数适合一次性的本地验证,却不适合携带密码。命令可能进入 Shell 历史和进程列表。数据库凭据应由部署平台的 Secret、挂载文件或专门的密钥服务注入,日志中也不要打印最终密码来“确认配置”。
如果只有一个临时属性,@Value("${taskhub.display-name}") 确实能取到值。但 TaskHub 已经有显示名称和默认分页大小,将来还可能增加批量操作上限。如果每个类都各自写一个 @Value,属性名会散落在代码里,类型转换和校验也很难集中管理。
我们把同一命名空间绑定成一个 record:
package com.welearn.taskhub.config;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties("taskhub")
public record TaskHubProperties(
@NotBlank String displayName,
@Min(1) @Max(50) int
@ConfigurationProperties("taskhub") 提供的是绑定元数据:Binder 会寻找 taskhub 前缀下的属性,把 display-name 转成 String,把 default-page-size 转成 int,再调用 record 的规范构造器创建不可变对象。这里没有反射“猜一个大概”,而是有明确的前缀匹配、宽松命名和类型转换过程。
@Validated 把 Jakarta Validation 接到绑定之后。对象不是“先随便创建,运行到某个请求时再看看值对不对”,而是在应用上下文启动阶段就校验。显示名称为空、页大小小于 1 或大于 50,都会阻止应用继续启动。这正是我们想要的:坏配置应该尽早失败,而不是带着隐患接收流量。
record 只有一个构造器,因此 Spring Boot 会按构造器参数完成绑定,不需要为每个字段写 setter。要让这个配置类型成为 Bean,在主应用类开启扫描:
package com.welearn.taskhub;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class TaskHubApplication {
public static void main(String[] args) {
SpringApplication.run(TaskHubApplication.class, args);
}
}@ConfigurationPropertiesScan 默认从主应用所在包向下寻找配置属性类型。我们的主类位于根包 com.welearn.taskhub,config 子包自然在扫描范围内。扫描完成后,TaskHubProperties 就是容器管理的 Bean,可以像 Service、Repository 一样通过构造器注入。
默认页大小并不是写完配置类就自动影响接口,必须有消费它的代码。TaskController 把 size 改成可省略的 Integer;请求未提供时才使用配置默认值:
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
private final TaskHubProperties properties;
public TaskController(TaskService service, TaskHubProperties properties) {
this.service = service;
this.properties = properties;
}
@GetMapping
public TaskPageResponse
现在 taskhub.default-page-size 控制“客户端未指定 size 时用多少”,而查询参数仍能覆盖它。配置约束与接口约束都限制在 1 到 50:前者保护部署配置,后者保护单次请求。两层看似重复,实际守的是两个不同入口。
可以故意给一个非法值,观察应用如何拒绝启动:
TASKHUB_DEFAULTPAGESIZE=0 ./mvnw spring-boot:run错误报告会把“哪个前缀、哪个属性、收到什么值、违反什么约束”连在一起:
APPLICATION FAILED TO START
Binding to target com.welearn.taskhub.config.TaskHubProperties failed:
Property: taskhub.default-page-size
Value: "0"
Reason: must be greater than or equal to 1这比把 0 带进分页查询后再遇到数据库异常清楚得多。看到这类报告时,先修配置来源,不要在 Controller 里给非法值悄悄兜底,否则部署错误会被掩盖。

@Value 并不是错误工具。一个类只需要读取一个简单开关时,它很直接:
@Value("${taskhub.display-name}")
private String displayName;问题出在规模和边界。这个字段只告诉读者“某个字符串会被塞进来”,看不出 TaskHub 总共有多少配置,也看不出显示名称与默认页大小属于一组。属性名被复制到多个注解后,改名前要全项目搜索;字段注入又让普通构造器无法表达这个类的完整依赖,单元测试必须借助容器或反射才能补值。
Environment#getProperty 更底层,适合框架代码或确实需要按动态键查询的场景。但在业务类里到处写 environment.getProperty("taskhub.default-page-size", Integer.class),只是把依赖藏进一个万能字典。编译器看不出哪些值必须存在,调用处也不知道何时会得到 null。
@ConfigurationProperties 的优势不是少写几个字符,而是建立一个明确的配置契约:
宽松绑定还负责常见命名差异。YAML 推荐写短横线形式 default-page-size,Java record 使用驼峰 defaultPageSize,环境变量则写大写形式。三者表达同一个规范属性,不需要我们手动做字符串替换。
类型也不只限于 String 和 int。以后 TaskHub 如果要配置导出任务的超时,可以把字段声明成 Duration exportTimeout,YAML 写 30s;如果要限制上传大小,可以使用 DataSize 并写 10MB。让单位进入配置值,远比写一个没有单位的 30000 更不容易误解。类型转换失败同样会在启动时给出清楚报告。
配置默认值要放在一个容易解释的位置。当前 default-page-size: 10 明确写在基础 YAML 中,所以查看配置文件就能知道应用默认行为。如果一个值是代码永远成立的兜底,也可以在构造器中定义;但不要同时在 YAML、注解占位符和 Java 字段初始化器里各写一个不同默认值。多个默认值不会更稳,只会让覆盖关系更难追踪。
最后要正视一个细节:有配置类不代表拼写错误都会被神奇纠正。比如把 display-name 拼坏,正确字段将收不到值;这里的 @NotBlank 会因此阻止启动。对每个必需字段设置能捕捉缺失值的约束,才能把“多写了一个没人消费的属性”转化为可见失败。
很多项目一上来就创建 application-dev.yml、application-test.yml、application-prod.yml,每份复制几十行相同内容。几个月后,三个文件里的公共配置悄悄分叉,谁也说不清哪份才是基准。
TaskHub 采用更简单的结构:application.yml 就是能在开发机直接运行的完整基线,生产环境只用 application-prod.yml 写差异。它不是另一份完整配置,而是一张覆盖单:
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
server:
shutdown: graceful
spring.lifecycle.timeout-per-shutdown-phase: 20s
logging:
structured:
format:
console: logstash激活 prod 后,Spring Boot 先读基础文件,再把 profile 专属文件叠上去。应用名、taskhub.*、Actuator 白名单仍来自基础配置;数据源和建表策略则被生产配置覆盖。
生产配置中的 ${DB_URL} 是属性占位符,表示启动时必须能从环境中解析出 DB_URL。这里故意不提供“change-me”一类默认密码。一个带着假密码继续启动、随后反复连接失败的应用,不如在启动阶段明确告诉部署系统:配置不完整。
启动生产配置时,同一份 JAR 只改变外部输入:
SPRING_PROFILES_ACTIVE=prod \
DB_URL='jdbc:postgresql://db.internal:5432/taskhub' \
DB_USERNAME='taskhub_app' \
DB_PASSWORD='由部署平台注入的值' \
java -jar target/taskhub-0.0.1-SNAPSHOT.jar日志开头会明确列出活动 Profile:
The following 1 profile is active: "prod"如果看到的是:
No active profile set, falling back to 1 default profile: "default"就不要继续排查“为什么还连着 H2”。这两行已经说明 prod 根本没有激活。常见原因是变量名写错、变量没有传进容器,或者把 spring.profiles.active=prod 写进了 application-prod.yml。Profile 专属文档不能靠自己激活自己;决定加载它的开关必须来自基础配置或更外层的启动参数。
Profile 也不是安全边界。名字叫 prod 不会自动保护端点、隐藏密码或开启 HTTPS,它只是一组条件化配置。安全控制仍要由网络、凭据管理和 Spring Security 明确完成。
顺便区分两种环境变量机制:DB_URL 能生效,是因为 YAML 里显式写了 ${DB_URL};SERVER_PORT 和 TASKHUB_DEFAULTPAGESIZE 能生效,则来自 Spring Boot 的宽松绑定。前者名字由我们在占位符中决定,后者要遵守规范属性到环境变量的转换规则。

把密码从 application-prod.yml 改成 ${DB_PASSWORD},解决了一个关键问题:仓库里不再保存生产明文,开发者也不需要为不同环境改源码。不过这只是把秘密的交付责任移到了部署层,并没有自动完成加密、轮换和权限隔离。
在共享服务器上,环境变量可能被同一权限域内的诊断工具读取;错误的进程转储、调试页面或部署日志也可能把它带出来。真正的生产系统通常由 Secret 管理服务保存凭据,在启动时以受限环境变量或只读挂载文件交给进程。谁能读取、何时轮换、旧值何时失效,都要由部署平台明确管理。
Spring Boot 也支持把目录树当作配置来源:文件名成为属性键,文件内容成为值。这很适合容器平台把 Secret 挂载成文件的方式。课程当前用三个直观环境变量,是为了看清绑定链;将来迁移到配置树或密钥服务时,Java 业务代码和 TaskHubProperties 不需要跟着重写,变化仍停留在配置来源层。
日志是秘密最容易意外泄露的地方。排查连接问题时,可以记录“正在连接哪个数据库主机”“使用了哪个 Profile”,但不要记录 JDBC URL 中可能附带的令牌,更不要输出密码。Actuator 的 env 和 configprops 即使会脱敏,也不在 TaskHub 的暴露白名单中。安全设计不能只依赖脱敏规则,而要先缩小可以被访问的表面。
生产 Profile 中不提供凭据默认值还有运维意义。假如密码轮换时变量名写错,实例应立即启动失败,部署系统保留旧实例继续服务;如果应用悄悄使用 change-me,它会启动、占用资源、不断重连,最后以“看似上线、实际不可用”的方式制造更难判断的故障。可预期的快速失败,是配置系统提供的一种保护。
上一节我们添加 JPA 和 H2 依赖后,Spring Boot 自动准备了 DataSource、EntityManager、事务管理器和 Repository 支持。它看起来像“依赖一加,对象就冒出来了”,但背后不是漫无目的地扫描并尝试创建所有 Bean。
自动配置类会声明一组条件,常见的判断包括:某个类是否在 classpath、某个属性是否存在、容器里是否已经有某种 Bean、当前是否为 Web 应用。以数据源为例,可以把决策过程理解成:
类路径里有 JDBC 与连接池吗?
↓ 有
配置环境里能找到数据源信息吗?
↓ 能
用户是否已经声明了自己的 DataSource Bean?
↓ 没有
应用默认的数据源自动配置最后一个判断解释了自动配置为什么“不会抢方向盘”。如果你明确提供自己的 DataSource Bean,带有“缺少用户 Bean 才生效”条件的默认方案就会后退。Spring Boot 提供的是合理候选项,不是无法替换的硬编码。
当结果与预期不一致时,用 --debug 输出条件评估报告:
java -jar target/taskhub-0.0.1-SNAPSHOT.jar --debug报告通常分成匹配与未匹配两部分。不要从第一行一直读到最后一行,先搜索你关心的能力,例如 DataSource、HibernateJpa 或 HealthContributor,再看它下面的条件说明:
Positive matches:
-----------------
DataSourceAutoConfiguration matched:
- required DataSource classes were found
- no user-defined DataSource bean was found
Negative matches:
-----------------
JndiDataSourceAutoConfiguration did not match:
- required JNDI property was not found“未匹配”不等于错误。TaskHub 没打算通过 JNDI 获取数据源,所以相关配置未匹配完全正常。条件报告真正有用的地方,是回答一个具体问题:我期待的自动配置为什么没进来,或者一个意外 Bean 为什么进来了?

例如启动报“找不到 DataSource”时,先看 JDBC starter 是否在 classpath,再看 prod 数据源占位符是否解析成功;如果你定义了自有 Bean,则看默认配置是否因为 @ConditionalOnMissingBean 正常后退。理解这套条件机制后,自动配置就从“黑箱魔法”变成了一张可读的决策记录。
--debug 会额外开启一小组选定框架日志,并不等于把整个应用所有 logger 都改成 DEBUG。问题定位完就应去掉这个开关,尤其不要长期在生产环境输出庞大的条件报告。
条件评估报告还提醒我们区分“配置类被考虑过”和“最终 Bean 一定可用”。某个自动配置匹配,只说明它的前置条件成立;Bean 创建阶段仍可能因为密码错误、驱动不兼容或迁移失败而中止。反过来,某个默认自动配置未匹配,也可能是因为我们主动提供了替代 Bean,这是正常的后退机制。
读报告时可以先问三个问题:目标类在不在依赖里?配置属性有没有进入最终 Environment?容器里是否已有同类型 Bean?这三个问题分别对应 classpath 条件、property 条件和 missing-bean 条件,已经能解释大多数“为什么自动装了/没装”的疑问。只有报告明确指出了具体自动配置类时,才考虑排除它;不要一遇到启动失败就把 DataSourceAutoConfiguration 整体 exclude 掉,那只是把真正的数据库配置问题藏起来。
进程存在,不代表应用健康。JVM 可能还活着,但数据库连接已经断开;HTTP 端口可能能接连接,但关键 Repository 每次查询都失败。Spring Boot Actuator 把这些运行状态整理成管理端点。
项目中加入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>Actuator 提供很多端点,但“项目里有端点”和“端点通过 HTTP 暴露”是两件事。TaskHub 只开放:
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized没有 env、configprops、beans,更没有图省事写 include: "*"。这些端点在排错时很有价值,却可能泄露配置结构、内部类名和运行细节。即使敏感值会被脱敏,也不应该把不需要的管理面暴露到公网。
启动应用后,匿名检查健康状态:
curl http://localhost:8080/actuator/health{"groups":["liveness","readiness"],"status":"UP"}响应里的两个 group 表示存活与就绪检查已经启用,UP 是总体结论。没有数据库详情,不是 Actuator 没检查,而是 show-details=when-authorized 隐藏了组件内容。第 10 节完整拆解认证授权后,运维身份可以看到各组件状态,普通匿名探针仍然只得到总体结论。端点暴露、访问授权、详情展示是三道不同的门。把端点加入 exposure 白名单,只解决第一道门。
info 适合放应用名称、版本等不敏感的身份信息;metrics 提供 JVM、HTTP 请求和连接池等指标入口。它们不是面向普通业务用户的 API,应该由内部监控系统在受控网络和授权规则下访问。
可观测性常被误解成“有日志就行”。实际上,健康状态、指标和日志处在不同粒度,各自适合不同问题。
健康检查给出当前结论:这个实例此刻能不能承担工作。监控平台需要一个稳定、快速、容易判断的结果,所以匿名响应只有 UP 或其他总体状态已经足够。它不适合承载历史趋势,也不应该返回整份线程栈和数据库错误。
指标给出可聚合的数值序列:请求数量、耗时分布、JVM 内存、连接池活跃连接等。一次请求变慢不一定让 health 变成 DOWN,但如果过去十分钟的高分位耗时不断升高,metrics 能让监控系统提前告警。查看可用指标名称:
curl -u admin:admin1234 http://localhost:8080/actuator/metrics再查询一个具体指标:
curl -u admin:admin1234 \
http://localhost:8080/actuator/metrics/http.server.requests具体端点会返回测量值和可用 tag,例如按方法、状态码、路径结果分类。实际监控系统通常定时采集并绘图,而不是让工程师手工刷新 JSON。由于 metrics 会透露内部负载和接口形态,它应在授权后的内部通道访问。
日志则保留离散事件和上下文。指标告诉你“5xx 比例升高”,日志帮助找到哪些 requestId、异常类型和任务操作造成了升高。三者正确的配合方式是:健康检查决定是否接流量,指标发现趋势,日志解释个案。把完整异常塞进 health,或给每次请求都创建一个高基数指标标签,都会让工具承担不适合的工作。
如果以后部署到编排平台,还会碰到存活与就绪的区别。存活检查回答“进程是否陷入无法恢复的状态”,失败可能触发重启;就绪检查回答“当前是否适合接收新流量”,数据库短暂维护时可以暂时摘流但不必立刻重启。不能因为依赖数据库暂时失败,就让所有实例同时被不断重启。TaskHub 目前先掌握统一 health 树,部署一节再把这些状态接到具体平台探针。
有了 JPA 数据源后,Actuator 会自动提供 db 健康贡献者,它检查能否从 DataSource 获得有效连接。为了看清自定义机制,我们再加一个 TaskRepositoryHealthIndicator,让健康树中出现 taskRepository 节点:
package com.welearn.taskhub.actuator;
import com.welearn.taskhub.task.TaskRepository;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class TaskRepositoryHealthIndicator implements HealthIndicator {
private final TaskRepository repository;
public TaskRepositoryHealthIndicator(TaskRepository repository) {
this.repository = repository;
}
@Override
HealthIndicator 是 Actuator 收集健康信息的扩展点。容器发现这个 Bean 后,会在访问 health 端点时调用 health(),再由状态聚合器把所有贡献者的结果合成总体状态。类名末尾的 HealthIndicator 会从节点名中去掉,因此 TaskRepositoryHealthIndicator 对应 taskRepository。
在有权查看详情时,该节点的结果类似:
{
"status": "UP",
"details": {
"taskCount": 2
}
}如果查询抛出异常,它返回 DOWN,整体健康状态也会按聚合规则变化。HTTP 健康端点通常会把 DOWN 映射成 503,让负载均衡器知道这个实例暂时不应继续接收流量。
自定义健康检查要克制。监控平台可能每隔几秒调用一次,检查逻辑必须只读、快速、有超时,绝不能顺手创建测试数据或调用一个需要几十秒的外部流程。count() 在当前小表上很清楚;数据量大后应换成更便宜的可达性检查,或者把任务总数交给 metrics,而不是让每次健康探测扫描大量数据。健康检查回答的是“能否工作”,不是替业务接口跑一套完整验收测试。

普通开发日志适合人眼阅读:时间、级别、线程、logger 和消息排成一行。但在线上,多实例日志会被统一采集。如果仍靠正则表达式从一段自然语言里抠出 taskId、状态码和耗时,字段稍微改一下,检索规则就会失效。
Spring Boot 4.1.0 已经能直接输出常见的 JSON 结构化格式。我们在生产 Profile 中选择 Logstash 格式:
logging:
structured:
format:
console: logstash这样,日志级别、线程名、logger 和消息会成为独立 JSON 字段。SLF4J 的键值日志和 MDC 中的上下文也会进入同一个 JSON 对象。将来如果要给 Service 增加“任务创建成功”这类业务事件,不必把所有信息拼进句子,可以使用键值日志:
private static final Logger log = LoggerFactory.getLogger(TaskService.class);
// 保存成功后
log.atInfo()
.addKeyValue("taskId", saved.id())
.addKeyValue("status", saved.status())
.log("任务创建成功");采集系统可以直接按 taskId 过滤,不必猜“任务编号”“id=”还是“task:”哪种文本格式。日志字段同样需要设计:数据库密码、认证头、完整请求体都不应进入日志;任务描述也可能包含隐私。记录定位问题所需的最小上下文,比“什么都打印出来以后再说”安全得多。
这两个概念经常被混在一起。把 logging.level.com.welearn.taskhub=DEBUG 打开,只是允许这个包输出更细的记录,不会自动变成 JSON;设置 Logstash 结构化格式,只是改变每条日志的字段形状,也不会凭空增加 DEBUG 事件。
本地开发可以针对自己的包开启 DEBUG,同时保持框架噪音在 INFO:
logging:
level:
root: INFO
com.welearn.taskhub: DEBUG不要为了看一条 Repository 日志就把 root 改成 TRACE。Hibernate、连接池、安全过滤器链和 Web 容器都会产生大量细节,真正有用的信息反而被淹没。更好的做法是先找到相关 logger 名称,只提高那一小块的级别,问题结束后恢复。
--debug 又是第三个概念。它开启 Spring Boot 选定的一组核心调试信息和条件报告,不等价于全局 logging.level.root=DEBUG。看到网上建议“启动加 debug”时,要先分清对方是需要条件评估报告,还是需要某个业务包的调试日志。
生产环境偏向结构化 INFO,是因为它兼顾检索和成本。DEBUG 日志可能包含参数、SQL 和内部状态,还会增加磁盘、网络与采集费用。遇到问题时可以短时间对特定 logger 调级,但需要访问控制、回收时间和审计,不能把动态调日志当成永久开关。
异常日志也要保留异常对象,而不是只写 exception.getMessage():
log.error("任务创建失败", exception);这样结构化日志仍能保存异常类型和堆栈。反过来,同一个异常不要在 Controller、Service、Repository 每层各打印一遍;如果最终由统一异常处理器记录,一次请求保留一份带 requestId 的完整异常就够了。重复日志会把一次故障看成三次,也增加告警噪音。
一次创建任务的请求会经过过滤器、Controller、Service 和 Repository。并发一高,不同请求的日志就会穿插在一起。只靠线程名也不稳,因为线程池会复用线程。我们给每次 HTTP 请求分配一个 requestId,并放入 SLF4J 的 MDC:
package com.welearn.taskhub.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
@Component
public class RequestIdFilter extends OncePerRequestFilter
OncePerRequestFilter 保证这段逻辑对一次请求执行一次。客户端给出的 X-Request-ID 只有符合长度和字符白名单时才接受,否则重新生成 UUID,避免有人把换行符或超长内容塞进日志。响应头回传相同 ID,调用方报错时就能把它交给服务端排查。
MDC 可以理解为当前执行上下文携带的一小组日志字段。同一请求后续在当前线程产生的日志都会带上 requestId。finally 中先写结束日志,再调用 MDC.remove 清理非常关键;线程会回到线程池,如果忘记清理,下一个请求可能继承上一个请求的 ID,排查结果反而更混乱。
生产 Profile 已经开启结构化控制台日志。下面这次查询使用一个符合规则的关联 ID;教学账户的访问规则会在安全一节专门拆解,这里只观察请求头和日志:
curl -i -u learner:learn1234 \
-H 'X-Request-ID: lesson-req-20260817' \
'http://localhost:8080/api/tasks?page=0&size=1'响应中能看到:
HTTP/1.1 200
X-Request-ID: lesson-req-20260817
Content-Type: application/json
{"content":[{"id":2,"title":"补上服务层测试","description":"让业务规则可以脱离 Web 层验证","status":"TODO","priority":4,"dueDate":"2026-08-21","createdAt":"2026-08-17T08:48:41.498984Z","version":这次请求留下的日志本来是一行 JSON,下面为了阅读做了换行。注意实际实现把 method、path、status 和 elapsedMs 放在 message 中,MDC 里的 requestId 则成为可单独检索的 JSON 字段:
{
"@timestamp": "2026-08-17T16:48:53.57498+08:00",
"@version": "1",
"message": "request completed method=GET path=/api/tasks status=200 elapsedMs=46",
"logger_name": "com.welearn.taskhub.web.RequestIdFilter",
"thread_name": "http-nio-8080-exec-1",
"level": "INFO",
"level_value": 20000,
"requestId": "lesson-req-20260817"
}requestId 是应用层的请求关联标识,不等同于分布式追踪的 traceId。将来引入 Micrometer Tracing 后,Spring Boot 还能把 traceId 和 spanId 放进日志,跨服务串联调用链;当前这个过滤器先把“一个 HTTP 请求内的日志要可关联”落实下来。
MDC 依赖执行上下文传播。当前 Spring MVC 请求大多在同一个工作线程中同步执行,所以过滤器放入的值能被 Controller 和 Service 看见。如果代码把任务交给另一个线程池,MDC 不会理所当然地跟过去;响应式链也有自己的 Context 传播机制。不能看到一段异步日志缺少 requestId,就简单地在静态变量里保存它,那会造成并发串号。后面的响应式课程会再讨论上下文如何随异步信号传播。
还要决定是否信任上游传来的请求 ID。网关已经为全链路生成合规 ID 时,应用可以在校验格式后继续使用;直接面向公网时,完全信任任意请求头会带来日志注入和超长字段风险。示例用字符白名单与长度上限守住入口,不合规就生成新 UUID。无论采取哪种策略,响应头和日志都应使用最终确定的同一个值。

配置错误经常发生在应用完全启动之前。此时 API 还不能访问,Actuator 也帮不上忙,启动报告就是第一现场。下面四类问题覆盖了最常见的排查路径。
先找启动开头的活动 Profile 行。预期生产配置,却看到 default,说明 application-prod.yml 根本没有进入 Environment。检查 SPRING_PROFILES_ACTIVE 是否真的传给进程,不要先去改数据库 URL。
No active profile set, falling back to 1 default profile: "default"APPLICATION FAILED TO START 下面会列出属性名、原始值、目标类型和原因。比如把页大小写成中文、写成 0,或者 YAML 缩进错误导致属性缺失。修复配置源,不要删除 @Validated 来让应用“先跑起来”。
Failed to bind properties under 'taskhub.default-page-size' to int
Reason: failed to convert java.lang.String to int只激活 prod,却没有传数据库变量:
SPRING_PROFILES_ACTIVE=prod \
java -jar target/taskhub-0.0.1-SNAPSHOT.jar占位符解析阶段就会失败:
Could not resolve placeholder 'DB_URL' in value "${DB_URL}"如果三个变量都存在,却出现 Connection refused、认证失败或超时,说明配置已经成功绑定,问题发生在网络或数据库认证阶段。这两类错误不能混为一谈:一个是“没有地址”,另一个是“按这个地址连接失败”。沿异常链找到第一个具体的连接原因,再检查主机名、端口、数据库状态和账号权限。
Web server failed to start. Port 8080 was already in use.先找是谁监听端口:
lsof -nP -iTCP:8080 -sTCP:LISTEN如果是另一份需要保留的服务,就给当前实例临时换端口:
SERVER_PORT=8081 ./mvnw spring-boot:run不要看到端口冲突就随手结束一个不认识的进程。配置覆盖本来就是用来安全解决这种环境差异的。
遇到“某个 Bean 为什么没创建”时,再补上 --debug 查条件评估报告。一个实用顺序是:先确认活动 Profile,再看绑定失败报告,再看异常链中的具体外部资源错误,最后用条件报告解释自动配置。这样每一步都在缩小范围,而不是同时改 YAML、POM 和 Java 代码碰运气。
Spring Boot 的失败分析通常还会给出 Description 和 Action。它们是框架根据异常类型整理的第一层提示,应该先读,但不能只读最上面一句。比如“无法配置 DataSource”可能由缺少 URL、驱动不在类路径、密码错误等不同原因触发;继续沿 Caused by 找到最具体、仍有业务意义的一层,才知道该改 POM、配置还是外部数据库。
配置绑定报告会标出属性来源和行号时,优先回到那个来源修复。一个属性同时出现在基础文件、环境变量和命令行里,盯着 YAML 修改却一直不生效,往往就是高优先级来源还在覆盖。排查过程中可以暂时删除命令行覆盖或在干净 Shell 中启动,用最少来源复现;不要通过打印全部环境变量来找差异,那可能顺带泄露其他秘密。
配置改动看起来没有改 Java,所以很容易被当成“无需测试的小事”。实际上,一行 YAML 可能让应用无法启动、连接错数据库、暴露额外端点或突然输出海量日志。可以用下面的顺序做一次轻量但完整的检查。
第一,确认归属。这个值是 Spring Boot 已有属性、TaskHub 自有配置,还是部署平台变量?放错命名空间会导致无人消费。第二,确认默认值。开发者克隆项目后是否还能安全启动,生产环境缺值时是否应该明确失败?第三,确认覆盖路径。写出基础值、Profile 差异和运行时变量,避免同一个环境同时从三处提供冲突值。
第四,验证类型和范围。数值有没有单位,页大小是否超过接口上限,空字符串是否应视为缺失?这些规则尽量落到 TaskHubProperties 的类型和约束里。第五,检查敏感性。值能否进入 Git、错误报告、Actuator 或日志?如果是密码和令牌,必须缩小读取权限,并设计轮换方式。
第六,运行两类测试:一个正常值应该成功启动,一个代表性非法值应该在启动期失败。只测成功路径无法证明约束真的工作。第七,检查运行面。健康端点仍然只返回该返回的内容,metrics 和管理端点没有因为通配符意外公开,日志量与结构符合预期。
最后才是发布。生产配置变更最好与应用版本一样可追踪、可回滚,并采用逐实例替换。先观察新实例通过健康和关键请求验证,再移除旧实例;不要同时重启全部节点,然后等待用户告诉你配置错了。即使课程里的 TaskHub 只有一个进程,也应该从一开始建立这种“配置也是交付物”的意识。
先回到没有激活 prod 的本地开发环境,执行测试和启动:
./mvnw test
./mvnw spring-boot:run测试汇总会明确告诉我们这次构建是否干净。当前项目的 PostgreSQL 容器测试在没有 Docker 时按设计跳过,其余测试全部通过:
Tests run: 9, Failures: 0, Errors: 0, Skipped: 1
BUILD SUCCESS启动阶段至少确认三件事:没有活动 Profile 时采用本地 H2;Tomcat 监听 8080;应用最终出现 Started TaskHubApplication,而不是只看到 Spring 标志就认为成功。
然后访问健康端点:
curl http://localhost:8080/actuator/health{"groups":["liveness","readiness"],"status":"UP"}接着用端口覆盖验证高优先级配置。先结束当前实例,再运行:
SERVER_PORT=8090 ./mvnw spring-boot:runTomcat started on port 8090 (http) with context path '/'
Started TaskHubApplication此时原地址应该连接失败,新地址返回健康状态:
curl http://localhost:8090/actuator/health{"groups":["liveness","readiness"],"status":"UP"}最后故意触发一次校验失败:
TASKHUB_DEFAULTPAGESIZE=80 ./mvnw spring-boot:run因为课程统一把单页上限设为 50,应用应在开放端口前失败,并指出 taskhub.default-page-size 超出上限。完成这个实验后清除临时环境变量,重新以正常值启动。
这一组动作看似只改了几个值,却验证了完整链路:配置源进入 Environment,Binder 创建 TaskHubProperties,校验器守住启动边界,自动配置按最终环境创建数据源,Actuator 汇总健康贡献者,日志再用 requestId 标记每次检查请求。以后遇到部署差异,你已经知道应该在哪一层找答案。
这一节没有给 TaskHub 增加新的任务接口,却补上了真实后端不可缺少的运行能力。
我们把本地 H2、生产 PostgreSQL、端口和日志格式从业务代码中分离出来;用“基础文件 → Profile 文件 → 环境变量 → 系统属性 → 命令行参数”理解常用覆盖关系;用 @ConfigurationProperties record 和 @Validated 把配置变成不可变、带类型、能在启动时失败的对象;又通过条件评估报告看清自动配置为何生效或后退。
应用启动后,Actuator 的 health、info、metrics 提供了受控的观测入口,自定义 TaskRepositoryHealthIndicator 把 Repository 可达性加入健康树。结构化 JSON 日志让字段可以被检索,requestId 则把一次请求穿过不同层产生的记录串成一条线。与此同时,我们始终保留安全边界:不暴露全部端点,不向匿名用户展示健康详情,不把密码和完整请求体写入日志。
现在 TaskHub 已经能根据环境调整自己,也能说明自己为什么启动、为什么失败、当前是否健康。下一节我们终于可以放心扩大数据处理能力:让任务列表支持状态筛选、关键字搜索、分页和排序,并用事务与乐观锁保证多个数据库操作在并发场景下仍然一致。这里配置好的 taskhub.default-page-size,也会成为那条分页链路的默认入口。