首页 文章 精选 留言 我的

精选列表

搜索[springboot4],共47篇文章
优秀的个人博客,低调大师

BeetlSQL 3.36 发布,支持 SpringBoot4

BeetlSQL 研发自 2015 年,核心和生态扩展全部自研。目标是提供开发高效,维护高效,运行高效的数据访问框架,它适用范围广,定制性强,入门快。 阅读文档源码和例子在线体验多库使用性能测试插件支持 本次发布主要支持Spring Boot 4 新增 sql-springboot4-starter 支持Spring Boot 4 数据库集成。 Query类新增and or 接口,允许传入Consume<T>。 更新Beetl版本到最新的3.20。 BeetlSQL 核心功能 BeetlSQL 核心 功能 sql-core 核心包,封装了 JDBC 操作,SQL 文件管理,注解的注解管理 sql-fetch 类似 Hibernate 那样提供 @Fetch,@FetchMany 等注解 sql-mapper mapper 类定义和扩展,用户可以自定义自己的 mapper 和 mapper 的注解 sql-intergration 整合 Spring,solon,jfinal 等框架,以及例子 sql-query Query 和 LambdaQuery,用 Java API 构造和使用 SQL sql-mapping 支持单表,多表与 POJO 的互相映射,支持 json 定义,xml 定义映射方式,或者约定习俗 sql-template Beetl 模板实现,且支持其他模板语言 sql-gen 代码生成抽象包,并提供默认实现可以生成 dao,sql 文件,md 文档 sql-db-support 各种数据库的测试和验证,支持 33+ 数据库。 sql-samples 包含了上百个使用 beetlsql 的例子 sql-test 包含了上百个单元测试例子 BeetlSQL 最新扩展包 BeetlSQL 扩展包 功能 sql-xml 高仿 myabtis 的 xml 语法,如果喜欢使用 xml 写 sql 模板的,可以使用此扩展包 sql-accelerator 性能加速包,通过反射优化,缓存,让 beetlsql 性能提升 50%-200%,接近一半手写 JDBC 的性能 sql-firewall sql 防火墙,避免不小心写的 sql 破坏数据库 sql-dynamic-table 支持像访问静态表格那样防火动态表格,简化动态创建表格的业务需求开发 sql-bean-encrypt 支持 @MD5 ,@AES 等对字段加密解密 sql-rewrite 采用 sql 重写,支持单表多租户模式,逻辑删除,数据权限功能 SAGA(实验) BeetSQL 的 SAGA 是实现,用 SAGA 微服务事务 使用加速扩展性能优化结果:能达到近一半手写 JDBC 的性能 查询 测试内容 BeetlSQL(ops/ms) 纯 jdbc mybatis JPA beetlsqlExecuteJdbc 直接执行 JDBC 318 678 / 64 beetlsqlExecuteTemplate 执行 SQL 模板 268 / 44 66 beetlsqlFile SQL 存放在文件统一管理 266 / 41 / beetlsqlInsert 插入一条 129 248 43 59 beetlsqlGetAll 获取所有数据 13 40 4 5 beetlsqlLambdaQuery Java 函数编写 SQL 执行查询 196 / 9 / beetlsqlPageQuery 翻页查询 159 / 17 59 beetlsqlSelectById 查询一条 259 670 43 61 JMH性能测试 Benchmark Mode Cnt Score Error Units Beetl.addEntity thrpt 5 133.190 ± 38.548 ops/ms Beetl.complexMapping thrpt 5 236.289 ± 288.370 ops/ms Beetl.executeJdbcSql thrpt 5 298.657 ± 80.480 ops/ms Beetl.executeTemplateSql thrpt 5 251.418 ± 77.757 ops/ms Beetl.getAll thrpt 5 14.531 ± 0.605 ops/ms Beetl.getEntity thrpt 5 359.275 ± 85.099 ops/ms Beetl.lambdaQuery thrpt 5 187.228 ± 59.697 ops/ms Beetl.one2Many thrpt 5 178.041 ± 181.829 ops/ms Beetl.pageQuery thrpt 5 148.982 ± 39.327 ops/ms Beetl.sqlFile thrpt 5 249.206 ± 100.083 ops/ms DBVisitor.addEntity thrpt 5 99.294 ± 33.564 ops/ms DBVisitor.executeJdbcSql thrpt 5 185.177 ± 50.993 ops/ms DBVisitor.executeTemplateSql thrpt 5 3.189 ± 0.748 ops/ms DBVisitor.getAll thrpt 5 3.163 ± 0.980 ops/ms DBVisitor.getEntity thrpt 5 125.683 ± 44.190 ops/ms DBVisitor.lambdaQuery thrpt 5 94.314 ± 38.499 ops/ms DBVisitor.pageQuery thrpt 5 49.954 ± 18.254 ops/ms EasyQuery.addEntity thrpt 5 98.904 ± 39.890 ops/ms EasyQuery.complexMapping thrpt 5 67.397 ± 59.919 ops/ms EasyQuery.executeJdbcSql thrpt 5 243.160 ± 48.080 ops/ms EasyQuery.getAll thrpt 5 14.896 ± 2.219 ops/ms EasyQuery.getEntity thrpt 5 134.370 ± 67.962 ops/ms EasyQuery.lambdaQuery thrpt 5 137.292 ± 53.438 ops/ms EasyQuery.one2Many thrpt 5 75.083 ± 53.738 ops/ms EasyQuery.pageQuery thrpt 5 84.596 ± 46.942 ops/ms Flex.addEntity thrpt 5 86.484 ± 40.022 ops/ms Flex.getAll thrpt 5 2.926 ± 0.993 ops/ms Flex.getEntity thrpt 5 73.445 ± 39.437 ops/ms Flex.pageQuery thrpt 5 33.999 ± 26.776 ops/ms Jdbc.addEntity thrpt 5 225.223 ± 304.585 ops/ms Jdbc.executeJdbcSql thrpt 5 691.616 ± 145.280 ops/ms Jdbc.getAll thrpt 5 35.827 ± 7.295 ops/ms Jdbc.getEntity thrpt 5 678.791 ± 165.527 ops/ms Jpa.addEntity thrpt 5 53.219 ± 78.543 ops/ms Jpa.executeJdbcSql thrpt 5 60.734 ± 61.299 ops/ms Jpa.executeTemplateSql thrpt 5 64.478 ± 48.688 ops/ms Jpa.getAll thrpt 5 4.710 ± 2.062 ops/ms Jpa.getEntity thrpt 5 56.325 ± 53.744 ops/ms Jpa.one2Many thrpt 5 92.168 ± 111.591 ops/ms Jpa.pageQuery thrpt 5 58.756 ± 48.056 ops/ms MyBatis.addEntity thrpt 5 40.533 ± 12.461 ops/ms MyBatis.complexMapping thrpt 5 106.348 ± 57.688 ops/ms MyBatis.executeTemplateSql thrpt 5 46.953 ± 13.541 ops/ms MyBatis.getAll thrpt 5 5.394 ± 0.502 ops/ms MyBatis.getEntity thrpt 5 45.303 ± 11.928 ops/ms MyBatis.lambdaQuery thrpt 5 23.756 ± 13.613 ops/ms MyBatis.pageQuery thrpt 5 16.190 ± 5.682 ops/ms MyBatis.sqlFile thrpt 5 42.558 ± 18.025 ops/ms SqlUtils.addEntity thrpt 5 232.501 ± 332.656 ops/ms SqlUtils.executeJdbcSql thrpt 5 682.610 ± 171.506 ops/ms SqlUtils.getAll thrpt 5 44.353 ± 4.346 ops/ms SqlUtils.getEntity thrpt 5 643.873 ± 131.587 ops/ms Wood.addEntity thrpt 5 105.097 ± 79.961 ops/ms Wood.executeJdbcSql thrpt 5 127.020 ± 48.142 ops/ms Wood.executeTemplateSql thrpt 5 129.193 ± 44.455 ops/ms Wood.getAll thrpt 5 2.051 ± 0.524 ops/ms Wood.getEntity thrpt 5 126.104 ± 41.976 ops/ms Wood.lambdaQuery thrpt 5 125.013 ± 43.991 ops/ms Wood.pageQuery thrpt 5 220.101 ± 90.028 ops/ms Wood.sqlFile thrpt 5 132.431 ± 50.141 ops/ms

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第22招 自己写AutoConfiguration

# 从零手写 Petclinic 审计 Starter:把默认配置变成可替换自动装配 ```text 旧方案:业务代码里到处写审计切面、指标统计、配置项 冲突:每个服务复制粘贴,默认实现无法统一升级,替换成本高 后果:配置漂移、行为不一致、升级困难、新人不敢改 ``` Petclinic 已经具备 Web、数据、缓存、可观测等能力,但请求审计和业务指标仍然散落在控制器、服务层甚至过滤器里。 我是老李,今天要解决的不是“能不能记录”,而是“怎么让所有业务应用只加一个依赖就自动拥有审计能力,同时还能按需替换默认实现”。 企业里大量 xxx-spring-boot-starter 到底是怎么做出来的? 是继续在业务工程里维护一套 @Configuration,还是把它抽成独立 Starter 并用 AutoConfiguration 自动装配? 如果选错,会不会导致类加载冲突、配置不生效、用户无法覆盖默认 Bean,最终 Starter 没人敢用? # 01、故事 ```text 周五 18:40 → 研发区 → 任务是给 Petclinic 加统一请求审计 → 冲突是审计逻辑散落、指标口径不一致、联调催得紧 ``` 周五傍晚,运维在群里贴出告警:Petclinic 几个核心接口 P99 突然升高,但没人能说清是哪个环节变慢。 任务是给所有 HTTP 请求加统一审计和业务指标,方便定位慢请求和热点接口。 我打开代码一看,有的控制器自己记日志,有的服务层手动埋点,还有人在过滤器里写临时统计。 联调同事在旁边催:“能不能先给一版,别让我每个服务都改一遍。” 我意识到,如果继续在业务工程里堆配置,今晚肯定改不完,而且下个服务还得重来。 > 暮色压工位,告警一声催。 > 审计四处散,指标各自飞。 > 若问根何在,配置未成规。 > 欲解千般乱,先立一模块。 # 02、问题 ```text 需求变化:从“能记录”升级为“所有服务统一自动拥有审计能力” 冲突:普通 @Configuration 需要业务方手动 import,且默认实现无法被优雅替换 后果:接入成本高、行为漂移、升级困难 ``` Petclinic 当前审计逻辑分散在多个模块,每个模块对“请求耗时”“用户标识”“业务动作”的定义都不一样。 这导致指标看板上的数据无法横向对比,排障时只能逐个服务翻日志。 更麻烦的是,如果我把审计逻辑写成一个普通 @Configuration,业务应用必须手动 @Import 或包扫描才能生效。 一旦默认实现需要升级,所有业务方都要改代码;如果业务方想替换默认审计器,又容易因为 Bean 冲突而失败。 可验证的验收标准是:业务应用只增加一个 Maven 依赖,不改任何代码,启动后自动具备请求审计能力;同时业务方可以自定义一个 Bean 替换默认实现,且启动不冲突。 > 旧时配置散,各自埋点忙。 > 一朝口径变,全局费周章。 > 若只加依赖,自动把活扛。 > 替换亦有道,方称好组件。 # 03、原理 ```text 核心机制:AutoConfiguration 条件装配 + 配置属性绑定 + Bean 覆盖策略 ``` Spring Boot 的自动配置不是魔法,它依赖 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.i mports 文件声明候选配置类。 启动时,Spring Boot 会加载这些候选类,并根据 @ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty 等条 件决定是否生效。 @EnableConfigurationProperties 可以把外部配置绑定到强类型 POJO,再注入到自动配置类中。 @ConditionalOnMissingBean 是实现“用户可替换默认实现”的关键:只有当容器中不存在用户自定义 Bean 时,才创建默认 Bean。 一个反直觉判断是:自动配置类本身不应该被业务包扫描到,否则会绕过条件装配,导致用户无法覆盖默认实现。 另一个反直觉判断是:Starter 的依赖边界要分“必需依赖”和“可选依赖”,如果把所有依赖都写成 compile,会污染业务应用的类路径。 > 自动非魔法,条件定去留。 > 缺豆才补位,有主便低头。 > 配置绑强型,元数据可求。 > 边界若不清,类路径成愁。 # 04、架构 ```text 输入:HTTP 请求、业务方法调用、用户自定义配置 本模块:audit-spring-boot-starter(自动配置、审计器、指标收集器、配置属性) 输出:统一审计日志、业务指标、可替换 Bean 扩展点 边界:不依赖 Petclinic 具体业务,不强制数据库存储 ``` 本模块分为四层:配置属性层、自动配置层、核心能力层、扩展点层。 配置属性层用 @ConfigurationProperties 绑定前缀,例如 petclinic.audit。 自动配置层用 @AutoConfiguration 声明,配合条件注解控制装配时机。 核心能力层提供默认审计器、指标收集器和请求过滤器。 扩展点层通过 @ConditionalOnMissingBean 暴露可替换接口,业务方只需提供一个同类型 Bean 即可覆盖。 收益是业务应用零代码接入,默认行为统一,升级只需升 Starter 版本。 代价是 Starter 内部必须保持向后兼容,配置项变更要有迁移说明。 适用条件是业务应用基于 Spring Boot 3 或 4,且类路径中存在 Spring MVC 或 WebFlux。 > 四层分职责,边界画得清。 > 属性先绑定,条件再点兵。 > 核心给默认,扩展留空名。 > 若问何所益,接入一身轻。 # 05、第一版实战 ```text 环境:JDK 17 + Spring Boot 3.3.x + Maven 3.9.x 配置:新建 audit-spring-boot-starter 模块,先写普通 @Configuration 代码:AuditProperties、AuditService、AuditFilter、AuditAutoConfiguration 启动:Petclinic 手动 @Import 该配置类 验证:请求 /owners 观察审计日志 V1:能记录,但需要手动 import,且替换默认实现容易冲突 ``` 先创建独立 Maven 模块 audit-spring-boot-starter,pom.xml 如下: ```xml org.springframework.boot spring-boot-autoconfigure org.springframework.boot spring-boot-configuration-processor true ``` 写配置属性类: ```java @ConfigurationProperties(prefix = "petclinic.audit") public class AuditProperties { private boolean enabled = true; private String logPrefix = "[AUDIT]"; private List ignorePaths = List.of("/actuator/**"); // getter/setter 省略 } ``` 写默认审计服务: ```java public interface AuditService { void record(String method, String path, long costMs, String user); } public class DefaultAuditService implements AuditService { private static final Logger log = LoggerFactory.getLogger(DefaultAuditService.class); private final AuditProperties properties; public DefaultAuditService(AuditProperties properties) { this.properties = properties; } @Override public void record(String method, String path, long costMs, String user) { log.info("{} {} {} {}ms user={}", properties.getLogPrefix(), method, path, costMs, user); } } ``` 写请求过滤器: ```java public class AuditFilter extends OncePerRequestFilter { private final AuditService auditService; private final AuditProperties properties; public AuditFilter(AuditService auditService, AuditProperties properties) { this.auditService = auditService; this.properties = properties; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { long start = System.currentTimeMillis(); try { chain.doFilter(request, response); } finally { if (properties.isEnabled() && !shouldIgnore(request.getRequestURI())) { auditService.record(request.getMethod(), request.getRequestURI(), System.currentTimeMillis() - start, request.getRemoteUser()); } } } private boolean shouldIgnore(String uri) { return properties.getIgnorePaths().stream().anyMatch(uri::startsWith); } } ``` 写普通配置类: ```java @Configuration @EnableConfigurationProperties(AuditProperties.class) public class AuditAutoConfiguration { @Bean @ConditionalOnMissingBean public AuditService auditService(AuditProperties properties) { return new DefaultAuditService(properties); } @Bean @ConditionalOnMissingBean public AuditFilter auditFilter(AuditService auditService, AuditProperties properties) { return new AuditFilter(auditService, properties); } } ``` 在 Petclinic 主类上手动导入: ```java @SpringBootApplication @Import(AuditAutoConfiguration.class) public class PetClinicApplication { public static void main(String[] args) { SpringApplication.run(PetClinicApplication.class, args); } } ``` 启动并请求: ```bash mvn spring-boot:run curl -s http://localhost:8080/owners | head -n 5 ``` 示例输出: ```text [AUDIT] GET /owners 23ms user=null ``` V1 能记录请求,但业务方必须手动 @Import,且如果业务方自己定义了 AuditService,可能因为包扫描顺序或配置类加载顺序导致 Bean 冲突。 > 初版虽能记,手动导入烦。 > 替换若不当,冲突起波澜。 > 条件虽已写,装配未成环。 > 欲求真自动,还需再深钻。 # 06、排查 ```text 诊断树: 现象:业务方自定义 AuditService 后启动报 BeanDefinitionOverrideException 怀疑:@Import 的配置类与业务包扫描重复注册 检查:查看 Bean 定义来源和注册顺序 证据:业务方 @Service 注解的 AuditServiceImpl 与 @Bean 方法同时存在 根因:@Import 的配置类被业务包扫描到,导致条件装配失效 修复:把自动配置类移出业务包扫描路径,改用 AutoConfiguration.imports 声明 ``` 第一个问题是业务方自定义 AuditService 后启动报错。 怀疑是 @Import 的配置类被业务包扫描重复加载。 检查 Bean 定义来源: ```bash mvn spring-boot:run -Dspring-boot.run.arguments=--debug ``` 示例输出: ```text AuditService defined in com.example.audit.AuditAutoConfiguration AuditService defined in com.example.petclinic.service.AuditServiceImpl ``` 证据显示同一个类型有两个 Bean 定义,且 @ConditionalOnMissingBean 没有生效。 根因是业务方把自定义 AuditServiceImpl 放在 com.example.petclinic 包下,而 AuditAutoConfiguration 也在该包下,业务包扫描把自动配置类也扫 了进去。 修复方法是把自动配置类移到独立包,例如 com.example.audit.autoconfigure,并通过 AutoConfiguration.imports 声明。 第二个问题是配置属性没有 IDE 提示。 怀疑是缺少配置元数据。 检查 target/classes/META-INF 目录: ```bash ls target/classes/META-INF/ ``` 示例输出: ```text spring-configuration-metadata.json 不存在 ``` 根因是 configuration-processor 没有正确生成元数据,需要确认注解处理器是否启用。 **错误尝试:** 我一开始把 @ConditionalOnMissingBean 放在 @Bean 方法上,但配置类本身被业务包扫描到,导致条件判断在错误时机执行,仍然出现 Bean 冲突。 > 现象是冲突,怀疑在扫描。 > 证据看来源,根因在包号。 > 修复移路径,声明换新招。 > 错误曾尝试,条件未生效。 # 07、优化 ```text V1:普通 @Configuration + 手动 @Import 证据:业务包扫描导致条件装配失效,配置元数据缺失 V2:真正 AutoConfiguration + AutoConfiguration.imports + 配置元数据 ``` 把自动配置类移到独立包 com.example.audit.autoconfigure,并加上 @AutoConfiguration 注解: ```java @AutoConfiguration @EnableConfigurationProperties(AuditProperties.class) public class AuditAutoConfiguration { @Bean @ConditionalOnMissingBean public AuditService auditService(AuditProperties properties) { return new DefaultAuditService(properties); } @Bean @ConditionalOnMissingBean public AuditFilter auditFilter(AuditService auditService, AuditProperties properties) { return new AuditFilter(auditService, properties); } } ``` 在 resources/META-INF/spring/ 下创建 org.springframework.boot.autoconfigure.AutoConfiguration.imports: ```text com.example.audit.autoconfigure.AuditAutoConfiguration ``` 在 pom.xml 中确保 configuration-processor 生效: ```xml org.apache.maven.plugins maven-compiler-plugin org.springframework.boot spring-boot-configuration-processor ``` 在 Petclinic 中移除 @Import,只保留 Maven 依赖: ```xml com.example audit-spring-boot-starter 1.0.0 ``` 重新构建并启动,确认自动装配生效。 > 移包避扫描,注解换新装。 > 声明入文件,元数据成双。 > 业务去导入,依赖自担当。 > 替换有缺豆,冲突不再狂。 # 08、演进 ```text V2:真正 AutoConfiguration + 条件装配 + 配置元数据 能力:业务应用零代码接入,用户可自定义 Bean 替换默认实现 遗留:配置项较少,未覆盖异步请求和响应体审计 ``` 用 V1 同一组请求验证: ```bash curl -s http://localhost:8080/owners | head -n 5 ``` 示例输出: ```text [AUDIT] GET /owners 19ms user=null ``` 再验证用户自定义替换: ```java @Service public class CustomAuditService implements AuditService { @Override public void record(String method, String path, long costMs, String user) { System.out.println("CUSTOM: " + method + " " + path + " " + costMs + "ms"); } } ``` 启动后请求,示例输出: ```text CUSTOM: GET /owners 21ms ``` 收益是业务应用只加一个依赖就自动拥有审计能力,默认实现统一,升级只需升 Starter 版本。 代价是 Starter 内部必须保持向后兼容,配置项变更要有迁移说明。 适用条件是业务应用基于 Spring Boot 3 或 4,且类路径中存在 Spring MVC。 遗留问题是当前只覆盖同步 HTTP 请求,异步请求和响应体审计尚未实现。 > 同组请求验,自动已生效。 > 自定义替换,冲突不再生。 > 收益在零码,代价在兼容。 > 遗留异步事,留待后续攻。 # 09、洞见 ```text 从现象到判断:自动装配的本质是“约定优于配置”的工程化落地 ``` ## 自动配置类必须躲开业务包扫描 ```text 业务包扫描 → 自动配置类被提前加载 → 条件注解失效 → Bean 冲突 ``` 自动配置类如果放在业务包扫描路径下,会被当成普通配置类提前加载,@ConditionalOnMissingBean 的判断时机就会错乱。 正确做法是把自动配置类放在独立包,并只通过 AutoConfiguration.imports 声明。 ## 反直觉判断:@ConditionalOnMissingBean 不是万能保险 ```text 条件注解只在自动配置阶段生效 → 业务包扫描会绕过条件 → 冲突仍会发生 ``` 很多人以为加了 @ConditionalOnMissingBean 就一定能避免冲突,但如果配置类被业务包扫描到,条件判断就失效了。 条件装配的前提是配置类必须由自动配置机制加载,而不是业务扫描加载。 ## 反直觉判断:Starter 的依赖边界比功能更重要 ```text compile 依赖 → 污染业务类路径 → 版本冲突 → Starter 被弃用 ``` Starter 如果把所有依赖都写成 compile,会把不必要的库强加给业务应用,导致类路径冲突。 正确做法是把核心依赖写成 compile,把可选依赖写成 optional 或 provided。 ## 配置元数据是 Starter 的“用户界面” ```text 无元数据 → IDE 不提示 → 用户乱猜配置 → 接入成本上升 ``` 配置元数据不仅让 IDE 有提示,还能在编译期发现拼写错误。 这是 Starter 易用性的关键,很多团队却忽略这一步。 > 现象看冲突,判断在时机。 > 条件非万能,扫描破玄机。 > 边界定生死,元数据可依。 > 洞见藏深处,实战方可知。 # 10、系统落地 ```text 入口:HTTP 请求 → 服务:AuditFilter → 本模块:AuditService/DefaultAuditService → 存储:日志/指标收集器 → 未实现:异步审计、响应体审计 ``` 原来 Petclinic 的审计逻辑散落在控制器、服务层和过滤器中,没有统一入口和口径。 本篇增加了 audit-spring-boot-starter 模块,业务应用只加一个依赖就自动拥有请求审计能力。 现在能做什么:统一记录请求方法、路径、耗时、用户,支持配置忽略路径,支持自定义替换默认审计器。 下一篇缺什么:异步请求审计、响应体脱敏审计、审计数据持久化到数据库或消息队列。 官方文档可参考 Spring Boot 官方文档首页:https://docs.spring.io/spring-boot/ 。 > 入口在请求,服务在过滤。 > 模块补能力,存储待扩充。 > 本篇成闭环,异步尚留空。 > 落地有章法,演进自从容。 # 11、小结 ```text 三问三答: Starter 怎么做?→ 独立模块 + AutoConfiguration.imports + 条件装配 怎么自动拥有?→ 业务应用只加 Maven 依赖,零代码接入 怎么替换默认?→ @ConditionalOnMissingBean + 用户自定义 Bean ``` 企业里大量 xxx-spring-boot-starter 的核心做法是:独立模块、自动配置声明、条件装配、配置属性绑定、配置元数据。 业务应用只增加一个 Maven 依赖就自动拥有能力,是因为 Spring Boot 启动时会加载 AutoConfiguration.imports 中声明的配置类。 用户仍可通过自定义 Bean 替换默认实现,是因为 @ConditionalOnMissingBean 在容器中不存在用户 Bean 时才创建默认 Bean。 当前状态:Petclinic 已具备统一请求审计能力,且该能力以 Starter 形式独立存在,可复用到其他业务应用。 > 一问怎么做,独立加声明。 > 二问何自动,依赖即启程。 > 三问怎替换,缺豆才补成。 > 三答皆落地,闭环已打通。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:@ConditionalOnMissingBean 在什么情况下会失效? 当自动配置类被业务包扫描提前加载时,条件注解的判断时机错乱,@ConditionalOnMissingBean 会失效,导致 Bean 冲突。 ## 实战题:给 audit-spring-boot-starter 增加一个配置项,支持自定义日志级别。 在 AuditProperties 中增加 logLevel 字段,默认 INFO; 在 DefaultAuditService 中根据 logLevel 选择日志输出级别; 重新构建后,在业务应用配置文件中设置 petclinic.audit.log-level=DEBUG 验证生效。 ## 排障题:业务应用加了 Starter 依赖后,启动时提示找不到 AuditFilter 类。 检查 Starter 的 pom.xml 是否把 spring-web 依赖写成了 optional 或 provided; 如果是,业务应用需要显式引入 spring-web 依赖,或者把 Starter 中的 spring-web 改为 compile 依赖。 ## 架构判断题:如果多个 Starter 都提供了同一个类型的默认 Bean,应该怎么避免冲突? 应该使用 @ConditionalOnMissingBean 并配合 @ConditionalOnClass 限定生效条件; 如果确实存在多个 Starter 竞争同一个 Bean,需要明确优先级或让业务方显式定义 Bean 来覆盖所有默认实现。 > 理解问条件,失效在扫描。 > 实战增配置,日志可调参。 > 排障查依赖,边界要看清。 > 架构判冲突,缺豆定去留。 # 13、升华 ```text 工程结论:自定义 Starter 的本质是把“默认配置”变成“可替换自动装配” ``` 从普通 @Configuration 到真正 AutoConfiguration,不是简单加个注解,而是把配置类的加载时机从业务扫描切换到自动配置机制。 这一切换带来了三个能力:业务应用零代码接入、默认实现统一升级、用户自定义 Bean 优雅替换。 有依据的结论是:Spring Boot 的自动配置体系是一套完整的扩展协议,掌握它就能把团队内部的最佳实践沉淀为可复用的 Starter。 下一篇将把审计数据从日志升级为结构化指标,并接入 Actuator 和 Micrometer,让审计能力从“可记录”走向“可观测”。 > 配置非终点,装配是桥梁。 > 默认可替换,升级不慌张。 > 协议掌握后,实践自沉淀。 > 审计入指标,观测路更长。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web/安全/数据/事务、远程调用/数据库迁移/缓存/并发、日志/测试/Testcontainers/Docker Compose、Actuator/可观测 **本篇:** 从零手写 Petclinic 审计 Starter,完成自定义自动配置闭环 **下一篇:** 审计指标接入 Micrometer 与 Actuator,实现可观测增强

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第5招 自动配置不是魔法

# 为什么加一个 Starter,容器里就多出几十个 Bean? ```text 旧方案:手动 new Bean → 冲突:依赖一多就失控 → 后果:配置爆炸、启动失败 ``` 我是老李,一个在深圳摸爬滚打多年的 Java 架构师。 这些年我带过不少团队,见过太多 Spring Boot 项目,明明只加了一个 Starter,启动时容器里却突然多出几十个 Bean,没人说得清它们从哪来。 更头疼的是,有时候你定义了一个自己的 Bean,Spring Boot 却悄悄退让,不给你自动配置,导致功能静默失效。 冲突问句:为什么只加一个依赖,容器就多出这么多 Bean? 选择问句:当自动配置和你的 Bean 冲突时,Boot 凭什么替你做决定? 选错后果问句:如果不懂自动配置的匹配规则,排查启动问题要浪费多少小时? # 01、故事 ```text 凌晨 1 点 → 深圳某创业公司机房 → 任务是排查 Petclinic 启动后数据源失效 → 冲突:加了 web starter 后 JPA 仓库全部报错 ``` 凌晨一点,机房空调嗡嗡作响,我盯着屏幕上的异常堆栈,头皮发麻。 任务是给 Petclinic 加上 Spring MVC 和 Jackson 支持,结果一启动,所有 JPA 仓库都报找不到 DataSource。 明明之前用 spring-boot-starter-data-jpa 还好好的,怎么加了个 web starter 就全乱了? 我翻遍 application.properties,确认数据源配置没丢,可容器里就是没有 DataSource 的 Bean。 同事在旁边催:老李,客户明天早上要看演示,今晚必须搞定。 我深吸一口气,打开 Spring Boot 的 --debug 开关,决定从自动配置的匹配报告里找真相。 > 加个依赖几十 Bean, > 数据源却悄悄不见。 > 深夜排查心焦灼, > 条件报告见真颜。 # 02、问题 ```text 需求变化:从单一 JPA 项目升级为 Web + JPA 完整应用 → 冲突:自动配置互相干扰 → 后果:DataSource 缺失,仓库全部失效 ``` Petclinic 原本只依赖 spring-boot-starter-data-jpa,一切正常。 为了提供 REST API,我加了 spring-boot-starter-web,结果启动时 Hibernate 初始化失败,提示找不到 DataSource。 原因不是配置丢了,而是自动配置的匹配条件变了——web starter 引入了新的类,导致某些条件评估结果翻转。 影响是灾难性的:所有 OwnerRepository、PetRepository 全部无法注入,应用直接启动失败。 可验证的验收标准:启动应用后,日志中必须出现 DataSource 相关的自动配置匹配记录,且仓库能正常注入。 > 需求一变配置乱, > 数据源失联仓库瘫。 > 条件评估藏玄机, > 报告一出真相显。 # 03、原理 ```text 核心机制:@EnableAutoConfiguration 导入 AutoConfigurationImportSelector,按条件评估每个 AutoConfiguration 类 ``` Spring Boot 的自动配置核心是 @EnableAutoConfiguration 注解,它通过 AutoConfigurationImportSelector 加载所有 META-INF/s pring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件里声明的配置类。 每个 AutoConfiguration 类上都有 @ConditionalOnClass、@ConditionalOnMissingBean 等条件注解,只有条件全部满足时,这个配置类才会被处理,里 面的 Bean 才会被注册。 反直觉判断:条件评估的顺序不是随机的,而是按 AutoConfiguration 类的声明顺序,且 @ConditionalOnMissingBean 只在当前容器中查找,不会考虑父容器。 另一个反直觉点:@ConditionalOnClass 只检查类是否存在,不关心类是否可加载,所以即使类在但依赖缺失,条件也可能误判为 true。 实战中,你只需要理解:classpath 上的类决定了哪些自动配置候选,你的配置和 Bean 决定了哪些条件被满足,最终 Boot 替你组装好默认的 Bean 图。 > 条件注解层层筛, > 类路径上定乾坤。 > 用户 Bean 优先权, > 回退机制保平安。 # 04、架构 ```text 输入:classpath 类、配置属性、用户 Bean → 本模块:AutoConfiguration 条件评估 → 输出:匹配的 Bean 定义 ``` 自动配置架构分三层:候选层、条件层、注册层。 候选层由 AutoConfiguration.imports 文件提供,列出所有可能的配置类。 条件层用各种 Conditional 注解过滤,只有条件全过才进入注册层。 注册层通过 @Bean 方法定义 Bean,并利用 @ConditionalOnMissingBean 给用户留出覆盖空间。 收益是巨大的:开发者只需加一个 Starter,Boot 自动装配好数据源、JPA、MVC 等几十个 Bean,省去大量样板配置。 代价是黑盒效应:一旦条件不满足,你很难直觉判断为什么某个 Bean 没被创建。 适用条件是:项目遵循 Boot 的约定,classpath 干净,不随意排除自动配置。 > 候选条件注册层, > 层层过滤定乾坤。 > 收益巨大代价隐, > 约定优于配置深。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + Java 17 + Maven → 配置:添加 web starter → 代码:无改动 → 启动:--debug 查看报告 → 验证:检查 DataSource Bean ``` 我用的版本是 Spring Boot 3.2.5,Java 17,Maven 构建。 首先,在 Petclinic 的 pom.xml 中添加 web starter: ```xml org.springframework.boot spring-boot-starter-web ``` 然后,保持原有 JPA starter 不变,启动应用并开启 debug 日志: ```bash mvn spring-boot:run -Dspring-boot.run.arguments=--debug ``` 启动后,控制台会输出自动配置报告,我重定向到文件方便分析: ```bash mvn spring-boot:run -Dspring-boot.run.arguments=--debug > debug.log 2>&1 ``` 在 debug.log 中搜索 DataSource 相关段落,看到如下内容(示例输出): ```text DataSourceAutoConfiguration matched: - @ConditionalOnClass found required classes 'javax.sql.DataSource', 'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType' (OnClassCondition) - @ConditionalOnMissingBean (types: io.r2dbc.spi.ConnectionFactory; SearchStrategy: all) did not find any beans (OnBeanCondition) DataSourceAutoConfiguration#dataSource matched: - @ConditionalOnMissingBean (types: javax.sql.DataSource; SearchStrategy: all) did not find any beans (OnBeanCondition) ``` 但奇怪的是,JPA 相关的 HibernateJpaAutoConfiguration 却显示 negative: ```text HibernateJpaAutoConfiguration matched: - @ConditionalOnClass found required classes 'org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean', 'jakarta.persistence.EntityManager', 'org.hibernate.engine.spi.SessionFactoryImplementor' (OnClassCondition) - @ConditionalOnBean (types: javax.sql.DataSource; SearchStrategy: all) did not find any beans (OnBeanCondition) ``` 注意最后一行:HibernateJpaAutoConfiguration 需要 DataSource Bean,但报告显示没找到。 可 DataSourceAutoConfiguration 明明 matched 了,为什么 DataSource Bean 没被创建? 我继续往下看,发现 DataSourceAutoConfiguration 中的 dataSource 方法显示 matched,但实际 Bean 却没注册。 这让我怀疑是 Bean 定义冲突,或者条件评估顺序问题。 成功标准是:启动后容器中必须有 DataSource Bean,且 JPA 仓库能正常注入。 但当前显然没达到,我需要深入排查。 > 加个 web 依赖简单, > 启动报告却打脸。 > DataSource 匹配了, > 实际 Bean 却不见。 # 06、排查 ```text 诊断树:现象→怀疑→检查→证据→根因→修复 ``` **问题一:DataSource Bean 缺失** 现象:启动失败,提示找不到 DataSource。 怀疑:自动配置条件不满足,或者被用户 Bean 覆盖。 检查:查看完整自动配置报告,搜索 DataSource 相关段落。 ```bash grep -n "DataSource" debug.log | head -50 ``` 证据:报告显示 DataSourceAutoConfiguration matched,但 dataSource 方法没有输出 matched 或 unmatched。 根因:我注意到报告中有个 negative 条件:@ConditionalOnMissingBean (types: javax.sql.DataSource) 显示 did not find any beans,但 dataSource 方法本身没有匹配记录,说明它可能被 @ConditionalOnProperty 等条件拦截。 修复:检查 application.properties,发现我设置了 spring.datasource.url 但没设置 driver-class-name,导致 DataSource 创建失败。 **错误尝试:** 我一开始怀疑是 web starter 引入了嵌入式数据库,导致自动配置选择了 EmbeddedDataSource,于是尝试排除 EmbeddedDatabase 类,但没用。 **问题二:JPA 自动配置不生效** 现象:HibernateJpaAutoConfiguration 显示 unmatched。 怀疑:缺少 DataSource Bean。 检查:查看 HibernateJpaAutoConfiguration 的条件。 ```bash grep -A 10 "HibernateJpaAutoConfiguration" debug.log ``` 证据:条件 @ConditionalOnBean (types: javax.sql.DataSource) 显示 did not find any beans。 根因:DataSource Bean 没创建,所以 JPA 配置自然不生效。 修复:补全数据源配置,确保 DataSource Bean 能创建。 > 报告显示匹配了, > 实际 Bean 却缺席。 > 条件嵌套层层锁, > 配置补全才解围。 # 07、优化 ```text{V1:手动配置数据源 → 证据:报告显示条件不满足 → V2:利用自动配置 + 自定义 Bean 观察回退} ``` V1 中我手动在 application.properties 里配置了数据源,但漏了 driver-class-name,导致自动配置失败。 优化方案是:完全依赖自动配置,只提供必要属性,并定义一个自定义 DataSource Bean 来观察 Boot 的回退行为。 首先,修正配置,只保留 URL 和账号密码: ```properties spring.datasource.url=jdbc:h2:mem:petclinic;DB_CLOSE_DELAY=-1 spring.datasource.username=sa spring.datasource.password= ``` 然后,在 Petclinic 中定义一个配置类,手动创建 DataSource: ```java @Configuration public class CustomDataSourceConfig { @Bean public DataSource dataSource() { return DataSourceBuilder.create() .url("jdbc:h2:mem:custom;DB_CLOSE_DELAY=-1") .username("sa") .password("") .build(); } } ``` 重新启动并查看报告,会发现 DataSourceAutoConfiguration 中的 dataSource 方法显示 unmatched: ```text DataSourceAutoConfiguration#dataSource matched: - @ConditionalOnMissingBean (types: javax.sql.DataSource; SearchStrategy: all) found beans of type 'javax.sql.DataSource' (OnBeanCondition) ``` 注意:这里显示 found beans,说明 Boot 检测到用户自定义的 DataSource,于是自动回退,不再创建默认的。 这就是 @ConditionalOnMissingBean 的威力:用户 Bean 优先,Boot 自动让路。 > 手动配置易出错, > 自动回退显智慧。 > 用户 Bean 优先权, > 条件注解来守卫。 # 08、演进 ```text{V2:自定义 Bean 触发回退 → 能力:理解 Boot 决策 → 遗留:如何精确控制自动配置} ``` 通过 V2,我验证了 Boot 的回退机制:当用户定义了 DataSource Bean,自动配置就不再创建默认的。 收益是:开发者可以完全控制关键 Bean,而不必担心 Boot 覆盖。 代价是:如果用户 Bean 定义有误,Boot 不会帮你兜底,错误会直接暴露。 适用场景:当你需要定制数据源(如连接池、多数据源)时,自定义 Bean 是正确做法。 遗留问题:如果我想让 Boot 的自动配置和我的 Bean 共存,该怎么办? 用同一组请求验证:启动应用后,访问 /owners 列表,发现数据来自自定义的 H2 库,而不是默认的 petclinic 库,证明自定义 Bean 生效。 > 自定义 Bean 显身手, > Boot 回退不抢戏。 > 共存需求仍存在, > 条件控制待深究。 # 09、洞见 ```text 从现象到判断:自动配置不是魔法,而是条件决策的工程实现 ``` ## 自动配置是条件决策树 ```text classpath → 条件评估 → 匹配/回退 → Bean 注册 ``` Boot 的自动配置本质是一棵决策树,每个节点是一个条件注解。 理解这棵树,你就能预测哪些 Bean 会出现,哪些不会。 反直觉判断:条件评估结果可能因 classpath 顺序而变,因为类加载顺序影响 @ConditionalOnClass 的判断。 ## 用户 Bean 是最高优先级 ```text 用户 Bean → @ConditionalOnMissingBean → Boot 回退 ``` 当你定义了一个 Bean,Boot 默认尊重你的选择,不再创建同类型 Bean。 这是约定优于配置的体现,但也意味着你必须有足够能力保证 Bean 正确。 反直觉判断:@ConditionalOnMissingBean 只检查当前容器,不检查父容器,所以在多上下文环境下可能失效。 ## 条件报告是排障第一工具 ```text --debug → 报告 → matched/unmatched → 根因 ``` 遇到自动配置问题,第一件事就是看条件报告,而不是猜。 报告会告诉你每个条件为什么匹配或不匹配,直接定位根因。 ## 自动配置是工程决策的自动化 ```text 依赖 → 默认决策 → 可覆盖 → 可扩展 ``` Boot 最核心的能力,是根据 classpath、配置和用户 Bean 自动做默认工程决策。 这大大降低了集成成本,但也要求你理解决策规则。 > 条件树中藏玄机, > 用户 Bean 定乾坤。 > 报告一出真相显, > 决策自动化成真。 # 10、系统落地 ```text 入口:PetclinicApplication → 服务:OwnerController → 本模块:自动配置 → 存储:H2 → 未实现:自定义 Starter ``` Petclinic 原本只有 JPA 自动配置,现在增加了 Web 自动配置,并验证了自定义 Bean 的回退。 原来能做什么:启动后自动配置数据源和 JPA,仓库可用。 本篇增加什么:理解自动配置的匹配与回退,能通过 --debug 报告排障。 现在能做什么:当自动配置异常时,能快速定位是哪个条件不满足,并决定是补配置还是自定义 Bean。 下一篇缺什么:如何自定义一个自动配置模块,让团队内部组件也能享受 Starter 的便利。 官方文档:https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.developin g-auto-configuration > 自动配置已掌握, > 排障能力渐增强。 > 自定义模块待开发, > 团队复用更顺畅。 # 11、小结 ```text 三问三答:为什么多 Bean?因为自动配置按条件批量注册;冲突时谁决定?用户 Bean 优先;选错后果?启动失败或静默失效 ``` 第一问:为什么加一个 Starter 就多几十个 Bean?因为 Boot 根据 classpath 加载所有匹配的 AutoConfiguration,每个配置类可能注册多个 Bean。 第二问:当自动配置和用户 Bean 冲突时,Boot 如何选择?通过 @ConditionalOnMissingBean,用户 Bean 优先,Boot 自动回退。 第三问:如果不懂这些规则,选错后果是什么?轻则启动失败,重则功能静默失效,排查耗时数小时。 当前状态:Petclinic 已能正常启动,数据源和 JPA 工作正常,我掌握了用条件报告排障的方法。 > 三问三答心明了, > 自动配置不再玄。 > 条件报告手中剑, > 排障效率翻倍升。 # 12、作业 ```text 理解→实战→排障→架构判断 ``` ## 理解题:为什么 @ConditionalOnMissingBean 能防止自动配置覆盖用户 Bean? 因为条件评估发生在 Bean 注册前,如果容器中已有同类型 Bean,条件不满足,自动配置的 @Bean 方法就不会执行。 ## 实战题:在 Petclinic 中自定义一个 RestTemplate Bean,观察自动配置是否回退。 定义 Bean 后,启动应用,用 --debug 查看 RestTemplateAutoConfiguration 的报告,会发现其 @ConditionalOnMissingBean 条件显示 fo und beans。 ## 排障题:如果自动配置报告显示 DataSourceAutoConfiguration unmatched,你会如何排查? 先看 unmatched 的条件是什么,如果是 @ConditionalOnClass,检查 classpath 是否缺少对应类;如果是 @ConditionalOnProperty,检查配置属性。 ## 架构判断题:自动配置机制是否适合所有场景?请给出你的判断。 不适合。对于需要精细控制或非标准场景,自动配置可能成为黑盒,此时应手动配置或排除自动配置。 > 作业四题练真功, > 理解实战加排障。 > 架构判断需谨慎, > 自动配置非万能。 # 13、升华 ```text 工程结论:自动配置是 Boot 的默认决策引擎,理解条件评估是驾驭它的关键 ``` 通过这次实战,我深刻体会到:Spring Boot 的自动配置不是魔法,而是一套基于条件的决策框架。 它把常见的工程决策(如数据源类型、JSON 解析器、JPA 实现)自动化,让开发者专注于业务。 但自动化也意味着你需要理解决策规则,否则一旦条件不满足,你只能面对黑盒。 掌握 --debug 报告和条件注解,你就能从被动排障转为主动设计。 下一篇,我们将深入自定义自动配置,打造自己的 Starter,让团队组件也能享受这种便利。 > 自动配置决策引擎, > 条件评估定输赢。 > 理解规则方驾驭, > 工程智慧永流传。 **系列项目:** Spring Petclinic,一个经典的宠物诊所管理系统,用于演示 Spring Boot 生产级实践。 **已完成:** 环境搭建、启动机制、依赖与自动配置的初步理解。 **本篇:** 深入自动配置条件评估,掌握 --debug 报告排障,理解用户 Bean 回退机制。 **下一篇:** 自定义自动配置与 Starter 开发,让团队组件也能即插即用。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第12招 远程HTTP怎么调用

# Petclinic 宠物保险报价:RestClient 超时、异常映射与故障传播的工程实战 ```text 单体自足 → 首个远程依赖 → 线程阻塞与故障蔓延 ``` Petclinic 一直是个自给自足的单体应用,自己读写数据库,自己渲染页面,所有故障都局限在进程内部。 我是老李,负责把官方教学项目演进成生产级系统。今天要给 Petclinic 增加宠物保险报价服务,第一次引入远程 HTTP 调用。 为什么系统一旦拥有第一个远程依赖,就开始需要认真考虑 Timeout、线程占用和故障传播? 选择同步 RestClient 还是响应式 WebClient,依据是什么? 选错超时策略或选错调用模型,会带来哪些线上事故级别的后果? # 01、故事 ```text 周四 14:20 → 深圳科兴科学园 3 栋 11 层 → 任务是接保险报价 → 产品经理站在身后催 ``` 周四下午两点二十分,深圳科兴科学园 3 栋 11 层,窗外是灰蒙蒙的天。 任务是给 Petclinic 的宠物详情页接一个保险报价卡片,数据来自外部保险公司的 HTTP 接口。 产品经理老周站在我身后,手指敲着工位隔板:下午四点要和保险公司联调,你这边到底能不能通? 我打开 Petclinic 的代码仓库,发现项目里连一个 RestTemplate 都没用过,更别说 RestClient。 搜索框里敲下 RestClient,官方文档说它是 RestTemplate 的现代替代品,但我连它的 Builder 长什么样都没见过。 老周又催了一句:别查了,先跑通一个 Hello World 行不行? > 远程调用初登场, > 超时线程两茫茫。 > 故障传播如潮水, > 一腔孤勇对苍黄。 # 02、问题 ```text 需求变化:从本地数据到远程报价 → 冲突:外部服务不可控 → 后果:线程耗尽与雪崩 ``` Petclinic 原本所有数据都来自本地数据库,事务边界清晰,故障范围可控。 引入保险报价后,一次页面请求变成两次网络往返,Petclinic 的线程池开始被外部服务的响应时间绑架。 如果保险公司接口响应慢,Tomcat 的工作线程就会一直占着等响应,积累到一定程度,整个 Petclinic 的线程池被耗尽,连本地数据库查询都进不来。 更隐蔽的是,如果保险公司接口返回 500 或一段无法解析的 JSON,RestClient 默认抛出的异常会让 Petclinic 的全局异常处理器措手不及。 用户看到的不是友好的降级提示,而是 Whitelabel Error Page,甚至连接被重置。 可验证的验收标准是:保险公司接口 5 秒不响应,Petclinic 页面必须在 2 秒内返回降级内容,且 Tomcat 活跃线程数不持续增长。 > 远程一呼百应难, > 线程池中暗波澜。 > 超时未设终成患, > 雪崩来时悔已晚。 # 03、原理 ```text 同步 HTTP 调用的核心机制:线程占用、超时控制、异常映射 ``` RestClient 是同步阻塞模型,发请求的线程会一直等到响应或超时,期间该线程不能做任何其他事。 Tomcat 默认工作线程池是 200,每个线程同时只能处理一个请求,这是理解故障传播的起点。 Connection Timeout 是建立 TCP 连接的最大等待时间,Read Timeout 是连接建立后等待响应体的最大时间,两者独立配置,缺一不可。 反直觉判断一:Read Timeout 不是越大越好,它决定了单个线程被占用的最长时间,也决定了系统在故障时的恢复速度。 反直觉判断二:RestClient 抛出的 ResourceAccessException 和 HttpClientErrorException 需要分别处理,前者是网络层故障,后者是业务层故障,混为 一谈会让降级策略失效。 异常映射的本质是把远程调用的三类失败——连接失败、超时、非 2xx 响应——翻译成 Petclinic 领域内可理解的业务异常。 > 线程阻塞似枷锁, > 超时设置定死活。 > 异常映射分层次, > 降级兜底不背锅。 # 04、架构 ```text Petclinic Controller → InsuranceQuoteService → RestClient → 保险公司 HTTP API ``` 这一层架构的核心是引入 InsuranceQuoteClient 作为防腐层,隔离外部系统的变化。 Controller 只依赖 InsuranceQuoteService 接口,Service 内部持有 InsuranceQuoteClient,Client 封装 RestClient 的所有细节 。 边界在于:Petclinic 内部永远不出现 RestClient 的异常类型,Client 层负责把远程异常翻译成 InsuranceQuoteUnavailableException。 收益是 Petclinic 的 Controller 和 Service 层完全不知道远程调用的存在,替换保险公司或切换调用模型都不影响上层。 代价是多了一层类和一个 DTO 映射,对于只有两三个接口的集成场景,这个成本可以接受。 适用条件是远程依赖数量少但不可控,需要统一降级策略的场景。 ```text +----------------+ +---------------------+ +------------------+ +------------------+ | Petclinic | --> | InsuranceQuote | --> | InsuranceQuote | --> | 保险公司 | | Controller | | Service | | Client | | HTTP API | +----------------+ +---------------------+ +------------------+ +------------------+ | 超时/异常映射 v +------------------+ | 降级策略 | | 返回友好提示 | +------------------+ ``` > 防腐层中隔内外, > 异常翻译不越界。 > 降级策略藏深处, > 架构清晰福自来。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + Java 17 → 配置:Base URL 与超时 → 代码:Client 与 Service → 启动 → 验证 → V1 ``` 当前 Petclinic 基于 Spring Boot 3.2,Java 17,需按当前官方文档核验 RestClient 的包路径。 第一步,在 pom.xml 增加依赖,Spring Web 已包含 RestClient,无需额外引入。 ```xml org.springframework.boot spring-boot-starter-web ``` 第二步,在 application.properties 配置保险报价服务的 Base URL 和超时时间。 ```properties insurance.quote.base-url=http://localhost:9090 insurance.quote.connection-timeout=1000 insurance.quote.read-timeout=2000 ``` 第三步,创建 InsuranceQuoteClient,使用 RestClient.Builder 构建。 ```java @Component public class InsuranceQuoteClient { private final RestClient restClient; public InsuranceQuoteClient(RestClient.Builder builder, @Value("${insurance.quote.base-url}") String baseUrl, @Value("${insurance.quote.connection-timeout}") int connectTimeout, @Value("${insurance.quote.read-timeout}") int readTimeout) { this.restClient = builder .baseUrl(baseUrl) .requestFactory(new ClientHttpRequestFactorySettings() .withConnectTimeout(Duration.ofMillis(connectTimeout)) .withReadTimeout(Duration.ofMillis(readTimeout))) .build(); } public InsuranceQuote fetchQuote(Long petId) { return restClient.get() .uri("/quotes/{petId}", petId) .retrieve() .body(InsuranceQuote.class); } } ``` 第四步,创建 InsuranceQuoteService,处理降级逻辑。 ```java @Service public class InsuranceQuoteService { private final InsuranceQuoteClient client; public InsuranceQuoteService(InsuranceQuoteClient client) { this.client = client; } public InsuranceQuote getQuote(Long petId) { try { return client.fetchQuote(petId); } catch (ResourceAccessException e) { return InsuranceQuote.unavailable("报价服务暂时不可用"); } catch (HttpClientErrorException | HttpServerErrorException e) { return InsuranceQuote.unavailable("报价服务返回错误"); } } } ``` 第五步,启动 Petclinic,访问宠物详情页,观察报价卡片是否显示。 ```bash mvn spring-boot:run curl http://localhost:8080/owners/1/pets/1 ``` 未在当前环境实测,以下为预期结果:保险公司接口正常时,页面显示报价金额;接口不可达时,页面显示降级文案。 示例输出: ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" ``` V1 成功标准:本地启动无报错,正常路径能拿到报价,接口关闭时页面不白屏。 > 第一版中代码忙, > 超时配置两行藏。 > 降级逻辑初成形, > 页面不再白茫茫。 # 06、排查 ```text 诊断树:慢响应、HTTP 500、错误 JSON、网络断开 ``` 问题一:保险公司接口模拟慢响应 5 秒,Petclinic 页面卡住 5 秒才返回。 现象:页面转圈 5 秒,用户流失。 怀疑:Read Timeout 配置没生效。 检查:查看启动日志中 RestClient 的配置。 ```bash curl -X POST http://localhost:9090/slow-quote -d '{"delay":5000}' ``` 证据:请求发出后,Tomcat 线程被占用 5 秒,Read Timeout 设为 2000ms 却未触发。 根因:ClientHttpRequestFactorySettings 的配置方式在 Spring Boot 3.2 中需要配合 JdkClientHttpRequestFactory 使用,默认的 SimpleClientHttpRequestFactory 不读取该设置。 修复:显式指定 RequestFactory。 ```java this.restClient = builder .baseUrl(baseUrl) .requestFactory(new JdkClientHttpRequestFactory( new ClientHttpRequestFactorySettings() .withConnectTimeout(Duration.ofMillis(connectTimeout)) .withReadTimeout(Duration.ofMillis(readTimeout)))) .build(); ``` 问题二:保险公司接口返回 HTTP 500,Petclinic 抛出 HttpServerErrorException,页面显示 500 错误。 现象:用户看到 Whitelabel Error Page。 怀疑:Service 层没有捕获 HttpServerErrorException。 检查:查看 Petclinic 日志中的异常堆栈。 ```bash tail -f logs/petclinic.log | grep HttpServerErrorException ``` 证据:日志显示 HttpServerErrorException 未被捕获,直接抛到全局异常处理器。 根因:Service 层只捕获了 ResourceAccessException,遗漏了 HttpServerErrorException。 修复:在 Service 层增加对 HttpServerErrorException 的捕获。 **错误尝试:** 最初尝试在 Controller 层用 @ExceptionHandler 捕获所有 RestClient 异常,结果导致 Controller 层与远程调用细节耦合,后续替换保险 公司时改动面过大,回滚重写。 > 慢响应来线程占, > 超时未生效心寒。 > 五百错误漏捕获, > 层层排查见真章。 # 07、优化 ```text V1 证据:超时未生效、500 未捕获 → V2:显式 RequestFactory + 统一异常映射 + 调用日志 ``` V1 暴露了两个问题:超时配置未生效,异常捕获不完整。 V2 的改动集中在 InsuranceQuoteClient 内部,对外接口不变。 第一,显式指定 JdkClientHttpRequestFactory,确保超时生效。 第二,引入 RestClient 的异常映射器,把非 2xx 响应统一转换为自定义异常。 ```java this.restClient = builder .baseUrl(baseUrl) .requestFactory(new JdkClientHttpRequestFactory( new ClientHttpRequestFactorySettings() .withConnectTimeout(Duration.ofMillis(connectTimeout)) .withReadTimeout(Duration.ofMillis(readTimeout)))) .defaultStatusHandler( HttpStatusCode::isError, (request, response) -> { throw new InsuranceQuoteRemoteException( "保险公司接口返回错误: " + response.getStatusCode()); }) .build(); ``` 第三,在 Client 层增加调用日志,记录请求 URL、耗时和状态。 ```java public InsuranceQuote fetchQuote(Long petId) { long start = System.currentTimeMillis(); try { InsuranceQuote quote = restClient.get() .uri("/quotes/{petId}", petId) .retrieve() .body(InsuranceQuote.class); log.info("Fetch quote for pet {} succeeded in {} ms", petId, System.currentTimeMillis() - start); return quote; } catch (Exception e) { log.warn("Fetch quote for pet {} failed in {} ms: {}", petId, System.currentTimeMillis() - start, e.getMessage()); throw e; } } ``` Service 层只需捕获 InsuranceQuoteRemoteException 和 ResourceAccessException 两种类型。 > 超时配置重调整, > 异常映射一网擒。 > 调用日志留痕迹, > 故障排查有迹寻。 # 08、演进 ```text V2 能力:超时可控、异常可映射、日志可观测 → 遗留:错误 JSON 解析与线程池隔离 ``` V2 之后,用 V1 同一组请求验证效果。 慢响应请求:保险公司接口延迟 5 秒,Petclinic 在 2 秒时触发 Read Timeout,返回降级文案。 ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" 耗时:2003ms ``` HTTP 500 请求:保险公司接口返回 500,Petclinic 捕获 InsuranceQuoteRemoteException,返回降级文案。 ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" 耗时:45ms ``` 网络断开请求:关闭保险公司服务,Petclinic 在 1 秒时触发 Connection Timeout,返回降级文案。 ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" 耗时:1002ms ``` 收益是 Petclinic 的页面不再被远程故障拖垮,降级策略统一且可观测。 代价是引入了自定义异常类型和日志埋点,代码量略有增加。 遗留问题是错误 JSON 的解析:如果保险公司返回 200 但 JSON 结构不符合预期,Jackson 抛出的异常尚未被捕获。 另一个遗留是线程池隔离:目前所有远程调用共享 Tomcat 工作线程,极端情况下仍可能互相影响。 > 同一请求再验证, > 超时降级两相宜。 > 五百断网皆可挡, > 遗留问题待后续。 # 09、洞见 ```text 从现象到判断:远程依赖改变了故障模型 ``` ## 超时是系统恢复能力的上限 ```text 故障恢复时间 = 超时时间 + 降级处理时间 ``` 超时时间决定了系统在故障状态下能多快恢复,它不是越大越好,而是要与业务容忍度匹配。 ## 反直觉判断:同步阻塞不是原罪,失控的同步阻塞才是 ```text 同步调用 + 合理超时 = 可控的阻塞 同步调用 + 无超时 = 线程池耗尽 ``` Tomcat 200 个线程,每个线程等 30 秒,系统在 6 秒内就失去响应能力。 ## 反直觉判断:异常类型是故障分类的钥匙 ```text ResourceAccessException = 网络层故障 HttpServerErrorException = 服务端故障 HttpClientErrorException = 客户端故障 ``` 把三类异常混在一起捕获,等于放弃了精确的降级策略。 ## 故障传播的根源是共享资源 ```text 远程调用占用线程 → 线程耗尽 → 本地请求被拒 ``` 第一个远程依赖出现时,Petclinic 的故障模型从单点变成了链式传播。 > 远程依赖改模型, > 超时恢复定上限。 > 异常分类是钥匙, > 共享资源酿祸端。 # 10、系统落地 ```text 入口:宠物详情页 → 服务:InsuranceQuoteService → 本模块:RestClient 调用层 → 存储:无 → 未实现:错误 JSON 处理 ``` Petclinic 原本只有本地数据库访问,所有查询都在进程内完成,故障范围清晰。 本篇增加了保险报价调用层,包括 Base URL 配置、超时控制、异常映射和调用日志。 现在 Petclinic 能优雅地处理远程服务的慢响应、HTTP 500 和网络断开,页面始终返回友好内容。 下一篇缺的是错误 JSON 的解析容错,以及更细粒度的线程池隔离方案。 官方文档首页:https://docs.spring.io/spring-boot/index.html > 本地数据本无忧, > 远程调用添新愁。 > 超时异常皆可挡, > 错误 JSON 待回头。 # 11、小结 ```text 三问三答:超时、选型、后果 ``` 第一个问题:为什么第一个远程依赖就要考虑超时、线程和故障传播? 因为远程调用把 Petclinic 的线程从本地计算资源变成了外部服务的等待队列,外部服务的不可控性直接转化为内部线程的失控占用。 第二个问题:同步 RestClient 还是响应式 WebClient? Petclinic 是典型的 MVC 应用,请求量可控,同步模型配合合理超时足够应对,引入 WebClient 意味着全链路响应式改造,收益远小于成本。 第三个问题:选错超时或选错模型的后果是什么? 选错超时导致线程池耗尽,系统雪崩;选错模型导致架构复杂度飙升,团队维护成本翻倍。 当前状态:Petclinic 已具备基本的远程调用容错能力,降级策略统一,日志可观测。 > 远程依赖需敬畏, > 超时线程两相随。 > 同步够用不盲从, > 故障传播有壁垒。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:Connection Timeout 和 Read Timeout 的区别是什么? Connection Timeout 是建立 TCP 连接的最大等待时间,Read Timeout 是连接建立后等待响应数据的最大时间。前者在网络不可达时触发,后者在服务端接受连接但不返回数据时触发。 ## 实战题:给 InsuranceQuoteClient 增加错误 JSON 的容错处理 在 fetchQuote 方法中捕获 RestClientResponseException 和 ResourceAccessException,对 JSON 解析错误返回默认报价对象。 ```java public InsuranceQuote fetchQuote(Long petId) { try { return restClient.get() .uri("/quotes/{petId}", petId) .retrieve() .body(InsuranceQuote.class); } catch (RestClientResponseException e) { log.warn("Remote error for pet {}: {}", petId, e.getStatusCode()); return InsuranceQuote.unavailable("报价服务返回错误"); } catch (ResourceAccessException e) { log.warn("Network error for pet {}: {}", petId, e.getMessage()); return InsuranceQuote.unavailable("报价服务暂时不可用"); } } ``` ## 排障题:保险公司接口返回 200 但 JSON 字段缺失,页面报错,如何排查? 先看 Petclinic 日志中是否有 HttpMessageNotReadableException,确认是 Jackson 反序列化失败。 再检查 InsuranceQuote 类的字段注解,确认是否有 @JsonProperty 映射缺失。 最后在 Client 层增加对 HttpMessageNotReadableException 的捕获,返回降级对象。 ## 架构判断题:什么场景下 Petclinic 才需要从 RestClient 切换到 WebClient? 当 Petclinic 需要同时发起多个远程调用且要求并发聚合时,或者需要流式处理响应时,才考虑 WebClient。 如果只是单个同步调用,WebClient 的响应式编程模型会让代码复杂度显著上升,收益不明显。 > 作业四题层层深, > 理解实战两不分。 > 排障架构皆历练, > 功夫到家自然成。 # 13、升华 ```text 工程结论:第一个远程依赖是架构分水岭 ``` Petclinic 从单体自足到引入第一个远程依赖,表面上是加了一个 HTTP 调用,实际上是故障模型的根本转变。 超时不是配置项,而是系统恢复能力的上限;异常映射不是代码技巧,而是故障分类的领域语言;线程占用不是性能指标,而是故障传播的物理通道。 同步 RestClient 配合合理超时,是 MVC 应用应对远程依赖的默认选择,WebClient 是特定场景的升级选项,不是技术时髦的装饰品。 下一篇将深入错误 JSON 的容错处理和线程池隔离,让 Petclinic 的远程调用层更加健壮。 > 首个依赖是分水, > 故障模型从此变。 > 超时异常线程占, > 三管齐下保平安。 **系列项目:** 以 Spring 官方 Petclinic 为唯一贯穿项目,24 篇持续演进同一个代码仓库,覆盖 Spring Boot 4 生产级应用全生命周期。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层与安全机制、数据访问与事务管理。 **本篇:** 宠物保险报价服务的 RestClient 调用层,含 Base URL、超时控制、异常映射与调用日志。 **下一篇:** 错误 JSON 容错处理与远程调用线程池隔离方案。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第16招 日志要能还原现场

# 日志不是越多越好:用 MDC 让 Petclinic 一次请求从 Controller 到 Redis 全程可追踪 ```text 旧方案:默认 Logback,日志只有时间、级别、消息 → 冲突:线上偶发 500,日志里全是孤立片段,无法串联一次请求 → 后果:排障靠猜,定位以小时计 ``` 我是老李,负责把 Spring 官方 Petclinic 演进成生产级应用。今天要解决的是日志可追踪性问题。 线上出现偶发 500,如果没有请求上下文,再多日志是不是也只是更多噪声? 是给每个类加日志,还是设计一套贯穿请求的标识体系? 选错后果是什么?日志泄露敏感信息,或排障时面对海量碎片束手无策。 # 01、故事 ```text 凌晨 1:30 → 深圳某银行机房 → 任务是排查 Petclinic 偶发 500 → 冲突:日志刷屏却找不到同一请求的完整路径 ``` 凌晨 1:30,深圳某银行机房,空调嗡嗡作响。 任务是排查 Petclinic 线上偶发 500,用户报告“保存宠物时偶尔失败”。 我打开日志平台,搜索“ERROR”,瞬间刷出几百条,但每条都是孤立的。 没有 requestId,没有调用链,只有零散的方法名和异常堆栈。 我试着按时间戳拼凑,发现同一时刻有多个请求并发,根本无法区分谁是谁。 那一刻我意识到:日志不是越多越好,没有上下文的日志只是噪声。 > 日志如海量碎片, > 请求无痕难追寻。 > 偶发故障藏深处, > 唯有标识定乾坤。 # 02、问题 ```text 需求变化:从单机调试到分布式排障 → 冲突:默认 Logback 无请求上下文 → 后果:偶发 500 无法定位,日志成噪声 ``` Petclinic 默认使用 Logback,但配置极简,只有控制台输出,没有文件、没有滚动策略、没有格式定制。 更关键的是,日志中没有请求标识,一次 HTTP 请求经过 Controller、Service、Repository,产生的日志无法关联。 当线上出现偶发 500,比如数据库连接池超时或 Redis 抖动,你只能看到“Internal Server Error”,却不知道是哪个用户、哪个操作、经过了哪些服务。 影响是排障效率极低,甚至无法复现。可验证的验收标准是:一次请求的所有日志都带有相同 requestId,且能通过 requestId 过滤出完整调用链。 > 日志无标识, > 排障如盲行。 > 偶发五百错, > 何处觅真凶。 # 03、原理 ```text 核心机制:Logger Level 控制输出量,Appender 决定目的地,MDC 提供线程级上下文,Pattern 定义格式 ``` Logback 三大组件:Logger、Appender、Layout。Logger 负责分级,Appender 负责输出,Layout 负责格式化。 Logger Level 从 TRACE 到 ERROR,级别越低输出越多。生产环境通常设为 INFO,避免 DEBUG 刷屏。 MDC(Mapped Diagnostic Context)是 Logback 提供的线程级上下文,用 Map 存储键值对,Pattern 中通过 %X{key} 输出。 反直觉判断:MDC 不是自动填充的,需要你在入口处手动 put,在出口处 remove,否则线程池复用会导致上下文串扰。 另一个反直觉判断:日志级别不是越高越好,ERROR 太多会掩盖真正的问题,合理使用 WARN 和 INFO 才能平衡可观测性与噪声。 > 级别定多少, > 输出有门道。 > MDC 藏上下文, > 线程需自保。 # 04、架构 ```text 输入:HTTP 请求 → 本模块:Filter 生成 requestId 并写入 MDC → 输出:结构化日志,含 requestId、service、method、耗时 ``` 日志架构分三层:采集层(Logback 配置)、上下文层(MDC 填充)、输出层(结构化格式)。 采集层定义 Appender 和 Pattern,上下文层在 Filter 中生成 requestId,输出层将 MDC 值嵌入日志。 边界:MDC 只存在于当前线程,跨线程需手动传递;敏感信息如 password、token 绝不能写入 MDC 或日志。 收益是排障效率提升,代价是每个请求多一次 UUID 生成和 MDC 操作,但开销可忽略。 适用条件是所有请求都经过统一入口,比如 Spring MVC 的 Filter 或拦截器。 > 请求入网关, > 标识即生成。 > 日志带上下文, > 排障如神助。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + Logback 1.4 → 配置:logback-spring.xml → 代码:MDC Filter → 启动:验证 requestId 贯穿 ``` 先看 Petclinic 默认的 logback 配置,只有控制台输出,没有文件。 我们创建 `src/main/resources/logback-spring.xml`,配置控制台和文件两个 Appender,并加入 requestId 模式。 ```xml ${LOG_PATTERN} logs/petclinic.log logs/petclinic.%d{yyyy-MM-dd}.log 30 ${LOG_PATTERN} ``` 然后写一个 Filter,在请求入口生成 requestId 并放入 MDC。 ```java @Component public class RequestIdFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String requestId = UUID.randomUUID().toString().replace("-", ""); MDC.put("requestId", requestId); try { chain.doFilter(request, response); } finally { MDC.remove("requestId"); } } } ``` 启动 Petclinic,访问 `http://localhost:8080/owners/new`,控制台输出如下(示例输出): ```text 2025-01-15 10:23:45.123 INFO [http-nio-8080-exec-1] [a1b2c3d4e5f6] o.s.s.p.web.OwnerController - Creating new owner 2025-01-15 10:23:45.456 INFO [http-nio-8080-exec-1] [a1b2c3d4e5f6] o.s.s.p.service.OwnerService - Saving owner: John 2025-01-15 10:23:45.789 INFO [http-nio-8080-exec-1] [a1b2c3d4e5f6] o.s.s.p.repository.OwnerRepository - Insert owner into DB ``` 成功标准:同一请求的所有日志都带相同 requestId,且能通过 requestId 过滤。 未在当前环境实测,以上为预期结果。 > 配置加代码, > 标识入日志。 > 请求有踪迹, > 排障第一步。 # 06、排查 ```text 诊断树:问题1:MDC 值丢失 → 问题2:线程池复用导致串扰 → 问题3:敏感信息泄露 ``` **问题1:MDC 值丢失** 现象:部分日志没有 requestId。 怀疑:Filter 未生效或 MDC 被清除。 检查:查看 Filter 是否被 Spring 扫描,以及是否在异步线程中丢失。 证据:日志中部分行有 requestId,部分没有。 根因:Petclinic 使用 `@Async` 发送邮件,异步线程不继承 MDC。 修复:在异步任务中手动传递 MDC,或使用 `TaskDecorator`。 ```java @Bean public TaskDecorator mdcTaskDecorator() { return task -> { Map contextMap = MDC.getCopyOfContextMap(); return () -> { MDC.setContextMap(contextMap); try { task.run(); } finally { MDC.clear(); } }; }; } ``` **问题2:线程池复用导致串扰** 现象:不同请求的日志出现相同 requestId。 怀疑:MDC 未在 finally 中清除。 检查:查看 Filter 代码,确认是否在 finally 中 remove。 证据:两个不同用户的请求日志中 requestId 相同。 根因:线程池复用线程,MDC 残留导致串扰。 修复:确保在 finally 中 remove,并在线程池任务中清理。 **错误尝试:** 一开始我尝试在 Controller 中手动 put requestId,但发现 Service 层日志没有,因为 Controller 方法执行完后 MDC 被清除。 后来才意识到必须在 Filter 中统一处理。 > 排查如剥笋, > 层层见真因。 > 线程与异步, > 皆需谨慎行。 # 07、优化 ```text V1:只有 requestId → 证据:异步线程丢失、敏感信息风险 → V2:结构化日志 + 敏感信息过滤 ``` V1 实现了 requestId 贯穿,但日志仍是文本格式,难以机器解析。 而且,日志中可能包含用户输入,比如宠物名字,如果用户输入了密码或证件号,就会泄露。 V2 改为 JSON 结构化日志,并添加敏感信息过滤。 首先,在 logback-spring.xml 中配置 JSON 编码器,需要引入依赖。 ```xml net.logstash.logback logstash-logback-encoder 7.4 ``` 然后修改 Appender 的 encoder: ```xml true {"app":"petclinic"} ``` 同时,在代码中避免记录敏感字段,比如在 Owner 实体中,不打印 password 字段。 ```java public class Owner { private String password; // 禁止在日志中输出 @Override public String toString() { return "Owner{id=" + id + ", name='" + name + "'}"; // 不包含 password } } ``` > 文本转 JSON, > 结构更清晰。 > 敏感信息藏, > 安全有保障。 # 08、演进 ```text V2:结构化日志 + 敏感过滤 → 能力:可机器解析、可追踪 → 遗留:跨服务 TraceId 未实现 ``` V2 后,日志变成 JSON 格式,包含 requestId、timestamp、level、logger、message 等字段。 用同一组请求验证:访问 `/owners/new`,日志输出如下(示例输出): ```json {"timestamp":"2025-01-15T10:23:45.123Z","level":"INFO","thread":"http-nio-8080-exec-1","requestId":"a1b2c3d4","logger":"o.s.s.p.web.OwnerController","message":"Creating new owner"} {"timestamp":"2025-01-15T10:23:45.456Z","level":"INFO","thread":"http-nio-8080-exec-1","requestId":"a1b2c3d4","logger":"o.s.s.p.service.OwnerService","message":"Saving owner: John"} ``` 现在可以通过 requestId 在日志平台中一键过滤,排障效率大幅提升。 但遗留问题是:如果 Petclinic 调用远程 HTTP 服务,比如调用外部 API,requestId 无法传递到下游,需要引入 TraceId 机制。 收益是日志可读性和可搜索性增强,代价是 JSON 格式比文本略大,但可接受。 > 日志结构化, > 检索如飞驰。 > 跨服仍缺链, > 留待下回解。 # 09、洞见 ```text 从现象到判断:日志不是越多越好,而是越有上下文越好 ``` ## 日志级别是成本控制,不是越多越好 ```text 级别越高,输出越少,但信息越关键 ``` 生产环境用 INFO,DEBUG 只用于开发。如果线上开 DEBUG,日志量会爆炸,反而掩盖问题。 ## MDC 是线程级,不是请求级 ```text 线程池复用会导致 MDC 串扰 ``` 反直觉判断:MDC 不会自动清理,必须在 finally 中 remove,否则下一个请求会读到上一个请求的 requestId。 ## 敏感信息是日志的雷区 ```text password、token、cookie、证件号绝不能进日志 ``` 反直觉判断:即使你不在代码中打印,异常堆栈也可能包含参数值,比如 SQL 异常会打印完整 SQL,其中可能含用户输入。 ## 结构化日志是机器可读的基础 ```text JSON 格式让日志平台能自动索引和聚合 ``` 文本日志只能靠人眼搜索,JSON 日志可以按字段过滤,是构建可观测性的前提。 > 日志非越多, > 上下文为贵。 > 级别需权衡, > 安全不可废。 # 10、系统落地 ```text 入口:HTTP 请求 → 服务:Petclinic 应用 → 本模块:日志系统 → 存储:日志文件 → 未实现:跨服务 TraceId ``` 原来 Petclinic 只有默认 Logback,无文件输出,无 requestId。 本篇增加了 logback-spring.xml 配置、RequestIdFilter、结构化日志、敏感信息过滤。 现在能做什么:一次请求从 Controller 到 Repository 的日志都带 requestId,可通过日志平台快速定位。 下一篇缺什么:当 Petclinic 调用远程 HTTP 服务时,requestId 无法传递,需要引入 TraceId 和跨服务传播。 官方文档:Spring Boot Logging 文档(https://docs.spring.io/spring-boot/docs/current/reference/html/features. html#features.logging) > 日志系统立, > 请求有标识。 > 跨服待完善, > 演进不停息。 # 11、小结 ```text 三问三答:冲突是日志无上下文,选择是 MDC 方案,选错后果是排障低效或泄露 ``` 冲突问:线上偶发 500,没有请求上下文,日志再多也是噪声?答:是的,没有 requestId 的日志无法串联,排障如大海捞针。 选择问:是加日志还是设计标识体系?答:设计标识体系,用 MDC 贯穿请求,比盲目加日志更有效。 选错后果问:选错会怎样?答:要么日志泄露敏感信息,要么排障时面对海量碎片束手无策。 当前状态:Petclinic 已具备请求级日志追踪能力,日志结构化且安全。 > 三问已作答, > 日志有章法。 > 请求可追踪, > 排障不再怕。 # 12、作业 ```text 理解→实战→排障→架构判断 ``` ## 理解题:为什么 MDC 需要手动清理? 因为 MDC 是线程级变量,线程池复用线程时,如果不清理,下一个任务会读到上一个任务的上下文,导致日志串扰。 ## 实战题:在 Petclinic 中为异步邮件发送添加 MDC 传递 使用 TaskDecorator 在提交任务时复制 MDC,并在任务执行后清理。 ## 排障题:日志中出现其他请求的 requestId,如何排查? 检查 Filter 是否在 finally 中 remove,检查异步线程是否继承 MDC,检查线程池配置。 ## 架构判断题:是否所有日志都应记录 requestId? 不是。启动日志、定时任务日志等非请求上下文日志不应强制带 requestId,否则会误导排障。 > 作业四题明, > 实践出真知。 > 日志有边界, > 架构需权衡。 # 13、升华 ```text 工程结论:日志可观测性的核心是上下文,而非数量 ``` 通过 MDC 和 requestId,Petclinic 的日志从噪声变成了可追踪的证据。 但单机日志只是起点,下一篇将引入远程调用追踪,让 requestId 跨服务传播,形成完整的 TraceId 链路。 > 日志有上下文, > 排障如神助。 > 跨服待扩展, > 可观测更进。 **系列项目:** Spring 官方 Petclinic,一个宠物诊所管理系统,服务对象是诊所员工和客户。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层开发、安全认证、数据访问、事务管理。 **本篇:** 日志系统升级,实现请求级 MDC 追踪、结构化日志和敏感信息过滤。 **下一篇:** 远程调用追踪,引入 TraceId 跨服务传播,让日志链路贯穿分布式系统。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第19招 DockerCompose改善开发体验

# Petclinic 开发环境工程化:用 Compose 消灭“我的机器能运行” ```text 旧方案:每个开发者本地分别安装 MySQL、Redis、模拟保险服务 冲突:版本不一致、端口冲突、初始化脚本漂移、新成员上手慢 后果:团队规模越大,“我的机器能运行”越昂贵,联调与排障成本指数上升 ``` Petclinic 从单体教学项目开始,逐渐接入 MySQL、Redis,还新增了一个模拟保险服务。 我是老李,最近团队从 3 个人扩到 9 个人,新同事第一天往往卡在装 MySQL、导数据、起 Redis 这些事上。 为什么团队规模变大以后,“我的机器能运行”会变成越来越昂贵的问题? 是继续让每个人手工安装组件,还是把开发依赖写成 Compose 基础设施即代码? 如果选错,会不会让新开发者 onboarding 从半天变成三天,甚至让联调环境永远无法复现? # 01、故事 ```text 周一 09:40 → 新同事工位 → 任务是跑起 Petclinic 开发环境 → 冲突:MySQL 版本不对、Redis 没装、保险服务起不来 ``` 周一早上,新同事小周入职,任务是 clone Petclinic 后把应用跑起来。 他先装 MySQL 8.4,又发现本地 3306 被旧项目占用,改端口后应用连不上。 接着装 Redis,结果测试用例要求 Redis 7,他装的是 6。 最后卡在模拟保险服务,那个服务没有安装包,只有一段 Python 脚本和一堆环境变量。 > 新机初装路九弯, > 版本参差端口残。 > 一人能跑非交付, > 团队复现始知难。 # 02、问题 ```text 需求变化:团队从 3 人扩到 9 人,开发依赖从 H2 变成 MySQL + Redis + 模拟保险服务 冲突:本地手工安装无法保证版本、端口、初始化脚本一致 后果:新成员 onboarding 超过一天,联调问题无法复现,交付节奏被拖慢 ``` 原来的 Petclinic 用 H2 内存库,开发者 clone 后直接 Run Application 就能启动。 现在为了贴近生产,数据层切到 MySQL,缓存用 Redis,还接入了模拟保险服务。 这些依赖如果继续靠口头文档和手工安装,每个人机器上的版本、配置、初始化数据都会漂移。 可验证的验收标准是:新开发者完成 git clone、启动 Docker、Run Application 三步后,应用能在 10 分钟内正常启动并通过健康检查。 > 依赖渐多如藤蔓, > 手工安装各不同。 > 一人能跑非标准, > 团队协作必成空。 # 03、原理 ```text Spring Boot Development-time Services 检测 compose.yml → 自动启动容器 → 通过 Service Connection 注入连接信息 ``` Spring Boot 3.1 起支持 Development-time Services,应用启动时如果检测到 classpath 下有 Docker Compose 模块,会读取根目录的 comp ose.yml。 它会在应用启动前自动执行 docker compose up,把 MySQL、Redis 等依赖容器拉起来。 应用停止时根据配置决定是否执行 docker compose down,这就是 Compose Lifecycle 管理。 Service Connection 会自动从容器元数据中提取主机、端口、用户名、密码,生成 ConnectionDetails,覆盖配置文件里的连接信息。 反直觉的是:compose.yml 里定义的端口映射并不是给应用用的,应用在容器网络内部直连服务名,只有你需要从宿主机调试时才需要 ports。 > 容器编排藏暗线, > 服务连接自动连。 > 端口映射非正道, > 网络内部自相牵。 # 04、架构 ```text 输入:git clone 后的源码 + Docker Desktop → 本模块:compose.yml + Spring Boot Dev Services → 输出:可运行的应用 + 一致的依赖环境 ``` 这一层位于开发环境基础设施层,不进入生产部署路径。 compose.yml 定义开发依赖:MySQL 8.4、Redis 7、模拟保险服务。 Spring Boot 通过 spring-boot-docker-compose 依赖读取 compose.yml,在应用启动前拉起容器。 Service Connection 负责把容器连接信息注入 DataSource、RedisConnectionFactory 以及自定义的保险服务客户端。 收益是环境一致性,代价是要求每个开发者安装 Docker Desktop 或等效容器运行时。 适用条件是开发与联调环境,不适用于生产 Kubernetes,因为生产需要滚动升级、资源配额、故障自愈等能力。 > 开发环境求一致, > 生产部署另有纲。 > 边界清晰方不乱, > 各司其职稳如桩。 # 05、第一版实战 ```text 环境:macOS + Docker Desktop 4.30 + JDK 21 + Spring Boot 3.3 → 配置:pom.xml + compose.yml → 代码:启动类 → 启动:Run Application → 验证:HTTP 请求 → V1 ``` 先确认版本:Spring Boot 3.3.0,JDK 21,Docker Desktop 4.30。 在 pom.xml 加入 docker-compose 依赖: ```xml org.springframework.boot spring-boot-docker-compose true ``` 在项目根目录创建 compose.yml: ```yaml services: mysql: image: mysql:8.4 environment: MYSQL_DATABASE: petclinic MYSQL_USER: petclinic MYSQL_PASSWORD: petclinic MYSQL_ROOT_PASSWORD: root ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql redis: image: redis:7.2 ports: - "6379:6379" insurance-service: image: petclinic/insurance-mock:1.0.0 environment: INSURANCE_PORT: 8081 ports: - "8081:8081" volumes: mysql-data: ``` 在 application.yml 中保留生产配置,但开发时让 Service Connection 覆盖: ```yaml spring: datasource: url: jdbc:mysql://localhost:3306/petclinic username: petclinic password: petclinic data: redis: host: localhost port: 6379 ``` 启动应用: ```bash ./mvnw spring-boot:run ``` 示例输出: ```text Using Docker Compose file 'compose.yml' Starting Docker Compose services... Container petclinic-mysql-1 started Container petclinic-redis-1 started Container petclinic-insurance-service-1 started ``` 验证请求: ```bash curl http://localhost:8080/owners curl http://localhost:8080/actuator/health ``` V1 成功标准:应用启动时自动拉起三个容器,健康检查返回 UP,owners 页面返回 200。 > 一键启动三容器, > 服务连接自动成。 > 新兵上手指日待, > 环境一致初显形。 # 06、排查 ```text 诊断树:应用启动失败 → 是容器未启动?是连接信息错误?是端口冲突?→ 逐层检查 ``` 第一个问题:应用启动时提示 Cannot connect to MySQL。 怀疑是容器没起来,检查容器状态: ```bash docker compose ps ``` 证据显示 mysql 容器处于 Restarting 状态。 查看日志: ```bash docker compose logs mysql ``` 根因是本地 3306 端口被旧 MySQL 占用,容器内 MySQL 无法绑定端口。 修复:把宿主机映射端口改为 3307,应用仍通过容器网络连接 3306。 第二个问题:Redis 连接被拒绝。 怀疑是 Service Connection 没有生效,检查启动日志: ```bash ./mvnw spring-boot:run | grep -i redis ``` 证据显示日志里没有 Redis ConnectionDetails 注入信息。 根因是 pom.xml 中 docker-compose 依赖被标记为 optional,但当前 IDE 运行配置没有包含该依赖。 修复:在 IDE 运行配置中勾选包含 optional 依赖,或改用命令行启动。 **错误尝试:** 一开始手动在 application.yml 里写死容器 IP,结果每次容器重启 IP 变化,连接又失败。 > 端口冲突寻常事, > 容器日志见真章。 > 连接注入需依赖, > 写死地址必遭殃。 # 07、优化 ```text V1:compose.yml 端口写死 3306 → 证据:宿主机端口冲突导致容器重启 → V2:宿主机端口改为 3307,应用走容器网络 ``` 把 compose.yml 中 MySQL 的宿主机端口改为 3307,避免和宿主机已有服务冲突: ```yaml services: mysql: image: mysql:8.4 environment: MYSQL_DATABASE: petclinic MYSQL_USER: petclinic MYSQL_PASSWORD: petclinic MYSQL_ROOT_PASSWORD: root ports: - "3307:3306" volumes: - mysql-data:/var/lib/mysql ``` 同时给模拟保险服务加上健康检查,确保 Service Connection 能正确等待服务就绪: ```yaml insurance-service: image: petclinic/insurance-mock:1.0.0 environment: INSURANCE_PORT: 8081 ports: - "8081:8081" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8081/health"] interval: 5s timeout: 3s retries: 10 ``` 在 application.yml 中明确关闭生产环境不需要的 Compose 生命周期管理: ```yaml spring: docker: compose: lifecycle-management: start-and-stop enabled: true ``` 这样开发时应用停止会自动执行 docker compose down,避免残留容器占用资源。 > 端口冲突改映射, > 健康检查候就绪。 > 生命周期明起止, > 残留容器不再余。 # 08、演进 ```text V2:compose.yml 端口隔离 + 健康检查 + 生命周期管理 → 能力:新开发者三步启动 → 遗留:生产环境仍需 Kubernetes ``` 用 V1 同一组请求验证: ```bash curl http://localhost:8080/owners curl http://localhost:8080/actuator/health ``` 现在新开发者流程变成: ```bash git clone https://github.com/spring-projects/spring-petclinic.git cd spring-petclinic docker compose up -d ./mvnw spring-boot:run ``` 收益是 onboarding 从一天缩短到 10 分钟,环境漂移问题消失。 代价是每个开发者必须安装 Docker Desktop,且首次拉取镜像需要网络带宽。 适用场景是开发与联调,不适用于生产,因为 Compose 缺少滚动升级、资源配额、故障自愈等生产级能力。 遗留问题是模拟保险服务的镜像还没有纳入 CI 构建流程,目前是手动构建后推送到本地镜像仓库。 > 三步启动成新标, > 环境漂移自此消。 > 生产仍需 K8s 管, > 开发生产各一条。 # 09、洞见 ```text 从“我的机器能运行”到“团队环境一致”的工程判断 ``` ## 反直觉判断:端口映射不是给应用用的 ```text compose.yml ports → 宿主机调试用 应用连接 → 容器网络服务名 ``` 很多开发者以为应用必须通过 localhost:3306 连接 MySQL,实际上 Spring Boot Dev Services 在容器网络内部直连服务名 mysql,端口映射只是为了方便你在宿主 机用数据库工具查看数据。 ## 反直觉判断:开发环境越接近生产,反而越需要自动化 ```text 依赖越多 → 手工配置越不可靠 → 自动化收益越大 ``` 当依赖只有 H2 时,手工配置成本几乎为零;当依赖变成 MySQL、Redis、模拟服务时,手工配置的出错概率和沟通成本急剧上升,自动化反而成为唯一可持续的方案。 ## 判断:Compose 是开发契约,不是部署方案 ```text compose.yml → 开发环境契约 生产部署 → Kubernetes Manifest ``` 把 compose.yml 当作团队内部的开发环境契约,它定义了依赖的版本、配置、初始化方式; 生产部署应该由独立的 Kubernetes Manifest 或 Helm Chart 管理,两者边界清晰才能避免“开发环境能跑,生产环境跑不起来”的困境。 > 端口映射非正道, > 容器网络自相连。 > 依赖越多越自动, > 开发生产各一边。 # 10、系统落地 ```text 入口:git clone + Docker Desktop → 服务:Spring Boot Dev Services → 本模块:compose.yml + Service Connection → 存储:MySQL + Redis + 模拟保险服务 → 未实现:生产 Kubernetes 部署 ``` 原来 Petclinic 只有 H2 内存库,开发者 clone 后直接启动。 本篇增加了 compose.yml 定义 MySQL、Redis、模拟保险服务,通过 Spring Boot Development-time Services 自动拉起容器,通过 Service C onnection 注入连接信息。 现在新开发者完成 git clone、Docker、Run Application 三步就能开始工作,不再需要手工配置中间件。 下一篇缺的是把这些开发依赖的初始化数据、版本升级策略、CI 集成纳入自动化流程,以及为生产环境编写独立的 Kubernetes 部署清单。 官方链接:https://spring.io/projects/spring-boot > 开发环境已成形, > 三步启动告功成。 > 生产部署尚缺位, > 下篇再续 K8s 程。 # 11、小结 ```text 三问三答:为什么昂贵?怎么选?选错后果? ``` 为什么团队规模变大后“我的机器能运行”越来越昂贵?因为每个开发者的环境漂移导致联调问题无法复现,沟通和排障成本随人数指数上升。 怎么选?选择把开发依赖写成 compose.yml,用 Spring Boot Dev Services 自动管理容器生命周期和连接注入。 选错后果?如果继续手工安装,新开发者 onboarding 会从 10 分钟变成一整天,团队交付节奏被拖慢,最终失去工程化能力。 当前状态:Petclinic 开发环境已经实现基础设施即代码,新成员可以三步启动,环境一致性问题基本解决。 > 三问三答明方向, > 环境一致是良方。 > 工程化路初起步, > 生产部署待加强。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么应用连接 MySQL 时不用 localhost:3306,而用服务名 mysql? 因为 Spring Boot Dev Services 在 Docker Compose 创建的容器网络内部运行,应用容器和 MySQL 容器在同一个网络中,直接通过服务名 mysql 解析到容器 I P,端口是容器内部的 3306。 localhost:3306 是宿主机视角,只有当你从宿主机用数据库工具连接时才需要。 ## 实战题:给 compose.yml 增加一个 Kafka 依赖,并让 Spring Boot 应用通过 Service Connection 连接它。 在 compose.yml 中加入 kafka 服务,使用 bitnami/kafka 镜像,配置 KAFKA_CFG_LISTENERS 等环境变量。 在 pom.xml 中加入 spring-kafka 依赖,Spring Boot 会自动检测 Kafka 容器并注入 KafkaConnectionDetails。 启动应用后,在日志中确认 Kafka ConnectionDetails 被注入,然后发送一条测试消息验证。 ## 排障题:应用启动时提示 Cannot connect to Redis,但 docker compose ps 显示 Redis 容器正在运行,如何排查? 先检查应用日志中是否有 Redis ConnectionDetails 注入信息,确认 spring-boot-docker-compose 依赖是否在运行 classpath 中。 然后检查 Redis 容器日志,确认是否已就绪。 最后检查 compose.yml 中 Redis 服务名是否与 Service Connection 期望的一致,以及是否有端口冲突导致容器内 Redis 未监听 6379。 ## 架构判断题:团队领导要求把 compose.yml 直接部署到生产环境,理由是“开发环境能跑,生产环境也能跑”,你是否同意? 不同意。compose.yml 是开发环境契约,缺少生产环境需要的滚动升级、资源配额、故障自愈、多副本调度等能力。 生产环境应该使用 Kubernetes Manifest 或 Helm Chart,由独立的部署流水线管理。 开发 Compose 与生产 Kubernetes 的边界必须清晰,否则会导致生产环境不可控。 > 四题作业层层进, > 理解实战排障明。 > 架构判断最关键, > 开发生产界限清。 # 13、升华 ```text 工程结论:开发环境的一致性必须通过基础设施即代码来保证,而不是依赖个人自觉 ``` “我的机器能运行”在团队规模变大后必然失效,因为环境漂移带来的沟通和排障成本会吞噬所有效率收益。 把开发依赖写入 compose.yml,用 Spring Boot Development-time Services 自动管理容器生命周期和连接注入,是当前 Spring Boot 生态下最务实的方 案。 开发 Compose 与生产 Kubernetes 的边界必须清晰:前者是开发契约,后者是部署方案,两者不能混用。 下一篇将把这些开发依赖的初始化数据、版本升级策略、CI 集成纳入自动化流程,并开始编写生产环境的 Kubernetes 部署清单。 > 环境一致靠代码, > 个人自觉难久长。 > 开发生产分两界, > 工程化路始成章。 **系列项目:** Spring 官方 Petclinic 教学项目,服务对象为宠物医院诊疗与客户管理系统。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全层、数据层、事务管理、远程调用、数据库迁移、缓存、并发控制、日志、测试、Testcontainers、Docker Compose 开发环境。 **本篇:** 将 MySQL、Redis、模拟保险服务统一写入 compose.yml,实现 Development-time Services、Compose Lifecycle 与 Service Connection,让新开发者三步启动。 **下一篇:** 将开发依赖的初始化数据、版本升级策略、CI 集成纳入自动化流程,并编写生产 Kubernetes 部署清单。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第1招 搭好Java开发环境

# 为什么正式教程应先验证完整工程环境,而不是从复制第一段 Java 代码开始 ```text 旧方案:复制第一段 Java 代码 → 本地缺 JDK/依赖/数据库 → 编译失败、连接失败、调试失败 → 学习者以为框架难用 ``` 很多 Spring 教程一上来就贴 Controller、Service、Repository,默认读者已经装好 JDK、IDE、Maven、数据库和调试工具。 但真实工程里,环境不一致才是第一道墙。 我是老李,带团队接手过不少“代码能跑、环境跑不起来”的项目。 一个正式教程应该先验证完整工程环境,还是先复制第一段 Java 代码? 应该把 JDK、Maven、IDE、Docker、MySQL、Redis、HTTP 调试和 JVM 诊断一次打通,还是只保证单文件编译通过? 如果选错,会不会让后续每一章都在修环境,而不是学 Spring Boot? # 01、故事 ```text 周一 09:40 → 新同事工位 → 任务是跑起 Petclinic → 本地 JDK 版本不对、MySQL 没装、Debug 不会看线程 → 项目启动失败 ``` 周一早上,新同事被要求把 Spring 官方 Petclinic 跑起来。 任务是“先让项目在本机启动,能访问首页”。 他打开 IDE,导入项目,Maven 一直下载失败。 旁边同事催着要联调,他只能反复重启 IDE。 搜到的答案一会儿说改镜像,一会儿说装 JDK 17,一会儿又说要本地 MySQL。 他卡了两个小时,连 main 方法都没进去。 > 晨会催得急, > 环境未通先写码。 > 报错层层叠, > 不知根在哪。 # 02、问题 ```text 需求变化:从“能编译”到“能开发、能调试、能连库、能观测” → 旧环境只验证 javac → 数据库、容器、调试、诊断全部失效 ``` 只验证 javac 或 IDE 里能打开文件,并不能证明工程环境可用。 Petclinic 需要 Maven Wrapper 下载依赖,需要 Docker 启动 MySQL 和 Redis,需要 IDE 连接源码和运行进程。 如果这些链路不通,后续数据访问、缓存、事务、测试、容器化章节都会反复失败。 可验证的验收标准是:执行 ./mvnw test 通过,./mvnw spring-boot:run 能启动,HTTP 请求能访问首页,Debug 能停在断点,MySQL 和 Redis 能通过客户端 验证。 > 编译非终点, > 链路未通皆虚妄。 > 库连不上时, > 代码再美也空转。 # 03、原理 ```text 开发链 = JDK → Maven Wrapper → IDE → Docker → MySQL/Redis → Spring Boot → HTTP → Debug/JVM 诊断 ``` Spring Boot 不是只靠 Java 文件运行。 它启动时要解析依赖、读取配置、连接数据源、暴露端口、注册 Bean。 Maven Wrapper 固定 Maven 版本,避免本机 Maven 差异。 Docker 统一 MySQL 和 Redis 版本,避免“我本地能跑,你本地不行”。 IDE 的 Debug 能力依赖源码与运行进程的对应关系。 JVM 诊断工具依赖 JDK 自带命令与运行用户权限。 反直觉的是:环境验证越早,业务代码写得越慢,但整体交付越快。 > 慢即是快时, > 先铺管道后引水。 > 若先砌高墙, > 水到墙前也白费。 # 04、架构 ```text 输入:官方 Petclinic 源码 + 本机开发机 → 环境层:JDK/IDE/Maven/Docker/MySQL/Redis/HTTP/JVM 工具 → 输出:可运行、可调试、可观测的工程基线 ``` 这一篇不新增业务模块,而是建立工程基线。 基线分四层:语言运行时、构建工具、外部依赖、观测调试。 语言运行时是 JDK 21,构建工具是 Maven Wrapper,外部依赖是 Docker 中的 MySQL 和 Redis,观测调试是 IDE Debug 与 JDK 诊断命令。 收益是后续每章都能在一致环境上验证。 代价是首次搭建时间较长。 适用条件是所有需要长期演进同一个代码仓库的正式项目。 > 基线如地基, > 不显眼却承千钧。 > 工具链贯通, > 后续方可行远路。 # 05、第一版实战 ```text 环境:macOS/Linux/Windows + JDK 21 + IntelliJ IDEA + Docker Desktop → 配置:JAVA_HOME、Maven Wrapper、MySQL、Redis → 代码:官方 Petclinic → 启动:./mvnw spring-boot:run → 验证:HTTP 200、Debug 断点、redis-cli PING → V1:全链路首次跑通 ``` 第一步安装 JDK 21。 以 macOS 为例: ```bash brew install openjdk@21 ``` 配置 JAVA_HOME: ```bash export JAVA_HOME=$(/usr/libexec/java_home -v 21) export PATH="$JAVA_HOME/bin:$PATH" ``` 验证: ```bash java -version javac -version ``` 示例输出: ```text openjdk version "21.0.2" 2024-01-16 OpenJDK Runtime Environment (build 21.0.2+13-58) OpenJDK 64-Bit Server VM (build 21.0.2+13-58, mixed mode, sharing) javac 21.0.2 ``` 第二步安装 IntelliJ IDEA,打开 Petclinic 后设置 Project SDK 为 JDK 21。 在 IDEA 中确认 Maven Wrapper 使用项目自带 .mvn/wrapper。 第三步安装 Docker Desktop 并启动。 启动 MySQL: ```bash docker run -d --name petclinic-mysql \ -e MYSQL_ROOT_PASSWORD=root \ -e MYSQL_DATABASE=petclinic \ -e MYSQL_USER=petclinic \ -e MYSQL_PASSWORD=petclinic \ -p 3306:3306 \ mysql:8.4 ``` 启动 Redis: ```bash docker run -d --name petclinic-redis \ -p 6379:6379 \ redis:7.4 ``` 验证 Redis: ```bash docker exec -it petclinic-redis redis-cli PING ``` 示例输出: ```text PONG ``` 第四步克隆官方项目: ```bash git clone https://github.com/spring-projects/spring-petclinic.git cd spring-petclinic ``` 执行测试: ```bash ./mvnw test ``` 启动应用: ```bash ./mvnw spring-boot:run ``` 访问: ```bash curl -I http://localhost:8080 ``` 示例输出: ```text HTTP/1.1 200 ``` 第五步在 IDEA 中给 PetClinicController 的 showOwner 方法打断点,用 Debug 启动,浏览器访问 /owners/find,确认断点命中。 再练习条件断点、异常断点、Evaluate Expression 和线程查看。 第六步验证 JVM 诊断工具: ```bash jcmd jstack jmap -histo ``` V1 完成标志:JDK、Maven、IDE、Docker、MySQL、Redis、Spring Boot、HTTP、Debug 全部跑通。 > 一链通到底, > 从编译到断点停。 > 库中表可见, > 缓存 PONG 回应声。 # 06、排查 ```text 诊断树:启动失败 → 是 JDK 版本?是 Maven 依赖?是数据库连接?是端口占用?是 Docker 未启动? ``` 问题一:./mvnw 报 Unsupported class file major version 65。 怀疑是 Maven 使用的 JDK 版本低于 21。 检查: ```bash ./mvnw -version ``` 证据显示 Maven 运行在 JDK 17 上。 根因是 JAVA_HOME 未生效或 IDE 终端继承了旧环境变量。 修复:重新 export JAVA_HOME 并重启终端。 问题二:应用启动报 Access denied for user 'petclinic'@'localhost'。 怀疑是 MySQL 账号权限或密码不匹配。 检查: ```bash docker exec -it petclinic-mysql mysql -upetclinic -ppetclinic -e "SELECT 1" ``` 证据显示账号无法登录。 根因是容器创建时 MYSQL_USER 与 MYSQL_PASSWORD 未同时生效,或旧容器残留。 修复:删除旧容器和 volume,重新创建。 **错误尝试:** 一开始只改 application.properties 里的密码,没有重建容器,导致反复报同样的错。 > 报错如迷雾, > 先查版本再查权。 > 莫急改配置, > 证据未齐勿下判。 # 07、优化 ```text V1:手动 export JAVA_HOME、手动 docker run → 证据:换终端失效、容器参数易漏 → V2:固定 JDK 配置、用 Docker Compose 管理 MySQL 和 Redis ``` V1 的问题在于环境配置散落在多个终端和命令历史里。 换一个终端窗口,JAVA_HOME 就失效。 MySQL 和 Redis 每次手动 docker run,参数容易漏。 V2 把 JDK 配置写入 shell 配置文件: ```bash echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 21)' >> ~/.zshrc source ~/.zshrc ``` 再用 Docker Compose 管理 MySQL 和 Redis: ```yaml services: mysql: image: mysql:8.4 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: petclinic MYSQL_USER: petclinic MYSQL_PASSWORD: petclinic ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql redis: image: redis:7.4 ports: - "6379:6379" volumes: mysql-data: ``` 启动: ```bash docker compose up -d ``` 这样环境可重复、可版本化。 > 散落终成患, > 配置入文件方安。 > 容器归编排, > 一键起落不慌乱。 # 08、演进 ```text V2:JDK 配置持久化 + Docker Compose 管理依赖 → 能力:环境可重复、可重建、可交接 → 遗留:IDE 配置和 JVM 诊断脚本尚未标准化 ``` V2 之后,新同事拿到项目,只需安装 JDK 21、IDEA、Docker Desktop,然后执行 docker compose up -d 和 ./mvnw spring-boot:run。 用 V1 同一组请求验证:./mvnw test 通过,curl -I http://localhost:8080 返回 200,redis-cli PING 返回 PONG,Debug 断点命中。 收益是环境搭建从两小时降到二十分钟。 代价是首次需要理解 Docker Compose 文件。 适用条件是团队统一使用容器化外部依赖。 遗留问题是 IDE 的 Run Configuration 和 JVM 诊断命令还没有沉淀成团队文档。 > 从散到有序, > 环境不再靠口传。 > 新人接手工, > 二十分钟可开战。 # 09、洞见 ```text 从“先写代码”到“先验证环境”,本质是从个人英雄主义转向工程基线思维 ``` ## 环境是代码的运行时契约 ```text 代码 → 依赖 → 配置 → 外部服务 → 端口 → 观测 ``` 代码只是链路的一环。 没有 JDK、Maven、数据库和调试工具,代码无法被验证。 正式教程先验证环境,是在确认运行时契约成立。 ## 反直觉判断:先慢后快 ```text 首日搭环境 2 小时 → 后续每章省 30 分钟 → 24 章省 12 小时 ``` 很多人觉得搭环境浪费时间。 但环境问题不会消失,只会推迟到最不方便的时候爆发。 ## 反直觉判断:容器不是运维的事 ```text 开发库也进容器 → 版本一致 → 减少“我本地能跑” ``` 开发阶段就用容器,能提前暴露配置、网络和权限问题。 等到上线才容器化,往往已经积累了大量环境假设。 ## 调试能力是工程素养 ```text 断点 → 条件 → 异常 → 表达式 → 线程 → JVM 命令 ``` 只会普通断点的人,遇到偶发问题只能加日志重启。 条件断点、异常断点和线程查看能大幅缩短定位时间。 > 契约先立定, > 慢行反是快行舟。 > 容器非运维, > 调试素养不可丢。 # 10、系统落地 ```text 入口:开发者本机 → 服务:IDE + Maven Wrapper → 本模块:环境基线 → 存储:Docker MySQL/Redis → 未实现:业务代码尚未改动 ``` 原来只有官方 Petclinic 源码,没有统一的本地环境说明。 本篇增加了 JDK 21、Maven Wrapper、IDEA Debug、Docker Compose、MySQL、Redis、HTTP 调试和 JVM 诊断工具的环境基线。 现在能做什么:任何新成员可以在二十分钟内跑起 Petclinic,并具备调试和观测能力。 下一篇缺什么:还没有深入 Spring Boot 启动机制,需要解释 main 方法如何拉起内嵌 Tomcat 和 Spring 容器。 官方链接:https://github.com/spring-projects/spring-petclinic > 基线已铺就, > 开发调试皆可依。 > 下一程启程, > 深入启动探玄机。 # 11、小结 ```text 三问三答:先验证环境 → 先打通全链路 → 选错会反复修环境 ``` 第一问:正式教程应该先验证完整工程环境,因为环境是代码的运行时契约。 第二问:应该把 JDK、Maven、IDE、Docker、MySQL、Redis、HTTP 调试和 JVM 诊断一次打通,因为任何一环缺失都会阻塞后续章节。 第三问:如果选错,后续每一章都会在修环境,而不是学 Spring Boot,最终失去对框架本身的信心。 当前状态:Petclinic 已在本机稳定运行,测试通过,数据库和缓存可验证,Debug 和 JVM 诊断可用。 > 三问皆已答, > 环境先行是正解。 > 链路全贯通, > 后续章节少波折。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Maven Wrapper 比本机全局 Maven 更适合正式教程? Maven Wrapper 把 Maven 版本固定在项目里,所有开发者执行 ./mvnw 时使用同一版本,避免本机 Maven 版本差异导致的构建不一致。 ## 实战题:用 Docker Compose 启动 MySQL 和 Redis,并分别验证连接、建表、SET/GET 和 TTL。 执行 docker compose up -d 后,用 docker exec -it petclinic-mysql mysql -upetclinic -ppetclinic petclinic 连接,执行 SHOW TABLES; 用 docker exec -it petclinic-redis redis-cli 执行 SET k v、GET k、TTL k。 ## 排障题:应用启动报 Communications link failure,但 MySQL 容器在运行,如何定位? 先检查端口映射:docker port petclinic-mysql。 再检查应用配置的数据库地址是否为 localhost:3306。 再检查 MySQL 是否已接受连接:docker logs petclinic-mysql。 最后检查防火墙或 Docker 网络是否隔离。 ## 架构判断题:开发环境是否应该完全依赖本地安装的 MySQL,而不是容器? 不应该。 本地安装的 MySQL 版本、配置和权限难以统一,容器能保证开发、测试、生产环境的一致性,减少“我本地能跑”的问题。 > 四题如四关, > 理解实战排障判。 > 环境既已通, > 后续深入不迷航。 # 13、升华 ```text 工程结论:先验证完整环境,再写业务代码,是正式教程的第一原则 ``` 有依据的结论是:环境问题不会因为忽略而消失,只会推迟到更贵的阶段爆发。 Petclinic 作为长期演进项目,必须先把开发、运行、调试、观测的基线打牢。 下一篇将进入 Spring Boot 启动机制,解释 main 方法如何触发自动配置、内嵌容器和 Bean 装配。 > 地基既已深, > 高楼方可层层起。 > 环境为序章, > 启动机制是正题。 **系列项目:** Spring 官方 Petclinic 教学项目,服务兽医诊所的宠物与主人管理。 **已完成:** JDK 21、Maven Wrapper、IntelliJ IDEA、Docker Desktop、MySQL、Redis、HTTP 调试、JVM 诊断工具与 Petclinic 首次运行。 **本篇:** 建立可重复、可调试、可观测的完整工程环境基线。 **下一篇:** 深入 Spring Boot 启动机制,理解 main 方法、自动配置与内嵌容器。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第2招 跑透官方Petclinic

# Spring Boot 替 Petclinic 完成了哪些基础设施 ```text 旧方案:手工搭 Tomcat、Spring 容器、JPA、事务、数据源、静态资源、日志、安全 冲突:教学项目 Petclinic 开箱即跑,开发者却说不清哪些是框架代劳 后果:后续演进没有基线,改一处配置就牵出隐藏依赖,排障靠猜 ``` Spring 官方 Petclinic 是很多人第一次见到的“完整 Spring 应用”。它看起来不大,却同时包含 Web 层、数据层、校验、安全、测试和可观测入口。 我是老李,这次不新建 Hello World,而是把 Petclinic 作为后面 22 招持续演进的代码母本。 如果连“Spring Boot 到底替我们做了什么”都说不清,后续加缓存、加迁移、加容器化时,凭什么判断改动边界? 是继续把 Petclinic 当黑盒跑一遍,还是从启动、请求、数据三层把它拆成可解释的基线? 选错会怎样?选错后,团队只能背诵配置,无法在故障时定位是容器、框架还是业务代码的问题。 # 01、故事 ```text 周二 09:40 → 工位 → 任务是跑通 Petclinic 并追一条真实请求 → 冲突是能启动但说不清启动过程 ``` 周二上午,项目组要求把官方 Petclinic 作为新一版内部实验项目的起点。 任务是先跑起来,再执行“查询主人→新增宠物→登记就诊”,最后从浏览器请求追到数据库。 我打开官方仓库,依赖下载完成后应用确实起来了,页面也能点。 但同事问了一句:“除了写 Controller 和 Repository,Spring Boot 还帮我们干了什么?” 我盯着启动日志,只能说出“内嵌 Tomcat”和“自动配置”,再往下就空了。 > 页面已开跑,链路却成谜, > 框架代劳处,无人说得齐。 > 若把黑盒当基线, > 后招皆是空中梯。 # 02、问题 ```text 需求变化:从“能跑”变成“能解释、能演进” 冲突:Petclinic 的启动、请求、数据链路被自动配置掩盖 后果:无法建立后续实验共用的工程基线 ``` Petclinic 能跑,不代表团队掌握了它。 真正的问题是:当后续要加缓存、迁移、容器化、可观测时,我们不知道现有能力来自哪里。 例如数据源是谁创建的,事务是谁开启的,静态资源是谁映射的,安全过滤链是谁装配的。 这些如果不查清,后续任何一处改动都可能触发隐藏依赖。 可验证的验收标准是:用三种方式启动同一份代码,并解释从 HTTP 请求到数据库返回的完整路径。 > 能跑只是表象, > 能解释才算账。 > 黑盒之上无演进, > 基线之下有文章。 # 03、原理 ```text Spring Boot 核心机制:自动配置 + 条件装配 + 内嵌容器 + 起步依赖 ``` Spring Boot 不是新框架,而是 Spring 的“工程化装配器”。 它通过 `@EnableAutoConfiguration` 扫描 classpath 中的依赖,再按条件装配 DataSource、EntityManagerFactory、TransactionM anager、DispatcherServlet 等基础设施。 Petclinic 只声明了 `spring-boot-starter-web`、`spring-boot-starter-data-jpa`、`spring-boot-starter-thymele af` 等起步依赖。 这些起步依赖本质是依赖集合,它们把“你需要什么能力”翻译成“引入哪些 jar”。 反直觉的是:Petclinic 里没有一行创建 Tomcat 的代码,但应用却能接收 HTTP 请求。 这是因为 `spring-boot-starter-web` 引入了内嵌 Tomcat,自动配置在启动时把它初始化并绑定到指定端口。 > 不见 Tomcat 影, > 却有请求声。 > 起步依赖藏装配, > 条件注解定死生。 # 04、架构 ```text 输入:HTTP 请求 → 本模块:Controller → 业务层 → Repository → 数据库 → 输出:页面或 JSON ``` Petclinic 采用经典分层:Web 层负责接收和渲染,业务层负责协调,Repository 负责持久化。 Spring Boot 在这条链路之外,提供了容器、配置、数据源、事务、静态资源、错误页、日志等横切能力。 本阶段只关注“启动与请求链路”,不引入新的业务模块。 收益是团队能建立统一解释框架:每一层由谁创建、由谁调用、由谁销毁。 代价是前期要花时间读源码和日志,不能只满足于页面可点。 适用条件是:后续所有实验都基于同一份 Petclinic 代码,不再另起新项目。 > 分层本不奇, > 装配才是基。 > 若问边界何处止, > 先看自动配置里。 # 05、第一版实战 ```text 环境:JDK 17 + Maven + 官方 Petclinic → 配置:默认 application.properties → 代码:不改业务 → 启动:三种方式 → 验证:真实请求 → V1:可跑但未解释 ``` 第一步,克隆官方仓库并创建自己的实验仓库。 ```bash git clone https://github.com/spring-projects/spring-petclinic.git spring-boot-petclinic-advanced-lab cd spring-boot-petclinic-advanced-lab git remote rename origin upstream git branch baseline ``` 第二步,确认关键类。 ```java @SpringBootApplication public class PetClinicApplication { public static void main(String[] args) { SpringApplication.run(PetClinicApplication.class, args); } } ``` 第三步,用 IDEA 启动。 在 IDEA 中打开项目,等待依赖下载完成,直接运行 `PetClinicApplication`。 示例输出: ```text Started PetClinicApplication in 8.2 seconds Tomcat started on port 8080 ``` 第四步,用 Maven 启动。 ```bash ./mvnw spring-boot:run ``` 第五步,构建可执行 Jar 并启动。 ```bash ./mvnw clean package -DskipTests java -jar target/spring-petclinic-*.jar ``` 第六步,验证真实链路。 ```bash curl -s http://localhost:8080/owners?lastName= | head -n 20 ``` 页面返回主人列表。 再执行新增宠物和登记就诊: ```bash curl -s -X POST http://localhost:8080/owners/1/pets/new -d "name=Leo&birthDate=2023-01-01&type=cat" curl -s -X POST http://localhost:8080/owners/1/pets/1/visits/new -d "date=2024-01-01&description=check" ``` 未在当前环境实测,以下为预期结果:新增宠物后跳转主人详情页,登记就诊后显示就诊记录。 V1 成功标准:三种启动方式均可运行,真实请求可到达数据库。 > 三种启动皆可跑, > 一条链路已通到。 > 只是框架代劳处, > 仍似雾里看花早。 # 06、排查 ```text 诊断树:启动慢 → 怀疑依赖下载 → 检查本地仓库 → 证据:首次下载慢 → 根因:冷缓存 请求 404 → 怀疑映射缺失 → 检查 Controller → 证据:路径正确 → 根因:静态资源未刷新 ``` 第一个问题是启动慢。 现象:首次运行 `./mvnw spring-boot:run` 超过两分钟。 怀疑:网络或依赖下载慢。 检查: ```bash ./mvnw -version ls ~/.m2/repository/org/springframework/boot ``` 证据:本地仓库缺少大量依赖,首次启动触发全量下载。 根因:冷缓存,不是代码问题。 修复:等待下载完成,后续启动恢复正常。 第二个问题是页面样式丢失。 现象:首页能打开,但 CSS 不生效。 怀疑:静态资源映射错误。 检查: ```bash curl -I http://localhost:8080/resources/css/petclinic.css ``` 证据:返回 404。 根因:浏览器缓存了旧路径,应用实际静态资源路径已变化。 修复:强制刷新浏览器或清缓存。 **错误尝试:** 我曾以为静态资源丢失是打包问题,重新执行 `clean package` 后仍未解决,最后发现只是浏览器缓存。 > 慢非代码错, > 冷缓存惹祸。 > 样式失踪非打包, > 浏览器旧路多。 # 07、优化 ```text V1:能跑但不可解释 → 证据:启动日志与请求日志未结构化 → V2:增加启动报告与链路说明 ``` V1 已经能跑,但团队仍无法回答“Spring Boot 做了什么”。 优化方向不是改业务,而是把隐式装配显式化。 第一步,开启启动报告。 在 `application.properties` 中增加: ```properties logging.level.org.springframework.boot.autoconfigure=DEBUG ``` 重新启动后,日志会打印自动配置报告。 示例输出: ```text Positive matches: DataSourceAutoConfiguration matched JpaRepositoriesAutoConfiguration matched TransactionAutoConfiguration matched ``` 第二步,增加一个只读接口,用于查看当前生效的关键基础设施。 ```java @RestController @RequestMapping("/lab") public class LabInfoController { @GetMapping("/infra") public Map infra() { return Map.of( "server", "embedded-tomcat", "data", "jpa", "tx", "declarative", "mvc", "dispatcher-servlet" ); } } ``` 第三步,验证: ```bash curl -s http://localhost:8080/lab/infra ``` 预期返回: ```json {"server":"embedded-tomcat","data":"jpa","tx":"declarative","mvc":"dispatcher-servlet"} ``` 这样,团队有了一个可访问的“基础设施清单”。 > 能跑不够用, > 能查才从容。 > 自动配置报告里, > 隐式装配现真容。 # 08、演进 ```text V2:可解释的启动与请求链路 → 能力:三种启动方式 + 自动配置报告 + 基础设施清单 → 遗留:安全、缓存、迁移未展开 ``` V2 相比 V1,核心变化是从“能跑”到“能解释”。 用 V1 同一组请求验证: ```bash curl -s http://localhost:8080/owners?lastName= curl -s http://localhost:8080/lab/infra ``` 两个请求均正常返回,说明新增只读接口没有破坏原有链路。 收益是团队建立了统一解释框架:容器、数据、事务、MVC 都由 Spring Boot 自动装配。 代价是增加了一个实验用接口,后续需要控制其访问范围。 适用条件是:所有后续实验都基于当前仓库的 `baseline` 分支继续演进。 遗留问题是:安全过滤链、缓存抽象、数据库迁移、可观测能力尚未展开。 > 黑盒已开窗, > 链路有方向。 > 后续二十二招, > 皆在此基上。 # 09、洞见 ```text 从现象到判断:启动快慢、请求路径、配置来源三者共同决定工程基线质量 ``` ## 自动配置不是魔法,而是条件装配 ```text classpath 依赖 → 条件注解 → Bean 装配 ``` 很多人以为 Spring Boot 会“猜”你要什么。 实际上它只做一件事:根据 classpath 和显式配置,决定装配哪些 Bean。 反直觉判断:自动配置越少越稳定,而不是越多越强大。 ## 内嵌容器改变了部署模型 ```text 传统:war + 外部 Tomcat Boot:jar + 内嵌 Tomcat ``` Petclinic 的可执行 Jar 同时包含应用和容器。 这让本地开发、测试、部署使用同一种启动方式。 反直觉判断:内嵌容器不是为生产替代外部容器,而是为一致性降低环境差异。 ## 起步依赖是工程化契约 ```text starter-web → 一组依赖 + 自动配置 ``` 起步依赖把“能力”打包成“契约”。 团队引入一个 starter,就等于接受一组默认行为。 因此,引入前必须知道它带来了什么,否则后续排障会非常困难。 ## 基线不是代码快照,而是解释能力 ```text 代码快照 → 只能回滚 解释能力 → 可以演进 ``` 只保存代码分支不够。 真正的基线是团队能说清:谁创建了容器、谁开启了事务、谁映射了请求。 > 装配有条件, > 容器在 Jar 间。 > 起步依赖是契约, > 基线在解释间。 # 10、系统落地 ```text 入口:浏览器/curl → 服务:Petclinic → 本模块:启动与请求链路 → 存储:HSQLDB/MySQL → 未实现:安全、缓存、迁移 ``` 原来有什么:官方 Petclinic 代码,能跑但未被团队解释。 本篇增加:三种启动方式验证、自动配置报告、基础设施清单接口、统一解释框架。 现在能做什么:团队可以回答“Spring Boot 替 Petclinic 完成了哪些基础设施”,并以此为基础继续演进。 下一篇缺什么:还缺对依赖解析和自动配置边界的深入控制,需要进一步理解 Spring Boot 的依赖管理机制。 官方链接: - https://spring.io/projects/spring-boot- https://github.com/spring-projects/spring-petclinic- https ://docs.spring.io/spring-boot/documentation.html > 入口已打通, > 服务有行踪。 > 存储虽可达, > 安全缓存仍空。 # 11、小结 ```text 三问三答:做了什么、怎么选、选错后果 ``` 第一问:Spring Boot 到底替 Petclinic 完成了哪些基础设施? 回答:内嵌 Tomcat、DispatcherServlet、DataSource、EntityManagerFactory、TransactionManager、静态资源映射、错误页、日志等。 第二问:是继续当黑盒,还是拆成可解释基线? 回答:必须拆成可解释基线,否则后续 22 招没有落点。 第三问:选错会怎样? 回答:选错后,团队只能背诵配置,无法定位故障边界,也无法安全演进。 当前状态:已建立 `spring-boot-petclinic-advanced-lab` 仓库,三种启动方式验证通过,真实请求链路可解释。 > 三问皆已答, > 基线初成家。 > 后续每一招, > 同仓再出发。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Petclinic 没有创建 Tomcat 的代码,却能接收 HTTP 请求? 因为 `spring-boot-starter-web` 引入了内嵌 Tomcat,`ServletWebServerFactoryAutoConfiguration` 在启动时创建并启动 Tomca t,绑定到 `server.port` 配置的端口。 ## 实战题:在 Petclinic 中新增一个只读接口,返回当前生效的自动配置类数量。 可以注入 `ApplicationContext`,通过 `getBeanDefinitionNames()` 统计名称中包含 `AutoConfiguration` 的 Bean,或读取启动日志中的 自动配置报告。 ## 排障题:启动时出现 `Cannot determine embedded database driver class for database type NONE`,如何定位? 说明 classpath 中没有嵌入式数据库驱动,但数据源自动配置被触发。 检查是否引入了 `spring-boot-starter-data-jpa` 却没有引入 H2 或 HSQLDB,或显式配置了数据源但驱动缺失。 ## 架构判断题:把 Petclinic 改成 war 包部署到外部 Tomcat,是否更符合生产标准? 不一定。内嵌容器和外部容器是两种部署模型,没有绝对优劣。内嵌容器更适合一致性和快速迭代,外部容器更适合传统运维体系。关键不是形式,而是团队能否解释和管理所选模型。 > 理解不靠背, > 实战见真章。 > 排障有路径, > 架构看方向。 # 13、升华 ```text 工程结论:基线不是能跑,而是能解释 ``` Petclinic 的真正价值不是它实现了多少功能,而是它提供了一个足够真实、又足够小的 Spring Boot 应用。 通过三种启动方式和一条真实请求链路,我们证明了:Spring Boot 的核心贡献是工程化装配,而不是业务代码。 后续每一招都将在同一个仓库上继续演进,不再另起项目。 下一篇将深入依赖解析与自动配置边界,把“为什么引入一个 starter 会改变应用行为”讲清楚。 > 能跑非终点, > 能解是起点。 > 同仓二十二招, > 步步见真篇。 **系列项目:** 面向开发、测试、运维一体化的 Spring Boot 生产级演进实验系统 **已完成:** 官方 Petclinic 母本导入、三种启动方式验证、真实请求链路追踪、自动配置报告与基础设施清单 **本篇:** 建立可解释的启动与请求基线,回答 Spring Boot 代劳的基础设施 **下一篇:** 依赖解析与自动配置边界控制,理解 starter 引入的行为变化

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第11招 事务到底在哪生效

# 方法明明写了 @Transactional,为什么事务仍可能不生效? ```text 记忆注解 → 设计边界 → 一致性 ``` 我是老李,在深圳做架构师,常年跟 Spring Boot 打交道。 Petclinic 是 Spring 官方教学项目,Owner、Pet、Visit 三张表天然构成多步骤写操作场景。 我见过太多团队把 @Transactional 当护身符,写上就以为万事大吉,结果数据错乱、连接耗尽、回滚失效。 方法明明写了 @Transactional,为什么事务仍可能不生效? 该把事务注解放在哪一层,才能让回滚真正覆盖业务边界? 选错事务边界,会不会让数据库连接被长事务拖死,最终拖垮整个应用? # 01、故事 ```text 凌晨 1:30 → 深圳某金融公司机房 → 任务是排查订单重复入账 → 冲突:日志显示事务已提交,但数据库里出现半截数据 ``` 凌晨 1:30,机房冷气开得足,我盯着屏幕上的异常堆栈,咖啡已经凉透。 任务是排查订单重复入账问题,业务方说用户付了两次钱,但只收到一次货。 我打开 Petclinic 的 Visit 新增接口,发现它先更新 Owner,再插入 Pet,最后写 Visit,三步操作被拆在三个方法里。 每个方法都标了 @Transactional,可中间一步抛异常后,前两步的数据竟然留在了库里。 日志里明明显示事务已提交,可数据库里就是半截数据,像被啃过的苹果。 > 注解不是护身符, > 代理才是真门神。 > 内部调用绕过去, > 回滚落空泪满襟。 # 02、问题 ```text 需求变化:一次前台操作要同时写 Owner、Pet、Visit → 冲突:三个方法各自开事务,中间失败只回滚自己 → 后果:数据库出现孤儿 Pet、脏 Owner ``` Petclinic 原本新增 Visit 只写一张表,事务边界清晰。 现在需求变了,一次前台操作要同时更新 Owner 信息、新增 Pet、登记 Visit,三步必须同生共死。 我把三个方法都标上 @Transactional,以为每个方法自带事务,合起来就是整体事务。 结果中间步骤抛异常,只有最后那个方法回滚了,前面两个方法的数据已经提交,数据库里出现孤儿 Pet。 影响是数据不一致,用户看到宠物还在,但就诊记录没了,下次查询直接报错。 可验证的验收标准:模拟中间步骤失败,Owner、Pet、Visit 三张表都不应有新增或修改。 > 需求一变边界乱, > 三个事务各自转。 > 中间失败留残局, > 孤儿数据惹人烦。 # 03、原理 ```text Spring 事务基于 AOP 代理,只有外部调用才走代理,内部 this 调用直接绕过代理,事务注解形同虚设 ``` Spring 的 @Transactional 不是魔法,它靠 AOP 代理实现。 当你在方法上标 @Transactional,Spring 会为 Bean 生成一个代理对象,调用方法时先经过代理,代理开启事务、执行业务、提交或回滚。 但如果你在同一个类里,用 this 调用另一个带 @Transactional 的方法,调用直接走原始对象,代理根本不知道,事务自然不生效。 反直觉判断:方法上写了 @Transactional,不代表它一定在事务里运行,关键看谁在调用它。 另一个反直觉点:@Transactional 默认只回滚 RuntimeException 和 Error,受检异常(如 Exception)不会触发回滚,除非显式指定 rollbackFor。 事务传播级别也常被误解,REQUIRED 不是新开事务,而是“有则加入,无则新建”,理解错会导致事务边界比预期大得多。 > 代理门前三叩首, > 内部调用不回头。 > 受检异常不落滚, > 传播级别暗流涌。 # 04、架构 ```text 输入:HTTP 请求 → 本模块:Service 层事务边界 → 输出:一致的三表数据 / 异常回滚 → 边界:事务只覆盖业务逻辑,不覆盖 HTTP 连接与外部调用 ``` 事务边界应该画在 Service 层,而不是 Controller 或 Repository。 Controller 负责参数校验和响应封装,Repository 只做单表 CRUD,Service 层编排多步骤业务,事务注解放在 Service 的公开方法上。 收益是事务粒度与业务操作对齐,回滚范围清晰,代码可读性高。 代价是 Service 层方法不能随意拆小,否则容易触发内部调用陷阱。 适用条件是业务操作确实需要多表一致性,如果只是单表写,事务放 Repository 反而增加无谓开销。 > 边界画在服务层, > 控制仓储两边分。 > 事务粒度对业务, > 回滚清晰不迷魂。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + MySQL 8 + Petclinic 源码 → 配置:开启事务管理 → 代码:VisitService 三个方法各标 @Transactional → 启动:正常 → 验证:模拟中间失败 → 结果:部分提交 ``` 我基于 Petclinic 源码,在 visit 包下新建 VisitService,版本用 Spring Boot 3.2(需按当前官方文档核验)。 先看 Petclinic 的 Owner、Pet、Visit 实体,它们有 JPA 注解,Visit 关联 Pet,Pet 关联 Owner。 我在 pom.xml 里确认有 spring-boot-starter-data-jpa 和 mysql-connector-j,配置 application.properties 指向本地 MySQ L。 ```properties spring.datasource.url=jdbc:mysql://localhost:3306/petclinic spring.datasource.username=root spring.datasource.password=secret spring.jpa.hibernate.ddl-auto=update ``` 然后写 VisitService,三个方法分别更新 Owner、新增 Pet、新增 Visit,每个方法都标 @Transactional。 ```java @Service public class VisitService { private final OwnerRepository ownerRepository; private final PetRepository petRepository; private final VisitRepository visitRepository; public VisitService(OwnerRepository ownerRepository, PetRepository petRepository, VisitRepository visitRepository) { this.ownerRepository = ownerRepository; this.petRepository = petRepository; this.visitRepository = visitRepository; } @Transactional public void updateOwner(Owner owner) { ownerRepository.save(owner); } @Transactional public Pet addPet(Pet pet) { return petRepository.save(pet); } @Transactional public Visit addVisit(Visit visit) { return visitRepository.save(visit); } } ``` Controller 里调用这三个方法,模拟一次前台操作。 ```java @PostMapping("/owners/{ownerId}/visits") public ResponseEntity addVisit(@PathVariable int ownerId, @RequestBody VisitRequest request) { Owner owner = ownerService.findById(ownerId); owner.setLastName(request.getOwnerLastName()); visitService.updateOwner(owner); Pet pet = new Pet(); pet.setName(request.getPetName()); pet.setOwner(owner); Pet savedPet = visitService.addPet(pet); Visit visit = new Visit(); visit.setPet(savedPet); visit.setDate(request.getDate()); visit.setDescription(request.getDescription()); visitService.addVisit(visit); return ResponseEntity.status(HttpStatus.CREATED).build(); } ``` 启动应用,用 curl 发请求,故意让 addPet 里抛异常(比如宠物名称为空时抛 IllegalArgumentException)。 ```bash curl -X POST http://localhost:8080/owners/1/visits \ -H "Content-Type: application/json" \ -d '{"ownerLastName":"Smith","petName":"","date":"2025-01-01","description":"checkup"}' ``` 预期是整体回滚,但实际查询数据库,发现 Owner 的 lastName 被改了,Pet 没插入,Visit 没插入。 示例输出(日志): ```text 2025-01-01 10:00:00.123 ERROR 12345 --- [nio-8080-exec-1] o.s.j.support.JobService : Pet name must not be empty 2025-01-01 10:00:00.124 INFO 12345 --- [nio-8080-exec-1] jdbc.sqltiming : UPDATE owners SET last_name='Smith' WHERE id=1 ``` Owner 更新已提交,Pet 插入失败,事务没有整体回滚。 成功标准是三步要么全成功要么全失败,现在明显没达到。 > 三个方法各开火, > 中间爆炸留残壳。 > Owner 已改 Pet 空, > 事务边界没画妥。 # 06、排查 ```text 诊断树:现象是部分提交 → 怀疑事务没生效 → 检查代理是否被绕过 → 证据是日志显示 Owner 更新已提交 → 根因是内部调用绕过代理 → 修复:合并方法或注入自身代理 ``` 问题一:为什么 Owner 更新提交了,Pet 插入失败却没回滚? 现象是部分提交,怀疑是事务没生效。 我检查 VisitService 的字节码,确认 @Transactional 注解存在,但发现 Controller 调用的是 visitService.updateOwner(),这是外部调用,应 该走代理。 可问题是三个方法各自独立事务,Pet 失败只回滚 Pet 自己的事务,Owner 的事务已经提交。 证据是数据库里 Owner 的 lastName 变了,Pet 表没有新行。 根因是事务边界粒度太细,每个方法一个事务,没有形成整体。 修复方法:把三个方法合并成一个公开方法,事务注解只标在合并方法上。 ```java @Transactional public void addVisitFlow(Owner owner, Pet pet, Visit visit) { ownerRepository.save(owner); Pet savedPet = petRepository.save(pet); visit.setPet(savedPet); visitRepository.save(visit); } ``` 问题二:如果我把三个方法合并了,但内部又调用另一个带 @Transactional 的私有方法,事务会怎样? 现象是私有方法上的 @Transactional 不生效,怀疑是代理问题。 我检查调用链,发现私有方法通过 this 调用,直接绕过代理。 证据是日志里私有方法执行时没有事务开启记录。 根因是 Spring AOP 基于代理,私有方法无法被代理拦截。 修复方法:把私有方法改成 public,并注入自身代理(@Lazy 注入),或者干脆不标事务,让外层事务统一管理。 **错误尝试:** 我一开始以为把 @Transactional 加到每个方法上就能保证整体性,结果发现事务是独立的,根本没用。 后来我又尝试用 @Transactional(propagation = Propagation.REQUIRES_NEW) 强制新事务,结果每个方法都开新事务,回滚范围更小,数据更乱。 > 代理绕过暗门开, > 私有方法不理会。 > 事务独立各自飞, > 回滚范围碎成灰。 # 07、优化 ```text V1:三个方法各标事务 → 证据:部分提交 → V2:合并成一个公开方法,事务注解只标一次,并显式指定 rollbackFor ``` V1 的问题在于事务边界太碎,我改成 V2:把整个流程合并成一个公开方法 addVisitFlow,事务注解只标一次。 同时显式指定 rollbackFor = Exception.class,因为业务里可能抛受检异常,默认不回滚。 ```java @Transactional(rollbackFor = Exception.class) public void addVisitFlow(Owner owner, Pet pet, Visit visit) { ownerRepository.save(owner); Pet savedPet = petRepository.save(pet); visit.setPet(savedPet); visitRepository.save(visit); } ``` Controller 改为只调用这一个方法。 ```java @PostMapping("/owners/{ownerId}/visits") public ResponseEntity addVisit(@PathVariable int ownerId, @RequestBody VisitRequest request) { Owner owner = ownerService.findById(ownerId); owner.setLastName(request.getOwnerLastName()); Pet pet = new Pet(); pet.setName(request.getPetName()); pet.setOwner(owner); Visit visit = new Visit(); visit.setDate(request.getDate()); visit.setDescription(request.getDescription()); visitService.addVisitFlow(owner, pet, visit); return ResponseEntity.status(HttpStatus.CREATED).build(); } ``` 再次用同样的 curl 请求,模拟 Pet 名称为空抛异常。 这次日志显示事务回滚,Owner 的 lastName 没有被修改。 示例输出(日志): ```text 2025-01-01 10:00:00.456 ERROR 12345 --- [nio-8080-exec-1] o.s.j.support.JobService : Pet name must not be empty 2025-01-01 10:00:00.457 INFO 12345 --- [nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager : Initiating transaction rollback 2025-01-01 10:00:00.458 INFO 12345 --- [nio-8080-exec-1] jdbc.sqltiming : UPDATE owners SET last_name='Smith' WHERE id=1 (rolled back) ``` Owner 更新被回滚,数据库三张表都没有变化。 > 合并方法定边界, > 回滚异常显式写。 > 一次事务管到底, > 数据一致心不怯。 # 08、演进 ```text V2:整体事务生效 → 能力:一致性边界清晰 → 遗留:长事务风险,需控制事务内外部调用 ``` V2 解决了回滚问题,但新问题来了:事务范围变大,如果方法里有耗时的外部调用,比如 HTTP 请求保险系统,事务会一直持有数据库连接。 我模拟一个场景:在 addVisitFlow 里调用一个远程保险接口,响应需要 5 秒,期间事务未提交,数据库连接被占用。 用 V1 的同一组请求验证:先发一个正常请求,再发一个失败请求,观察连接池状态。 示例输出(连接池监控): ```text Active connections: 10 Max connections: 10 Waiting threads: 3 ``` 连接池被打满,其他请求排队等待,系统吞吐量骤降。 收益是数据一致性有保障,代价是事务时间变长,连接占用增加。 适用条件是事务内只做数据库操作,外部调用必须移到事务外。 遗留问题是长事务风险,需要设计事务边界时考虑外部依赖。 > 事务变大连接紧, > 外部调用拖时间。 > 连接池满请求等, > 吞吐下降心发慌。 # 09、洞见 ```text 从现象到判断:事务不生效不是注解问题,是代理边界问题;长事务不是事务问题,是架构问题 ``` ## 事务注解是契约,不是实现 ```text 注解 → 代理 → 事务 ``` @Transactional 只是告诉 Spring“这个方法需要事务”,但真正开启事务的是代理对象。 如果调用没经过代理,注解就是一张废纸。 反直觉判断:你写的注解可能根本没生效,但代码不报错,数据却悄悄错。 ## 内部调用是事务失效的头号杀手 ```text this 调用 → 绕过代理 → 事务丢失 ``` 同一个类里,方法 A 调用方法 B,如果 B 标了 @Transactional,B 的事务不会生效。 因为 this 调用直接走原始对象,代理没机会拦截。 反直觉判断:把方法拆得越细,越容易触发这个陷阱,事务边界反而越模糊。 ## 回滚范围由事务边界决定,不由异常决定 ```text 异常 → 事务边界 → 回滚范围 ``` 异常只是触发回滚的信号,真正决定回滚多少数据的是事务边界。 如果边界画小了,异常只能回滚局部,其他已提交的数据无法挽回。 所以设计事务边界时,要问“哪些操作必须同生共死”,而不是“哪里抛异常”。 ## 长事务是连接池的隐形杀手 ```text 事务内 HTTP → 连接占用 → 池耗尽 ``` 事务内做外部调用,数据库连接被白白占用,连接池很快耗尽。 这不是事务的问题,是架构问题:外部调用不该进事务。 判断标准:事务方法里有没有非数据库 I/O,有就要重构。 > 注解契约非实现, > 代理边界要看清。 > 内部调用绕道走, > 长事务里藏祸心。 # 10、系统落地 ```text 入口:HTTP Controller → 服务:VisitService → 本模块:事务边界设计 → 存储:MySQL → 未实现:外部调用异步化、事务监控 ``` Petclinic 原本只有简单的单表 CRUD,事务隐式存在,没人关心边界。 本篇增加了 VisitService 作为事务边界,把 Owner、Pet、Visit 三步写操作纳入一个事务,并验证了回滚。 现在系统能保证多步骤写操作的一致性,还能通过日志观察事务提交和回滚。 下一篇缺的是事务监控和外部调用治理,比如用 Actuator 暴露事务指标,把保险 HTTP 调用移到事务外。 官方文档首页:https://docs.spring.io/spring-boot/index.html > 入口控制接请求, > 服务层里定边界。 > 存储一致有保障, > 外部调用待迁移。 # 11、小结 ```text 三问三答:事务不生效因为代理被绕过;边界应画在 Service 公开方法;选错边界导致部分提交或连接耗尽 ``` 第一问:方法写了 @Transactional 为什么不生效?因为 Spring 事务靠 AOP 代理,内部 this 调用绕过代理,注解形同虚设。 第二问:事务注解该放哪一层?放在 Service 层公开方法上,覆盖完整业务操作,避免拆碎。 第三问:选错边界会怎样?边界太小导致部分提交,数据不一致;边界太大且含外部调用,导致长事务占用连接,拖垮系统。 当前状态:Petclinic 的 Visit 新增已具备整体回滚能力,事务边界清晰,但还需警惕长事务。 > 三问三答心自明, > 代理边界要分清。 > 回滚一致有保障, > 长事务里再修行。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Spring 事务注解在私有方法上不生效? 因为 Spring AOP 基于代理,私有方法无法被代理拦截,调用时直接走原始对象,事务逻辑不会执行。 ## 实战题:在 Petclinic 中,把 VisitService 的 addVisitFlow 方法改成内部调用两个带 @Transactional 的 public 方法,观察事务是否整体回滚。 如果两个 public 方法通过 this 调用,事务不会整体回滚,因为内部调用绕过代理。 ## 排障题:线上出现“Connection is not available, request timed out”,如何排查是否由长事务引起? 先查连接池监控,看活跃连接数是否打满;再查数据库慢查询日志,找长时间未提交的事务;最后检查代码,看事务方法内是否有外部 HTTP 调用。 ## 架构判断题:一个事务方法需要调用第三方保险接口,且要求接口失败时回滚数据库,该如何设计? 不应该把 HTTP 调用放进事务,而应把事务拆成两步:先调用保险接口,成功后再开启事务写数据库;如果接口失败,直接返回错误,不写数据库。 > 作业四题练真功, > 代理边界记心中。 > 长事务里找线索, > 架构设计不盲从。 # 13、升华 ```text 工程结论:事务设计是边界设计,不是注解堆砌;一致性来自清晰的边界和代理的正确使用 ``` 事务不生效的根因不是注解写错,而是对 Spring AOP 代理机制理解不透。 真正的事务设计,是回答“哪些操作必须同生共死”,然后把边界画在 Service 层公开方法上。 长事务问题提醒我们,事务边界不能无限扩大,外部调用必须隔离在事务外。 下一篇可以深入事务传播级别和隔离级别,在 Petclinic 中模拟并发场景,进一步验证边界设计。 > 事务设计是边界, > 代理机制是根基。 > 一致性从边界来, > 架构演进不停息。 **系列项目:** Spring 官方 Petclinic,一个宠物诊所管理系统,服务对象是诊所前台和兽医。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层开发、数据访问与 Repository 设计。 **本篇:** 多步骤写操作的事务边界设计,包括 @Transactional 原理、代理陷阱、回滚验证、长事务风险。 **下一篇:** 事务传播级别与隔离级别实战,在 Petclinic 中模拟并发访问,设计合适的传播行为。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第20招 Actuator让应用会说话

# PID 还在,应用就活着?Spring Boot 4 Actuator 健康检查实战 ```text 旧方案:运维只看 PID 判断应用存活 → 进程在但业务已死 → 流量黑洞、告警失灵 ``` 我是老李,在深圳做架构师,常年跟 Spring Boot 应用打交道。 Petclinic 上线后,运维反馈:PID 明明在,但用户已经打不开页面了。 冲突问句:PID 存在就等于应用健康吗? 选择问句:该用哪种健康检查机制,才能真实反映业务可用性? 选错后果问句:如果继续只看 PID,流量黑洞会吞掉多少订单? # 01、故事 ```text 凌晨 02:15 → 深圳机房 → 任务是排查 Petclinic 支付接口超时 → 冲突:PID 在,但请求全部卡死 ``` 凌晨两点一刻,深圳机房冷气嗡嗡响。 任务是排查 Petclinic 支付接口超时,监控面板上 PID 还亮着绿灯。 可用户投诉已经刷屏,说页面转圈、下单失败。 我 ssh 上去,ps 看到 Java 进程还在,CPU 却飙到 300%。 再 curl 健康端点,等了 10 秒没响应。 那一刻我意识到:PID 只是进程存活的证据,根本不是业务可用的证明。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 02、问题 ```text 需求变化:从“进程不挂”到“业务可用” → 冲突:PID 无法反映线程池耗尽、DB 断连 → 后果:故障发现延迟、用户流失 ``` Petclinic 从开发环境走向生产,监控需求从“进程不挂”升级为“业务可用”。 但现有监控只看 PID,无法感知线程池耗尽、数据库连接池枯竭、Redis 超时。 后果是故障发现延迟,用户流失,运维背锅。 可验证的验收标准:当 MySQL 停止时,健康检查能在 5 秒内返回 DOWN,且 HTTP 状态码变为 503。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 03、原理 ```text 核心机制:Actuator 健康检查聚合所有 HealthIndicator,通过 HTTP 状态码映射整体状态 ``` Spring Boot Actuator 的 /actuator/health 端点会聚合所有 HealthIndicator。 每个依赖(如 MySQL、Redis)都有自己的 HealthIndicator,返回 UP 或 DOWN。 整体状态由 StatusAggregator 决定:任一 DOWN 则整体 DOWN,HTTP 返回 503。 反直觉判断:liveness 探针检查的是“进程是否该被重启”,readiness 探针检查的是“能否接收流量”,两者语义完全不同。 PID 存在只代表进程活着,不代表线程池可用、数据库连接正常。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 04、架构 ```text 输入:HTTP 请求 /actuator/health → 本模块:HealthIndicator 聚合 → 输出:JSON 状态 + HTTP 状态码 ``` 健康检查架构分三层:端点层、聚合层、指标层。 端点层暴露 HTTP 接口,聚合层调用所有 HealthIndicator,指标层由各依赖的自动配置提供。 收益是统一入口、自动发现依赖、状态可定制。 代价是每个 HealthIndicator 都可能增加响应延迟,需设置超时。 适用条件是生产环境必须区分 liveness 和 readiness,避免误杀。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Maven → 配置:添加 actuator 依赖 → 代码:无 → 启动:验证 /actuator/health → V1:基础健康检查 ``` 在 Petclinic 的 pom.xml 中添加依赖: ```xml org.springframework.boot spring-boot-starter-actuator ``` 版本需按当前官方文档核验,Spring Boot 4 中 Actuator 默认只暴露 health。 启动应用: ```bash mvn spring-boot:run ``` 验证请求: ```bash curl http://localhost:8080/actuator/health ``` 示例输出: ```json {"status":"UP"} ``` 成功标准:返回 UP,且 HTTP 状态码 200。 未在当前环境实测,以下为预期结果。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 06、排查 ```text 诊断树:健康检查返回 UP 但业务不可用 → 检查依赖健康 → 发现 MySQL 连接池耗尽 ``` 问题一:健康检查返回 UP,但 Petclinic 页面打不开。 现象:/actuator/health 返回 UP,但 /owners 接口超时。 怀疑:健康检查未覆盖关键依赖。 检查:查看健康详情: ```bash curl http://localhost:8080/actuator/health ``` 示例输出: ```json {"status":"UP"} ``` 证据:没有 db 和 redis 组件。 根因:默认 health 只显示简单状态,未暴露组件详情。 修复:配置显示详情: ```yaml management: endpoint: health: show-details: always ``` 问题二:配置后仍显示 UP,但 MySQL 已停止。 现象:MySQL 服务停止,health 仍 UP。 怀疑:MySQL 的 HealthIndicator 未生效。 检查:查看依赖是否引入: ```bash mvn dependency:tree | grep mysql ``` 证据:mysql-connector-j 存在,但 health 无 db 组件。 根因:Spring Boot 4 中,DataSource 健康检查需要 spring-boot-starter-jdbc 或 data-jpa。 修复:确认 Petclinic 已引入 spring-boot-starter-data-jpa。 **错误尝试:** 直接重启应用,以为能恢复,但 MySQL 未启动,重启后 health 仍 UP。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 07、优化 ```text {V1→证据→V2:配置 show-details,暴露 db、redis、自定义组件健康} ``` V1 只返回简单 UP,无法诊断。 证据:curl 输出只有 status,没有组件信息。 V2 配置 show-details: always,并添加 Redis 依赖: ```yaml management: endpoint: health: show-details: always ``` 添加 Redis 依赖: ```xml org.springframework.boot spring-boot-starter-data-redis ``` 重启后验证: ```bash curl http://localhost:8080/actuator/health ``` 示例输出: ```json { "status": "DOWN", "components": { "db": {"status": "UP"}, "redis": {"status": "DOWN"} } } ``` 现在能准确反映依赖状态。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 08、演进 ```text {V2→能力:区分 liveness/readiness → 遗留:自定义健康检查未实现} ``` V2 后,健康检查能反映依赖状态,但无法区分“进程该重启”和“能否接流量”。 演进:配置 liveness 和 readiness 探针: ```yaml management: endpoint: health: probes: enabled: true ``` 验证: ```bash curl http://localhost:8080/actuator/health/liveness curl http://localhost:8080/actuator/health/readiness ``` 示例输出: ```json {"status":"UP"} ``` 收益:K8s 能正确重启或摘流量。 代价:需要理解探针语义,避免误配置。 遗留:保险服务的健康检查尚未实现。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 09、洞见 ```text 从现象到判断:PID 是进程级信号,健康检查是业务级信号,两者不可混用 ``` ## 反直觉判断:liveness 探针失败不代表应用不可用 ```text liveness 失败 → 重启;readiness 失败 → 摘流量;两者混淆 → 频繁重启或流量黑洞 ``` liveness 探针检查的是进程是否死锁或卡死,失败时 K8s 会重启 Pod。 readiness 探针检查的是能否接收流量,失败时 K8s 会从 Service 摘除。 如果 liveness 检查了数据库连接,数据库抖动会导致 Pod 被重启,而不是优雅降级。 反直觉的是:liveness 应该只检查进程内部状态,不检查外部依赖。 ## 反直觉判断:健康检查的 HTTP 状态码比 JSON 内容更重要 ```text 200 = UP,503 = DOWN;K8s 只看状态码,不看 body ``` K8s 探针只关心 HTTP 状态码,200 表示成功,其他表示失败。 即使 JSON 里 status 是 DOWN,如果 HTTP 返回 200,K8s 也会认为健康。 所以配置健康检查时,必须确保 DOWN 时返回 503。 Spring Boot 默认如此,但自定义 HealthIndicator 时容易忽略。 ## 健康检查是系统信号,不是业务指标 ```text 健康检查回答“能不能服务”,业务指标回答“服务得好不好” ``` 健康检查是二值的,UP 或 DOWN。 业务指标如响应时间、错误率是连续的,需要监控系统处理。 不要用健康检查替代业务监控,两者互补。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 10、系统落地 ```text 入口:K8s 探针 → 服务:Petclinic → 本模块:Actuator 健康检查 → 存储:MySQL/Redis → 未实现:保险服务健康检查 ``` Petclinic 原本只有 PID 监控,无法感知依赖故障。 本篇增加了 Actuator 健康检查,能反映 MySQL、Redis 状态。 现在能做的:通过 /actuator/health 实时查看依赖健康,K8s 能正确摘除故障实例。 下一篇缺什么:保险服务的健康检查,需要自定义 HealthIndicator。 官方文档:https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 11、小结 ```text 三问三答:PID 不等于健康;健康检查需区分 liveness/readiness;选错后果是流量黑洞 ``` 冲突问句:PID 存在就等于应用健康吗? 答:不等于,PID 只代表进程存活,不代表线程池、数据库、Redis 可用。 选择问句:该用哪种健康检查机制? 答:用 Actuator 健康检查,并区分 liveness 和 readiness 探针。 选错后果问句:如果继续只看 PID? 答:流量黑洞持续,故障发现延迟,用户流失。 当前状态:Petclinic 已具备基础健康检查能力,能反映依赖状态。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 12、作业 ```text 理解→实战→排障→架构判断 ``` ## 理解题:为什么 PID 存在不能代表应用可以继续接流量? PID 存在只代表操作系统认为进程还活着,但线程池可能已耗尽、数据库连接可能已断开、JVM 可能已死锁。 这些情况都会导致请求无法处理,但 PID 不会消失。 ## 实战题:为 Petclinic 添加 Redis 健康检查,并验证关闭 Redis 后 health 状态变化 添加 spring-boot-starter-data-redis 依赖,配置 show-details: always。 启动应用后,用 `redis-cli shutdown` 关闭 Redis,再 curl /actuator/health,观察 redis 组件状态变为 DOWN。 ## 排障题:健康检查返回 UP,但业务接口超时,如何排查? 先查看 /actuator/health 的详情,确认 db、redis 组件状态。 再查看 /actuator/metrics 和 /actuator/threaddump,检查线程池和 JVM 状态。 最后用 jstack 查看线程堆栈,定位死锁或阻塞。 ## 架构判断题:在 K8s 中,liveness 探针是否应该检查数据库连接? 不应该。liveness 探针失败会导致 Pod 重启,数据库抖动会引发频繁重启。 应该用 readiness 探针检查数据库连接,失败时摘除流量,保留 Pod 等待恢复。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 # 13、升华 ```text 工程结论:健康检查是生产可用的第一道防线,必须从 PID 思维升级到业务可用性思维 ``` PID 是进程级信号,健康检查是业务级信号,两者不可混用。 Spring Boot Actuator 提供了标准化的健康检查机制,但需要正确配置和语义理解。 下一篇将深入自定义 HealthIndicator,实现保险服务的健康检查。 > 进程在,心已死, > 流量黑洞吞订单。 > 只看 PID 如盲人, > 健康检查方见真。 **系列项目:** Spring Petclinic 生产级演进 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 安全、数据访问、事务管理 **本篇:** Actuator 健康检查,区分 liveness/readiness,实现 MySQL/Redis 健康检查 **下一篇:** 自定义 HealthIndicator,实现保险服务健康检查

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第15招 异步定时和虚拟线程

# Petclinic 异步通知与每日就诊提醒:从线程池到虚拟线程的工程演进 ```text 旧方案:同步发送提醒 + 默认线程池 + 无界队列 冲突:一个 10 分钟定时任务拖住后续任务,线程膨胀压垮数据库 后果:提醒延迟、连接耗尽、服务雪崩 ``` Petclinic 现在每天要提醒宠主带宠物复诊,还要在预约变更后异步发通知。 我是老李,负责把官方教学项目改造成能扛住真实流量的生产级应用。 团队第一版用 `@Scheduled` 直接查库发邮件,结果某天一个慢查询跑了 10 分钟,后面所有提醒全部延迟。 为什么一个执行 10 分钟的定时任务可能拖住后面的任务? 为什么无限增加线程又可能把下游数据库压死? 如果选错线程池或拒绝策略,提醒会丢、数据库会崩,我们该怎么选? # 01、故事 ```text 周一 09:00 → 宠物医院运营部 → 发送每日就诊提醒 → 慢查询拖垮整批任务 ``` 周一早上九点,运营同事在群里催:今天的复诊提醒还没发出去。 任务是每天上午给所有当天有预约的宠主发提醒,同时异步通知预约变更。 我打开日志,发现定时任务线程卡在一条统计查询上,已经跑了 10 分钟。 后面的提醒任务全部排队,运营群里消息越来越多,数据库连接池也开始告警。 > 晨会群里催声急, > 慢查一跑全排队。 > 线程池里无空闲, > 提醒还在数据库。 # 02、问题 ```text 需求变化:同步发送 → 每日定时 + 异步通知 冲突:单线程调度 + 共享执行器 + 无界队列 后果:慢任务拖尾、线程膨胀、下游连接耗尽 ``` 原方案是请求里同步发邮件,现在要改成每天定时批量提醒,还要在预约变更后异步发通知。 失效原因是定时任务和异步通知共用默认执行器,一个慢任务占住线程,后续任务只能排队。 更糟的是,为了缓解排队,有人把线程池调大,结果并发查库把数据库连接池打满。 可验证的验收标准是:单条慢任务不能阻塞其他提醒,数据库并发连接数不超过 20,提醒延迟不超过 30 秒。 > 慢查占线十钟, > 后队排成长龙。 > 加线程解一时, > 下游先被打崩。 # 03、原理 ```text 调度线程 → 任务队列 → 工作线程池 → 阻塞 I/O → 下游资源 ``` Spring Boot 的 `@Scheduled` 默认由单线程 `TaskScheduler` 执行,一个慢任务会阻塞后续所有调度。 `@Async` 默认使用 `SimpleAsyncTaskExecutor`,它不复用线程,每个任务新建线程,高并发下线程数会失控。 线程池的核心参数是核心线程数、最大线程数、队列容量和拒绝策略。 反直觉的是:队列越大,越容易掩盖慢任务,最终导致请求超时和线程堆积。 JDK 21 的虚拟线程由 JVM 调度,阻塞 I/O 时线程会释放载体线程,适合大量阻塞场景。 但虚拟线程不会让 CPU 密集任务自动变快,因为 CPU 核心数没变。 > 单线程调度如独木, > 慢任务一过全堵住。 > 虚拟线程解阻塞, > CPU 密集仍有限。 # 04、架构 ```text 预约事件 → 异步通知服务 → 专用线程池 → 邮件/短信 定时触发 → 就诊提醒服务 → 批量查询 → 分批发送 ``` 我们把异步通知和定时提醒拆成两个独立执行器,避免互相干扰。 异步通知用虚拟线程执行器,适合大量阻塞 I/O;定时提醒用固定大小线程池,控制数据库并发。 边界是:定时任务只做查询和发送,不做复杂计算;异步通知不直接查大表,只发已准备好的消息。 收益是慢任务隔离、下游并发可控、线程资源弹性伸缩。 代价是配置复杂度上升,需要监控每个执行器的队列和拒绝次数。 适用条件是阻塞 I/O 占比高、任务量波动大、下游有明确并发上限。 > 两条执行路, > 各管各的务。 > 慢查不拖尾, > 并发有定数。 # 05、第一版实战 ```text JDK 21 → Spring Boot 4 → 默认执行器 → @Scheduled + @Async → 验证失败 ``` 我们使用 JDK 21 和 Spring Boot 4,先按最简方式实现。 在 `PetclinicApplication` 上开启 `@EnableAsync` 和 `@EnableScheduling`。 ```java @SpringBootApplication @EnableAsync @EnableScheduling public class PetclinicApplication { public static void main(String[] args) { SpringApplication.run(PetclinicApplication.class, args); } } ``` 创建 `VisitReminderService`,定时查询当天预约并发送提醒。 ```java @Service public class VisitReminderService { private final VisitRepository visitRepository; private final MailService mailService; public VisitReminderService(VisitRepository visitRepository, MailService mailService) { this.visitRepository = visitRepository; this.mailService = mailService; } @Scheduled(cron = "0 0 9 * * *") public void sendDailyReminders() { List visits = visitRepository.findByDate(LocalDate.now()); visits.forEach(v -> mailService.sendReminder(v)); } } ``` 创建 `AppointmentNotificationService`,异步发送预约变更通知。 ```java @Service public class AppointmentNotificationService { @Async public void sendChangeNotification(AppointmentEvent event) { mailService.send(event); } } ``` 启动应用后,手动触发一次慢查询模拟。 ```bash curl -X POST http://localhost:8080/visits/reminders/simulate-slow ``` 示例输出显示定时任务线程被占用,后续任务排队。 ```text 2025-01-06 09:00:00.123 INFO scheduling-1 : sendDailyReminders started 2025-01-06 09:10:00.456 INFO scheduling-1 : sendDailyReminders finished 2025-01-06 09:10:00.457 INFO scheduling-1 : next reminder started after 10 minutes delay ``` 成功标准未达成:慢任务阻塞了后续提醒,延迟超过 30 秒。 > 默认执行器, > 单线走到底。 > 慢查一占线, > 后队全延期。 # 06、排查 ```text 现象:提醒延迟 → 怀疑单线程调度 → 查线程名 → 确认 scheduling-1 被占 现象:线程膨胀 → 怀疑 SimpleAsyncTaskExecutor → 查线程数 → 确认每任务新建线程 ``` 第一个问题是提醒延迟。 怀疑是单线程调度被慢任务占用,检查线程名。 ```bash jstack | grep -A 5 "scheduling" ``` 示例输出显示 `scheduling-1` 线程一直处于 RUNNABLE,执行慢查询。 ```text "scheduling-1" #23 prio=5 os_prio=0 tid=0x00007f9a5c001000 nid=0x1a2b runnable at org.hibernate.Session.query at VisitRepository.findByDate ``` 根因是 `@Scheduled` 默认单线程,慢任务占住唯一调度线程。 第二个问题是异步通知线程膨胀。 怀疑是 `SimpleAsyncTaskExecutor` 不复用线程,检查线程数。 ```bash jcmd Thread.print | grep -c "SimpleAsyncTaskExecutor" ``` 示例输出显示线程数随请求线性增长。 ```text SimpleAsyncTaskExecutor-1 SimpleAsyncTaskExecutor-2 ... SimpleAsyncTaskExecutor-500 ``` 根因是 `@Async` 默认执行器为每个任务新建线程,高并发下线程数失控。 **错误尝试:** 我们曾把 `spring.task.execution.pool.max-size` 调到 200,结果数据库连接池被并发查询打满。 > 查线程名知单线, > 数线程数见膨胀。 > 调大池子更糟糕, > 下游先被打穿。 # 07、优化 ```text V1:默认执行器 + 单线程调度 + 无界队列 证据:scheduling-1 被占 10 分钟,SimpleAsyncTaskExecutor 线程数 500+ V2:专用调度线程池 + 虚拟线程异步执行器 + 有界队列 ``` 我们为定时任务配置专用调度线程池,避免单线程阻塞。 ```yaml spring: task: scheduling: pool: size: 4 thread-name-prefix: petclinic-scheduling- ``` 为异步通知配置虚拟线程执行器,并设置有界队列和拒绝策略。 ```java @Configuration public class AsyncConfig { @Bean(name = "notificationExecutor") public AsyncTaskExecutor notificationExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setVirtualThreads(true); executor.setQueueCapacity(200); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setThreadNamePrefix("notification-vt-"); executor.initialize(); return executor; } } ``` 在 `@Async` 上指定执行器。 ```java @Async("notificationExecutor") public void sendChangeNotification(AppointmentEvent event) { mailService.send(event); } ``` 定时任务改为分批查询,每批 50 条,控制数据库并发。 ```java @Scheduled(cron = "0 0 9 * * *") public void sendDailyReminders() { Pageable page = PageRequest.of(0, 50); Page visits; do { visits = visitRepository.findByDate(LocalDate.now(), page); visits.forEach(v -> mailService.sendReminder(v)); page = page.next(); } while (visits.hasNext()); } ``` > 调度池分四线, > 异步用虚拟。 > 队列设上限, > 拒绝不丢弃。 # 08、演进 ```text V2:专用调度池 + 虚拟线程 + 有界队列 + 分批查询 能力:慢任务隔离、线程弹性、下游并发可控 遗留:拒绝策略需监控,虚拟线程 CPU 密集场景无优势 ``` 用 V1 同一组请求验证:先模拟慢查询,再触发后续提醒。 ```bash curl -X POST http://localhost:8080/visits/reminders/simulate-slow curl -X POST http://localhost:8080/visits/reminders/trigger-next ``` 示例输出显示慢任务在 `petclinic-scheduling-1` 执行,后续提醒在 `petclinic-scheduling-2` 正常启动。 ```text 2025-01-06 09:00:00.123 INFO petclinic-scheduling-1 : slow reminder started 2025-01-06 09:00:01.456 INFO petclinic-scheduling-2 : next reminder started 2025-01-06 09:00:01.789 INFO petclinic-scheduling-2 : next reminder finished ``` 异步通知线程数不再随请求线性增长,虚拟线程复用载体线程。 ```text notification-vt-1 notification-vt-2 ... notification-vt-20 ``` 收益是慢任务隔离、线程弹性伸缩、数据库并发控制在 20 以内。 代价是配置复杂度上升,需要监控队列深度和拒绝次数。 遗留问题是 `CallerRunsPolicy` 在队列满时会让调用线程执行任务,可能拖慢主流程。 > 慢查不再拖尾, > 线程弹性伸缩。 > 并发有了上限, > 监控还需补全。 # 09、洞见 ```text 从现象到判断:慢任务拖尾 → 单线程调度;线程膨胀 → 默认执行器;下游压垮 → 无界并发 ``` ## 反直觉判断:队列越大越危险 ```text 无界队列 → 任务堆积 → 请求超时 → 线程堆积 ``` 很多人以为队列大能缓冲流量,实际上无界队列会掩盖慢任务,导致请求超时和内存压力。 有界队列配合拒绝策略,才能让系统在过载时快速失败或降级。 ## 反直觉判断:虚拟线程不加速 CPU 密集任务 ```text 虚拟线程 → 阻塞 I/O 释放载体 → 吞吐提升 虚拟线程 → CPU 密集占用载体 → 吞吐不变 ``` 虚拟线程的价值在于阻塞 I/O 时释放载体线程,让少量载体线程支撑大量并发。 但 CPU 密集任务会一直占用载体线程,虚拟线程切换反而增加开销。 ## 定时任务要分批 ```text 全量查询 → 长事务 → 锁表 → 连接占用 分批查询 → 短事务 → 锁释放 → 并发可控 ``` 定时任务一次查全量数据,容易产生长事务和锁竞争。 分批查询能缩短事务时间,控制数据库并发,避免影响在线业务。 > 队列大藏隐患, > 虚拟线有边界。 > 分批查控并发, > 监控才能心安。 # 10、系统落地 ```text 入口:预约变更事件、每日定时触发 服务:AppointmentNotificationService、VisitReminderService 本模块:专用调度池 + 虚拟线程执行器 + 有界队列 + 分批查询 存储:MySQL 预约表、邮件发送记录 未实现:分布式调度、消息队列、死信重试 ``` 原来 Petclinic 只有同步发送邮件,没有定时任务和异步通知。 本篇增加了每日就诊提醒、预约变更异步通知、专用线程池和虚拟线程执行器。 现在能隔离慢任务、控制数据库并发、弹性伸缩阻塞 I/O 线程。 下一篇缺分布式调度和消息队列,单机调度在集群下会重复执行。 官方文档见 Spring Boot 官方文档首页和 GitHub 仓库。 > 从同步到异步, > 从单线到多线。 > 从无界到有界, > 从压垮到可控。 # 11、小结 ```text 三问三答:慢任务拖尾 → 单线程调度;线程膨胀 → 默认执行器;下游压垮 → 无界并发 ``` 为什么一个执行 10 分钟的定时任务可能拖住后面的任务? 因为 `@Scheduled` 默认单线程调度,慢任务占住唯一线程,后续任务排队。 为什么无限增加线程又可能把下游数据库压死? 因为线程数增加会放大并发查询,数据库连接池和锁竞争先达到瓶颈。 选错拒绝策略会怎样? 丢弃策略会丢提醒,调用者执行策略会拖慢主流程,需要按业务选择并监控。 当前状态是定时提醒和异步通知已隔离,数据库并发可控,虚拟线程支撑阻塞 I/O。 > 单线调度是根因, > 无界并发是帮凶。 > 有界队列加监控, > 虚拟线程解阻塞。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么虚拟线程不能加速 CPU 密集任务? 虚拟线程由 JVM 调度到少量载体线程上,CPU 密集任务会一直占用载体线程,无法释放。 虚拟线程切换反而增加调度开销,吞吐不会提升,甚至可能下降。 ## 实战题:为 Petclinic 配置一个虚拟线程执行器,队列容量 100,拒绝策略为 AbortPolicy,并验证队列满时抛异常。 ```java @Bean(name = "vtExecutor") public AsyncTaskExecutor vtExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setVirtualThreads(true); executor.setQueueCapacity(100); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; } ``` 队列满时提交任务会抛 `TaskRejectedException`,可在日志中验证。 ## 排障题:定时任务延迟,但线程池大小已调到 10,如何定位? 先查调度线程名和状态,确认是否被慢任务占用。 ```bash jstack | grep -A 10 "petclinic-scheduling" ``` 再查数据库慢查询日志,确认慢任务根因。 最后查队列深度和拒绝次数,确认是否队列堆积。 ## 架构判断题:单机定时任务在集群下会重复执行,是否应该立即引入分布式调度? 不一定。如果重复执行不产生副作用,可以先用单机调度加幂等控制。 如果重复执行会导致重复扣款或重复发送,需要引入分布式调度或分布式锁。 > 理解虚拟边界, > 实战配置执行。 > 排障查根因, > 架构看场景。 # 13、升华 ```text 工程结论:线程池是资源管理工具,不是性能银弹 ``` 线程池的核心是隔离和限流,不是无限放大并发。 虚拟线程解决阻塞 I/O 的资源模型问题,但不改变 CPU 密集任务的物理限制。 定时任务要分批、有界、可监控,才能避免拖尾和下游压垮。 下一篇我们将引入分布式调度和消息队列,解决集群下的重复执行和任务可靠性。 > 线程池里藏乾坤, > 有界队列守城门。 > 虚拟线程解阻塞, > 分批查库保安稳。 **系列项目:** Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全、数据访问、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置、容器构建 **本篇:** 每日就诊提醒与异步通知,专用调度池、虚拟线程执行器、有界队列、分批查询 **下一篇:** 分布式调度与消息队列,解决集群重复执行和任务可靠性

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第17招 测试别全用SpringBootTest

# Petclinic 测试金字塔:从全量 SpringBootTest 到分层切片 ```text 旧方案:所有测试都启动完整 Spring Context → 冲突:测试数量增长后,启动慢、依赖脆弱、故障定位难 → 后果:CI 反馈变慢,开发不敢频繁跑测试,测试系统逐渐失去保护价值 ``` Petclinic 官方项目自带一组测试,但很多团队接手后会把所有测试都写成 `@SpringBootTest`。 我是老李,最近在给一个 Petclinic 改造项目做代码评审,发现测试类里几乎每个用例都加载完整应用上下文。 一开始大家觉得“真实环境最可靠”,可当测试超过 200 个以后,本地跑一遍要十几分钟,CI 经常超时。 为什么“所有测试都是真实 Spring Context”听起来很好,实际却可能得到最慢、最脆弱的测试系统? 应该按什么边界把测试拆成 Pure Unit Test、Web Slice、Data Slice 和完整 @SpringBootTest? 如果选错测试层级,会不会让快速反馈消失,同时让故障定位成本成倍上升? # 01、故事 ```text 周四 23:40 → 公司 CI 服务器 → 修复 Petclinic 账单查询缺陷 → 全量测试跑了 18 分钟还没结束 ``` 晚上十一点多,CI 屏幕上 Petclinic 的测试任务还在转圈。 任务是修复一个账单查询接口的空指针问题,只改了一个 Service 方法。 旁边同事在催发布窗口,运维等着打包,可测试套件像被粘住一样。 我打开日志,发现几百个测试都在重复启动同一个 Spring 上下文,Bean 初始化日志刷了几十屏。 想定位失败用例,却要先从一堆上下文启动耗时里找真正的断言错误。 > 夜深构建仍未归, > 满屏 Bean 载如潮水。 > 只改一行查询错, > 却等全量测试陪。 # 02、问题 ```text 需求变化:测试数量从几十增长到几百 → 冲突:所有用例共享完整 Spring Context 启动成本 → 后果:反馈慢、环境依赖多、失败原因被淹没 ``` 全量 `@SpringBootTest` 会加载所有配置类、数据源、事务管理器、Web 容器和第三方客户端。 测试数量少时,这点开销不明显;数量一多,每个测试类都可能触发新的上下文缓存键。 只要某个 `@MockBean`、`@TestPropertySource` 或 profile 不同,Spring 就会重新创建上下文。 结果 CI 时间线性甚至指数上升,本地开发也不敢频繁跑测试。 可验证的验收标准是:同一组核心用例,分层后总耗时下降 50% 以上,失败日志能直接指向业务断言。 > 真实二字最诱人, > 全量启动却伤身。 > 缓存键变上下文, > 慢与脆从根上生。 # 03、原理 ```text 测试金字塔核心机制:按集成广度分层 → 越底层启动越少 Spring 基础设施 → 越顶层越接近生产,但数量应越少 ``` JUnit Jupiter 负责生命周期与扩展模型,AssertJ 负责可读断言,MockMvc 负责 Web 层切片。 Spring Test 的 `ApplicationContextRunner` 和 `@DataJpaTest` 等切片注解,只加载当前层需要的 Bean。 Pure Unit Test 不启动 Spring,直接 new 对象并注入 Mockito mock,速度最快。 Web Slice 使用 `@WebMvcTest`,只加载 Controller、ControllerAdvice、Filter 等 Web 组件。 Data Slice 使用 `@DataJpaTest`,只加载 JPA 映射、Repository 和嵌入式数据库。 完整 `@SpringBootTest` 加载所有 Bean,适合少量端到端冒烟测试。 反直觉的是:测试越“真实”,并不等于越能快速发现业务错误;真实上下文反而会放大环境噪声。 > 分层不是偷懒计, > 各守边界最清晰。 > 全量只做冒烟用, > 快速反馈靠底层。 # 04、架构 ```text 输入:Petclinic 测试需求 → 本模块:四层测试体系 → 输出:不同启动成本、验证边界与故障定位能力 ``` 第一层 Pure Unit Test 验证 Service、Validator、工具类纯逻辑,不加载 Spring。 第二层 Web Slice 验证 Controller 参数绑定、校验、异常映射和 HTTP 状态码。 第三层 Data Slice 验证 Repository 查询、审计字段和事务回滚行为。 第四层 Full SpringBootTest 验证配置装配、核心链路和外部依赖集成。 收益是启动成本从高到低分布,数量从少到多分布,形成金字塔。 代价是需要团队理解每层边界,避免把同一逻辑重复测试。 适用条件是应用具备清晰分层,Controller、Service、Repository 职责不混乱。 > 四层如塔立工程, > 底层最多顶层精。 > 各测边界不越位, > 快慢分明排障轻。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Java 21 + Maven → 配置:保留官方 Petclinic 依赖 → 代码:先写一个全量 @SpringBootTest 验证账单查询 → 启动:mvn test → 验证:观察 Bean 数量与耗时 → V1:全量测试通过但成本高 ``` 在 `pom.xml` 中确认测试依赖包含 `spring-boot-starter-test`、`spring-boot-testcontainers` 和 `junit-jupiter`。 ```xml org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-testcontainers test ``` 创建全量测试类 `BillingServiceFullContextTest`。 ```java @SpringBootTest class BillingServiceFullContextTest { @Autowired private BillingService billingService; @Test void shouldFindBillByVisitId() { Bill bill = billingService.findByVisitId(100L); assertThat(bill).isNotNull(); assertThat(bill.getAmount()).isPositive(); } } ``` 运行测试并统计上下文启动信息。 ```bash mvn -Dtest=BillingServiceFullContextTest test ``` 示例输出显示加载了大量与账单无关的 Bean。 ```text 示例输出: Started PetclinicApplication in 8.2 seconds Loaded 412 beans ``` V1 测试通过,但一个简单查询也要启动整个应用。 > 全量测试先落地, > 四百 Bean 皆加载。 > 查询一行虽通过, > 八秒启动已嫌多。 # 06、排查 ```text 诊断树:测试慢 → 怀疑全量上下文启动 → 检查 Spring Test 上下文缓存 → 发现不同测试类因注解差异反复重建 → 根因:上下文缓存键不一致 ``` 第一个问题是测试总耗时异常增长。 怀疑是每个测试类都启动新上下文,于是检查测试报告中的上下文缓存命中情况。 ```bash mvn test -Dspring.test.context.cache.max.size=32 -Dspring.test.context.cache.diagnostics=always ``` 日志显示多个上下文键不同,原因是不同测试类使用了不同的 `@MockBean` 或 profile。 ```text 示例输出: Context cache key mismatch due to @MockBean differences ``` 第二个问题是失败日志被启动信息淹没。 检查 CI 日志,发现前 200 行都是 Bean 初始化,真正的断言失败在最后。 根因是全量测试把环境准备和业务验证混在一起。 **错误尝试:** 曾尝试只调大 JVM 堆内存,结果启动时间没明显下降,反而掩盖了上下文重复创建问题。 > 慢病不只在内存, > 缓存键异最伤神。 > 日志翻尽无断言, > 根因藏在上下文。 # 07、优化 ```text V1:全量 @SpringBootTest 验证账单查询 → 证据:412 Bean、8.2 秒启动、失败日志难定位 → V2:拆成 Pure Unit、Web Slice、Data Slice 和少量全量测试 ``` 先为 `BillingService` 写 Pure Unit Test,不启动 Spring。 ```java class BillingServicePureUnitTest { private BillingRepository billingRepository; private BillingService billingService; @BeforeEach void setUp() { billingRepository = mock(BillingRepository.class); billingService = new BillingService(billingRepository); } @Test void shouldReturnBillWhenExists() { Bill bill = new Bill(); bill.setAmount(BigDecimal.TEN); when(billingRepository.findByVisitId(100L)).thenReturn(Optional.of(bill)); Bill result = billingService.findByVisitId(100L); assertThat(result.getAmount()).isEqualByComparingTo("10.00"); } } ``` 再为 Controller 写 Web Slice。 ```java @WebMvcTest(BillingController.class) class BillingControllerWebSliceTest { @Autowired private MockMvc mockMvc; @MockitoBean private BillingService billingService; @Test void shouldReturnBillJson() throws Exception { Bill bill = new Bill(); bill.setAmount(BigDecimal.TEN); when(billingService.findByVisitId(100L)).thenReturn(bill); mockMvc.perform(get("/bills/100")) .andExpect(status().isOk()) .andExpect(jsonPath("$.amount").value(10.0)); } } ``` 为 Repository 写 Data Slice。 ```java @DataJpaTest class BillingRepositoryDataSliceTest { @Autowired private BillingRepository billingRepository; @Test void shouldFindByVisitId() { Bill bill = new Bill(); bill.setVisitId(100L); bill.setAmount(BigDecimal.TEN); billingRepository.save(bill); Optional result = billingRepository.findByVisitId(100L); assertThat(result).isPresent(); assertThat(result.get().getAmount()).isEqualByComparingTo("10.00"); } } ``` 保留一个全量冒烟测试验证核心链路。 ```java @SpringBootTest class PetclinicApplicationSmokeTest { @Test void contextLoads() { } } ``` > 拆开之后天地宽, > 各层只载自家 Bean。 > 单测毫秒即反馈, > 切片秒级可排障。 # 08、演进 ```text V2:四层测试体系 → 能力:按启动成本与验证边界分层 → 遗留:全量测试仍依赖真实数据库或 Testcontainers ``` 用同一组账单查询需求验证前后差异。 Pure Unit Test 不加载 Spring,运行时间通常小于 1 秒。 Web Slice 只加载 Web 相关 Bean,Bean 数量约 30 到 60 个,启动约 2 到 3 秒。 Data Slice 只加载 JPA 相关 Bean,Bean 数量约 40 到 80 个,启动约 2 到 4 秒。 全量测试 Bean 数量超过 400,启动约 8 到 12 秒。 分层后核心测试总耗时从 18 分钟下降到约 6 分钟,失败日志直接显示业务断言。 代价是测试类数量增加,需要维护更多测试文件。 适用场景是持续集成频繁运行、团队需要快速反馈。 遗留问题是全量测试仍依赖数据库状态,下一篇可引入 Testcontainers 固定外部依赖。 > 同组需求前后比, > 耗时减半日志清。 > 分层不是万能药, > 外部依赖待固定。 # 09、洞见 ```text 从现象到判断:测试慢与脆不是硬件问题,而是上下文边界问题 ``` ## 测试速度由上下文启动次数决定,而不是测试数量 ```text 测试数量多 → 上下文缓存命中高 → 速度可接受 上下文键差异多 → 反复启动 → 速度崩塌 ``` 反直觉判断:增加 100 个相同上下文的测试,可能只增加几秒;增加 5 个不同 `@MockBean` 的测试类,可能增加几十秒。 ## 真实上下文会掩盖业务错误 ```text 全量启动 → 环境噪声多 → 失败原因被淹没 切片启动 → 只留当前层 → 断言失败直接可见 ``` 反直觉判断:测试越接近生产,排障体验可能越差,因为失败可能来自任意一个 Bean。 ## 测试金字塔是反馈频率设计 ```text 底层测试多 → 反馈快 → 开发敢频繁跑 顶层测试少 → 反馈慢 → 只做发布前验证 ``` 测试体系的目标不是覆盖所有组合,而是让高频反馈足够快,低频验证足够真实。 > 慢与脆非天命, > 边界不清是根因。 > 分层之后方明白, > 快慢皆在上下文。 # 10、系统落地 ```text 入口:开发者提交代码或本地运行测试 → 服务:Maven Surefire 执行 JUnit Jupiter → 本模块:四层测试体系 → 存储:H2 或 Testcontainers 数据库 → 未实现:外部依赖固定与并行测试 ``` 原来 Petclinic 只有少量官方测试,且多数依赖完整上下文。 本篇增加了 Pure Unit Test、Web Slice、Data Slice 和全量冒烟测试的分层结构。 现在能按启动成本选择测试层级,快速定位 Controller、Service 或 Repository 问题。 下一篇缺少 Testcontainers 固定数据库和消息队列等外部依赖,避免测试受环境漂移影响。 官方文档可参考 Spring Boot 测试主页:https://spring.io/projects/spring-boot > 原是小测不成军, > 今有四层可守城。 > 快速反馈护开发, > 外部依赖待固定。 # 11、小结 ```text 三问三答: 全真实上下文为何慢?→ 上下文缓存键差异导致反复启动 如何分层?→ Pure Unit、Web Slice、Data Slice、Full SpringBootTest 选错后果?→ 反馈慢、排障难、测试系统失去保护价值 ``` 全真实上下文慢,是因为不同测试类注解差异导致 Spring 反复创建上下文。 分层按集成广度划分,底层不启动 Spring,上层只加载当前层 Bean。 选错层级会让快速反馈消失,故障定位成本成倍上升。 当前 Petclinic 测试体系已具备四层结构,核心链路耗时下降,失败日志更清晰。 > 三问皆有解, > 分层是正途。 > 快慢各有位, > 测试护工程。 # 12、作业 ```text 理解→实战→排障→架构判断 ``` ## 理解题:为什么 `@WebMvcTest` 比 `@SpringBootTest` 启动更快? `@WebMvcTest` 只加载 Controller、ControllerAdvice、Filter 等 Web 组件,不加载 Service、Repository、数据源等完整 Bean,因此上 下文更小,启动更快。 ## 实战题:把 Petclinic 的 `OwnerController` 测试从全量改为 Web Slice,并比较 Bean 数量。 使用 `@WebMvcTest(OwnerController.class)`,用 `@MockitoBean` 模拟 `OwnerService`,通过 MockMvc 验证 `/owners` 返 回 200。运行后观察日志中的 Bean 数量,通常从 400 多降到几十个。 ## 排障题:Data Slice 测试通过,但全量测试中 Repository 查询失败,可能是什么原因? 可能是全量测试使用了真实数据库或不同 schema,而 Data Slice 默认使用嵌入式 H2;也可能是全量上下文中有额外配置覆盖了 JPA 属性。检查数据源配置和初始化脚本差异。 ## 架构判断题:是否应该把所有 Service 测试都写成 Pure Unit Test? 不应该。纯逻辑 Service 适合 Pure Unit Test,但涉及事务、审计或复杂 JPA 查询的 Service,需要 Data Slice 或全量测试验证集成行为。 分层依据是验证边界,而不是类名。 > 四题如梯步步高, > 理解实战再排障。 > 架构判断最难得, > 边界清晰方为高。 # 13、升华 ```text 工程结论:测试系统的价值不在于“真实”,而在于反馈速度与故障定位能力的平衡 ``` Petclinic 测试金字塔证明,全量 Spring Context 不是默认选择,而是少量冒烟测试的专用工具。 Pure Unit Test 保护业务逻辑,Web Slice 保护 HTTP 边界,Data Slice 保护持久化边界。 当测试按启动成本分层后,开发才敢频繁运行测试,CI 才能快速反馈。 下一篇将引入 Testcontainers 固定数据库和外部依赖,让全量测试也能稳定可重复。 > 莫把真实当信仰, > 分层方得反馈快。 > 测试如塔守工程, > 快慢相济方不败。 **系列项目:** 基于 Spring 官方 Petclinic 的宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全层、数据层、事务管理、远程调用、数据库迁移、缓存、并发控制、日志、测试基础、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置、容器构建、生产上线 **本篇:** 测试金字塔分层体系,Pure Unit、Web Slice、Data Slice 与全量 SpringBootTest 的边界与成本比较 **下一篇:** 用 Testcontainers 固定外部依赖,提升全量测试稳定性与可重复性

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第18招 Testcontainers替掉假环境

# Testcontainers 真库验证:从 H2 假绿到 MySQL 首分钟失败 ```text 旧方案:H2 兼容模式 + Mock Redis → 测试全绿 → 生产 MySQL 首分钟失败 ``` Petclinic 的测试长期依赖 H2 内存库和 Mock Redis。开发环境跑得飞快,CI 也一路绿灯。 但最近一次发布后,应用连接生产 MySQL 不到一分钟就报错回滚。我是老李,当时被运维拉进排障群,第一反应就是测试环境与生产环境差异太大。 为什么单元测试和 H2 全部通过,程序仍然可能在连接生产 MySQL 的第一分钟就失败? 在 Mock、Fake、H2、Testcontainers 之间,应该按什么测试范围选择,才能既保速度又保真实? 如果继续用 H2 假绿掩盖方言差异,下一次发布会不会直接造成数据损坏或线上事务失效? # 01、故事 ```text 凌晨 02:17 → 生产发布群 → 任务是回滚 Petclinic 新版本 → 冲突是 MySQL 报错但测试全绿 ``` 凌晨两点十七分,发布群突然刷出红色告警。任务是回滚刚上线的 Petclinic 版本。 运维贴出日志:MySQL 语法错误,紧接着事务超时。开发负责人坚持说测试全绿,H2 上跑过几十遍。 群里开始互相甩锅,没人能立刻说清是 SQL 方言问题、Schema 迁移问题,还是连接参数问题。我打开仓库一看,测试配置里赫然写着 H2 兼容模式。 > 夜半红灯照发布, > 测试全绿却翻车。 > 内存库中皆幻象, > 真库方知差异多。 # 02、问题 ```text 需求变化:验证真实 MySQL/Redis 行为 → 冲突:H2 与 Mock 掩盖差异 → 后果:生产首分钟失败 ``` Petclinic 从教学项目演进为生产级应用后,测试目标已经改变。现在必须验证 MySQL SQL 方言、Schema 迁移、事务隔离级别、Redis TTL 等真实行为。 H2 兼容模式只能模拟部分 MySQL 语法,遇到 `ON DUPLICATE KEY UPDATE`、`LIMIT ? OFFSET ? `、特定索引行为时经常静默通过或直接跳过。 Mock Redis 更不会真正执行过期策略。 可验证的验收标准是:CI 中自动拉起真实 MySQL 和 Redis,应用能完成迁移、读写、事务回滚、TTL 过期,且测试结束后容器自动销毁。 > 假库如镜花水月, > 真库似照妖镜悬。 > 方言迁移与事务, > 唯有实境可检验。 # 03、原理 ```text Testcontainers 生命周期:启动容器 → 等待就绪 → 注入连接 → 执行测试 → 销毁容器 ``` Testcontainers 通过 Docker API 启动一次性容器,用 `WaitStrategy` 探测端口或日志,确认服务就绪后再执行测试。 Spring Boot 3.1+ 的 Service Connection 机制能自动识别 `MySQLContainer` 和 `GenericContainer`,把容器连接参数注入 `DataS ource` 和 `RedisConnectionFactory`,无需手写 URL、端口、账号。 反直觉的是:Testcontainers 并不比 H2 慢多少。容器启动约 10~20 秒,但能消除方言差异带来的排障成本,整体工程效率反而更高。 另一个反直觉判断:Mock 适合验证调用契约,但永远无法验证 SQL 执行计划、锁行为、字符集排序规则。 > 容器起落如潮汐, > 真库真缓存真协议。 > 自动注入免硬编码, > 短命依赖可复现。 # 04、架构 ```text 测试进程 → Testcontainers Docker API → MySQL/Redis 容器 → Spring Boot Service Connection → DataSource/RedisConnectionFactory ``` 测试层分为四个边界:测试类声明容器、Testcontainers 管理生命周期、Spring Boot 自动注入连接、应用代码使用真实依赖。 收益是测试环境与生产环境高度一致,能提前暴露方言、迁移、事务、TTL 问题。代价是测试启动时间增加,且 CI 必须支持 Docker。 适用条件是:任何涉及数据库 Schema、SQL 方言、事务行为、缓存过期的集成测试。纯业务逻辑单元测试仍应使用 Mock 或 Fake。 > 分层清晰边界明, > 容器短命却真实。 > 单元测试保速度, > 集成测试保一致。 # 05、第一版实战 ```text 环境:Spring Boot 3.3 + Java 21 + Docker → 配置:testcontainers 依赖 → 代码:MySQLContainer + RedisContainer → 启动:CI Docker → 验证:真实 SQL 与 TTL → V1 ``` 在 `pom.xml` 中加入 Testcontainers 依赖。版本需按当前官方文档核验。 ```xml org.springframework.boot spring-boot-testcontainers test org.testcontainers mysql test org.testcontainers junit-jupiter test ``` 创建测试基类,声明 MySQL 和 Redis 容器。 ```java @Testcontainers @SpringBootTest public abstract class AbstractIntegrationTest { @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") .withDatabaseName("petclinic") .withUsername("petclinic") .withPassword("petclinic"); @Container static final GenericContainer REDIS = new GenericContainer<>("redis:7.4") .withExposedPorts(6379); @DynamicPropertySource static void registerProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", MYSQL::getJdbcUrl); registry.add("spring.datasource.username", MYSQL::getUsername); registry.add("spring.datasource.password", MYSQL::getPassword); registry.add("spring.data.redis.host", REDIS::getHost); registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); } } ``` 编写一个真实 SQL 方言测试。 ```java class OwnerRepositoryIntegrationTest extends AbstractIntegrationTest { @Autowired OwnerRepository ownerRepository; @Test void shouldPersistOwnerWithMySQLDialect() { Owner owner = new Owner(); owner.setFirstName("李"); owner.setLastName("雷"); owner.setCity("深圳"); ownerRepository.save(owner); List found = ownerRepository.findByLastName("雷"); assertThat(found).hasSize(1); } } ``` 运行测试命令。 ```bash mvn -Dtest=OwnerRepositoryIntegrationTest test ``` 示例输出: ```text [INFO] Starting MySQLContainer... [INFO] Container mysql:8.4 started in 12.4s [INFO] Starting RedisContainer... [INFO] Container redis:7.4 started in 3.1s [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 ``` V1 成功:真实 MySQL 和 Redis 在测试中启动,应用完成真实读写。 > 容器初起破假象, > 真库真缓存登场。 > 方言迁移皆验证, > 首版落地见真章。 # 06、排查 ```text 诊断树:CI 失败 → 怀疑 Docker 不可用 → 检查 Docker socket → 证据:权限拒绝 → 根因:CI runner 未挂载 socket → 修复:配置 Docker-in-Docker ``` 第一个问题:CI 上测试启动失败,报 `Could not find a valid Docker environment`。 怀疑是 Docker 未安装或权限不足。检查命令: ```bash docker info ``` 示例输出: ```text ERROR: permission denied while trying to connect to the Docker daemon socket ``` 根因是 CI runner 未把 Docker socket 挂载进测试容器。修复方式是在 CI 配置中使用 Docker-in-Docker 或挂载 `/var/run/docker.sock`。 第二个问题:本地测试通过,但 CI 上 MySQL 容器启动超时。 怀疑是镜像拉取慢。检查日志: ```text Waiting for database connection to become available at jdbc:mysql://localhost:33061/petclinic ``` 根因是 CI 网络拉取 `mysql:8.4` 镜像耗时超过默认等待时间。修复方式是增加 `withStartupTimeout(Duration.ofSeconds(120))`。 **错误尝试:** 一开始把 MySQL 容器端口固定为 3306,结果本地已有 MySQL 占用端口,容器启动失败。后来改为动态端口映射才解决。 > 排查如剥洋葱皮, > 层层深入见根因。 > 权限网络与端口, > 皆是容器常见坑。 # 07、优化 ```text V1:手动 DynamicPropertySource → 证据:硬编码账号密码 → V2:Spring Boot Service Connection 自动注入 ``` V1 中手动注册属性虽然可用,但账号密码硬编码在测试类里,且每个容器都要写一遍。 Spring Boot 3.1+ 提供 Service Connection 机制,能自动识别容器并注入连接参数。优化后测试基类大幅简化。 ```java @Testcontainers @SpringBootTest public abstract class AbstractIntegrationTest { @Container @ServiceConnection static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") .withDatabaseName("petclinic"); @Container @ServiceConnection static final GenericContainer REDIS = new GenericContainer<>("redis:7.4") .withExposedPorts(6379); } ``` 移除 `@DynamicPropertySource` 后,Spring Boot 自动把容器连接参数注入 `DataSource` 和 `RedisConnectionFactory`。 账号密码由容器随机生成,不再硬编码。 > 手动注入多繁琐, > 自动连接更从容。 > 账号密码随机化, > 安全简洁两相融。 # 08、演进 ```text V2:Service Connection 自动注入 → 能力:CI 自动拉起真实依赖 → 遗留:容器启动时间与镜像缓存 ``` 用 V1 同一组测试请求前后验证。V1 需要手动注册属性,V2 只需声明 `@ServiceConnection`。 测试结果一致,但代码量减少约 40%,且消除了硬编码账号密码的安全隐患。 收益是 CI 能自动拉起一套真实但短命的应用依赖,测试环境与生产环境差异大幅缩小。代价是每次测试多花 15~20 秒启动容器。 遗留问题是容器镜像缓存策略尚未优化,冷启动时拉取镜像可能拖慢 CI。 > 自动注入减负担, > 真实依赖可复现。 > 镜像缓存待优化, > 短命容器护平安。 # 09、洞见 ```text 从 H2 假绿到 Testcontainers 真验证,核心是测试环境与生产环境的一致性工程 ``` ## 测试金字塔的底座必须是真实依赖 ```text 单元测试 Mock → 集成测试 Testcontainers → 端到端测试真实环境 ``` 单元测试用 Mock 验证业务逻辑,集成测试必须用 Testcontainers 验证真实依赖。H2 只能作为本地快速冒烟,不能作为发布依据。 ## 反直觉判断:Testcontainers 的慢是工程效率的加速器 ```text 容器启动 15 秒 → 消除方言差异排障 3 小时 → 净收益为正 ``` 很多人认为 Testcontainers 拖慢测试。实际上,一次生产方言事故的排障成本远超容器启动时间。 ## 反直觉判断:Service Connection 的价值不在少写代码,而在消除配置漂移 ```text 硬编码 URL → 环境差异 → 配置漂移 → 自动注入 → 单一事实来源 ``` 自动注入的真正价值是让连接参数由容器实例决定,而不是由测试代码或配置文件决定,从源头消除配置漂移。 ## 测试范围选择是一个经济决策 ```text Mock 验证契约 → Fake 验证逻辑 → H2 验证映射 → Testcontainers 验证真实行为 ``` 选择哪种测试替身,取决于失败成本和验证目标。涉及方言、迁移、事务、TTL 时,必须用 Testcontainers。 > 假绿如糖衣毒药, > 真验似苦口良方。 > 容器短命却真实, > 工程效率自昭彰。 # 10、系统落地 ```text 测试入口 → Spring Boot Test → Testcontainers → MySQL/Redis 容器 → 真实数据与缓存 → 未实现:镜像缓存与并行化 ``` 原来 Petclinic 测试依赖 H2 和 Mock Redis,只能验证逻辑正确性。 本篇增加 Testcontainers 与 Service Connection,现在能验证真实 SQL 方言、Schema 迁移、事务行为、Redis TTL。 下一篇缺的是容器镜像缓存策略和测试并行化,以进一步缩短 CI 反馈时间。官方文档可参考 Spring Boot 与 Testcontainers 文档首页。 > 系统落地步步实, > 真实依赖入测试。 > 缓存并行待优化, > 反馈加速正当时。 # 11、小结 ```text 三问三答:H2 假绿因方言差异;按范围选择测试替身;继续假绿将导致生产事故 ``` 为什么 H2 全绿生产仍失败?因为 H2 兼容模式无法覆盖 MySQL 方言、迁移、事务隔离级别等真实行为。 如何选择测试替身?单元测试用 Mock,业务逻辑用 Fake,本地冒烟用 H2,集成测试用 Testcontainers。 选错后果是什么?继续用 H2 假绿,下一次发布可能造成数据损坏、事务失效或线上排障成本飙升。当前状态:CI 已能自动拉起真实 MySQL 和 Redis。 > 三问三答明方向, > 假绿真验自分明。 > 测试范围按需选, > 生产安全有保证。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 H2 兼容模式无法替代 Testcontainers 验证 MySQL 方言? H2 兼容模式只模拟部分 MySQL 语法,遇到 `ON DUPLICATE KEY UPDATE`、`LIMIT ? OFFSET ? `、特定索引行为时可能静默通过或跳过,无法验证真实执行计划、锁行为和字符集排序规则。 ## 实战题:在 Petclinic 中为 VetRepository 编写一个 Testcontainers 集成测试,验证真实 MySQL 下的分页查询。 继承 `AbstractIntegrationTest`,注入 `VetRepository`,插入 20 条 Vet 数据,调用分页查询方法,断言返回条数和排序符合 MySQL 方言预期。 ## 排障题:CI 上 Testcontainers 测试报 `Container startup failed`,本地却通过,如何排查? 先检查 CI 是否支持 Docker,执行 `docker info` 确认权限。再检查镜像拉取是否超时,增加 `withStartupTimeout`。最后检查端口冲突,确保使用动态端口映射。 ## 架构判断题:如果团队要求所有测试必须在 30 秒内完成,是否应该放弃 Testcontainers? 不应该。应把 Testcontainers 限定在集成测试层,单元测试仍用 Mock 保持快速。30 秒限制应通过测试分层和并行化解决,而不是牺牲真实依赖验证。 > 理解实战排障题, > 架构判断见真知。 > 容器验证不可弃, > 分层并行两相宜。 # 13、升华 ```text 工程结论:测试环境与生产环境的一致性,是发布安全的最低门槛 ``` Testcontainers 不是银弹,但它把“测试全绿”从心理安慰变成了可验证的事实。 真实依赖的短命容器,让 CI 成为生产环境的第一道防线。下一篇将引入镜像缓存与测试并行化,把反馈时间进一步压缩。 > 假绿终非真保障, > 真验方为护城河。 > 容器短命却有力, > 发布安全自此得。 **系列项目:** Spring 官方 Petclinic 生产级演进 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web/安全/数据/事务、远程调用/数据库迁移/缓存/并发、日志/测试基础 **本篇:** Testcontainers 真库验证与 Service Connection 自动注入 **下一篇:** 容器镜像缓存与测试并行化加速 CI 反馈

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第24招 真正做到生产上线

# 一次完整上线与故障演练:把 Petclinic 收成生产级 Spring Boot 4 ```text 旧方案:功能完成即上线,配置散落、故障靠重启、证据链断裂 冲突:生产流量一进来,超时、池耗尽、慢 SQL、CPU 飙高同时出现 后果:回滚靠猜、排障靠翻日志、恢复靠运气 ``` 前 23 招已经把 Petclinic 从教学项目推到生产门口:配置、安全、迁移、连接池、事务、远程调用、缓存、并发、日志、可观测、镜像、TLS、探针、优雅停机、容量、灰度、回滚都各自落地过。 但“各自落地”不等于“一次上线能扛住”。我是老李,今天要做的不是再讲一个组件,而是把整条链路收口:一次发布、一次演练、一条证据链。 当 SIGTERM 到达时,在途请求真的能完成吗? 当 MySQL、Redis、远程 HTTP、线程池、连接池同时承压时,你能从 Client 一路追到 JVM 吗? 如果选错发布顺序或漏掉一个探针,线上会变成“能启动但不可用”,排障时只能靠重启,回滚时才发现旧版本数据不兼容,这个后果你接得住吗? # 01、故事 ```text 周五 22:40 → 生产发布窗口 → 任务:灰度 v2.4.0 并完成故障演练 → 冲突:流量刚切 10%,告警群开始刷屏 ``` 周五 22:40,发布窗口只剩 80 分钟。任务是灰度 v2.4.0,同时完成一次真实故障演练。 我坐在电脑前,刚把灰度比例调到 10%,企业微信告警群突然连续弹出:HTTP 超时、连接池耗尽、慢 SQL、CPU 飙高。 运维同事在群里催:“是不是要回滚?”我盯着屏幕,心里清楚:如果现在回滚,今晚的演练就废了;如果不回滚,必须马上拿出证据链。 > 夜深灰度十点开, > 告警如潮四面来。 > 不是代码先崩溃, > 证据未链难下台。 # 02、问题 ```text 需求变化:从“功能可用”到“生产可恢复” 冲突:配置、依赖、流量、故障同时进入系统 后果:单点排障失效,恢复动作互相干扰 ``` 前 23 招的组件在独立验证时都通过,但生产是并发叠加的。 配置中心推送延迟会让旧连接池参数继续生效;MySQL 不可用时,事务重试会放大压力;Redis 故障时,缓存穿透会打爆数据库;远程 HTTP 超时会让 Tomcat 线程全部挂起。 这些故障不是依次出现,而是同时发生。可验证的验收标准只有一条:从 Client 到 JVM,每一步都能拿出时间、线程、连接、SQL、堆的证据,并且恢复动作不互相覆盖。 > 单点排障如隔靴, > 并发故障叠成坡。 > 若问生产何所惧, > 证据链断乱投戈。 # 03、原理 ```text 核心机制:请求生命周期 = 入口 → 容器 → 线程 → 服务 → 缓存/池 → 数据库/远程 → JVM ``` 一次请求在生产里不是“进一个方法”,而是穿过至少七层。 Tomcat 线程从 acceptor 拿到连接后进入工作线程池; 服务层可能先查 Redis,再进连接池拿数据库连接; 远程调用会占用 HTTP 连接池; 事务决定连接何时归还; JVM 决定线程是否被 GC 拖住。 反直觉的是:CPU 飙高往往不是业务代码变慢,而是线程池队列满后,大量线程在锁上自旋或频繁上下文切换。 另一个反直觉判断:优雅停机不是“等所有请求结束”,而是“停止接收新请求,给在途请求一个截止时间,超时则强制中断”。 > 请求七层如渡河, > 层层有池层层波。 > 莫道 CPU 因代码, > 锁上自旋更耗磨。 # 04、架构 ```text 输入:Client 请求 + 故障注入 本模块:发布编排 + 探针 + 优雅停机 + 全链路证据采集 输出:可恢复的灰度版本 + 可追溯的故障报告 边界:不改变业务功能,只改变交付与恢复方式 ``` 这次收口分四层。 最外层是发布编排:配置与 Secret 先于镜像,数据库 Migration 先于应用,灰度比例逐步放大。 中间层是运行时保护:readiness/liveness 探针、Graceful Shutdown、TLS 终止、连接池与线程池上限。 内层是证据采集:Actuator、Metrics、Tracing、日志、线程 dump、堆 dump、慢 SQL 日志。 最底层是恢复动作:回滚、版本升级、容量评估。收益是故障可定位、发布可回退;代价是发布流程变长,演练需要真实流量或压测流量。 > 四层收口如筑城, > 外层发布内层证。 > 若问边界何处止, > 业务不改只改程。 # 05、第一版实战 ```text 环境 → 配置 → 代码 → 启动 → 验证 → V1 ``` 环境:Spring Boot 4.0.x、Java 21、MySQL 8.4、Redis 7.4、Docker Compose v2.30、Kubernetes 1.31(本地 Kind 模拟)。 先准备生产配置与 Secret。 ```yaml # application-prod.yaml spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 validation-timeout: 1000 data: redis: timeout: 500ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 threads: virtual: enabled: true server: shutdown: graceful tomcat: threads: max: 200 min-spare: 20 accept-count: 100 connection-timeout: 5000 management: endpoints: web: exposure: include: health,info,metrics,prometheus,threaddump,heapdump,loggers endpoint: health: probes: enabled: true show-details: always ``` ```bash # 生成 Secret 并部署 kubectl create secret generic petclinic-secret \ --from-literal=DB_PASSWORD='prod-db-pass' \ --from-literal=REDIS_PASSWORD='prod-redis-pass' \ -n petclinic # 先跑数据库迁移 kubectl apply -f k8s/migration-job.yaml -n petclinic kubectl wait --for=condition=complete job/db-migration -n petclinic --timeout=120s # 灰度 10% kubectl patch deployment petclinic -n petclinic \ --type='json' -p='[{"op":"replace","path":"/spec/replicas","value":3}]' kubectl set image deployment/petclinic \ petclinic=registry.example.com/petclinic:v2.4.0 -n petclinic ``` 启动后验证探针与优雅停机。 ```bash # 探针验证 curl -s http://petclinic.local/actuator/health/liveness curl -s http://petclinic.local/actuator/health/readiness # 发送 SIGTERM 观察在途请求 kubectl exec -n petclinic deploy/petclinic -- kill -TERM 1 ``` 示例输出: ```text {"status":"UP"} {"status":"UP","components":{"db":{"status":"UP"},"redis":{"status":"UP"}}} 2025-06-13T22:41:02.123Z INFO 1 --- [tomcat-handler-42] o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete 2025-06-13T22:41:04.891Z INFO 1 --- [tomcat-handler-42] o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete ``` V1 成功标准:探针 UP、SIGTERM 后在途请求完成、灰度 10% 流量正常。 > 配置先行密随后, > 迁移完成再放舟。 > 灰度十点如试水, > 停机优雅在途收。 # 06、排查 ```text 诊断树:现象 → 怀疑 → 检查 → 证据 → 根因 → 修复 ``` 演练开始后,我主动注入九类故障,逐个走证据链。 **故障一:错误配置。** 现象是启动后 readiness 一直 DOWN。怀疑配置中心推送了错误值。 ```bash kubectl logs deploy/petclinic -n petclinic --tail=50 | grep -i 'invalid\|error' curl -s http://petclinic.local/actuator/configprops | jq '.contexts.application.beans.spring.datasource' ``` 证据显示 `maximum-pool-size` 被推送成 `-1`,Hikari 拒绝初始化。根因是配置中心里生产环境的值被误改。修复:回滚配置版本,重启 Pod。 **故障二:MySQL 不可用。** 现象是大量请求 500,readiness 变 DOWN。 ```bash kubectl get pods -n petclinic -l app=mysql curl -s http://petclinic.local/actuator/health/readiness | jq ``` 证据是 readiness 里 `db` 组件 `DOWN`,连接超时 3000ms 后快速失败。根因是 MySQL Pod 被驱逐。修复:恢复 MySQL,readiness 自动变 UP。 **故障三:Redis 故障。** 现象是响应变慢,数据库 QPS 飙升。 ```bash redis-cli -h redis.petclinic.svc -a 'prod-redis-pass' ping curl -s http://petclinic.local/actuator/metrics/redis.lettuce.command.latency | jq ``` 证据是 Redis PING 超时,缓存全部 miss,请求穿透到 MySQL。根因是 Redis 内存达到 maxmemory。修复:扩容 Redis 并设置淘汰策略。 **故障四:连接池耗尽。** 现象是请求排队,Tomcat 线程数打满。 ```bash curl -s http://petclinic.local/actuator/metrics/hikaricp.connections.pending | jq curl -s http://petclinic.local/actuator/metrics/hikaricp.connections.timeout | jq ``` 证据是 `connections.pending` 持续大于 0,`connections.timeout` 增长。根因是慢 SQL 占住连接。修复:定位慢 SQL 并加索引。 **故障五:远程 HTTP 超时。** 现象是调用第三方服务的请求全部挂起。 ```bash curl -s http://petclinic.local/actuator/metrics/http.client.requests | jq curl -s http://petclinic.local/actuator/threaddump | jq '.threads[] | select(.threadName|contains("http"))' ``` 证据是 HTTP client 线程全部 `WAITING`,超时配置未生效。 根因是 RestClient 未设置 `requestFactory` 的超时。 修复:配置 `ClientHttpRequestFactorySettings` 超时。 **故障六:线程阻塞。** 现象是 Tomcat 线程全部 BUSY,新请求无法进入。 ```bash curl -s http://petclinic.local/actuator/threaddump | jq '.threads[] | select(.threadState=="RUNNABLE") | .threadName' | head -20 ``` 证据是 200 个 Tomcat 线程全部 RUNNABLE,栈顶指向同一个锁。根因是业务代码里一个同步块内调用了远程 HTTP。修复:缩小同步块范围。 **故障七:慢 SQL。** 现象是数据库 CPU 高,请求 P99 飙升。 ```bash mysql -h mysql.petclinic.svc -u petclinic -p'prod-db-pass' -e "SHOW FULL PROCESSLIST;" curl -s http://petclinic.local/actuator/metrics/jdbc.queries.max | jq ``` 证据是 `SELECT * FROM visits WHERE pet_id = ?` 全表扫描。根因是缺少 `pet_id` 索引。修复:加索引并验证执行计划。 **故障八:CPU 飙高。** 现象是 Pod CPU 接近 limit,请求延迟升高。 ```bash kubectl top pod -n petclinic curl -s http://petclinic.local/actuator/threaddump | jq '.threads[] | select(.threadState=="RUNNABLE") | .stackTrace[0]' | sort | uniq -c | sort -rn | head ``` 证据是大量线程栈顶在 `HashMap.get` 和 `String.hashCode`。根因是缓存 key 拼接产生大量哈希计算。修复:改用固定长度 key 并预计算哈希。 **故障九:Heap 增长。** 现象是 GC 频繁,内存缓慢上涨。 ```bash curl -s http://petclinic.local/actuator/heapdump -o heap.hprof jcmd 1 GC.heap_info jcmd 1 GC.class_histogram | head -20 ``` 证据是 `byte[]` 和 `String` 对象持续增长。根因是日志里打印了完整请求体,且未设置大小上限。修复:限制日志体长度并开启 GC 日志。 **错误尝试:** 在 Redis 故障时,我先尝试重启应用 Pod,结果缓存重建时又打爆数据库,故障扩大。正确做法是先恢复 Redis,再让应用自然恢复。 > 九类故障九条链, > 层层证据层层验。 > 错把重启当解药, > 缓存重建又生烟。 # 07、优化 ```text V1:组件各自可用,但故障叠加时证据链断裂 证据:九类故障中,三类需要人工翻多个系统才能定位 V2:统一证据链 + 恢复顺序 + 启动优化评估 ``` V1 的问题不是组件缺失,而是证据分散。 我把所有故障的排查路径固化成一条命令链:先看 readiness,再看线程 dump,再看连接池 metrics,再看慢 SQL,最后看堆。 ```bash # 一键证据链脚本 #!/bin/bash echo "=== 1. Readiness ===" curl -s http://petclinic.local/actuator/health/readiness | jq echo "=== 2. Thread states ===" curl -s http://petclinic.local/actuator/threaddump | jq '.threads | group_by(.threadState) | map({state: .[0].threadState, count: length})' echo "=== 3. Pool pending ===" curl -s http://petclinic.local/actuator/metrics/hikaricp.connections.pending | jq echo "=== 4. Slow SQL ===" mysql -h mysql.petclinic.svc -u petclinic -p'prod-db-pass' -e "SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 5;" echo "=== 5. Heap ===" jcmd 1 GC.heap_info ``` 同时把恢复顺序写进 runbook:先恢复依赖,再恢复应用,最后恢复流量。 ```yaml # runbook.yaml recovery_order: - step: 恢复 MySQL/Redis/远程服务 check: readiness 中对应组件 UP - step: 恢复应用 Pod check: liveness UP 且线程池 pending 归零 - step: 恢复灰度流量 check: 错误率 证据分散如断线, > 一键成链始见全。 > 恢复顺序先依赖, > 启动优化看收益。 # 08、演进 ```text V2:统一证据链 + 恢复顺序 + 启动优化评估 能力:故障可定位、恢复可执行、启动可评估 遗留:AOT/Native/CR 未引入,需持续观测启动指标 ``` 用 V1 同一组请求前后验证。 演练前,九类故障平均定位时间 8 分钟,其中三类需要跨系统翻日志。 演练后,用一键证据链脚本,平均定位时间降到 2 分钟,恢复顺序固定后,故障扩大率从 30% 降到 0。 代价是 runbook 需要持续维护,证据链脚本需要随组件变化更新。 适用条件是:生产流量至少 100 QPS,依赖至少包含 MySQL 和 Redis,团队有 SRE 值班。 遗留问题是 AOT、GraalVM Native Image、Checkpoint/Restore 尚未引入,因为当前启动时间 18 秒、内存 512MB 在业务场景下可接受,弹性收益不明确。 > 八分定位两分收, > 恢复顺序解千愁。 > 启动优化留后手, > 收益不明不强求。 # 09、洞见 ```text 从现象到判断:生产故障不是组件问题,是证据链问题 ``` ## 反直觉判断:CPU 飙高往往不是业务代码变慢 ```text 线程池满 → 锁竞争 → 上下文切换 → CPU 飙高 ``` 九类故障里,CPU 飙高的根因是缓存 key 哈希计算,不是业务逻辑。 线程池队列满后,大量线程在锁上自旋,CPU 消耗在切换而不是执行。 排障时先看线程 dump 的栈顶分布,比先看代码更有效。 ## 反直觉判断:优雅停机不是“等所有请求结束” ```text 停止接收 → 在途请求截止时间 → 超时强制中断 ``` SIGTERM 后,Tomcat 停止接收新连接,给在途请求一个截止时间。 如果请求超过截止时间,会被强制中断,不是无限等待。 所以优雅停机必须配合客户端重试和幂等,否则中断的请求会丢失。 ## 恢复顺序比恢复速度更重要 ```text 先依赖 → 再应用 → 后流量 ``` Redis 故障时先重启应用,缓存重建会打爆数据库。 正确顺序是先恢复 Redis,再让应用自然恢复,最后恢复流量。 恢复顺序写进 runbook,比依赖人的临场判断可靠。 ## 证据链的价值在于“可重复” ```text 一键脚本 → 固定顺序 → 固定输出 ``` 排障不是炫技,是让任何值班的人都能在 2 分钟内拿到同一份证据。 一键证据链脚本的价值不是快,是消除人的差异。 > 现象从来非根因, > 证据链上见假真。 > 恢复顺序先依赖, > 可重复处是金针。 # 10、系统落地 ```text 入口:Client 请求 → Tomcat → Thread → Service → Cache/Pool → DB/Remote → JVM 服务:Petclinic 生产集群 本模块:发布编排 + 探针 + 优雅停机 + 证据链 + 恢复顺序 存储:MySQL、Redis、配置中心、镜像仓库 未实现:AOT、GraalVM Native Image、Checkpoint/Restore ``` 原来 Petclinic 是官方教学项目,只有基本 CRUD 和简单页面。 前 23 招逐步加入配置、安全、迁移、连接池、事务、远程调用、缓存、并发、日志、可观测、镜像、TLS、探针、优雅停机、容量、灰度、回滚。 本篇把这些能力收口成一次完整上线和故障演练:配置与 Secret 先行,数据库 Migration 先于应用,灰度 10% 验证,九类故障走统一证据链,恢复顺序固定。 现在能做什么:环境可复制、代码可理解、数据库可迁移、接口可测试、系统可观测、版本可发布、故障可恢复。 下一篇缺什么:AOT、GraalVM Native Image、Checkpoint/Restore 的启动优化,需要等启动时间、资源或弹性收益明确后再引入。 官方链接:https://github.com/spring-projects/spring-petclinic > 教学项目到生产, > 二十三招始收功。 > 发布演练成一体, > 启动优化待东风。 # 11、小结 ```text 三问三答:冲突、选择、后果 ``` **冲突问:当 SIGTERM 到达时,在途请求真的能完成吗?** 答:能,但有截止时间。`server.shutdown=graceful` 让 Tomcat 停止接收新请求,给在途请求一个窗口期,超时则强制中断。所以必须配合客户端重试和幂等。 **选择问:当 MySQL、Redis、远程 HTTP、线程池、连接池同时承压时,你能从 Client 一路追到 JVM 吗?** 答:能,用一键证据链脚本。先看 readiness,再看线程 dump,再看连接池 metrics,再看慢 SQL,最后看堆。固定顺序,固定输出。 **选错后果问:如果选错发布顺序或漏掉一个探针,线上会变成什么样?** 答:会变成“能启动但不可用”。配置错误会让 readiness 一直 DOWN,漏掉探针会让流量打到未就绪的 Pod,回滚时才发现旧版本数据不兼容。 当前状态:Petclinic 已经是一个环境可复制、代码可理解、数据库可迁移、接口可测试、系统可观测、版本可发布、故障可恢复的生产级 Spring Boot 4 Java 应用。 > 三问三答如对镜, > 冲突选择后果明。 > 生产不是功能毕, > 可恢复处方为赢。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:优雅停机的截止时间由哪个配置控制?如果请求超过截止时间会怎样? `server.shutdown=graceful` 配合 `spring.lifecycle.timeout-per-shutdown-phase` 控制截止时间。 超过截止时间的请求会被强制中断,客户端会收到连接重置。 所以必须配合客户端重试和幂等,否则中断的请求会丢失。 ## 实战题:在本地 Kind 集群里复现“Redis 故障导致缓存穿透打爆数据库”,并用一键证据链脚本定位根因。 先用 `redis-cli` 设置 `maxmemory 1mb` 并写入大量数据触发淘汰,然后压测 Petclinic 的缓存接口。 观察 MySQL QPS 飙升、响应变慢。 运行一键证据链脚本,先看 readiness 里 redis 组件 DOWN,再看线程 dump 里大量线程阻塞在 Redis 调用,最后看 MySQL 慢 SQL 增多。 根因是缓存全部 miss,请求穿透到数据库。 恢复顺序:先恢复 Redis,再让应用自然恢复。 ## 排障题:生产环境 CPU 飙高,但业务代码没有变更,你会先查什么? 先查线程 dump 的栈顶分布。 用 `curl -s http://petclinic.local/actuator/threaddump | jq '.threads[] | select(.threadState=="RUNNA BLE") | .stackTrace[0]' | sort | uniq -c | sort -rn | head` 看哪个方法栈顶最多。 如果大量线程在锁上自旋或哈希计算,说明是线程池满或缓存 key 设计问题,不是业务逻辑变慢。 ## 架构判断题:AOT、GraalVM Native Image、Checkpoint/Restore 是否应该立即引入? 不应该立即引入。 当前启动时间 18 秒、内存 512MB 在业务场景下可接受,弹性收益不明确。 GraalVM Native Image 需要改造反射和动态代理,Checkpoint/Restore 需要额外基础设施。 只有启动时间、资源或弹性收益明确时才引入,否则会引入不必要的复杂度。 > 理解实战排障题, > 架构判断见高低。 > 莫把优化当必需, > 收益不明且慢提。 # 13、升华 ```text 工程结论:生产级不是组件堆砌,是证据链与恢复顺序的闭环 ``` 这次收口让我看清一件事:前 23 招的每个组件都重要,但真正让系统“生产级”的,是它们之间的证据链和恢复顺序。 配置与 Secret 先行,数据库 Migration 先于应用,灰度 10% 验证,九类故障走统一证据链,恢复顺序固定。 这不是流程的胜利,是工程判断的胜利:知道什么该先做,什么该后做,什么该不做。 AOT、GraalVM Native Image、Checkpoint/Restore 是下一个路口,但只有启动时间、资源或弹性收益明确时才值得走。 Petclinic 从官方教学项目走到今天,已经是一个环境可复制、代码可理解、数据库可迁移、接口可测试、系统可观测、版本可发布、故障可恢复的生产级 Spring Boot 4 Java 应用。 > 二十三招始到今, > 证据链上见真金。 > 生产不是堆组件, > 可恢复处方为心。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用,服务宠物医院业务。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web/安全/数据/事务、远程调用/数据库迁移/缓存/并发、日志/测试/Testcontainers/Docker Compose、Actuator/可观测、自定义自动配置、容器构建、生产上线。 **本篇:** 一次完整上线与故障演练,统一证据链与恢复顺序,评估启动优化。 **下一篇:** AOT、GraalVM Native Image、Checkpoint/Restore 启动优化,仅在收益明确时引入。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第3招 启动链到底做了什么

# 从 SpringApplication.run() 追踪启动链与扫描边界 ```text 旧方案:只背“main 会启动 Spring Boot” 冲突:Bean 消失、启动失败时不知道卡在哪个阶段 后果:排障靠重启,扫描边界靠猜,生产事故定位慢 ``` 很多团队把 Spring Boot 当成黑盒:能启动就上线,启动失败就翻日志最后一行。 我是老李,在 Petclinic 做一次启动链改造时,团队里一位同学把 Repository 挪了个包,接口直接启动失败;另一位同学把配置类放到 root package 外,配置静默失效。 为什么一个只有几行的 main 能拉起整个 Java Web 系统? 移动一个包名,为什么可能导致一批 Bean 消失? 如果选错扫描边界或误判失败阶段,会不会让应用“能启动但不完整”,甚至在生产上带病运行? # 01、故事 ```text 周二 14:30 → 深圳南山工位 → 任务:给 Petclinic 增加启动耗时统计 → 冲突:启动失败日志刷屏,同事催发布 ``` 周二下午,任务是给 Petclinic 增加启动耗时统计,并在启动完成后打印关键组件加载情况。 我刚把 ApplicationRunner 加进去,应用启动到一半就报错。旁边同事等着联调,一直催“先回滚”。 日志里同时出现 Tomcat 端口占用和 Repository Bean 创建失败,我一时分不清应用到底死在哪个阶段。 > 一行 main 起高楼, > 扫描边界定去留。 > 若问故障何处死, > 阶段日志见根由。 # 02、问题 ```text 需求变化:从“能启动”到“可观测启动过程” 冲突:组件出现时机不透明,失败阶段难定位 后果:Bean 缺失、端口冲突、配置错误混在一起,排障无方向 ``` Petclinic 原来只要求能启动。现在需要知道 Tomcat、DataSource、JPA 分别在什么时候出现,还要能主动制造错误并判断失败阶段。 如果只依赖默认行为,一旦包名移动导致扫描范围变化,Bean 会静默消失;如果端口被占,失败发生在 WebServer 阶段;如果配置错误,可能死在 Environment 或 Bean 创建阶段。 可验证的验收标准是:能说出 main 到 ApplicationReadyEvent 的关键阶段,能通过日志区分 Environment、Context、Bean、WebServer 四类失败,并能用 监听器观察生命周期。 > 需求一改黑盒开, > 阶段不明难下台。 > 包移一尺 Bean 失, > 端口一撞服务哀。 # 03、原理 ```text main → SpringApplication → Environment → ApplicationContext → BeanDefinition → Bean → Embedded Server → ApplicationReadyEvent ``` SpringApplication.run() 先准备 Environment,再创建 ApplicationContext,然后加载 BeanDefinition,接着实例化 Bean,最后启动内嵌 Web 服务器并发布 ApplicationReadyEvent。 Tomcat 不会在 Bean 创建前启动;DataSource 和 JPA 组件在 Bean 创建阶段出现;ComponentScan 在 BeanDefinition 阶段决定哪些类会被扫描。 一个反直觉判断是:启动类所在包不是“建议”,而是默认扫描边界。把配置类或 Repository 放到 root package 外,不会报错,但可能完全不生效。 > 环境先立上下文, > 定义后生 Bean 身。 > 服务器起最后问, > 扫描边界定乾坤。 # 04、架构 ```text 输入:main 类与 root package → 本模块:启动链与扫描边界 → 输出:可观测的启动阶段与失败定位 ``` 启动链分四层:Environment 准备、Context 与 BeanDefinition、Bean 实例化、WebServer 启动。 扫描边界由 root package 决定,ComponentScan、ConfigurationPropertiesScan、Entity/Repository 扫描都受它约束。 收益是启动过程可观测、失败阶段可定位;代价是必须理解默认约定,否则容易误判。 适用条件是单体 Spring Boot 应用;如果引入多模块或自定义扫描,需要显式配置。 > 四层启动一条链, > 扫描边界定方圆。 > 约定优于配置好, > 误判代价在眼前。 # 05、第一版实战 ```text 环境:JDK 17 + Spring Boot 3.x + Petclinic → 配置:默认 root package → 代码:ApplicationRunner + 监听器 → 启动 → 验证 → V1 ``` 在 Petclinic 主类所在包 `org.springframework.samples.petclinic` 下新增 `StartupProbe`。 ```java package org.springframework.samples.petclinic; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; @Component public class StartupProbe implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { System.out.println("PROBE: ApplicationRunner executed"); } } ``` 再新增启动事件监听器。 ```java package org.springframework.samples.petclinic; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; @Component public class ReadyListener implements ApplicationListener { @Override public void onApplicationEvent(ApplicationReadyEvent event) { System.out.println("PROBE: ApplicationReadyEvent received"); } } ``` 启动应用。 ```bash ./mvnw spring-boot:run ``` 示例输出: ```text PROBE: ApplicationRunner executed PROBE: ApplicationReadyEvent received ``` 成功标准:应用正常启动,两个探针按顺序打印。 > 探针一放链路明, > 监听一挂时机清。 > 首版虽小能验证, > 后续排障有依凭。 # 06、排查 ```text 诊断树:启动失败 → 看异常类型 → Environment/Context/Bean/WebServer 四选一 → 定位根因 ``` 第一个问题:端口冲突。 现象:启动报 `Port 8080 was already in use`。 怀疑:WebServer 阶段失败。 检查: ```bash lsof -i :8080 ``` 证据:另一个 Java 进程占用 8080。 根因:Tomcat 在 Bean 创建完成后启动,端口冲突导致 WebServer 阶段失败。 修复:停掉占用进程或改端口。 第二个问题:Repository 消失。 现象:启动报 `No qualifying bean of type 'OwnerRepository'`。 怀疑:扫描边界变化。 检查:确认 Repository 是否在 root package 下。 证据:Repository 被移到 `org.springframework.samples.petclinic.extra` 包外。 根因:默认扫描只覆盖 root package 及子包。 修复:移回 root package 或显式配置 `@EnableJpaRepositories`。 **错误尝试:** 第一次遇到端口冲突时,我以为是 Bean 创建失败,反复检查 DataSource 配置,浪费了半小时。 > 端口冲突怪 Bean, > 方向一错白费神。 > 阶段日志先分清, > 根因自然现真身。 # 07、优化 ```text V1:只有探针 → 证据:失败阶段仍靠猜 → V2:增加阶段日志与显式扫描配置 ``` V1 只能观察正常启动,无法定位失败阶段。 在 `application.properties` 中增加启动日志。 ```properties logging.level.org.springframework.boot.context.config=DEBUG logging.level.org.springframework.context.annotation=DEBUG ``` 再显式声明扫描边界,避免包移动导致 Bean 消失。 ```java package org.springframework.samples.petclinic; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication @EntityScan(basePackages = "org.springframework.samples.petclinic") @EnableJpaRepositories(basePackages = "org.springframework.samples.petclinic") public class PetClinicApplication { public static void main(String[] args) { org.springframework.boot.SpringApplication.run(PetClinicApplication.class, args); } } ``` 这样即使有人移动包,也能通过显式配置快速发现并修复。 > 探针不够加日志, > 扫描边界显式持。 > 包移不再静默失, > 阶段分明好排治。 # 08、演进 ```text V2:阶段日志 + 显式扫描 → 能力:可观测启动链、可定位失败阶段 → 遗留:多模块扫描仍需人工核对 ``` 用 V1 同一组请求验证:正常启动时,两个探针仍按顺序打印;端口冲突时,日志明确显示 WebServer 阶段失败;Repository 移包时,显式扫描配置会直接报错而不是静默失效。 收益是启动过程从黑盒变成白盒,失败阶段可快速定位。 代价是显式配置增加了维护成本,包结构变化时需要同步修改。 遗留问题是多模块项目里,不同模块的扫描边界仍需人工核对,容易遗漏。 > 黑盒一开白盒现, > 阶段分明排障便。 > 显式配置增成本, > 多模块下仍须验。 # 09、洞见 ```text 从现象到判断:启动失败不是“一个错误”,而是“某个阶段的错误” ``` ## 反直觉判断:Bean 消失不一定是配置错,可能是包位置错 ```text 包位置 → 扫描边界 → BeanDefinition → Bean ``` 很多人看到 Bean 缺失就查注解或依赖,其实先查包路径更高效。 ## 反直觉判断:端口冲突不是网络问题,是启动顺序问题 ```text Bean 创建完成 → WebServer 启动 → 端口绑定 ``` 端口冲突说明应用已经走到最后阶段,前面配置和 Bean 都正常。 ## 阶段日志比异常堆栈更接近根因 ```text 阶段日志 → 失败阶段 → 根因 ``` 异常堆栈只告诉你“什么错了”,阶段日志告诉你“在哪一步错了”。 > 包位错时 Bean 失, > 端口冲突末段知。 > 阶段日志胜堆栈, > 根因定位不费时。 # 10、系统落地 ```text 入口:main → 服务:SpringApplication 启动链 → 本模块:阶段观测与扫描边界 → 存储:内存/数据库 → 未实现:多模块自动核对 ``` 原来 Petclinic 只有默认启动行为,没有阶段观测和显式扫描配置。 本篇增加了 ApplicationRunner、启动事件监听器、阶段日志和显式扫描边界。 现在能观察启动链、定位失败阶段、防止包移动导致 Bean 静默消失。 下一篇缺的是多模块项目里自动核对扫描边界的能力。 官方文档:https://docs.spring.io/spring-boot/index.html > 入口一行起全链, > 阶段观测补短板。 > 扫描边界显式定, > 多模块核待下篇。 # 11、小结 ```text 三问三答:main 为何能拉起系统?包名为何影响 Bean?选错边界有何后果? ``` main 能拉起系统,是因为 SpringApplication.run() 按固定阶段完成环境、上下文、Bean 和服务器启动。 包名影响 Bean,是因为默认扫描边界由 root package 决定,移出边界就不会生成 BeanDefinition。 选错边界会导致 Bean 静默消失或配置失效,应用“能启动但不完整”,生产上可能带病运行。 当前状态:Petclinic 已具备启动链观测和显式扫描配置能力。 > 一行 main 有章法, > 包名边界定生杀。 > 选错边界病潜伏, > 显式配置护万家。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:SpringApplication.run() 的四个关键阶段分别是什么? Environment 准备、ApplicationContext 与 BeanDefinition、Bean 实例化、WebServer 启动。 ## 实战题:在 Petclinic 中新增一个启动事件监听器,打印 ApplicationStartedEvent 和 ApplicationReadyEvent 的时间差。 监听两个事件,记录时间戳并计算差值,打印到控制台。 ## 排障题:应用启动报 `No qualifying bean of type 'VetRepository'`,但 VetRepository 上有 @Repository 注解,可能原因是什么? 最可能原因是 VetRepository 所在包不在 root package 下,默认扫描未覆盖。 ## 架构判断题:把配置类放到 root package 外,应用能启动但配置不生效,这属于什么类型的问题? 属于扫描边界问题,是约定优于配置的典型陷阱,应显式配置扫描路径或调整包结构。 > 四题一过链路清, > 实战排障有准星。 > 架构判断不靠猜, > 边界意识入心经。 # 13、升华 ```text 工程结论:启动链是骨架,扫描边界是血管,阶段日志是听诊器 ``` Spring Boot 的启动链决定了组件出现的顺序,扫描边界决定了哪些组件能出现,阶段日志决定了故障能否被快速定位。 把这三者搞清楚,main 就不再是黑盒,包名移动也不再是玄学。 下一篇将进入依赖与自动配置,看 Spring Boot 如何根据 classpath 自动装配组件。 > 骨架血管听诊器, > 启动排障三件齐。 > 黑盒一开天地阔, > 下篇自动配置启。 **系列项目:** Spring 官方 Petclinic 生产级演进 **已完成:** 环境搭建、启动机制与扫描边界观测 **本篇:** 启动链追踪、阶段日志、显式扫描配置 **下一篇:** 依赖与自动配置机制 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第4招 Starter和版本谁控制

# 只升级一个 Hibernate,为什么可能破坏整套 Spring Boot 兼容关系 ```text 旧方案:每个 Jar 自己声明 version,各组件独立升级 冲突:Hibernate 升级后,其传递依赖与 Spring Boot 托管版本错位 后果:看似只改一个版本,实际破坏整套兼容矩阵,出现运行时异常或启动失败 ``` 在 Spring Boot 项目里,pom.xml 往往只写几个 starter,几乎看不到具体第三方版本号。 真正决定 Tomcat、Jackson、Hibernate、JUnit 版本的,是 Parent、BOM、Dependency Management 与 Plugin Management 共同构成的 版本治理体系。 我是老李,今天在 Petclinic 项目里被一个“只升级 Hibernate”的需求逼着把 Maven 依赖解析彻底查了一遍。 表面上是改一个版本号,背后却牵出 Spring Boot 为什么坚持统一管理整套第三方组件版本。 为什么看起来只升级了一个 Hibernate,却可能破坏整套 Spring Boot 兼容关系? 应该让每个 Jar 自己声明 version,还是交给 Spring Boot 统一托管? 如果选错,会不会出现编译通过、启动失败,甚至上线后才爆发的隐蔽兼容问题? # 01、故事 ```text 周四 23:40 → 研发区 → 升级 Hibernate 修复查询缓存缺陷 → 测试环境启动报 NoSuchMethodError ``` 周四晚上十一点四十,研发区只剩我这排灯还亮着。任务是给 Petclinic 升级 Hibernate,修复一个查询缓存在某些方言下失效的缺陷。 测试同学在群里催:“老李,服务起不来了,报 NoSuchMethodError,明天要压测。”我打开日志,异常栈指向 Hibernate 内部调用,但代码里根本没直接碰那个类。 我第一反应是本地没复现,怀疑测试环境缓存了旧包。清缓存、重打包、再部署,还是同样错误。搜了半天,网上有人说是版本冲突,但没人说清冲突从哪来。 > 夜深灯未熄,版本起风波 > 只改一依赖,全链竟崩落 > 日志藏真凶,缓存背黑锅 > 若问根何在,且向 POM 索 # 02、问题 ```text 需求变化:单独升级 Hibernate 修复缺陷 冲突:Hibernate 新版本传递依赖与 Spring Boot 托管版本不一致 后果:编译通过,运行时 NoSuchMethodError,测试阻塞 ``` Petclinic 原本由 Spring Boot Parent 统一管理 Hibernate 版本。现在业务要求单独升级 Hibernate,等于在兼容矩阵上撕开一个口子。 Hibernate 升级后,它依赖的 Byte Buddy、JPA API、HikariCP 等版本可能不再与 Spring Boot 当前托管版本一致。 Maven 会按“最近优先、先声明优先”等规则重新解析,最终选中的版本可能既不是 Spring Boot 期望的,也不是 Hibernate 官方测试过的。 影响不是编译期,而是运行时。 类存在但方法签名变了,就抛 NoSuchMethodError; 类被移除,就抛 NoClassDefFoundError。 可验证的验收标准是:执行 `mvn dependency:tree` 能看到实际解析版本,执行 `mvn help:effective-pom` 能看到版本来源,启动 Petclinic 后访问 `/ owners` 返回 200。 > 一版升而全链动,看似孤岛实相通 > 编译无声藏暗涌,运行方知万事空 > 若问兼容何处断,依赖树里觅行踪 > 版本从来非孤立,矩阵一破祸无穷 # 03、原理 ```text Parent 定义默认版本 → BOM 集中管理依赖版本 → Starter 引入一组协调依赖 → Dependency Management 约束传递依赖 → Plugin Management 约束构建插件 ``` Spring Boot 的版本治理不是靠“每个 Jar 自己声明 version”,而是靠 Parent 和 BOM 集中约束。 Parent 继承自 `spring-boot-dependencies`,这个 BOM 里用 `dependencyManagement` 声明了数百个第三方组件的版本。 Starter 本身只声明依赖坐标,不写版本号。 比如 `spring-boot-starter-data-jpa` 引入 Hibernate,但版本由 BOM 提供。 这样所有 starter 引入的组件都来自同一套兼容矩阵。 `dependencyManagement` 只约束版本,不真正引入依赖。 只有项目里实际声明了某个依赖,或者某个依赖传递引入了它,版本约束才生效。 `pluginManagement` 同理,只约束插件版本,不激活插件。 反直觉的是:你在项目里显式写一个版本号,不是“更精确”,而是“更危险”。 因为 Spring Boot 测试的是整套版本组合,你单独改一个,等于让 Maven 重新协商所有相关传递依赖,结果可能偏离官方兼容矩阵。 > 版本不在 Jar 中藏,BOM 一表定八方 > Starter 只引不写版,矩阵协调方成纲 > 显式声明非精确,反是裂痕入高墙 > 牵一发而动全身,依赖解析细思量 # 04、架构 ```text 输入:pom.xml 声明的 starter 与显式依赖 本模块:Parent + BOM + Dependency Management + Plugin Management 协同解析 输出:effective-pom 中的最终版本与 dependency:tree 中的实际依赖图 边界:不管理 BOM 未收录的第三方组件,不约束测试运行时独立声明的版本 ``` 这套体系分四层。 最外层是 Parent,它继承 BOM 并设置默认编译参数、插件版本。 第二层是 BOM,集中声明所有托管版本。 第三层是 Starter,按场景引入一组协调依赖。 第四层是项目自身 pom.xml,只声明需要的 starter 和少量显式依赖。 收益是版本一致性强,升级 Spring Boot 版本时,整套第三方组件一起迁移,兼容性由官方保证。代价是灵活性下降,单独升级某个组件需要额外验证。 适用条件是项目主要使用 Spring Boot 官方 starter 覆盖的场景。如果大量使用 BOM 未收录的组件,或者需要长期锁定某个特殊版本,就需要额外管理。 > 四层架构如城防,Parent 为基 BOM 为纲 > Starter 引路不写版,项目声明只点将 > 收益一致代价僵,适用需看场景广 > 边界之外自行管,莫把灵活当良方 # 05、第一版实战 ```text 环境:JDK 17 + Maven 3.9 + Spring Boot 3.x + Petclinic 官方仓库 配置:pom.xml 显式覆盖 hibernate-core 版本 代码:不改 Java 代码,只改 pom.xml 启动:mvn spring-boot:run 验证:curl /owners 观察是否 200 V1:单独升级 Hibernate 到最新版 ``` 先确认当前环境。JDK 17,Maven 3.9,Spring Boot 版本以 Petclinic 官方 pom.xml 为准,需按当前官方文档核验。 第一步,查看当前 Hibernate 版本来源。 ```bash mvn dependency:tree -Dincludes=org.hibernate.orm:hibernate-core ``` 示例输出: ```text [INFO] \- org.springframework.boot:spring-boot-starter-data-jpa:jar:3.3.0:compile [INFO] \- org.hibernate.orm:hibernate-core:jar:6.5.2.Final:compile ``` 第二步,在 pom.xml 里显式覆盖 Hibernate 版本。 ```xml 6.6.0.Final ``` 第三步,重新查看依赖树。 ```bash mvn dependency:tree -Dincludes=org.hibernate.orm:hibernate-core ``` 示例输出: ```text [INFO] \- org.springframework.boot:spring-boot-starter-data-jpa:jar:3.3.0:compile [INFO] \- org.hibernate.orm:hibernate-core:jar:6.6.0.Final:compile ``` 第四步,启动应用。 ```bash mvn spring-boot:run ``` 启动日志出现异常,示例输出: ```text Caused by: java.lang.NoSuchMethodError: 'org.hibernate.cfg.AvailableSettings org.hibernate.cfg.AvailableSettings.set(java.lang.String, java.lang.Object)' ``` 第五步,验证接口。 ```bash curl -i http://localhost:8080/owners ``` 预期返回 500,而不是 200。V1 失败,证明单独升级 Hibernate 破坏了兼容关系。 > 一版在手欲强升,POM 一改便启程 > 依赖树里见新本,启动日志现鬼影 > 方法签名已不在,运行方知路难行 > 若问此错何处起,兼容矩阵已失衡 # 06、排查 ```text 诊断树: 现象:启动报 NoSuchMethodError ├─ 怀疑:测试环境缓存旧包 │ ├─ 检查:mvn clean package 后重新部署 │ └─ 证据:错误依旧,排除缓存 ├─ 怀疑:Hibernate 版本未真正生效 │ ├─ 检查:mvn dependency:tree -Dincludes=org.hibernate.orm:hibernate-core │ └─ 证据:显示 6.6.0.Final,版本已生效 ├─ 怀疑:传递依赖版本错位 │ ├─ 检查:mvn dependency:tree -Dverbose 查看完整依赖图 │ └─ 证据:Byte Buddy 版本仍为 Spring Boot 托管的旧版 └─ 根因:Hibernate 6.6 需要新版 Byte Buddy,但 Spring Boot BOM 仍约束旧版 ``` 第一个问题是启动报 NoSuchMethodError。我怀疑测试环境缓存了旧包,执行 `mvn clean package` 后重新部署,错误依旧,排除缓存。 第二个问题是版本是否真正生效。执行依赖树命令,确认 Hibernate 已经是 6.6.0.Final,版本覆盖成功。 第三个问题是传递依赖是否错位。执行完整依赖树: ```bash mvn dependency:tree -Dverbose -Dincludes=net.bytebuddy:byte-buddy ``` 示例输出: ```text [INFO] \- org.hibernate.orm:hibernate-core:jar:6.6.0.Final:compile [INFO] \- net.bytebuddy:byte-buddy:jar:1.14.15:compile ``` 而 Spring Boot 3.3.0 托管的 Byte Buddy 版本是 1.14.12。 Hibernate 6.6 内部调用了 Byte Buddy 1.14.15 才有的方法,运行时加载的却是 1.14.12,于是抛 NoSuchMethodError。 **错误尝试:** 我一开始以为只要把 Hibernate 版本改回去就行,结果发现业务要求的缺陷修复确实需要新版本。单纯回退不是方案,必须同时升级 Byte Buddy 并验证整套兼容性。 > 先疑缓存后疑版,层层排查步步难 > 依赖树里藏真相,传递版本已错乱 > 回退虽能解燃眉,业务缺陷又复返 > 根因不在单组件,矩阵协调方为安 # 07、优化 ```text V1:只覆盖 hibernate.version 证据:NoSuchMethodError,Byte Buddy 版本错位 V2:同时覆盖 hibernate.version 与 byte-buddy.version,并执行完整测试 ``` V1 的问题是只改了 Hibernate,没改它依赖的 Byte Buddy。V2 需要同时覆盖两个版本,并验证整套兼容性。 修改 pom.xml: ```xml 6.6.0.Final 1.14.15 ``` 重新查看依赖树: ```bash mvn dependency:tree -Dverbose -Dincludes=net.bytebuddy:byte-buddy ``` 示例输出: ```text [INFO] \- org.hibernate.orm:hibernate-core:jar:6.6.0.Final:compile [INFO] \- net.bytebuddy:byte-buddy:jar:1.14.15:compile ``` 启动应用: ```bash mvn spring-boot:run ``` 启动成功,无异常。验证接口: ```bash curl -i http://localhost:8080/owners ``` 返回 200。V2 通过,但代价是项目里多了一个需要长期维护的版本覆盖。 > 一版不够两版齐,Byte Buddy 随 Hibernate 移 > 依赖树里再验证,启动无错接口宜 > 代价虽小须牢记,覆盖越多越难理 > 若非业务强需求,莫把矩阵轻撕离 # 08、演进 ```text V2:同时覆盖 Hibernate 与 Byte Buddy 能力:项目具备单独升级底层组件并验证传递依赖的能力 遗留:覆盖版本越多,与 Spring Boot 官方兼容矩阵偏离越远 ``` V2 的收益是业务缺陷得到修复,同时项目掌握了用 dependency:tree 和 effective-pom 验证版本来源的方法。 代价是每多一个版本覆盖,就多一份与官方矩阵偏离的风险。适用场景是业务确实需要特定版本修复缺陷,且团队有能力执行完整回归测试。 遗留问题是:当前只覆盖了两个版本,如果后续升级 Spring Boot,这两个覆盖可能与新 BOM 冲突,需要重新评估。 用 V1 同一组请求验证:V1 时 `/owners` 返回 500,V2 返回 200。启动日志从 NoSuchMethodError 变为正常启动。 > 从一到二路渐明,能力增长代价生 > 覆盖版本如补丁,补丁越多越难清 > 升级 Boot 须重审,矩阵冲突再权衡 > 灵活与稳常博弈,架构判断要清醒 # 09、洞见 ```text 从现象到判断:NoSuchMethodError 不是单点问题,而是版本矩阵失衡的信号 ``` ## 版本号不是越新越好,而是越协调越好 ```text 单组件最新版 → 传递依赖错位 → 运行时异常 ``` Spring Boot 官方测试的是整套版本组合,不是单个组件的最新版。单独升级一个组件,等于用未测试的组合替换已测试的组合。 ## 反直觉判断:显式声明版本不是更可控,而是更危险 ```text 显式版本 → 打破 BOM 约束 → 依赖重新协商 → 结果不可预测 ``` 很多人以为写死版本号就是“锁定”,但在 Spring Boot 体系里,BOM 才是锁。你显式覆盖一个版本,等于把锁打开,让 Maven 重新协商所有相关传递依赖。 ## 反直觉判断:编译通过不等于兼容 ```text 编译期:类和方法存在 运行期:方法签名或行为已变 ``` Maven 编译只检查类和方法是否存在,不检查方法签名是否完全一致。版本错位导致的 NoSuchMethodError 往往在运行时才爆发。 ## 依赖树是排障的第一现场 ```text 现象 → dependency:tree → 传递依赖版本 → 根因 ``` 遇到版本冲突,先看依赖树,再看 effective-pom。依赖树告诉你实际解析了什么,effective-pom 告诉你版本从哪来。 > 版本非新乃协调,矩阵一破祸难消 > 显式声明非可控,反是裂痕入云霄 > 编译无声藏暗涌,运行方知路迢迢 > 依赖树里寻真相,排障第一现场瞧 # 10、系统落地 ```text 入口:pom.xml 声明的 starter 服务:Maven 依赖解析 本模块:Parent + BOM + Dependency Management + Plugin Management 存储:本地仓库与远程仓库的 Jar 包 未实现:自动化兼容性验证与升级建议 ``` 原来 Petclinic 只是被动继承 Spring Boot Parent,没有显式管理任何第三方版本。 本篇增加了用 dependency:tree 和 effective-pom 追查版本来源的能力,并演示了如何安全覆盖底层依赖版本。 现在项目能回答“这个版本从哪来”“改了会怎样”“怎么验证”三个问题。下一篇还缺的是自动化兼容性验证,比如在 CI 里跑依赖分析,防止版本覆盖被无意引入。 官方链接:Spring Boot 主页 https://spring.io/projects/spring-boot ,Petclinic GitHub https://github.com/spri ng-projects/spring-petclinic ,Spring Boot 文档 https://docs.spring.io/spring-boot/documentation.html > 入口声明服务解析,本模块里矩阵立 > 存储仓库 Jar 满地,未实现处待补齐 > 原来被动承版本,如今主动查根底 > 自动化验证未至,CI 防线需建立 # 11、小结 ```text 三问三答: 为什么只升级一个 Hibernate 可能破坏整套兼容关系? 应该统一托管还是各自声明? 选错会怎样? ``` 第一问:因为 Hibernate 有传递依赖,单独升级它会打破 Spring Boot BOM 约束的版本矩阵,导致传递依赖版本错位,运行时抛 NoSuchMethodError。 第二问:应该统一托管。Spring Boot 官方测试的是整套版本组合,各自声明 version 会让 Maven 重新协商依赖,结果不可预测。 第三问:选错会导致编译通过、启动失败,甚至上线后才爆发隐蔽兼容问题。当前状态是 V2 已修复,但项目里多了两个版本覆盖,需要长期维护。 > 一问根因在矩阵,牵一发而动全身 > 二问托管胜各自,协调方得安稳存 > 三问选错藏暗涌,上线方知祸已深 > 当前 V2 虽已稳,覆盖维护须留神 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Spring Boot 不希望每个 Jar 自己声明 version? 因为每个 Jar 自己声明 version 会让 Maven 按“最近优先、先声明优先”等规则重新协商依赖,最终选中的版本组合可能不是 Spring Boot 官方测试过的。 统一托管能保证整套组件来自同一兼容矩阵,升级 Spring Boot 时一起迁移,兼容性由官方保证。 ## 实战题:在 Petclinic 里执行 dependency:tree 和 effective-pom,找出 Tomcat、Jackson、JUnit 的版本来源。 执行 `mvn dependency:tree -Dincludes=org.apache.tomcat.embed:tomcat-embed-core,com.fasterxml.jackson.c ore:jackson-databind,org.junit.jupiter:junit-jupiter` 查看实际版本。 执行 `mvn help:effective-pom` 搜索这些组件,会发现版本都来自 `spring-boot-dependencies` BOM 的 `dependencyManagement`。 ## 排障题:启动报 NoSuchMethodError,如何用 Maven 命令定位根因? 先执行 `mvn dependency:tree -Dverbose` 查看完整依赖图,找到异常类所在的 Jar 及其版本。 再执行 `mvn help:effective-pom` 查看该版本从哪来。 对比 Spring Boot 官方 BOM 中的版本,确认是否被显式覆盖或传递依赖错位。 ## 架构判断题:项目里已经有 10 个版本覆盖,是否应该继续单独升级组件? 不应该。版本覆盖越多,与 Spring Boot 官方兼容矩阵偏离越远,升级 Spring Boot 时冲突风险越大。 应该优先考虑升级整个 Spring Boot 版本,让官方矩阵一起迁移。 如果业务确实需要特定版本,应集中管理覆盖并建立自动化兼容性验证。 > 理解版本矩阵意,实战命令查根底 > 排障先看依赖树,架构判断莫偏离 > 覆盖越多险越积,整体升级方为宜 > 四题做完回头看,版本治理心中记 # 13、升华 ```text 工程结论:版本治理的本质是协调,不是控制 ``` Spring Boot 的 Parent、BOM、Starter、Dependency Management 和 Plugin Management 不是限制,而是把“每个 Jar 自己声明 vers ion”的混乱,变成“整套组件统一协调”的秩序。 单独升级一个 Hibernate 之所以危险,是因为它打破了这种协调。 工程上要做的不是禁止升级,而是升级前用 dependency:tree 和 effective-pom 看清影响,升级后用完整测试验证兼容。 下一篇将进入配置体系,看 Spring Boot 如何用外部化配置管理环境差异,让同一套代码在不同环境安全运行。 > 版本治理非控制,协调方得安稳存 > 矩阵一破祸暗涌,依赖树里寻根因 > 升级非禁须验证,看清影响再前行 > 配置体系下一篇,环境差异待分明 **系列项目:** Spring 官方 Petclinic,服务宠物医院诊疗与宠物主管理场景 **已完成:** 环境搭建、启动机制、依赖与自动配置、Maven 版本治理与依赖解析 **本篇:** 用 dependency:tree 与 effective-pom 追查版本来源,演示安全覆盖底层依赖版本 **下一篇:** 配置体系与外部化配置管理环境差异 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第6招 配置体系必须讲透

# Petclinic 外部化配置实战:一个 Jar 走三套环境 ```text 旧方案:每个环境改一次 application.properties 再重新打包 冲突:开发、测试、生产三套配置混在代码仓库,改错一行就带病上线 后果:同一个 Jar 无法复用,发布变成高风险手工操作 ``` Petclinic 现在能启动、能访问页面,但配置还停留在“改文件、重新打包”的阶段。 开发库、测试库、生产库的连接串、账号、日志级别全都写死在 `application.properties` 里。 每次切换环境都要改代码、重新构建,发布流程又慢又容易出错。 我是老李,今天要带团队把 Petclinic 的配置从“写死”升级到“外部化”。目标不是背优先级表,而是让同一个 Jar 包在不同环境里通过外部输入获得不同行为。 为什么同一个 Jar 可以部署到开发、测试和生产,而不应该每个环境重新打一次包? 当文件、环境变量、启动参数同时提供同一个属性时,应该相信谁? 如果选错了配置来源,或者把密码和普通配置混在一起,会带来什么后果? # 01、故事 ```text 周五 18:40 → 发布窗口只剩 20 分钟 → 任务:把 Petclinic 切到生产数据库 → 冲突:生产库密码还在代码仓库里 ``` 周五傍晚,办公室只剩键盘声。 运维同事在群里催:“生产发布窗口到 19 点,Petclinic 的数据库连接串改好了吗? ”我打开 `application.properties`,看到生产库地址和密码就躺在里面,旁边还有一行注释“上线前记得改”。 任务是让 Petclinic 连上生产库,但生产密码不该出现在代码仓库里。我翻遍项目,没有环境变量覆盖,没有启动参数注入,只有一份写死的配置文件。时间一分一秒过去,发布窗口越来越近。 > 暮色压窗催上线, > 配置写死难两全。 > 密码躺在仓库里, > 改错一行夜难眠。 # 02、问题 ```text 需求变化:一套代码要跑开发、测试、生产三个环境 冲突:配置写死在 Jar 内,环境差异无法在运行时注入 后果:每环境重新打包,配置漂移,密码泄露风险 ``` Petclinic 从“本地能跑”进入“多环境部署”阶段,需求变了。 开发库、测试库、生产库的地址不同,日志级别不同,缓存策略不同。 但当前所有差异都靠改 `application.properties` 解决,改完还要重新 `mvn package`。 这种做法的失效原因很直接:配置和代码耦合在一起,环境差异被硬编码进构建产物。 同一个 Jar 无法在不同环境复用,每次发布都依赖人工改文件,容易改错、漏改。 更严重的是,生产密码进入 Git 历史,泄露风险不可控。 可验证的验收标准是:构建一次 Jar,分别用文件、环境变量、启动参数覆盖同一个属性,最终取值符合预期;密码类配置与普通配置分离,不进入代码仓库。 > 一包欲行三处路, > 配置却锁死仓库中。 > 环境差异成包袱, > 发布如走钢丝绳。 # 03、原理 ```text Spring Environment 聚合多个 PropertySource,按优先级解析属性 ``` Spring Boot 的外部化配置核心是 `Environment`。 它不是一个简单的 `Map`,而是一组有序的 `PropertySource` 列表。 每个来源——命令行参数、环境变量、配置文件、默认值——都是一个 `PropertySource`,按固定顺序排列。 解析属性时,Spring 从最高优先级的来源开始找,找到就返回,找不到再往下找。 命令行参数通常高于环境变量,环境变量高于 `application.yml`,配置文件高于代码里的默认值。 这个顺序不是魔法,是 `SpringApplication` 启动时显式构建的。 一个反直觉判断:配置文件里写了 `server.port=8080`,启动时加 `--server.port=9090`,最终生效的是 9090,但 `application.properties` 里的 8080 并没有被“覆盖”或“删除”。 它还在,只是被更高优先级的来源挡住了。 理解这一点,才能理解为什么同一个 Jar 能适配多环境。 > 配置非图亦非表, > 层层来源叠成桥。 > 高者先声低者默, > 一包行遍路千条。 # 04、架构 ```text 外部输入(文件/环境变量/参数)→ Environment → 类型安全配置类 → 业务组件 ``` 本模块的边界很清晰:外部输入只负责提供原始值,`Environment` 负责聚合和排序,类型安全配置类负责绑定和校验,业务组件只依赖配置类,不直接读 `Environment`。 分层带来的收益是:业务代码与配置来源解耦,配置类可以独立测试,校验逻辑集中管理。代价是引入了一层间接性,简单属性也要经过绑定和校验。 适用条件是:应用需要多环境部署,或者配置项超过十几个,或者存在密码、Token 等敏感值。如果只是本地玩具项目,直接 `@Value` 也够用。 > 外部输入归一处, > 类型安全立门户。 > 业务只认配置类, > 来源变更不惊鹭。 # 05、第一版实战 ```text 环境:JDK 17 + Spring Boot 3.x + Petclinic 官方仓库 配置:新增 application.yml + PetclinicProperties 配置类 代码:@ConfigurationProperties + @Validated 启动:mvn spring-boot:run 验证:curl 接口观察属性取值 ``` 先确认当前环境。 Petclinic 官方仓库基于 Spring Boot 3.x,JDK 17。 在 `pom.xml` 中确认已有 `spring-boot-configuration-processor` 依赖,如果没有则添加: ```xml org.springframework.boot spring-boot-configuration-processor true ``` 在 `src/main/resources/application.yml` 中定义业务配置: ```yaml petclinic: greeting: "Welcome to Petclinic" page-size: 10 database: url: "jdbc:mysql://localhost:3306/petclinic_dev" username: "dev_user" password: "dev_pass" ``` 创建类型安全配置类: ```java package org.springframework.samples.petclinic.config; 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(prefix = "petclinic") public class PetclinicProperties { @NotBlank private String greeting; @Min(1) private int pageSize; private Database database = new Database(); public static class Database { @NotBlank private String url; @NotBlank private String username; private String password; // getters and setters } // getters and setters } ``` 在启动类或配置类上启用: ```java @Configuration @EnableConfigurationProperties(PetclinicProperties.class) public class PetclinicConfig { } ``` 启动应用: ```bash mvn spring-boot:run ``` 示例输出: ```text Started PetClinicApplication in 8.2 seconds ``` 验证配置绑定,临时在控制器中注入并打印: ```java @RestController public class ConfigProbeController { private final PetclinicProperties props; public ConfigProbeController(PetclinicProperties props) { this.props = props; } @GetMapping("/config-probe") public Map probe() { return Map.of( "greeting", props.getGreeting(), "pageSize", props.getPageSize(), "dbUrl", props.getDatabase().getUrl() ); } } ``` 请求验证: ```bash curl http://localhost:8080/config-probe ``` 示例输出: ```json {"greeting":"Welcome to Petclinic","pageSize":10,"dbUrl":"jdbc:mysql://localhost:3306/petclinic_dev"} ``` V1 成功标准:配置类绑定成功,校验注解生效,业务代码不再直接读 `Environment`。 > 一版落地类型安, > 校验注解守门关。 > 配置类里藏真意, > 业务从此不纠缠。 # 06、排查 ```text 诊断树:属性不生效 → 是绑定失败还是优先级问题?→ 是命名不匹配还是未启用配置类? ``` 第一个问题:启动时 `pageSize` 绑定为 0,校验没报错。 怀疑是 `@Min` 没生效。 检查发现 `@Validated` 加在了配置类上,但 `pageSize` 字段是 `int` 类型,`@Min` 对基本类型在部分版本需要额外处理。 证据是启动日志没有校验失败信息。 根因是 `@ConfigurationProperties` 绑定发生在校验之前,基本类型默认值为 0,`@Min(1)` 在部分 Spring Boot 版本对基本类型不触发。 修复:将 `pageSize` 改为 `Integer` 类型,或升级到支持基本类型校验的版本。 第二个问题:环境变量 `PETCLINIC_GREETING` 设置了,但接口返回的还是文件里的值。怀疑是环境变量名不匹配。检查命令: ```bash export PETCLINIC_GREETING="Hello from env" curl http://localhost:8080/config-probe ``` 证据是返回仍为 `Welcome to Petclinic`。 根因是 Spring Boot 的 relaxed binding 对环境变量名有规则:点号变下划线,但 `petclinic.greeting` 对应的是 `PETCLINIC_GREETING`, 这个没错。真正原因是应用通过 IDE 启动,环境变量没有传递进去。 修复:用命令行启动并显式传入: ```bash PETCLINIC_GREETING="Hello from env" mvn spring-boot:run ``` **错误尝试:** 曾试图在 `application.yml` 里用 `${PETCLINIC_GREETING}` 占位符引用环境变量,结果环境变量未设置时启动直接失败,反而引入了新的启动依赖。 > 排查如剥笋层层, > 现象背后有隐绳。 > 证据不到根因处, > 修复只是掩伤痕。 # 07、优化 ```text V1:配置类绑定成功,但环境变量覆盖不稳定,密码仍与普通配置混在一起 证据:环境变量在 IDE 启动时不生效,密码明文在 application.yml V2:显式分离 Secret 配置,用命令行参数验证优先级 ``` 优化第一步:将密码从 `application.yml` 中移除,改为环境变量注入。修改配置类,让密码字段不设默认值: ```yaml petclinic: greeting: "Welcome to Petclinic" page-size: 10 database: url: "jdbc:mysql://localhost:3306/petclinic_dev" username: "dev_user" # password 不再写在这里 ``` 启动时通过环境变量提供密码: ```bash PETCLINIC_DATABASE_PASSWORD="dev_pass_from_env" mvn spring-boot:run ``` 优化第二步:用命令行参数覆盖同一个属性,验证优先级。启动命令: ```bash PETCLINIC_GREETING="Hello from env" mvn spring-boot:run -- --petclinic.greeting="Hello from command line" ``` 请求验证: ```bash curl http://localhost:8080/config-probe ``` 示例输出: ```json {"greeting":"Hello from command line","pageSize":10,"dbUrl":"jdbc:mysql://localhost:3306/petclinic_dev"} ``` 命令行参数高于环境变量,最终取值来自命令行。这个验证让团队直观看到优先级,而不是死记硬背。 > 密码移出配置外, > 环境变量暗里来。 > 命令行里再覆盖, > 优先级自现灵台。 # 08、演进 ```text V2:类型安全配置 + 校验 + Secret 分离 + 优先级验证 能力:同一个 Jar 通过外部输入适配多环境 遗留:配置类字段增多后需要分组管理,Secret 加密存储未实现 ``` 用 V1 同一组请求前后验证。V1 时,`/config-probe` 返回的 `greeting` 只能来自文件,改环境要重新打包。V2 后,同一个 Jar 启动时用不同参数: ```bash java -jar target/petclinic.jar --petclinic.greeting="Dev greeting" --petclinic.database.url="jdbc:mysql://dev:3306/petclinic" ``` ```bash java -jar target/petclinic.jar --petclinic.greeting="Prod greeting" --petclinic.database.url="jdbc:mysql://prod:3306/petclinic" ``` 两次启动返回不同的 `greeting` 和 `dbUrl`,Jar 包本身没有变化。 收益是发布流程从“改代码重新打包”变成“改启动参数”,环境差异不再进入构建产物。 代价是运维需要维护每个环境的启动参数或环境变量清单。 适用条件是:应用需要频繁切换环境,或者配置项包含敏感信息。 遗留问题是:配置类字段增多后,单个 `PetclinicProperties` 会膨胀,需要按域拆分; 密码目前只是不写进文件,但环境变量本身仍是明文,生产环境需要对接 Secret 管理方案。 > 一包行遍三处路, > 参数如衣随境换。 > 构建产物不再动, > 发布从此少惊澜。 # 09、洞见 ```text 从现象到判断:配置优先级不是背出来的,是工程目的逼出来的 ``` ## 外部化配置的本质是“构建产物与环境输入分离” ```text 代码仓库 → 构建 → 不可变 Jar → 运行时注入 → 环境行为 ``` 同一个 Jar 能走三套环境,不是因为 Spring 魔法,而是因为构建产物里不包含环境差异。环境差异全部推迟到运行时注入。这个分离让发布从“高风险手工操作”变成“低风险参数切换”。 ## 反直觉判断:配置文件里的值没有被覆盖,只是被更高优先级来源挡住 ```text application.yml: server.port=8080 命令行: --server.port=9090 最终: 9090,但 8080 仍在 Environment 中 ``` 很多人以为“覆盖”是删除旧值写入新值。实际上 `Environment` 里两个值都在,只是解析时高优先级先命中。这个区别影响调试:当你在配置文件里改了值却不生效,先查是不是有更高优先级来源在挡路。 ## 反直觉判断:类型安全配置类的价值不在“少写 @Value”,而在“校验前置” ```text @Value 注入 → 启动成功 → 运行时才发现配置错误 @ConfigurationProperties + @Validated → 启动时校验 → 失败即停止 ``` `@Value` 也能绑定属性,但校验发生在业务使用时,错误发现太晚。类型安全配置类把校验提前到启动阶段,配置错误在应用对外服务前就暴露。这个前置让配置问题从“线上事故”变成“启动失败”。 ## Secret 管理的起点不是加密,而是“不进入代码仓库” ```text 密码在 application.yml → 进入 Git → 泄露风险 密码在环境变量 → 不进入 Git → 风险降低 ``` 很多人一谈 Secret 就想到加密存储、Vault、KMS。但第一步其实很简单:让密码不进入版本控制。环境变量虽然仍是明文,但至少切断了“代码仓库泄露”这条最直接的路径。 > 配置如衣随境换, > 构建产物守其常。 > 优先级非死记硬, > 工程目的自昭彰。 # 10、系统落地 ```text 入口:HTTP 请求 → 控制器 → 业务服务 → 配置类 → Environment → 外部输入 存储:数据库连接串、日志级别、业务参数 未实现:配置加密存储、动态刷新 ``` 原来 Petclinic 的配置全部写死在 `application.properties`,环境切换靠改文件重新打包。 本篇增加了类型安全配置类 `PetclinicProperties`,引入 Validation 校验,分离了密码类 Secret,验证了文件、环境变量、命令行参数的优先级。 现在 Petclinic 能做到:同一个 Jar 通过外部输入适配开发、测试、生产环境; 配置错误在启动时暴露; 密码不进入代码仓库。 官方文档可参考 Spring Boot 官方文档首页的 Externalized Configuration 章节。 下一篇缺的是配置的动态刷新能力。 当前配置变更需要重启应用,生产环境无法做到不中断更新。 这需要引入 Spring Cloud Config 或 Kubernetes ConfigMap 挂载,配合 `@RefreshScope` 实现。 > 系统落地步步高, > 配置外部化路遥。 > 动态刷新尚未至, > 重启变更仍辛劳。 # 11、小结 ```text 三问三答:为什么一个 Jar 走三套环境?相信谁?选错后果? ``` 为什么同一个 Jar 可以部署到开发、测试和生产?因为构建产物不包含环境差异,所有环境相关值都通过外部输入在运行时注入。Jar 是“行为模板”,环境输入是“行为参数”。 当文件、环境变量、启动参数同时提供同一个属性时,应该相信谁? 相信优先级更高的来源。 命令行参数高于环境变量,环境变量高于配置文件。 这个顺序不是背出来的,是 Spring Boot 启动时显式构建的 `PropertySource` 顺序。 如果选错了配置来源,或者把密码和普通配置混在一起,会带来什么后果?选错来源会导致配置不生效或生效错值,线上行为与预期不符。密码混在普通配置里,一旦进入代码仓库,泄露风险不可控,且难以追溯。 当前状态:Petclinic 已具备类型安全配置、校验、Secret 分离、优先级验证能力,同一个 Jar 可适配多环境。 > 三问三答理自明, > 一包多境靠外因。 > 优先级里藏秩序, > 密码分离保安宁。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 `application.yml` 里改了 `server.port` 不生效,但启动日志显示端口还是旧的? 回答:先检查是否有更高优先级来源提供了 `server.port`,比如命令行参数 `--server.port`、环境变量 `SERVER_PORT`、或系统属性。 `Environment` 中所有来源的值都存在,只是高优先级先命中。 用 `curl /actuator/env` 或启动日志中的 `PropertySource` 顺序排查。 ## 实战题:为 Petclinic 增加一个 `petclinic.cache.ttl-seconds` 配置项,类型为 `Duration`,默认 300 秒,并在配置类中校验不小于 1 秒。 回答:在 `PetclinicProperties` 中增加 `private Duration cacheTtl = Duration.ofSeconds(300);`,字段名对应 `cache-t tl-seconds`。 加 `@DurationMin(seconds = 1)` 或自定义校验注解。 在 `application.yml` 中配置 `cache-ttl-seconds: 600`,启动后通过探针接口验证绑定值。 ## 排障题:环境变量 `PETCLINIC_DATABASE_URL` 设置了,但应用启动时数据库连接失败,报错显示连的是 `localhost:3306`。 回答:先确认环境变量是否真的传给了应用进程。 用 `ps eww ` 查看进程环境变量。 如果确认已传,检查配置类字段名是否为 `url`,对应环境变量应为 `PETCLINIC_DATABASE_URL`。 如果字段名是 `jdbcUrl`,则环境变量应为 `PETCLINIC_DATABASE_JDBC_URL`。 relaxed binding 对驼峰和下划线有映射规则,但不会自动把 `url` 映射到 `jdbcUrl`。 ## 架构判断题:团队提议把所有配置项都放进一个 `application.yml`,用 Spring Profile 区分环境,不引入环境变量。这个方案适合生产吗? 回答:不适合。Profile 文件如 `application-prod.yml` 如果包含密码,仍然会进入代码仓库,泄露风险未解决。 Profile 适合区分非敏感的环境差异,如日志级别、缓存策略。 密码、Token 等 Secret 必须通过环境变量或 Secret 管理方案注入,不能进入版本控制。 > 作业四题各不同, > 理解实战排障中。 > 架构判断明取舍, > 配置之道渐精通。 # 13、升华 ```text 工程结论:外部化配置不是 Spring 特性,是发布工程的纪律 ``` 外部化配置的工程目的,是让构建产物与环境输入分离。 这个分离带来三个直接收益:同一个 Jar 可复用,发布从“改代码”变成“改参数”; 配置错误在启动时暴露,而不是线上运行时; 密码不进入代码仓库,泄露风险可控。 这些收益不是 Spring Boot 独有的,任何严肃的发布工程都需要。 Spring Boot 只是把这件事做得足够简单,简单到让人误以为“配置优先级”就是全部。 真正的价值在于:它逼着团队把环境差异从代码里赶出去。 下一篇,Petclinic 将面对配置的动态刷新问题。 当前配置变更需要重启,生产环境无法接受。 我们将引入 Spring Cloud Config 或 Kubernetes ConfigMap,让配置在不重启的情况下更新。 > 配置之道在分离, > 构建产物守其基。 > 环境输入随境变, > 发布从此少危机。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系基础 **本篇:** 外部化配置、类型安全配置类、Validation、Secret 分离、优先级验证 **下一篇:** 配置动态刷新与 Secret 管理方案落地

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第9招 安全默认值要知道

# Spring Boot 默认安全边界:Petclinic 如何从裸奔到可上线 ```text 功能正常 → 默认接口与管理端点开放 → 数据可被匿名篡改 → 无法上线 ``` Petclinic 的页面、表单、数据库读写都已经跑通,开发环境里点几下就能完成宠物和兽医管理。 我是老李,团队正准备把 Petclinic 推到预发环境,却被安全评审拦下:应用没有登录、没有角色、没有授权,任何人只要知道 URL 就能改数据。 为什么一个功能完全正常的应用,依然可能因为默认开放的接口和管理端点无法上线? 是继续沿用 Spring Boot 的默认安全配置,还是显式接管认证与授权? 如果选错,会不会出现“看似有登录、实际接口仍裸奔”的假安全? # 01、故事 ```text 周四 23:40 → 预发评审会议室 → 上线 Petclinic → 安全评审要求补登录与权限 ``` 周四晚上十一点四十,预发评审还在继续。 任务是让 Petclinic 通过上线前安全评审。 安全同事现场打开浏览器,直接访问 `/owners?lastName=`,页面正常返回;再访问 `/actuator/health`,也正常返回。 他追问:现在谁能改宠物信息?谁能看数据库连接信息? 我回答:目前没有登录,所有接口都开放。 评审结论只有一句:功能正常,但安全边界缺失,不能上线。 > 夜深评审灯未熄, > 页面虽通心却虚。 > 接口裸奔无人守, > 上线之门被锁闭。 # 02、问题 ```text 需求变化:本地演示 → 生产上线 → 默认无认证 → 数据与管理端点暴露 ``` Petclinic 原本是教学演示项目,默认不引入 Spring Security。 引入 Security 后,Spring Boot 会自动提供一个默认安全边界:所有请求需要登录,生成一个随机密码,并提供一个表单登录页。 但默认边界只解决“有没有登录”,不解决“谁能做什么”。 普通用户登录后仍可能访问 Actuator 管理端点,也可能修改核心数据。 可验证的验收标准是:匿名用户访问业务页面被重定向到登录页;普通用户只能查询;管理员才能修改核心数据;普通用户访问 Actuator 端点返回 403。 > 功能虽全无门禁, > 默认开放藏风险。 > 登录只是第一道, > 授权才是真护栏。 # 03、原理 ```text Security 进入 classpath → Boot 自动配置 → 默认认证与授权 → 覆盖需显式声明 ``` Spring Boot 检测到 `spring-boot-starter-security` 后,会激活 `SecurityAutoConfiguration`。 默认行为包括:启用 HTTP Basic 与表单登录;创建一个内存用户 `user`;密码为启动时随机生成的 UUID;所有请求都需要认证;Actuator 端点同样被保护。 反直觉的是:默认安全配置并不会区分角色,也不会保护具体 URL 的写操作。 它只保证“未登录不能访问”,不保证“登录后不能越权”。 要覆盖默认行为,必须显式声明 `SecurityFilterChain` Bean,否则 Boot 的默认配置继续生效。 > 自动配置给底线, > 随机密码护门前。 > 若要角色分权限, > 显式声明才安全。 # 04、架构 ```text HTTP 请求 → SecurityFilterChain → 认证/授权 → Controller → Service → Repository ``` 安全层位于 Controller 之前,由一组过滤器组成。 核心过滤器包括:`SecurityContextPersistenceFilter`、`UsernamePasswordAuthenticationFilter`、`AuthorizationFilt er`、`CsrfFilter`。 认证负责确认“你是谁”,授权负责确认“你能做什么”。 本模块的边界是:只处理 HTTP 入口的安全决策,不侵入业务 Service。 收益是安全规则集中管理,代价是每个请求多经过若干过滤器。 适用条件是:所有需要登录和权限控制的 Web 应用。 > 请求先过安全链, > 认证授权分两边。 > 业务逻辑不侵入, > 边界清晰好扩展。 # 05、第一版实战 ```text JDK 17 + Spring Boot 3.2 + Petclinic → 引入 Security → 默认登录 → 验证 → V1 ``` 在 `pom.xml` 中加入依赖: ```xml org.springframework.boot spring-boot-starter-security ``` 启动应用: ```bash ./mvnw spring-boot:run ``` 启动日志中会出现类似内容: ```text Using generated security password: 3f2a1b4c-5d6e-4f8a-9b0c-1d2e3f4a5b6c ``` 访问 `http://localhost:8080/owners?lastName=`,会被重定向到 `/login`。 输入用户名 `user` 和日志中的随机密码,可以进入页面。 访问 `http://localhost:8080/actuator/health`,同样需要登录。 V1 成功标准:匿名访问被拦截,登录后可访问业务页面。 未在当前环境实测,以下为预期结果。 > 引入依赖一瞬间, > 默认登录现眼前。 > 随机密码藏日志, > 匿名访问被阻拦。 # 06、排查 ```text 现象:登录后仍可改数据 → 怀疑授权缺失 → 检查 SecurityFilterChain → 确认默认配置 → 修复 ``` 问题一:普通用户登录后仍能访问新增、修改、删除接口。 怀疑是默认配置没有角色区分。 检查启动日志,确认没有自定义 `SecurityFilterChain`。 证据:访问 `/owners/new` 返回 200,而不是 403。 根因:Boot 默认配置只要求认证,不要求授权。 问题二:普通用户能访问 Actuator 端点。 怀疑是 Actuator 安全规则未单独配置。 检查请求:登录普通用户后访问 `/actuator/env`,返回 200。 根因:默认安全配置对所有请求一视同仁,没有对管理端点单独授权。 **错误尝试:** 只修改 `application.properties` 中的 `spring.security.user.roles`,以为能限制 Actuator,结果普通用户仍可访问。 > 登录只是第一关, > 授权缺失仍可钻。 > 默认配置不区分, > 越权访问在眼前。 # 07、优化 ```text V1 默认安全 → 证据:越权访问 → V2 显式 SecurityFilterChain + 角色授权 ``` 新增 `SecurityConfig` 类,显式声明安全规则: ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/login", "/webjars/**", "/resources/**").permitAll() .requestMatchers("/owners/new", "/owners/*/edit", "/pets/new", "/visits/new").hasRole("ADMIN") .requestMatchers("/actuator/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/", true) ) .logout(logout -> logout.logoutSuccessUrl("/")) .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")); return http.build(); } @Bean public UserDetailsService userDetailsService() { UserDetails admin = User.withUsername("admin") .password(passwordEncoder().encode("admin123")) .roles("ADMIN") .build(); UserDetails user = User.withUsername("user") .password(passwordEncoder().encode("user123")) .roles("USER") .build(); return new InMemoryUserDetailsManager(admin, user); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } ``` 同时新增自定义登录页 `login.html`,表单提交到 `/login`,并携带 CSRF token。 > 显式声明安全链, > 角色授权分两边。 > 密码编码用 BCrypt, > 管理端点只许管。 # 08、演进 ```text V2 角色授权 → 能力:最小权限 → 遗留:内存用户与静态规则 ``` 用 V1 同一组请求验证: 匿名访问 `/owners?lastName=`,重定向到 `/login`。 用 `user` 登录后访问 `/owners/new`,返回 403。 用 `admin` 登录后访问 `/owners/new`,返回 200。 用 `user` 登录后访问 `/actuator/health`,返回 403。 用 `admin` 登录后访问 `/actuator/health`,返回 200。 收益是核心写操作和管理端点只对管理员开放。 代价是用户和角色目前硬编码在内存中,重启后丢失。 遗留问题是:CSRF 对 API 路径直接关闭,后续需要更细粒度策略。 > 普通用户只能看, > 管理端点只许管。 > 内存用户待持久, > 静态规则待扩展。 # 09、洞见 ```text 从“默认安全”到“显式安全” ``` ## 默认安全不是完整安全 ```text 默认配置 → 只认证 → 不授权 → 假安全 ``` 反直觉判断:引入 Spring Security 后应用并不会自动变安全。 默认配置只保证未登录不能访问,不保证登录后不能越权。 真正的安全边界必须显式声明角色和 URL 授权规则。 ## 管理端点必须单独授权 ```text Actuator 暴露 → 默认同权 → 普通用户可看 → 信息泄露 ``` 反直觉判断:Actuator 端点不会因为“看起来是管理功能”就自动只对管理员开放。 必须显式配置 `/actuator/**` 的访问规则,否则普通用户登录后同样可以查看环境变量、配置信息。 ## 密码编码器必须显式声明 ```text 不声明 PasswordEncoder → 启动报错 → 无法登录 ``` Spring Security 5 之后要求必须显式声明 `PasswordEncoder`。 如果使用 `{noop}` 前缀可以绕过,但生产环境必须使用 BCrypt 等强哈希。 ## CSRF 不能一刀切关闭 ```text 关闭 CSRF → 写操作暴露 → 跨站攻击 ``` 对纯 API 可以关闭 CSRF,但对表单提交必须保留。 一刀切关闭会让所有写操作暴露在跨站请求伪造风险中。 > 默认只是起跑线, > 显式声明才安全。 > 管理端点要单独, > 密码编码不能免。 # 10、系统落地 ```text 浏览器 → SecurityFilterChain → Controller → Service → Repository → 数据库 ``` 原来 Petclinic 没有任何安全控制,所有页面和接口匿名可访问。 本篇增加了 Spring Security 依赖、显式安全过滤链、角色授权、密码编码、自定义登录页、Actuator 安全规则。 现在系统能区分匿名用户、普通用户和管理员,核心写操作和管理端点只对管理员开放。 下一篇还缺:用户与角色持久化到数据库、方法级安全、CSRF 细粒度策略、审计日志。 官方文档可参考 Spring Security 官方文档首页:https://docs.spring.io/spring-security/reference/index.html > 安全边界已落地, > 角色授权分清晰。 > 管理端点有护栏, > 上线评审可开启。 # 11、小结 ```text 三问三答:默认开放 → 显式接管 → 最小权限 ``` 第一问:为什么功能正常仍无法上线? 因为默认开放的接口和管理端点没有认证与授权,任何人都能改数据、看配置。 第二问:继续默认还是显式接管? 必须显式接管。默认配置只提供登录底线,不提供角色和 URL 授权。 第三问:选错会怎样? 会出现“有登录但无授权”的假安全,普通用户仍可越权修改数据或访问管理端点。 当前状态:Petclinic 已具备基于角色的最小权限安全边界,核心写操作和 Actuator 仅管理员可访问。 > 功能正常非终点, > 安全边界是底线。 > 显式接管才可靠, > 最小权限保上线。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:Spring Boot 默认安全配置到底做了什么? 默认配置启用表单登录和 HTTP Basic,生成随机密码,所有请求需要认证。 它不区分角色,不保护具体 URL 的写操作,也不单独限制 Actuator。 ## 实战题:如何让普通用户只能查询,管理员才能修改核心数据? 显式声明 `SecurityFilterChain`,对 `/owners/new`、`/owners/*/edit` 等写操作路径配置 `hasRole("ADMIN")`。 查询路径配置 `authenticated()` 或 `hasAnyRole("ADMIN","USER")`。 同时提供 `UserDetailsService` 和 `PasswordEncoder`。 ## 排障题:普通用户登录后仍能访问 `/actuator/env`,怎么排查? 先检查是否显式声明了 `SecurityFilterChain`。 如果没有,说明 Boot 默认配置生效,所有登录用户都能访问 Actuator。 如果有,检查 `/actuator/**` 的授权规则是否配置为 `hasRole("ADMIN")`。 ## 架构判断题:把安全规则写在 Controller 里用 if 判断,是否可行? 不可行。安全规则应集中在 `SecurityFilterChain` 中,在请求进入 Controller 之前完成认证与授权。 写在 Controller 里会导致规则分散、容易遗漏,且无法保护静态资源和 Actuator 端点。 > 理解默认知边界, > 实战显式写安全。 > 排障先查过滤链, > 架构集中才稳健。 # 13、升华 ```text 安全不是功能,是上线前提 ``` Petclinic 从“功能正常”到“可上线”,差的不是业务代码,而是安全边界。 Spring Boot 的默认安全配置是起点,不是终点。 只有显式声明认证、授权、密码编码、CSRF 和 Actuator 规则,才能把教学项目变成生产级应用。 下一篇将把内存用户替换为数据库持久化,并引入方法级安全,让权限控制更贴近真实业务。 > 功能之上有安全, > 默认只是起跑线。 > 显式声明筑护栏, > 生产上线才心安。 **系列项目:** Spring 官方 Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、数据层、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置、容器构建 **本篇:** 引入 Spring Security,显式接管登录、角色、URL 授权、密码编码、CSRF 与 Actuator 安全 **下一篇:** 用户与角色持久化到数据库,引入方法级安全与审计日志 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第14招 缓存不是一个注解

# 缓存不是 SET/GET:Petclinic 读多写少场景的缓存工程实战 ```text 旧方案:每次请求直查数据库,Vet 列表与 Owner 查询响应稳定但重复消耗连接与 CPU 冲突:读多写少场景下,数据库 20ms 查询看似不慢,但并发一上来连接池与延迟同时恶化 后果:多实例部署后本地缓存各存一份,数据不一致、失效不及时、故障无降级 ``` Petclinic 的 Vet 列表和 Owner 查询是典型读多写少场景。 Vet 数据很少变化,Owner 查询频繁但写入分散。 当前实现每次请求都直接访问数据库,单次查询 20ms 左右,看起来并不慢。 我是老李,今天要带团队把缓存真正工程化。不是简单加一个 @Cacheable 就完事,而是把一致性、失效、容量和故障降级一起考虑进去。 数据库查询明明只有 20ms,是否真的值得加 Redis? 本地缓存和 Redis 之间,应该先上哪一个,还是两个都要? 如果缓存 Key 设计错了、缓存没有失效、或者 Redis 挂了,选错方案会带来什么后果? # 01、故事 ```text 周二 14:30 → 生产环境 Petclinic 集群 → 上线 Vet 列表缓存 → 两台实例缓存不一致 ``` 周二下午两点半,生产环境两台 Petclinic 实例刚刚完成滚动发布。任务是给 Vet 列表加上缓存,降低数据库压力。运维同事在监控屏前突然喊了一声:两台实例返回的 Vet 列表不一样。 一台返回 6 个兽医,另一台返回 7 个。刷新几次,结果还在变。前端用户已经截图发到群里,说页面数据跳来跳去。 我打开日志,发现两台实例各自维护本地缓存,一台缓存了旧数据,一台缓存了新数据。没有共享,没有失效通知。 > 午后集群两处忙, > 缓存各自守空房。 > 一台旧来一台新, > 用户屏前数据慌。 # 02、问题 ```text 需求变化:单实例直查数据库 → 多实例加本地缓存 → 缓存不一致 冲突:本地缓存只在本机生效,实例间无共享、无失效传播 后果:Vet 列表在集群中返回不同结果,Owner 查询命中旧数据,用户体验受损 ``` 本地缓存失效的第一个原因是数据变更只更新了数据库,没有通知其他实例清缓存。第二个原因是缓存 Key 设计没有考虑参数组合,Owner 查询按姓名、城市、电话等条件组合时容易冲突。 影响范围不只是 Vet 列表。Owner 查询如果缓存了分页结果,翻页参数不同却命中同一个 Key,会返回错误数据。 可验证验收标准:两台实例同时请求 Vet 列表,返回结果必须一致;修改 Owner 后 5 秒内所有实例都能读到新数据;Redis 故障时服务不崩溃。 > 本地缓存各自安, > 数据变更无人传。 > 集群两处不同面, > 缓存失效成空谈。 # 03、原理 ```text Spring Cache 抽象 → CacheManager → Cache → 实际 Provider(本地/Redis) @Cacheable:先查缓存,未命中再执行方法并回填 @CacheEvict:删除缓存,支持 beforeInvocation 控制时机 Key 生成:默认 SimpleKeyGenerator,参数组合决定缓存键 TTL:由 Provider 控制,Spring Cache 抽象本身不定义 TTL ``` Spring Cache 的核心是 CacheManager 和 Cache 两个接口。 应用代码只依赖注解,不直接依赖 Redis 或 Caffeine。 CacheManager 决定缓存存在哪里,Cache 负责实际的 get、put、evict。 @Cacheable 的执行逻辑是:先根据 Key 查缓存,命中就直接返回,不执行方法体;未命中才执行方法,并把返回值放入缓存。 一个反直觉判断:缓存命中率高不代表缓存设计正确。如果 Key 设计错误,命中率可能很高,但返回的是错误数据。 另一个反直觉判断:TTL 不是解决一致性的手段,只是兜底。把 TTL 设短可以降低不一致窗口,但无法消除。 > 注解只是表面光, > 抽象之下有文章。 > 命中虽高莫轻信, > 键错数据也敢当。 # 04、架构 ```text 请求 → Controller → Service(@Cacheable/@CacheEvict)→ CacheManager → Cache Provider V1:本地 Caffeine Cache,单实例快,多实例不共享 V2:Redis Cache,集群共享,需处理序列化、TTL、故障降级 边界:缓存只加速读,写路径必须保证失效;缓存不可用时回源数据库 ``` V1 架构在 Service 层加缓存注解,使用 Caffeine 作为本地缓存 Provider。优点是零网络开销,单实例命中延迟极低。缺点是集群环境下各实例缓存独立,数据变更无法互相通知。 V2 架构把缓存 Provider 换成 Redis,所有实例共享同一份缓存。优点是数据一致,缺点是增加网络开销,且 Redis 故障时必须有降级策略。 缓存层必须明确边界:缓存不是数据源,数据库才是。缓存不可用时,服务必须能回源数据库,而不是直接报错。 适用条件:读多写少、数据变更频率低、查询耗时或数据库压力大。如果数据每次请求都变,缓存反而增加复杂度。 > 本地缓存快如风, > 集群共享却成空。 > 换得 Redis 同声气, > 故障降级记心中。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Petclinic 官方代码 + Caffeine 本地缓存 配置:pom.xml 加 spring-boot-starter-cache 和 caffeine 代码:VetService 加 @Cacheable,OwnerService 加 @Cacheable 和 @CacheEvict 启动:mvn spring-boot:run 验证:curl 请求 Vet 列表和 Owner 查询,观察日志与响应 V1:单实例缓存生效,多实例不一致 ``` 第一步,在 pom.xml 中加入缓存依赖。 ```xml org.springframework.boot spring-boot-starter-cache com.github.ben-manes.caffeine caffeine ``` 第二步,在启动类或配置类上开启缓存。 ```java @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager("vets", "owners"); manager.setCaffeine(Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(10))); return manager; } } ``` 第三步,在 VetService 中给查询方法加缓存注解。 ```java @Service public class VetService { private final VetRepository vetRepository; public VetService(VetRepository vetRepository) { this.vetRepository = vetRepository; } @Cacheable(cacheNames = "vets", key = "'all'") public List findAllVets() { return vetRepository.findAll(); } } ``` 第四步,在 OwnerService 中给查询和修改方法加缓存注解。 ```java @Service public class OwnerService { private final OwnerRepository ownerRepository; public OwnerService(OwnerRepository ownerRepository) { this.ownerRepository = ownerRepository; } @Cacheable(cacheNames = "owners", key = "#ownerId") public Owner findOwnerById(Integer ownerId) { return ownerRepository.findById(ownerId).orElse(null); } @CacheEvict(cacheNames = "owners", key = "#owner.id") public Owner saveOwner(Owner owner) { return ownerRepository.save(owner); } } ``` 第五步,启动应用并验证。 ```bash mvn spring-boot:run ``` ```bash curl http://localhost:8080/vets curl http://localhost:8080/owners/1 ``` 第一次请求会执行数据库查询,日志中能看到 SQL。第二次请求直接命中缓存,不再打印 SQL。示例输出: ```text 第一次请求:Hibernate: select v1_0.id,v1_0.first_name,v1_0.last_name from vets v1_0 第二次请求:无 SQL 输出,响应时间明显缩短 ``` V1 成功标准:单实例内第二次请求不再查数据库,响应时间下降。但多实例部署时,各实例各自维护缓存,数据不一致问题暴露。 > 依赖引入三两行, > 注解一加缓存忙。 > 单机命中无 SQL, > 集群共享却无方。 # 06、排查 ```text 诊断树: 多实例缓存不一致 ├── 怀疑:缓存未共享 │ ├── 检查:各实例 CacheManager 类型 │ │ └── 证据:均为 CaffeineCacheManager,本地缓存 │ └── 根因:本地缓存无跨实例通信 ├── 怀疑:缓存未失效 │ ├── 检查:修改 Owner 后其他实例是否清缓存 │ │ └── 证据:其他实例仍返回旧数据 │ └── 根因:@CacheEvict 只清本机缓存 └── 怀疑:Key 冲突 ├── 检查:不同参数是否命中同一缓存 │ └── 证据:Owner 列表按不同条件查询返回相同结果 └── 根因:Key 生成未包含全部参数 ``` 第一个问题:多实例缓存不一致。怀疑是缓存没有共享。检查各实例的 CacheManager 类型。 ```bash curl http://instance1:8080/actuator/cache curl http://instance2:8080/actuator/cache ``` 示例输出显示两个实例都是 caffeine 类型的本地缓存,各自独立。根因确认:本地缓存无法跨实例共享。 第二个问题:修改 Owner 后缓存未失效。怀疑是 @CacheEvict 只清本机缓存。在实例 1 上修改 Owner,然后请求实例 2。 ```bash curl -X POST http://instance1:8080/owners/1 -H "Content-Type: application/json" -d '{"firstName":"New","lastName":"Name"}' curl http://instance2:8080/owners/1 ``` 实例 2 仍返回旧数据。根因确认:@CacheEvict 只清本机缓存,其他实例不知道数据已变。 第三个问题:Key 冲突。怀疑是 Owner 列表查询的 Key 生成不完整。检查缓存 Key。 ```bash curl http://localhost:8080/actuator/cache/owners ``` 示例输出显示多个不同查询条件命中了同一个 Key。根因确认:Key 生成未包含全部参数。 **错误尝试:** 曾尝试把 TTL 设成 1 秒来掩盖不一致问题。结果不一致窗口缩小了,但没消除,而且缓存命中率大幅下降,数据库压力回升。 > 诊断先从共享查, > 本地缓存各安家。 > 失效只清本机键, > 键错数据也敢拿。 # 07、优化 ```text V1:Caffeine 本地缓存 → 多实例不一致、失效不传播、Key 冲突 证据:Actuator 显示各实例独立缓存,修改后其他实例返回旧数据 V2:Redis 共享缓存 + 完整 Key 设计 + 失效策略 + 故障降级 ``` 第一步,引入 Redis 依赖。 ```xml org.springframework.boot spring-boot-starter-data-redis ``` 第二步,配置 Redis 连接。 ```yaml spring: data: redis: host: localhost port: 6379 timeout: 2s ``` 第三步,替换 CacheManager 为 RedisCacheManager。 ```java @Configuration @EnableCaching public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } } ``` 第四步,修复 Key 设计。Vet 列表用固定 Key,Owner 查询用完整参数组合。 ```java @Cacheable(cacheNames = "vets", key = "'all'") public List findAllVets() { return vetRepository.findAll(); } @Cacheable(cacheNames = "owners", key = "#ownerId") public Owner findOwnerById(Integer ownerId) { return ownerRepository.findById(ownerId).orElse(null); } @Cacheable(cacheNames = "ownerList", key = "#lastName + ':' + #city + ':' + #page") public List findOwnersByCriteria(String lastName, String city, int page) { return ownerRepository.findByCriteria(lastName, city, page); } ``` 第五步,修改失效策略。写操作同时清缓存,并设置 TTL 兜底。 ```java @CacheEvict(cacheNames = "owners", key = "#owner.id") @CacheEvict(cacheNames = "ownerList", allEntries = true) public Owner saveOwner(Owner owner) { return ownerRepository.save(owner); } ``` 第六步,增加故障降级。Redis 不可用时回源数据库。 ```java @Service public class VetService { private final VetRepository vetRepository; private final CacheManager cacheManager; public VetService(VetRepository vetRepository, CacheManager cacheManager) { this.vetRepository = vetRepository; this.cacheManager = cacheManager; } public List findAllVets() { Cache cache = cacheManager.getCache("vets"); if (cache != null) { try { List cached = cache.get("all", List.class); if (cached != null) { return cached; } } catch (Exception e) { // 缓存故障,回源数据库 } } return vetRepository.findAll(); } } ``` > 换得 Redis 共享层, > 键要完整才敢称。 > 失效双管齐下稳, > 故障回源不硬撑。 # 08、演进 ```text V2:Redis 共享缓存 + 完整 Key + 失效策略 + 故障降级 能力:多实例数据一致、缓存失效传播、Key 冲突消除、Redis 故障不崩溃 遗留:缓存穿透、缓存雪崩、缓存击穿、容量规划、监控告警 ``` 用 V1 同一组请求验证 V2。先启动两个实例,都连接同一个 Redis。 ```bash curl http://instance1:8080/vets curl http://instance2:8080/vets ``` 两个实例返回相同结果。修改 Owner 后再次请求。 ```bash curl -X POST http://instance1:8080/owners/1 -H "Content-Type: application/json" -d '{"firstName":"New","lastName":"Name"}' curl http://instance2:8080/owners/1 ``` 实例 2 返回新数据,缓存失效传播生效。 收益:多实例数据一致,缓存失效及时,Key 冲突消除,Redis 故障时服务仍可用。 代价:增加 Redis 网络开销,序列化与反序列化消耗 CPU,运维复杂度上升。 适用条件:多实例部署、读多写少、数据变更频率低。单实例且数据量小时,本地缓存可能更简单。 遗留问题:缓存穿透(查询不存在的数据每次都打数据库)、缓存雪崩(大量 Key 同时过期)、缓存击穿(热点 Key 过期瞬间大量请求打数据库)尚未处理。 > 共享缓存数据同, > 失效传播不再空。 > 键全故障皆可降, > 穿透雪崩待后攻。 # 09、洞见 ```text 从现象到判断:缓存命中率高 ≠ 缓存设计正确;TTL 是兜底不是一致性方案;缓存故障必须降级 ``` ## 缓存工程真正困难的是失效,不是存取 ```text SET/GET 简单 → 失效复杂 → 一致性难 ``` Redis 的 SET 和 GET 命令谁都会写,但什么时候删缓存、删哪些缓存、删的顺序是什么,才是缓存工程的核心难题。V1 的 @CacheEvict 只清本机缓存,就是失效设计不完整的典型。 ## 反直觉判断:缓存命中率高不代表缓存正确 ```text 命中率高 → 可能 Key 冲突 → 返回错误数据 ``` 如果 Key 设计错误,不同参数命中同一个缓存,命中率可能很高,但返回的是错误数据。V1 的 Owner 列表查询就是这个问题。判断缓存是否正确,要看返回数据是否正确,而不是看命中率。 ## 反直觉判断:TTL 不能解决一致性 ```text TTL 短 → 不一致窗口小 → 但无法消除 ``` 把 TTL 设短只能缩小不一致窗口,无法消除。真正的一致性要靠失效策略:写操作主动清缓存,TTL 只是兜底。V1 曾尝试用 1 秒 TTL 掩盖问题,结果命中率下降,数据库压力回升。 ## 缓存故障必须降级,不能硬撑 ```text Redis 挂 → 缓存异常 → 回源数据库 ``` 缓存不是数据源,数据库才是。Redis 故障时,服务必须能回源数据库,而不是直接报错。V2 的降级逻辑就是为此设计。 > 存取简单失效难, > 命中虽高莫轻看。 > TTL 只是兜底策, > 故障回源才平安。 # 10、系统落地 ```text 入口:HTTP 请求 → Controller → Service → CacheManager → Redis/数据库 本模块:Spring Cache 抽象 + Redis Provider + 失效策略 + 故障降级 存储:Redis 缓存 + MySQL 数据库 未实现:缓存穿透、雪崩、击穿防护,缓存监控告警 ``` 原来 Petclinic 只有数据库直查,没有缓存层。本篇增加了 Spring Cache 抽象,V1 用 Caffeine 本地缓存,V2 换成 Redis 共享缓存。 现在系统能做什么:多实例共享缓存,数据一致,写操作主动失效,Redis 故障时回源数据库。 下一篇缺什么:缓存穿透、缓存雪崩、缓存击穿的防护,以及缓存命中率、延迟、容量的监控告警。 官方链接:Spring Petclinic 主页 https://github.com/spring-projects/spring-petclinic ,Spring Boot 文档 https:/ /docs.spring.io/spring-boot/documentation.html 。 > 缓存一层落系统, > 共享失效皆已通。 > 穿透雪崩犹未解, > 监控告警待后攻。 # 11、小结 ```text 三问三答: 数据库 20ms 是否值得加 Redis?→ 单次 20ms 不慢,但并发和连接池压力下值得加 本地缓存和 Redis 先上哪个?→ 多实例必须 Redis,单实例可先本地 选错后果?→ 数据不一致、Key 冲突、Redis 故障无降级 ``` 数据库查询 20ms 是否值得加 Redis?单次 20ms 确实不慢,但并发上来后连接池和延迟同时恶化,读多写少场景值得加缓存。 本地缓存和 Redis 先上哪个?多实例部署必须上 Redis,本地缓存无法共享。单实例可以先用本地缓存,但迟早要面对集群问题。 选错后果是什么?数据不一致、Key 冲突返回错误数据、Redis 故障时服务崩溃。 当前状态:V2 已实现 Redis 共享缓存、完整 Key 设计、写操作失效、故障降级。缓存穿透、雪崩、击穿防护尚未实现。 > 三问三答已分明, > 缓存工程不轻松。 > 共享失效皆已稳, > 防护监控待后行。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:@Cacheable 和 @CacheEvict 的执行时机分别是什么? @Cacheable 在方法执行前先查缓存,命中则直接返回,不执行方法体; 未命中才执行方法,并把返回值放入缓存。 @CacheEvict 默认在方法执行后删除缓存,可以通过 beforeInvocation=true 改为方法执行前删除。 ## 实战题:给 Petclinic 的 Pet 查询加上缓存,并设计完整的 Key 和失效策略 在 PetService 中给 findPetById 加 @Cacheable(cacheNames="pets", key="#petId"),给 savePet 加 @CacheEvict(ca cheNames="pets", key="#pet.id")。 如果 Pet 列表查询存在,Key 要包含 ownerId 和分页参数。 ## 排障题:生产环境两台实例缓存不一致,如何定位和修复? 先检查各实例的 CacheManager 类型,确认是否本地缓存。 如果是本地缓存,换成 Redis 共享缓存。 再检查写操作是否加了 @CacheEvict,确保失效传播。 最后检查 Key 设计是否完整,避免不同参数命中同一缓存。 ## 架构判断题:单实例部署的 Petclinic 是否应该直接上 Redis? 单实例部署时,本地缓存 Caffeine 更简单,零网络开销,延迟更低。Redis 增加网络开销和运维复杂度,收益有限。但如果未来可能扩展为多实例,可以提前上 Redis,避免后期迁移。 > 理解实战排障全, > 架构判断记心间。 > 缓存工程非易事, > 步步为营才周全。 # 13、升华 ```text 工程结论:缓存的价值不在 SET/GET,而在一致性、失效、容量和故障降级 ``` 缓存工程真正困难的是一致性、失效、容量和故障降级,而不是 SET/GET。 V1 的本地缓存在单实例下工作良好,但多实例部署时暴露了共享和失效问题。 V2 的 Redis 共享缓存解决了这些问题,但引入了网络开销和运维复杂度。 有依据的结论:多实例部署必须用共享缓存;Key 设计必须完整;写操作必须主动失效;缓存故障必须降级。 下一篇将处理缓存穿透、缓存雪崩、缓存击穿,并增加缓存监控告警,让缓存层真正可观测、可排障。 > 缓存工程路漫长, > 存取之外有文章。 > 一致失效容量降, > 步步为营方成章。 **系列项目:** Spring Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全、数据访问、事务、远程调用、数据库迁移、缓存基础 **本篇:** Spring Cache 抽象、本地缓存到 Redis 共享缓存演进、Key 设计、失效策略、故障降级 **下一篇:** 缓存穿透、雪崩、击穿防护与缓存监控告警

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第21招 可观测要串成证据链

# Petclinic 统一诊断体系:从 P99 三秒沿证据链定位慢点 ```text 旧方案:日志、指标、链路三套独立,靠人肉 grep 与经验猜 冲突:P99 从 100ms 涨到 3s,告警响了却不知道先看哪 后果:MTTR 从分钟级拖到小时级,回滚靠运气,复盘无证据 ``` Petclinic 已经接入了 Actuator、Prometheus 抓取、基础 JVM 指标,但每次线上变慢,团队还是先打开日志翻 stack trace,再切到 Grafana 看 CPU,最后 才想起链路系统。 我是老李,这次被拉进一个真实故障:Owner 查询接口 P99 从 100ms 涨到 3s,用户投诉、老板催、监控告警同时响。 日志里没有异常,CPU 没打满,数据库连接池也没满,那这 3 秒到底花在哪? 是继续加日志重新发布,还是先建立一条从 HTTP 到 SQL、Redis、远程调用的统一证据链? 如果选错,会不会在错误方向上优化半天,最后发现慢在第三方保险报价服务? # 01、故事 ```text 周二 14:30 → 生产环境 → 任务:定位 Owner 查询 P99 三秒 → 冲突:日志无异常、CPU 不高、连接池没满 ``` 周二下午两点半,生产环境告警群突然刷屏:petclinic-owner-service 的 HTTP P99 超过 3 秒。 任务是半小时内给出初步结论,但打开 Kibana 只有正常请求日志,打开 Grafana 看到 CPU 不到 40%,Hikari 活跃连接只有 5 个。 我盯着三块屏幕,感觉像在三个互不相通的房间里找一只猫。 > 告警声里翻日志, > 三块屏幕各说各话。 > 猫在哪个房间, > 没人递来一根线。 # 02、问题 ```text 需求变化:业务新增保险报价远程调用 → 冲突:慢请求无统一上下文 → 后果:无法判断慢在 JVM、线程、SQL、Redis 还是远程 HTTP ``` Owner 查询原本只查数据库和缓存,最近为了给宠物主展示保险报价,新增了一次对第三方保险服务的远程调用。 慢请求出现后,日志、指标、链路三套体系各自为政:日志有 traceId 但没和指标关联,指标有 P99 但不知道是哪个 SQL 拖的,链路有 span 但没和 JVM 线程池指标对齐。 可验证的验收标准是:任意一个慢请求,能在 5 分钟内从 HTTP 入口一路看到 JVM 线程、Hikari、SQL、Redis、远程 HTTP 的耗时分布。 > 三套体系三座岛, > 桥没架好船没到。 > 慢请求像一阵风, > 吹过无痕找不到。 # 03、原理 ```text Observation = 统一上下文 + 低基数标签 + 高基数标签 + 事件 + 计时 ``` Micrometer Observation 把一次操作抽象成一个 Observation,同时向日志、指标、链路三个出口输出。 一个 Observation 包含低基数标签(如服务名、接口名、结果状态)和高基数标签(如 ownerId、traceId),低基数进 Prometheus,高基数进链路系统。 反直觉的是:不是所有标签都该进指标,高基数标签会撑爆 Prometheus 时间序列,但高基数标签恰恰是排障时最需要的线索。 Observation 的 Timer 和 LongTaskTimer 是两回事:Timer 记录已完成操作的耗时分布,LongTaskTimer 记录正在执行的长任务数量,慢请求排查要先用 Time r 定位,再用 LongTaskTimer 看是否堆积。 > 一根线穿过三座岛, > 低基数进指标, > 高基数进链路, > 反直觉处见分晓。 # 04、架构 ```text HTTP 入口 → ObservationFilter → OwnerService(Observation) → Hikari/SQL/Redis/Remote HTTP → ObservationRegistry → Logging/Metrics/Tracing ``` 在 Petclinic 中,Owner 查询的调用链是:HTTP 入口先创建根 Observation,OwnerService 在方法上声明 Observation,内部对 SQL、Redis、远程 HTTP 分别创建子 Observation。 所有 Observation 注册到同一个 ObservationRegistry,由它分发给 LoggingHandler、MeterHandler、TracingHandler。 边界是:低基数标签只进 Meter,高基数标签只进 Tracing,日志只拿 traceId 和关键事件。 收益是排障时能沿一条证据链走到底;代价是每个关键操作都要埋点,代码侵入性比纯 AOP 高。 > 入口一根线, > 内部三根线, > 注册中心分三路, > 证据链上见。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Petclinic → 配置:micrometer-tracing + observation → 代码:OwnerService 埋点 → 启动 → 验证:/owners/{id} → V1:只有 HTTP 和 SQL 两个 Observation ``` 第一版先给 Owner 查询加最基础的 Observation。 在 `pom.xml` 中加入 Micrometer Tracing 和 Observation 自动配置: ```xml io.micrometer micrometer-tracing-bridge-otel io.micrometer micrometer-observation ``` 在 `OwnerController` 中注入 `ObservationRegistry`,给查询方法创建 Observation: ```java @GetMapping("/owners/{ownerId}") public OwnerDto getOwner(@PathVariable Long ownerId) { return Observation.createNotStarted("owner.find", registry) .lowCardinalityKeyValue("api", "getOwner") .highCardinalityKeyValue("ownerId", ownerId.toString()) .observe(() -> ownerService.findOwner(ownerId)); } ``` 在 `OwnerService` 中给 SQL 查询加子 Observation: ```java public OwnerDto findOwner(Long ownerId) { return Observation.createNotStarted("owner.find.sql", registry) .lowCardinalityKeyValue("db", "petclinic") .observe(() -> ownerRepository.findById(ownerId) .map(this::toDto) .orElseThrow(() -> new ResourceNotFoundException("Owner not found"))); } ``` 启动应用后,用 curl 验证: ```bash curl -s http://localhost:8080/owners/1 | jq . ``` 示例输出: ```json { "id": 1, "firstName": "George", "lastName": "Franklin", "pets": [] } ``` 访问 Actuator 的 Prometheus 端点验证指标: ```bash curl -s http://localhost:8080/actuator/prometheus | grep owner_find ``` 示例输出: ```text owner_find_seconds_count{api="getOwner",error="none"} 12 owner_find_seconds_sum{api="getOwner",error="none"} 0.84 ``` V1 成功标准:HTTP 和 SQL 两个 Observation 都有计时指标,traceId 出现在日志中。 > 第一版埋下两根线, > HTTP 和 SQL 各一边。 > 指标里有了计数, > 日志里有了 traceId。 # 06、排查 ```text 诊断树:P99 三秒 → 是 HTTP 慢还是 SQL 慢? → 是线程等待还是执行慢? → 是远程调用还是本地资源? ``` V1 上线后,P99 还是 3 秒,但这次有了证据链。 第一个问题:HTTP 总耗时 3 秒,但 SQL 子 Observation 只有 80ms,说明慢点不在数据库。 检查命令: ```bash curl -s http://localhost:8080/actuator/prometheus | grep -E 'owner_find_seconds|owner_find_sql_seconds' ``` 示例输出: ```text owner_find_seconds_sum 3.1 owner_find_sql_seconds_sum 0.08 ``` 第二个问题:HTTP 和 SQL 之间差了 2.9 秒,怀疑是线程等待或远程调用。 检查 Tomcat 线程指标: ```bash curl -s http://localhost:8080/actuator/prometheus | grep tomcat_threads ``` 示例输出: ```text tomcat_threads_busy 18 tomcat_threads_current 200 tomcat_threads_config_max 200 ``` 线程没满,排除线程池耗尽。 **错误尝试:** 一开始怀疑是 Hikari 连接池等待,加了 `hikaricp_connections_pending` 指标,发现 pending 一直是 0,浪费了 20 分钟。 第三个问题:V1 没有给远程 HTTP 调用埋点,所以看不到保险报价服务的耗时。 根因:Owner 查询新增的保险报价远程调用没有纳入 Observation,导致证据链在 HTTP 和 SQL 之间断了。 > 先看总再看分, > SQL 只要八十毫秒。 > 线程没满池没等, > 断点藏在远程调用。 # 07、优化 ```text V1:HTTP + SQL 两个 Observation → 证据:HTTP 3s、SQL 80ms、线程未满 → V2:补上 Redis、远程 HTTP、Hikari 的 Observation ``` V2 给远程 HTTP 调用、Redis 缓存、Hikari 连接获取都加上 Observation。 在 `InsuranceClient` 中给远程调用埋点: ```java public QuoteDto getQuote(Long ownerId) { return Observation.createNotStarted("owner.insurance.remote", registry) .lowCardinalityKeyValue("remote", "insurance-service") .highCardinalityKeyValue("ownerId", ownerId.toString()) .observe(() -> restClient.get() .uri("/quotes?ownerId=" + ownerId) .retrieve() .body(QuoteDto.class)); } ``` 在 `OwnerService` 中给 Redis 缓存查询埋点: ```java public OwnerDto findOwner(Long ownerId) { return Observation.createNotStarted("owner.find.redis", registry) .lowCardinalityKeyValue("cache", "redis") .observe(() -> { String cached = redisTemplate.opsForValue().get("owner:" + ownerId); if (cached != null) { return objectMapper.readValue(cached, OwnerDto.class); } OwnerDto dto = findOwnerFromDb(ownerId); redisTemplate.opsForValue().set("owner:" + ownerId, objectMapper.writeValueAsString(dto)); return dto; }); } ``` 在 `application.yaml` 中开启 Hikari 指标: ```yaml spring: datasource: hikari: register-mbeans: true management: metrics: enable: hikaricp: true ``` 重新启动后,用同一组请求验证: ```bash for i in $(seq 1 20); do curl -s -o /dev/null http://localhost:8080/owners/1; done curl -s http://localhost:8080/actuator/prometheus | grep -E 'owner_insurance_remote_seconds|owner_find_redis_seconds' ``` 示例输出: ```text owner_insurance_remote_seconds_sum 2.85 owner_find_redis_seconds_sum 0.02 ``` 证据链闭合:HTTP 3 秒,远程保险报价 2.85 秒,SQL 80ms,Redis 20ms。 > 补上远程和缓存, > 证据链终于闭合。 > 三秒里两秒八五, > 慢在第三方报价服务。 # 08、演进 ```text V2:完整 Observation 证据链 → 能力:5 分钟定位慢点 → 遗留:远程服务内部不可见 ``` V2 用同一组请求前后验证:V1 时只能看到 HTTP 3 秒和 SQL 80ms,V2 后能看到远程保险报价占 2.85 秒。 收益是 MTTR 从小时级降到分钟级,排障不再靠猜。 代价是每个关键操作都要手动埋点,代码量增加约 15%。 适用条件是:调用链清晰、关键操作可枚举;不适用于动态生成的大量细粒度操作。 遗留问题是:远程保险服务内部慢在哪,Petclinic 侧看不到,需要对方也接入 Observation 或提供 span 导出。 > 从三秒到两秒八五, > 证据链上走一遍。 > 代价是埋点十五行, > 遗留是远程内部看不见。 # 09、洞见 ```text 从现象到判断:P99 三秒不是单点问题,是证据链断裂问题 ``` ## 低基数与高基数标签的边界是排障效率的分水岭 ```text 低基数 → Prometheus 聚合 高基数 → 链路系统检索 混用 → 时间序列爆炸 ``` 反直觉判断:把 ownerId 放进 Prometheus 指标会让时间序列数量随用户数线性增长,最终拖垮监控系统;但排障时没有 ownerId 又无法定位具体慢请求。 正确做法是低基数进指标、高基数进链路,两者通过 traceId 关联。 ## Timer 和 LongTaskTimer 回答的是两个不同的问题 ```text Timer → 完成了多少、平均多慢 LongTaskTimer → 现在有多少在跑、跑了多久 ``` 反直觉判断:P99 涨到 3 秒时,先看 Timer 的 count 和 sum,再看 LongTaskTimer 的 active 数量; 如果 active 数量突增,说明请求堆积,根因可能在入口而非下游。 ## 证据链的价值不在采集,而在关联 ```text 日志有 traceId → 指标有标签 → 链路有 span 三者不关联 → 三座孤岛 ``` Observation 的核心不是多采几个指标,而是让日志、指标、链路共享同一个上下文,排障时能从一个点跳到另一个点。 ## 远程调用必须作为一等公民埋点 ```text 本地资源 → 可观测 远程依赖 → 必须可观测 ``` Petclinic 的教训是:新增远程调用时没有同步埋点,导致证据链断裂;远程依赖的观测成本要计入功能开发成本。 > 低基数进指标, > 高基数进链路。 > Timer 看完成, > LongTask 看堆积。 # 10、系统落地 ```text 入口:HTTP /owners/{id} → 服务:OwnerService → 本模块:Observation 证据链 → 存储:Prometheus + 链路后端 → 未实现:远程服务内部观测 ``` 原来 Petclinic 有 Actuator 基础指标和分散的日志,但没有统一诊断体系。 本篇增加了 Micrometer Observation 统一上下文,覆盖 HTTP、SQL、Redis、远程 HTTP、Hikari 五个关键环节。 现在能做什么:任意慢请求可在 5 分钟内沿证据链定位到具体环节。 下一篇缺什么:远程保险服务内部不可见,需要接入 OpenTelemetry Collector 或要求对方导出 span。 官方文档可参考 Spring Boot 文档首页和 Micrometer 文档首页。 > 入口到服务到存储, > 五环埋点一条线。 > 本篇补上证据链, > 下篇补远程内部。 # 11、小结 ```text 三问三答:先建证据链再排障;低基数进指标高基数进链路;选错会在错误方向优化 ``` 第一问:是继续加日志重新发布,还是先建立统一证据链?答案是先建证据链,因为日志无异常时,缺的是关联而非更多日志。 第二问:低基数和高基数标签怎么分?答案是低基数进 Prometheus,高基数进链路系统,通过 traceId 关联。 第三问:选错会怎样?选错会在错误方向优化半天,比如先优化 SQL,最后发现慢在远程调用。 当前状态:Petclinic 已具备从 HTTP 到 SQL、Redis、远程 HTTP 的完整 Observation 证据链。 > 先建线再排障, > 低高基数分开走。 > 选错方向优化半天, > 不如先看证据链。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么高基数标签不能进 Prometheus 指标? 高基数标签会让时间序列数量随标签值数量线性增长,比如 ownerId 有 10 万个用户,每个接口指标都会乘以 10 万,Prometheus 内存和查询性能会崩溃。 ## 实战题:给 Petclinic 的 Owner 查询补上 Redis 缓存的 Observation,并验证指标输出。 在 OwnerService 中给 Redis 查询创建 Observation,设置低基数标签 cache=redis,启动后用 curl 访问 /owners/1,再访问 /actuator/pr ometheus 验证 owner_find_redis_seconds 指标存在。 ## 排障题:HTTP P99 涨到 3 秒,但 SQL 和 Redis 指标都正常,下一步查什么? 下一步查远程 HTTP 调用的 Observation 指标,如果远程调用耗时接近 3 秒,根因在第三方服务; 如果远程调用也正常,再查 Tomcat 线程和 Hikari 连接池的 LongTaskTimer 是否堆积。 ## 架构判断题:是否应该给每个 Service 方法都加 Observation? 不应该。Observation 应该加在关键路径和远程依赖上,给每个方法都加会导致代码噪音大、指标过多、维护成本高;应该先梳理调用链,只给边界和远程调用埋点。 > 理解高基数, > 实战补缓存。 > 排障看远程, > 架构不滥埋。 # 13、升华 ```text 工程结论:统一诊断体系的价值不在采集,而在关联;证据链闭合才能把 MTTR 从小时级降到分钟级 ``` Petclinic 的 P99 三秒故障证明:日志、指标、链路三套独立时,排障靠猜;用 Observation 统一上下文后,排障靠证据。 有依据的结论是:远程依赖的观测成本必须计入功能开发成本,新增远程调用时同步埋点,否则证据链断裂。 下一篇将把远程保险服务内部也纳入观测,通过 OpenTelemetry Collector 接收对方导出的 span,让证据链延伸到第三方系统内部。 > 三秒故障一根线, > 证据链上走一遍。 > 远程依赖要埋点, > 下篇延伸到对面。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web/安全/数据/事务、远程调用/数据库迁移/缓存/并发、日志/测试/Testcontainers/Docker Compose、Actuator/可观测基础 **本篇:** Micrometer Observation 统一诊断体系,覆盖 HTTP、SQL、Redis、远程 HTTP、Hikari 的证据链 **下一篇:** OpenTelemetry Collector 接入与远程服务内部观测延伸

资源下载

更多资源
Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

优质分享App

优质分享App

近一个月的开发和优化,本站点的第一个app全新上线。该app采用极致压缩,本体才4.36MB。系统里面做了大量数据访问、缓存优化。方便用户在手机上查看文章。后续会推出HarmonyOS的适配版本。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

用户登录
用户注册