首页 文章 精选 留言 我的

精选列表

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

《SpringBoot4进阶实战》第23招 包和容器怎么真正构建

# 从可执行 Jar 到 OCI 镜像:Petclinic 容器构建的工程冲突与分层价值 ```text 旧方案:COPY target/app.jar app.jar → 镜像层巨大 → 每次发布全量传输 冲突:开发频繁、带宽有限、发布窗口短 后果:构建慢、推送慢、回滚慢、排障难 ``` Spring Petclinic 已经能通过 `mvn package` 打出可执行 Jar,本地 `java -jar` 也能启动。 但到了容器发布阶段,团队发现一个尴尬事实:Jar 能跑,不代表镜像构建方案成熟。 我是老李,负责把 Petclinic 推到测试环境。运维催镜像,开发催发布,网络只有 10Mbps 专线。 为什么 `COPY target/app.jar app.jar` 能运行,却仍然不是一个成熟的 Java 容器构建方案? 是继续用传统 Dockerfile 手写分层,还是切换到 Cloud Native Buildpacks 自动构建 OCI 镜像? 如果选错,会不会让每次发布都变成几百 MB 的全量推送,最终拖垮交付节奏? # 01、故事 ```text 周四 23:40 → 测试环境发布机 → 推送 Petclinic 镜像 → 带宽占满被运维叫停 ``` 晚上十一点四十分,我还在测试环境发布机前。任务是推送 Petclinic 新镜像到 Harbor。 开发刚修完一个登录 Bug,催我赶紧发。运维在群里说专线流量异常,让我先停。 我打开 Dockerfile,只有一行 `COPY target/petclinic.jar app.jar`。镜像 680MB,推送进度条像卡住一样。 > 夜深镜像推不动, > 专线告急催更凶。 > 一行拷贝虽能跑, > 全量传输路难通。 # 02、问题 ```text 需求变化:每周多次发布 → 镜像全量构建 → 推送与部署耗时剧增 冲突:Jar 可运行但镜像不可增量 后果:发布阻塞、回滚缓慢、存储膨胀 ``` 传统 Dockerfile 把整个 Fat Jar 放进一层。任何业务代码改动都会导致该层哈希变化。 Docker 构建缓存失效后,后续层全部重建。推送时,客户端与仓库之间传输的是完整层数据。 在 10Mbps 专线下,680MB 镜像推送需要约 9 分钟。一次发布至少构建、推送、部署三步串行。 可验证验收标准:业务代码只改一个 Controller 时,镜像推送增量应小于 5MB,而不是全量 680MB。 > 一层打包百兆重, > 每次发布全网送。 > 缓存虽在难复用, > 交付节奏被打痛。 # 03、原理 ```text Boot Loader 启动 → 解析 Jar 内依赖 → 按 Layer Index 分层 → 镜像层缓存复用 ``` Spring Boot 可执行 Jar 由 Boot Loader 负责启动。它先读取 `META-INF/MANIFEST.MF` 中的 `Main-Class`。 然后通过 `LaunchedURLClassLoader` 加载 `BOOT-INF/lib` 下的依赖 Jar,再加载 `BOOT-INF/classes` 下的业务类。 这种结构天然适合分层:依赖变化频率低,业务类变化频率高。把变化频率相近的内容放同一层,能最大化缓存复用。 反直觉判断:镜像体积大不一定慢,层变化频繁才慢。一个 680MB 但分层合理的镜像,可能比 300MB 单层镜像发布更快。 > 启动加载有顺序, > 依赖业务本分离。 > 分层若按频率切, > 缓存命中省力气。 # 04、架构 ```text 源码 → Maven 构建 → 可执行 Jar → 分层索引 → OCI 镜像 → Registry ``` 本模块位于构建与发布之间。输入是 Maven 产出的可执行 Jar,输出是符合 OCI 规范的容器镜像。 分层边界包括:`dependencies`、`spring-boot-loader`、`snapshot-dependencies`、`application`。 收益是构建缓存复用、推送增量传输、回滚时只拉变化层。代价是需要维护分层策略或引入 Buildpacks 工具链。 适用条件:应用依赖稳定、发布频繁、网络带宽受限。如果应用从不更新,分层收益可以忽略。 > 输入 Jar 包输出镜像, > 分层边界要划清。 > 依赖稳定业务变, > 缓存复用才可行。 # 05、第一版实战 ```text 环境:JDK 17 + Maven 3.9 + Docker 24 → 配置 spring-boot-maven-plugin → 构建 Jar → 传统 Dockerfile → 验证运行 → V1 ``` 当前 Petclinic 使用 Spring Boot 3.x,构建工具为 Maven。先确认 `pom.xml` 中插件版本。 ```xml org.springframework.boot spring-boot-maven-plugin ${spring-boot.version} ``` 执行打包命令,生成可执行 Jar。 ```bash mvn clean package -DskipTests ``` 查看 Jar 内部结构,确认 Boot Loader 与依赖位置。 ```bash jar tf target/petclinic.jar | head -30 ``` 示例输出: ```text META-INF/MANIFEST.MF org/springframework/boot/loader/Launcher.class BOOT-INF/lib/spring-core-6.1.x.jar BOOT-INF/classes/org/springframework/samples/petclinic/PetClinicApplication.class ``` 编写传统 Dockerfile。 ```dockerfile FROM eclipse-temurin:17-jre COPY target/petclinic.jar app.jar ENTRYPOINT ["java", "-jar", "/app.jar"] ``` 构建镜像并运行。 ```bash docker build -t petclinic:v1 . docker run -p 8080:8080 petclinic:v1 ``` 验证请求: ```bash curl http://localhost:8080/owners?lastName= ``` 返回 200 与 HTML 页面,说明 V1 可运行。成功标准是容器能启动并响应 HTTP 请求。 > 插件打包 Jar 已成, > 一行拷贝镜像生。 > 容器启动能响应, > 初版虽通隐患增。 # 06、排查 ```text 诊断树:推送慢 → 层是否复用?→ 否 → 单层是否过大?→ 是 → 根因:全量 Jar 单层 ``` 第一个问题:推送慢。怀疑镜像层没有复用。检查镜像构建历史。 ```bash docker history petclinic:v1 ``` 示例输出: ```text IMAGE CREATED SIZE abc123 2 minutes ago 680MB ``` 证据显示只有一个 680MB 的大层。任何代码改动都会让该层哈希变化,缓存完全失效。 第二个问题:镜像体积大。怀疑基础镜像包含不必要的 JDK。检查镜像内 Java 版本。 ```bash docker run --rm petclinic:v1 java -version ``` 示例输出: ```text openjdk version "17.0.9" ``` 根因是使用 `eclipse-temurin:17-jre` 但 Jar 内已包含全部依赖,基础镜像仍有 200MB 以上运行时开销。 **错误尝试:** 我曾尝试用 `docker save` 压缩镜像再传输,结果压缩耗时比推送还长,且解压后仍需完整存储。 > 历史一层六百八, > 缓存失效全白搭。 > 基础镜像也偏大, > 压缩传输更抓瞎。 # 07、优化 ```text V1 单层全量 → 证据:docker history 显示 680MB 单层 → V2 使用 Buildpacks 自动分层 ``` 改用 Spring Boot Maven Plugin 的 `build-image` 目标,底层使用 Cloud Native Buildpacks。 ```bash mvn spring-boot:build-image -Dspring-boot.build-image.imageName=petclinic:v2 ``` 该命令会自动完成:分析 Jar 结构、生成 Layer Index、调用 Paketo Buildpacks 构建 OCI 镜像。 查看生成的镜像分层。 ```bash docker history petclinic:v2 ``` 示例输出: ```text IMAGE CREATED SIZE COMMENT abc456 1 minute ago 2.1MB application abc455 1 minute ago 0B spring-boot-loader abc454 1 minute ago 45MB snapshot-dependencies abc453 1 minute ago 210MB dependencies abc452 1 minute ago 180MB base image ``` 业务代码改动只影响 `application` 层,推送时只需传输 2.1MB 增量。 > 构建镜像换工具, > 自动分层有依据。 > 业务改动只传小, > 推送时间降到底。 # 08、演进 ```text V2 分层镜像 → 能力:增量推送、缓存复用 → 遗留:基础镜像与 JVM 参数仍需治理 ``` 用 V1 同一组请求验证 V2 镜像。 ```bash docker run -p 8080:8080 petclinic:v2 curl http://localhost:8080/owners?lastName= ``` 返回 200,功能与 V1 一致。收益是推送增量从 680MB 降到 2.1MB,构建时间因缓存复用缩短。 代价是引入 Buildpacks 工具链,本地首次构建需要拉取构建器镜像,约 500MB。 适用场景:频繁发布、依赖稳定、网络受限。遗留问题:容器默认以 root 运行,JVM 内存参数未显式设置。 > 功能不变收益显, > 增量推送省时间。 > 首次构建有代价, > 用户权限待完善。 # 09、洞见 ```text 从现象到判断:镜像慢不是体积问题,是层变化问题 ``` ## 分层比瘦身更重要 ```text 单层 300MB vs 多层 680MB → 多层发布更快 ``` 反直觉判断:镜像总体积小不一定发布快。多层镜像虽然总体积大,但业务改动只触发小层变化,推送增量远小于单层瘦身镜像。 ## 可运行不等于可发布 ```text java -jar 成功 → 容器启动成功 → 但发布效率可能极低 ``` `COPY app.jar` 能跑通功能,但每次发布都是全量传输。成熟方案必须考虑构建缓存、推送增量、回滚粒度。 ## 工具链切换有隐藏成本 ```text Buildpacks 首次构建 → 拉取构建器 → 本地缓存 → 后续才快 ``` 反直觉判断:切换到 Buildpacks 后第一次构建可能比传统 Dockerfile 更慢。因为要下载构建器镜像和依赖元数据,但第二次开始收益显著。 ## 分层策略依赖变化频率 ```text 依赖稳定 → 分层收益大;依赖频繁变 → 分层收益小 ``` 如果项目每周都升级大量依赖,`dependencies` 层也会频繁变化,分层缓存收益会打折扣。 > 镜像快慢看层变, > 能跑未必能上线。 > 工具切换有成本, > 分层收益看频变。 # 10、系统落地 ```text 入口:mvn spring-boot:build-image → 服务:Buildpacks 分析 Jar → 本模块:分层镜像 → 存储:Registry → 未实现:容器用户与 JVM 参数治理 ``` 原来 Petclinic 只有可执行 Jar,容器构建靠手写 Dockerfile 单层拷贝。 本篇增加 Buildpacks 自动分层构建 OCI 镜像能力,实现依赖层、快照层、应用层分离。 现在能做什么:业务代码改动只推送 2MB 左右增量,构建缓存跨发布复用。 下一篇缺什么:容器内用户权限、JVM 内存与 GC 参数、镜像体积进一步优化。 官方文档可参考 Spring Boot Maven Plugin 文档首页与 Paketo Buildpacks 文档首页。 > 入口插件构建镜像, > 服务分析 Jar 内层。 > 本模块分层落地, > 存储推送增量轻。 # 11、小结 ```text 三问三答:为什么单层不成熟?→ 全量传输;选 Dockerfile 还是 Buildpacks?→ 分层优先;选错后果?→ 发布阻塞 ``` 第一问:`COPY app.jar` 能运行但不成熟,因为单层导致任何改动都全量传输,缓存失效。 第二问:传统 Dockerfile 手写分层可行但易错,Buildpacks 自动分层更适合频繁发布的 Java 应用。 第三问:选错会导致每次发布推送几百 MB,专线占满,回滚缓慢,交付节奏被打乱。 当前状态:Petclinic 已具备分层 OCI 镜像构建能力,推送增量从 680MB 降到 2.1MB。 > 单层全量不成熟, > 自动分层解忧愁。 > 选错方案发布堵, > 当前增量已轻舟。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Spring Boot 可执行 Jar 适合分层? 因为 Boot Loader 将依赖放在 `BOOT-INF/lib`,业务类放在 `BOOT-INF/classes`,两者变化频率不同。分层后依赖层可长期缓存,业务层每次发布只更新小层。 ## 实战题:用 Buildpacks 构建 Petclinic 镜像并验证分层 执行 `mvn spring-boot:build-image`,然后用 `docker history` 查看分层。修改一个 Controller 后重新构建,对比推送增量是否小于 5MB。 ## 排障题:Buildpacks 构建时拉取构建器镜像失败 检查网络是否能访问 `ghcr.io` 或配置的镜像仓库。可设置 `DOCKER_HOST` 或代理。若公司网络受限,需配置私有构建器镜像缓存。 ## 架构判断题:依赖频繁变化的项目是否适合 Buildpacks 分层? 不适合。依赖层频繁变化会导致缓存命中率低,分层收益被抵消。此时应优先稳定依赖版本,或考虑将应用拆分为更小服务。 > 理解分层看频率, > 实战构建验增量。 > 排障网络查代理, > 架构判断看依赖。 # 13、升华 ```text 工程结论:容器构建的成熟度不在能跑,而在能增量、能缓存、能治理 ``` 有依据的结论:分层是 Java 容器构建的核心工程手段,Buildpacks 将分层自动化,降低维护成本。 下一篇将治理容器内用户权限与 JVM 参数,让镜像不仅构建快,运行也更安全、更可控。 > 能跑只是第一步, > 增量缓存才成熟。 > 分层自动化落地, > 安全可控待下篇。 **系列项目:** Spring Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全层、数据层、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置 **本篇:** 从可执行 Jar 到 OCI 镜像的分层构建能力 **下一篇:** 容器用户权限与 JVM 参数治理 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。

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

《SpringBoot4进阶实战》第10招 数据库自动配置怎么来

# Petclinic 换 MySQL:从 H2 到生产数据库的自动配置追踪 ```text 旧方案:H2 内存库一切正常 → 换 MySQL 后偶发 SQL 报错、类型不匹配、事务超时、连接池耗尽 → 上线即故障 ``` 很多团队在 Petclinic 这类教学项目上会先跑通 H2,再切 MySQL。 切换时只改一个 JDBC URL,以为数据库只是“换个地址”。 结果测试环境一压就出问题:SQL 方言不同、类型映射不同、事务隔离不同、连接池参数不同。 我是老李,这次带团队把 Petclinic 真正接到第 1 招准备的 MySQL,沿着 `spring.datasource` 到 `DataSourceAutoConfiguration`、Hika riCP、JPA/Hibernate、Repository、MySQL 的链路逐层追踪。 为什么 H2 完全正常,换成 MySQL 后仍然可能暴露 SQL、类型、事务和连接池问题? 是继续沿用 H2 的开发习惯,还是按生产数据库重新校验每一层自动配置? 如果选错,会不会在压测或上线当天才发现连接池被打满、事务回滚失效、SQL 全表扫描? # 01、故事 ```text 周四 23:40 → 研发区 → 任务是把 Petclinic 切到 MySQL 并跑通冒烟 → 测试催着要环境,日志里全是连接超时 ``` 周四晚上十一点四十,研发区只剩我们这一排还亮着灯。 任务是第二天早上把 Petclinic 切到 MySQL,给测试一个能压的环境。 我把 `jdbc:mysql://localhost:3306/petclinic` 填进去,启动应用。 控制台刷了几十行后停住,最后一行是 `HikariPool-1 - Connection is not available, request timed out after 30000ms`。 测试同学在群里催:“环境好了没? 我们八点要跑第一轮。 ”我盯着屏幕,脑子里全是 H2 时代从没见过的报错。 > 夜深屏幕冷光白,连接池里等不来 > 测试催声如鼓点,H2 旧梦已不在 > 一行 URL 换天地,自动配置藏暗礁 > 若问故障何处起,层层追到数据桥 # 02、问题 ```text 需求变化:从演示用 H2 内存库 → 生产级 MySQL → 自动配置默认值不再适配 → SQL、类型、事务、连接池四类风险 ``` H2 是嵌入式内存库,应用启动时创建,关闭即销毁。 MySQL 是独立服务,有网络延迟、连接数上限、严格 SQL 模式、事务隔离级别和持久化约束。 Spring Boot 对两者的自动配置虽然入口相同,但底层方言、驱动、连接池行为完全不同。 失效原因不是配置写错,而是把 H2 的“零配置体验”误当成 MySQL 的“零配置安全”。 H2 默认宽松,MySQL 默认严格。 可验证的验收标准是:应用启动后能通过 HTTP 接口完成一次 Owner 新增、查询、修改,且日志中能看到真实 SQL 和连接池获取/归还记录。 > 内存库中无风浪,一朝出海见波涛 > 方言不同语句错,类型映射藏暗刀 > 事务隔离分高下,连接池里争分秒 > 不是配置写不对,是把演示当生产 # 03、原理 ```text spring.datasource → DataSourceAutoConfiguration → HikariDataSource → EntityManagerFactory → JpaTransactionManager → Repository → MySQL ``` Spring Boot 的 Data 自动配置由 `DataSourceAutoConfiguration` 触发。 它读取 `spring.datasource.*` 属性,创建 HikariCP 数据源。 随后 `HibernateJpaAutoConfiguration` 用这个数据源构建 `EntityManagerFactory`,扫描 `@Entity` 和 Repository。 HikariCP 默认最大连接数是 10,连接超时 30 秒。 Hibernate 默认 `ddl-auto` 在 H2 上常配合 `create-drop`,在 MySQL 上如果沿用会直接删表重建。 MySQL 驱动通过 `com.mysql.cj.jdbc.Driver` 注册,方言由 Hibernate 根据连接元数据自动识别。 一个反直觉判断:连接池参数不是越大越好。MySQL 每个连接都占用服务端线程和内存,连接数过大反而会让数据库上下文切换加剧,吞吐下降。 > 自动配置一条链,层层默认藏玄机 > 数据源起 Hikari,实体扫描靠 JPA > 方言驱动自动认,池中连接有上限 > 反直觉处须牢记,连接不是越多越强 # 04、架构 ```text HTTP 请求 → Controller → Service → Repository → EntityManager → Hibernate → HikariCP → MySQL ``` 本模块的边界是持久层:从 Repository 接口到 MySQL 连接。 Controller 和 Service 不感知数据库类型,这是 JPA 抽象的价值。 但抽象不能消除差异,只能把差异压缩到方言、类型映射和连接管理三层。 收益是业务代码不用改,代价是排障时必须穿透抽象看真实 SQL 和连接行为。适用条件是单一数据库、标准 JPA 映射。一旦用了 MySQL 特有函数或 JSON 类型,抽象就开始泄漏。 > 上层不晓底层事,抽象压差入方言 > 业务代码稳如山,排障却要穿三层 > 收益代价两相抵,适用边界在标准 > 若用特性超映射,抽象泄漏见真身 # 05、第一版实战 ```text 环境:JDK 17 + Spring Boot 3.2 + Petclinic 官方代码 + MySQL 8.0 → 配置 → 启动 → 验证 → V1 ``` 第一步,在 `pom.xml` 确认依赖。Petclinic 官方已含 `spring-boot-starter-data-jpa`,需要把 H2 换成 MySQL 驱动。 ```xml com.mysql mysql-connector-j runtime ``` 第二步,在 `application.properties` 写数据源配置。 ```properties spring.datasource.url=jdbc:mysql://localhost:3306/petclinic?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 spring.datasource.username=petclinic spring.datasource.password=petclinic spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true ``` 第三步,启动应用。 ```bash ./mvnw spring-boot:run ``` 第四步,验证新增 Owner。 ```bash curl -X POST http://localhost:8080/owners/new \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "firstName=李&lastName=雷&address=深圳&city=深圳&telephone=13800138000" ``` 示例输出: ```text Hibernate: insert into owners (first_name,last_name,address,city,telephone) values (?,?,?,?,?) ``` V1 成功标准:应用启动不报错,Owner 新增后能在 MySQL 中查到记录。 > 依赖换罢配置填,启动命令敲回车 > 一条插入落日志,MySQL 中见新行 > 看似成功已到手,隐患还在暗处藏 > 第一版只验通路,未验压力与边界 # 06、排查 ```text 诊断树:连接池超时 → 怀疑连接泄漏 → 查 Hikari 日志 → 发现连接未归还 → 根因是事务未提交 ``` 第一个问题:压测时连接池超时。现象是并发 20 个请求后,日志出现 `Connection is not available`。怀疑连接泄漏,检查 Hikari 指标。 ```bash curl http://localhost:8080/actuator/metrics/hikaricp.connections.active ``` 示例输出: ```text {"name":"hikaricp.connections.active","measurements":[{"statistic":"VALUE","value":10.0}]} ``` 证据是活跃连接数一直等于 10,说明连接只借不还。 根因是 Service 方法缺少 `@Transactional`,Repository 调用后 EntityManager 未关闭,连接一直挂在事务上。 第二个问题:MySQL 报 `Data too long for column`。 现象是 Owner 的 address 字段写入长字符串时报错。 怀疑 H2 的 VARCHAR 长度在 MySQL 上被严格校验。 检查表结构。 ```sql SHOW CREATE TABLE owners; ``` 示例输出: ```text `address` varchar(255) DEFAULT NULL ``` 证据是 MySQL 严格模式拒绝超长数据,而 H2 默认截断。根因是实体字段未加 `@Column(length=...)`,Hibernate 按默认 255 建表。 **错误尝试:** 第一次遇到连接池超时,我直接把 `maximum-pool-size` 调到 100。结果 MySQL 端线程数暴涨,CPU 飙到 90%,问题更严重。 > 连接池满查指标,活跃数定格十 > 借而不还因无事务,连接泄漏是根因 > 类型报错查表结构,默认长度二五五 > 错误尝试加池数,反让数据库更苦 # 07、优化 ```text V1:无事务、默认池参数、无 SQL 日志规范 → 证据:连接泄漏、类型报错 → V2:显式事务、池参数调优、日志分级 ``` 第一处修改:给 Service 层加 `@Transactional`。Petclinic 的 `ClinicServiceImpl` 需要显式声明事务边界。 ```java @Service public class ClinicServiceImpl implements ClinicService { @Transactional @Override public Owner saveOwner(Owner owner) { return ownerRepository.save(owner); } } ``` 第二处修改:调整连接池参数。连接数按 CPU 核数和 MySQL 最大连接数综合设定,不盲目调大。 ```properties spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=5000 spring.datasource.hikari.idle-timeout=600000 spring.datasource.hikari.max-lifetime=1800000 ``` 第三处修改:SQL 日志按环境分级,开发环境打印,生产环境关闭。 ```properties spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=false logging.level.org.hibernate.SQL=debug logging.level.org.hibernate.type.descriptor.sql.BasicBinder=trace ``` > 事务注解补上去,连接借还归正途 > 池参数按核来定,不追大数追匹配 > 日志分级分环境,开发可见生产隐 > 三处修改皆对症,V2 方敢称可用 # 08、演进 ```text V2:显式事务 + 池参数调优 + 日志分级 → 能力:连接不泄漏、类型可约束、SQL 可追踪 → 遗留:迁移脚本未管 ``` 用 V1 同一组请求前后验证。V1 时并发 20 个请求,10 个超时;V2 后同样并发,全部 200 返回。连接活跃数峰值 20,请求结束后回落到 5。 ```bash curl http://localhost:8080/actuator/metrics/hikaricp.connections.active ``` 示例输出: ```text {"name":"hikaricp.connections.active","measurements":[{"statistic":"VALUE","value":5.0}]} ``` 收益是连接生命周期可控,类型约束明确,SQL 排障有据。 代价是每个 Service 方法都要显式考虑事务边界,开发心智增加。 遗留问题是数据库表结构变更还没有纳入版本管理,`ddl-auto=update` 在生产环境有风险。 > 同组请求再验证,超时清零全通过 > 活跃连接有起落,峰值二十回落五 > 收益代价两分明,事务边界要常想 > 遗留迁移未管住,下篇要把脚本收 # 09、洞见 ```text 从现象到判断:H2 正常不代表 MySQL 正常,抽象不消除差异,连接池是数据库的客户端限流器 ``` ## 反直觉判断:H2 越正常,MySQL 越危险 ```text H2 宽松 → 掩盖问题 → MySQL 严格 → 集中爆发 ``` H2 的宽松默认值会掩盖类型、事务、SQL 方言问题。开发期越顺利,说明代码越依赖 H2 的宽容,切换 MySQL 时问题越集中。 ## 反直觉判断:连接池是保护数据库,不是压榨数据库 ```text 池小 → 数据库稳 → 请求排队 → 池大 → 数据库崩 → 全部超时 ``` 连接池的本质是客户端限流器。池参数调大看似提高并发,实际是把压力转嫁给数据库。数据库一旦过载,所有请求一起超时。 ## 事务边界是连接归还的开关 ```text 无事务 → 连接不归还 → 池耗尽 → 有事务 → 连接按时还 → 池健康 ``` Spring 的事务管理器和连接池是联动的。事务提交或回滚时,连接才会归还池中。漏写 `@Transactional` 等于让连接只借不还。 ## SQL 日志是生产排障的底线 ```text 无日志 → 黑盒 → 有日志 → 可追踪 → 分级 → 可控制 ``` 生产环境不能靠 `show-sql` 全开,但也不能完全没有 SQL 日志。按包名和级别精细控制,才能在排障时看到真实 SQL,又不拖垮性能。 > 洞见不在多,在穿透表象 > H2 是温床,MySQL 是考场 > 连接池是盾,不是攻城锤 > 事务与日志,一收一放间 # 10、系统落地 ```text 入口:HTTP 请求 → Controller → Service → Repository → HikariCP → MySQL → 未实现:Flyway 迁移 ``` 原来 Petclinic 只有 H2 内存库,重启即丢数据。 本篇增加了 MySQL 持久化、HikariCP 连接池调优、显式事务边界、SQL 日志分级。 现在应用可以连接真实 MySQL,支持并发读写,连接生命周期可控。 下一篇缺的是数据库迁移管理。 `ddl-auto=update` 在开发期方便,在生产环境不可控。 需要引入 Flyway 或 Liquibase,把表结构变更纳入版本管理。 官方文档见 Spring Boot 文档首页和 Petclinic GitHub 仓库。 > 入口请求层层下,直到池中取连接 > 原来内存随启灭,如今 MySQL 持久存 > 本篇补上池与务,日志分级可追踪 > 下篇要管迁移事,表结构变更有版本 # 11、小结 ```text 三问三答:为什么 H2 正常 MySQL 出问题?选 H2 习惯还是生产校验?选错会怎样? ``` 为什么 H2 正常 MySQL 出问题?因为 H2 的宽松默认值掩盖了 SQL 方言、类型长度、事务边界和连接池配置四类差异。MySQL 严格模式让这些问题集中暴露。 选 H2 习惯还是生产校验?必须按生产数据库重新校验每一层自动配置。H2 的零配置体验不能迁移到 MySQL,连接池参数、事务注解、类型约束都要重新审视。 选错会怎样?连接池耗尽导致服务不可用,类型报错导致写库失败,事务缺失导致数据不一致,SQL 方言差异导致全表扫描。当前状态是 V2 已通过并发验证,连接生命周期可控,SQL 可追踪。 > 三问三答皆落地,不靠猜想来答题 > H2 宽松藏隐患,MySQL 严格见真章 > 生产校验不可省,层层配置要过堂 > 当前状态已可用,迁移管理待下章 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 HikariCP 的 maximum-pool-size 不是越大越好? 连接池过大意味着每个应用实例都持有大量数据库连接。 MySQL 每个连接消耗服务端线程和内存,连接数超过数据库承载能力后,上下文切换加剧,整体吞吐反而下降。 连接池的本质是客户端限流器,保护数据库不被压垮。 ## 实战题:把 Petclinic 的 Owner 实体 address 字段加上 @Column(length=500),并验证 MySQL 表结构变化。 在 `Owner` 实体的 `address` 字段上添加 `@Column(length=500)`,重启应用后执行 `SHOW CREATE TABLE owners`,确认 `address` 列变为 `varchar(500)`。 注意 `ddl-auto=update` 只加长不缩短,缩短长度需要手动迁移。 ## 排障题:压测时日志出现 HikariPool-1 - Connection is not available,如何定位是连接泄漏还是池太小? 先查 Hikari 指标,看活跃连接数是否长时间等于最大值。 如果活跃数一直满且请求结束后不回落,是连接泄漏,检查 Service 层是否漏写 `@Transactional`。 如果活跃数随请求波动但峰值触顶,是池太小,按 CPU 核数和数据库承载能力调大。 ## 架构判断题:生产环境能否继续使用 spring.jpa.hibernate.ddl-auto=update? 不能。`ddl-auto=update` 由 Hibernate 在启动时自动修改表结构,生产环境不可控。 它可能执行危险的 DDL,且无法回滚。 生产环境应使用 Flyway 或 Liquibase 管理数据库迁移,`ddl-auto` 设为 `validate` 或 `none`。 > 四题四层递进,从懂到做再到断 > 理解池中真意,实战改列验证 > 排障看指标分,架构判断守底线 > 作业不是终点,是下篇的起点 # 13、升华 ```text 工程结论:数据库切换不是改 URL,而是重新校验整条持久化链路的默认值 ``` 从 H2 到 MySQL,表面是 JDBC URL 的变化,实质是持久化链路从演示模式切换到生产模式。 连接池、事务、类型、方言、日志,每一层都要重新审视。 Spring Boot 的自动配置给了我们一条清晰的追踪路径,但默认值只保证“能启动”,不保证“能生产”。 下一篇将引入 Flyway,把数据库表结构变更纳入版本管理,让 Petclinic 的持久化层具备可迁移、可回滚、可审计的生产能力。 > 一条链路穿到底,层层默认要过堂 > 切换不是改地址,是换模式换心脏 > 自动配置给路径,生产校验给安全 > 下篇引入 Flyway,表结构变更有回响 **系列项目:** Spring 官方 Petclinic,服务宠物诊所的预约、宠物与兽医管理。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全层、数据源切换 MySQL、连接池调优、事务边界、SQL 日志。 **本篇:** 沿 spring.datasource 到 MySQL 追踪 Data 自动配置,解决连接池、类型、事务、日志四类问题。 **下一篇:** 引入 Flyway 管理数据库迁移,替代 ddl-auto=update。

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

《SpringBoot4进阶实战》第7招 Profile别变成配置垃圾场

# Profile 不是配置的垃圾桶:Petclinic 多环境配置治理实战 ```text 旧方案:一个 application.yml 塞满 local/test/prod 分支 冲突:环境越多,@Profile 和 if(prod) 越多 后果:配置像垃圾桶,改一处炸一片,新人不敢动 ``` Petclinic 从教学项目走向生产时,配置最先失控。 数据库、Redis、外部保险服务、可观测、安全策略在不同环境都不一样。 团队一开始把差异都写进同一个 application.yml,再用 spring.profiles.active 切换。 环境从 2 个涨到 5 个以后,文件超过 800 行,没人能说清一个开关会影响哪些服务。 我是老李,这次要带团队把 Petclinic 的配置从“能跑”治理到“能维护”。问题不是 Spring Boot 不支持多环境,而是我们把 Profile 当成了万能开关。 为什么 Profile 越建越多以后,配置系统反而越来越难理解? 是继续增加 Profile 和条件注解,还是把部署环境组合与模块化配置拆开? 如果选错,会不会出现生产配置被测试默认值悄悄覆盖,或者改一个业务开关要同时改五个文件? # 01、故事 ```text 周四 23:47 → 生产发布值班群 → 任务:切 production profile 上线保险服务 → 冲突:Redis 地址仍是 localhost ``` 周四晚上十一点四十七分,发布群突然弹消息。 任务是让 Petclinic 的生产环境启用外部保险服务,同时把数据库切到生产只读副本。 运维同学在 application.yml 里把 spring.profiles.active 改成 production,然后重启。 应用起来了,但日志里 Redis 连接一直报 Connection refused。我打开配置一看,Redis 的 host 还写着 localhost。生产环境根本没有本地 Redis。 群里开始催:为什么改个 profile 还会连本地?为什么测试环境好的配置到生产就失效?为什么没人知道 production 到底激活了哪些配置? > 夜深发布灯犹亮, > 一行配置改错行。 > 本地 Redis 连不上, > 才知环境已混装。 # 02、问题 ```text 需求变化:local/test/prod 都要独立配置 冲突:Profile 文件互相覆盖,默认值混进生产 后果:配置来源不唯一,排查靠猜,发布靠运气 ``` Petclinic 现在有三个部署环境:本地开发、测试集群、生产集群。 数据库、Redis、外部保险服务、可观测和安全策略都不同。 团队为每个环境建了 application-local.yml、application-test.yml、application-prod.yml。 但问题在于,很多公共配置还留在 application.yml 里。 比如 Redis host 默认写 localhost,生产文件里漏了覆盖,就出现故事里的故障。 更糟的是,有人开始在业务代码里写 if(prod) 来切换逻辑,配置和代码耦合越来越深。 失效原因是配置来源不唯一:默认文件、Profile 文件、环境变量、启动参数都能改同一个键,但优先级和覆盖关系没人梳理。 影响是生产事故风险高,每次发布都要靠人肉核对。 可验证的验收标准是:任意环境启动后,数据库、Redis、保险服务、安全策略的最终生效值都能从配置文件中唯一确定,不依赖启动顺序和默认值巧合。 > 环境三套各自忙, > 默认值里藏祸殃。 > 生产漏写一行字, > 本地配置上厅堂。 # 03、原理 ```text Profile 表达部署环境组合 → 不是模块开关 spring.profiles.active 激活环境 → profile-specific files 覆盖默认值 Profile Groups 组合多个环境 → spring.config.activate.on-profile 限定文件生效条件 ``` Spring Boot 的 Profile 机制本质是“条件化配置”。 spring.profiles.active 指定当前激活的环境名,application-{profile}.yml 会在主配置之后加载,同名键覆盖主配置。 这个顺序是固定的,但容易让人误以为“生产文件一定会覆盖所有默认值”。 实际上,如果生产文件里没有写某个键,主配置里的默认值仍然生效。 这就是 Redis 连 localhost 的根因。 反直觉的是:Profile 文件不是“完整替代”主配置,而是“增量覆盖”。 你越依赖默认值,生产配置就越不可预测。 Profile Groups 是 Spring Boot 2.4 之后的能力,允许用一个组名同时激活多个 Profile。 比如 production 组可以包含 prod、prod-db、prod-observability、prod-security。 这样部署时只写一个名字,但配置仍然分文件维护。 spring.config.activate.on-profile 则让一个文件只在特定 Profile 激活时生效,适合做条件化片段。 > 覆盖不是全替换, > 默认值里藏隐患。 > 组合环境成一键, > 文件边界要划断。 # 04、架构 ```text 输入:部署环境名 → 配置加载链 → 输出:唯一生效配置集 边界:application.yml 只放公共默认值,profile-specific 文件只放环境差异 ``` 治理后的配置架构分三层。 第一层是 application.yml,只放所有环境都相同的公共配置,比如应用名、通用日志格式。 第二层是 profile-specific 文件,每个文件只描述一个部署环境的差异,比如 application-prod-db.yml 只写生产数据库连接。 第三层是 Profile Group,把多个差异文件组合成一个部署环境。 这样做的收益是配置来源唯一:一个键要么在公共文件,要么在某个环境差异文件,不会同时出现在多个地方互相覆盖。代价是文件数量变多,需要约定命名规范。适用条件是部署环境之间有明确差异,且差异可以按模块拆分。 边界要划清楚:业务逻辑里禁止出现 if(prod)。如果某个行为需要按环境切换,应该用配置项驱动,而不是硬编码环境名。Profile 只表达“当前在哪运行”,不表达“代码该怎么走”。 > 公共默认归一处, > 环境差异各成书。 > 组合一键启生产, > 业务不写环境符。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + Java 17 → 配置:application.yml + 三个 profile 文件 → 代码:@Profile 注解 → 启动:local/test/prod → 验证:配置生效 → V1 ``` 先看当前 Petclinic 的依赖。pom.xml 里 Spring Boot 版本是 3.2.x,Java 17。配置文件结构如下: ```yaml # application.yml spring: application: name: petclinic datasource: url: jdbc:mysql://localhost:3306/petclinic username: root password: root data: redis: host: localhost port: 6379 insurance: base-url: http://localhost:8081 ``` ```yaml # application-local.yml spring: datasource: url: jdbc:mysql://localhost:3306/petclinic_local ``` ```yaml # application-test.yml spring: datasource: url: jdbc:mysql://test-db:3306/petclinic data: redis: host: test-redis ``` ```yaml # application-prod.yml spring: datasource: url: jdbc:mysql://prod-db:3306/petclinic username: prod_user password: ${DB_PASSWORD} insurance: base-url: https://insurance.prod.example.com ``` 启动命令: ```bash java -jar petclinic.jar --spring.profiles.active=prod ``` 验证请求: ```bash curl http://localhost:8080/actuator/env/spring.data.redis.host ``` 示例输出: ```json {"property":{"source":"application.yml","value":"localhost"}} ``` V1 成功标准是应用能启动,但 Redis 配置明显错误。生产环境没有 localhost Redis,这个值来自主配置的默认值,prod 文件里漏写了。 > 三套文件已分列, > 默认值里仍藏劣。 > 生产启动连本地, > 配置治理未了结。 # 06、排查 ```text 现象:prod 启动后 Redis 连 localhost 怀疑:prod 文件没覆盖 → 检查:actuator/env 看配置来源 → 证据:source 是 application.yml → 根因:默认值未被覆盖 ``` 第一个问题是 Redis host 错误。怀疑是 application-prod.yml 漏写。检查命令: ```bash curl http://localhost:8080/actuator/env/spring.data.redis.host ``` 示例输出: ```json {"property":{"source":"application.yml","value":"localhost"}} ``` 证据显示值来自 application.yml,说明 prod 文件没有覆盖这个键。 根因是团队把“公共默认值”和“环境差异”混在一起,导致生产文件不完整。 修复方式是在 prod 文件里补上 Redis 配置。 第二个问题是保险服务地址在 test 环境也指向了 localhost。检查命令: ```bash curl http://localhost:8080/actuator/env/insurance.base-url ``` 示例输出: ```json {"property":{"source":"application.yml","value":"http://localhost:8081"}} ``` 测试环境没有本地保险服务,这个默认值同样是错的。根因和第一个问题一样:默认值被当成“所有环境都能用”。 **错误尝试:** 一开始想在代码里判断 profile,如果 prod 就用生产地址,否则用本地地址。这样虽然能跑,但把环境判断写进了业务代码,后面每加一个环境都要改代码。 > 配置来源查得清, > 默认值里藏祸心。 > 莫把环境写进码, > 边界划好才安心。 # 07、优化 ```text V1:默认值混在 application.yml → 证据:actuator/env 显示 source 错误 → V2:公共默认值最小化 + 环境文件补全 ``` 优化第一步是把 application.yml 里的环境相关默认值全部移除,只保留真正公共的配置。数据库、Redis、保险服务这些有环境差异的键,必须在每个环境文件里显式写全。 ```yaml # application.yml spring: application: name: petclinic ``` ```yaml # application-local.yml spring: datasource: url: jdbc:mysql://localhost:3306/petclinic_local username: root password: root data: redis: host: localhost port: 6379 insurance: base-url: http://localhost:8081 ``` ```yaml # application-test.yml spring: datasource: url: jdbc:mysql://test-db:3306/petclinic username: test_user password: ${TEST_DB_PASSWORD} data: redis: host: test-redis port: 6379 insurance: base-url: http://insurance.test.example.com ``` ```yaml # application-prod.yml spring: datasource: url: jdbc:mysql://prod-db:3306/petclinic username: prod_user password: ${DB_PASSWORD} data: redis: host: prod-redis port: 6379 insurance: base-url: https://insurance.prod.example.com ``` 这样每个环境的完整配置都在自己的文件里,不再依赖主配置的默认值。验证命令: ```bash java -jar petclinic.jar --spring.profiles.active=prod curl http://localhost:8080/actuator/env/spring.data.redis.host ``` 示例输出: ```json {"property":{"source":"application-prod.yml","value":"prod-redis"}} ``` > 默认值里清干净, > 环境文件各写全。 > 配置来源唯一化, > 排查不再靠猜拳。 # 08、演进 ```text V2:环境文件完整 → 能力:配置来源唯一 → 遗留:文件变大,生产配置混杂多个模块 ``` V2 解决了默认值覆盖问题,但带来新问题:application-prod.yml 里同时包含数据库、Redis、保险服务、可观测、安全策略,文件超过 200 行。 每次改安全配置都要在数据库配置旁边翻找,模块边界模糊。 用 V1 同一组请求验证: ```bash curl http://localhost:8080/actuator/env/spring.data.redis.host curl http://localhost:8080/actuator/env/insurance.base-url ``` 示例输出: ```json {"property":{"source":"application-prod.yml","value":"prod-redis"}} {"property":{"source":"application-prod.yml","value":"https://insurance.prod.example.com"}} ``` 配置来源唯一了,但文件内部结构混乱。收益是排查快,代价是维护难。适用场景是环境少、差异小的情况。遗留问题是生产配置需要按模块拆分,同时保持一键激活。 > 来源唯一已达成, > 文件膨胀又生疼。 > 模块边界要划清, > 组合激活待启程。 # 09、洞见 ```text 从现象到判断:Profile 不是配置的垃圾桶 ``` ## Profile 应表达部署环境组合,而不是代替模块化配置 ```text Profile 数量增长 → 配置理解成本指数上升 ``` 当 Profile 既表示环境又表示模块时,组合数量爆炸。 比如 prod、prod-db、prod-redis、prod-security 同时存在,没人知道 prod 到底包含哪些。 正确做法是 Profile 只表示部署环境,模块差异用文件拆分,再用 Profile Group 组合。 ## 反直觉判断:默认值越少,配置系统越安全 ```text 默认值多 → 覆盖链长 → 生产事故概率高 ``` 很多人觉得默认值方便,少写几行。但默认值会让配置来源不唯一,生产环境可能悄悄继承开发环境的地址。把默认值最小化,强制每个环境显式声明,反而更安全。 ## 反直觉判断:if(prod) 是配置治理失败的信号 ```text 业务代码出现环境判断 → 配置边界已经失守 ``` 如果代码里需要判断当前是不是生产环境,说明有行为差异没有通过配置表达。应该把差异抽成配置项,让代码只读配置,不关心环境名。这样测试环境可以模拟生产行为,生产环境也可以安全切换。 ## Profile Group 的价值在于“一键组合”,不是“多建几个 Profile” ```text production 组 → prod-db + prod-observability + prod-security ``` Group 让部署命令保持简单,同时配置文件可以按模块维护。一个 production 名字背后是多个文件的组合,但每个文件只关心自己的模块。这样加一个可观测配置,不需要动数据库配置。 > 环境组合是正道, > 模块配置各成书。 > 默认值少才安全, > 业务不写环境符。 # 10、系统落地 ```text 入口:部署命令 --spring.profiles.active=production → 服务:Petclinic 应用 → 本模块:Profile Group 组合配置 → 存储:数据库/Redis/保险服务 → 未实现:配置加密与动态刷新 ``` 原来 Petclinic 只有 application.yml 和几个零散的 profile 文件,配置来源混乱。 本篇增加了 Profile Group 机制,把生产环境拆成 prod-db、prod-observability、prod-security 三个模块文件,再用 production 组一键激活。 现在能做的事情:部署时只写一个环境名,配置文件按模块维护,每个键的来源唯一可查。下一篇缺的是配置加密和动态刷新能力,生产密码目前还是明文环境变量。 官方文档可参考 Spring Boot 官方文档首页:https://docs.spring.io/spring-boot/index.html > 一键组合生产境, > 模块文件各分明。 > 配置来源可追溯, > 加密刷新待前行。 # 11、小结 ```text 三问三答:Profile 为何难懂?如何选择?选错后果? ``` 第一问:为什么 Profile 越建越多以后,配置系统反而越来越难理解?因为 Profile 被同时用来表示环境和模块,组合爆炸,覆盖链变长,来源不唯一。 第二问:是继续增加 Profile 和条件注解,还是把部署环境组合与模块化配置拆开?应该拆开。Profile 只表示部署环境,模块差异用独立文件,再用 Profile Group 组合。 第三问:选错会怎样? 会出现生产配置被默认值覆盖、业务代码里到处 if(prod)、改一个开关要动五个文件。 当前状态是配置来源已唯一,生产环境通过 production 组一键激活,但配置加密和动态刷新还没做。 > 三问已答心自明, > 环境模块要分清。 > 组合一键启生产, > 配置治理路渐平。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:spring.profiles.active 和 spring.config.activate.on-profile 有什么区别? spring.profiles.active 是启动时指定激活哪些 Profile,属于入口配置。 spring.config.activate.on-profile 是写在配置文件内部,声明这个文件只在特定 Profile 激活时生效。 前者决定“激活谁”,后者决定“谁被激活时加载”。 ## 实战题:把 Petclinic 的生产配置拆成 prod-db、prod-observability、prod-security 三个文件,并用 production 组组合。 创建 application-prod-db.yml、application-prod-observability.yml、application-prod-security.yml,分别写数据库、可 观测、安全配置。 然后在 application.yml 里定义: ```yaml spring: profiles: group: production: - prod-db - prod-observability - prod-security ``` 启动时用 --spring.profiles.active=production 验证三个文件的配置都生效。 ## 排障题:生产环境启动后,数据库连接池一直报连接超时,但配置里写的是 prod-db,怎么排查? 先查配置来源: ```bash curl http://localhost:8080/actuator/env/spring.datasource.url ``` 看 source 是哪个文件。 如果 source 是 application.yml,说明 prod-db 文件没被激活,检查 Profile Group 定义和启动参数。 如果 source 正确但还超时,检查网络策略和数据库白名单。 ## 架构判断题:团队想在代码里用 @Profile("prod") 来切换保险服务的调用逻辑,应该支持吗? 不应该。@Profile 用在 Bean 定义上可以,但用来切换业务逻辑会把环境判断写进代码。 正确做法是把差异抽成配置项,比如 insurance.mode=real 或 mock,代码只读配置。 这样测试环境可以模拟生产行为,生产环境也能安全切换。 > 理解实战排障题, > 架构判断要清晰。 > 环境不写业务里, > 配置治理见真章。 # 13、升华 ```text 工程结论:Profile 是部署环境的组合开关,不是配置的垃圾桶 ``` Petclinic 的配置治理证明了一件事:配置系统的复杂度不取决于 Profile 的数量,而取决于边界是否清晰。 把部署环境组合和模块化配置拆开,用 Profile Group 做一键激活,才能让配置可维护、可追溯、可发布。 下一篇要把生产密码从明文环境变量升级为配置加密,并引入动态刷新能力,让配置变更不再需要重启应用。 > 环境组合是正道, > 模块配置各成书。 > 一键激活生产境, > 配置治理路已铺。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系基础 **本篇:** 多环境 Profile 治理与 Profile Group 组合 **下一篇:** 配置加密与动态刷新

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

《SpringBoot4进阶实战》第8招 Web请求完整走一遍

# Owner 查询 JSON API:从 HTTP 到 Response 的完整调用链 ```text 旧方案:仅返回 Thymeleaf 视图,无标准 JSON API 冲突:前端/第三方需要结构化数据,但现有 Controller 只返回视图名 后果:无法复用业务逻辑,接口契约不清晰,错误响应格式不统一 ``` Petclinic 官方项目默认面向服务端渲染,OwnerController 的查询方法返回 ModelAndView 或视图名。 当团队需要给移动端、管理后台或开放平台提供 Owner 查询能力时,直接复用现有 Controller 会暴露 HTML 片段,而不是结构化 JSON。 我是老李,今天要带你把 Owner 查询改造成标准 JSON API,并且把 HTTP 请求从进入 Tomcat 到最终返回 Response 的每一层都追清楚。 为什么 Controller 里只写了一句 return owner,最终客户端却能收到一个完整的 HTTP JSON Response? 是继续在现有 Controller 上增加 @ResponseBody,还是单独建立 ApiController 并引入 DTO、校验和统一异常处理? 如果选错,会不会出现实体直接序列化导致循环引用、校验错误返回 500、或者 404 与业务异常格式不一致的问题? # 01、故事 ```text 周二 14:30 → 研发区 → 给运营后台提供 Owner 查询接口 → 前端催接口、现有代码只返回 HTML ``` 周二下午两点半,我坐在研发区,任务是给运营后台提供一个按 ID 查询 Owner 的 JSON 接口。 前端同事在群里催:“接口什么时候好?我现在拿到的全是 HTML 片段,根本没法解析。” 我打开 Petclinic 的 OwnerController,发现查询方法返回的是视图名,数据通过 Model 传给 Thymeleaf 渲染。 如果直接加 @ResponseBody 返回 Owner 实体,又担心宠物集合被序列化出一大堆嵌套对象,甚至可能出现循环引用。 > 午后工位催声急, > 旧日视图难解意。 > 欲把主人成数据, > 却愁嵌套无穷已。 # 02、问题 ```text 需求变化:服务端渲染 → 标准 JSON API 冲突:Controller 返回视图,实体直接序列化风险高 后果:接口不可用、错误格式混乱、前后端联调成本高 ``` 现有 OwnerController 的查询逻辑绑定视图渲染,无法直接满足 JSON 接口需求。 如果直接返回 Owner 实体,JPA 关联的 pets、visits 可能被 Jackson 深度序列化,导致响应体膨胀、性能下降,甚至双向关联引发循环引用。 同时,参数缺失、ID 不存在、业务校验失败等场景没有统一的错误结构,前端需要针对不同状态码和响应体写多套解析逻辑。 可验证的验收标准是:GET /api/owners/{id} 返回标准 JSON; 参数错误返回 400; 不存在返回 404; 业务异常返回 409; 未预期异常返回 500,且错误体符合 Problem Detail 规范。 > 视图转身成接口, > 实体裸奔隐患久。 > 错误若还无定式, > 联调终夜空白首。 # 03、原理 ```text HTTP → Tomcat → Filter → DispatcherServlet → HandlerMapping → HandlerAdapter → ArgumentResolver → Validation → Controller → Service → MessageConverter → Response ``` Spring MVC 的核心是 DispatcherServlet,它把请求分发给 HandlerMapping 找到的处理器,再由 HandlerAdapter 完成参数解析、校验、调用和返回值处理 。 Controller 方法返回的 Owner 对象并不是 HTTP 响应本身,它只是返回值。 真正把它变成 JSON 的是 HttpMessageConverter,Spring Boot 默认配置了 MappingJackson2HttpMessageConverter。 一个反直觉判断是:Controller 里那句 return owner 并没有“发送响应”,它只是把对象交给框架,框架在方法返回后才开始序列化和写响应。 另一个反直觉判断是:@Valid 校验失败抛出的 MethodArgumentNotValidException 如果不在统一异常处理中捕获,默认会变成 500,而不是 400。 > 一句返回非终点, > 框架接力方成全。 > 校验若无人接住, > 五百错把四百演。 # 04、架构 ```text 输入:GET /api/owners/{id} 本模块:ApiController → OwnerRequest/Response DTO → Validation → OwnerService → ExceptionHandler → ProblemDetail 输出:标准 JSON + 正确 HTTP Status 边界:不直接暴露 JPA 实体,不把业务异常泄漏到客户端 ``` 本模块分为四层:Controller 负责接收 HTTP 请求并调用 Service; DTO 负责请求和响应契约; Service 复用现有业务逻辑; ExceptionHandler 负责把异常映射为 Problem Detail。 Controller 不直接返回 Owner 实体,而是返回 OwnerResponse DTO,这样可以把 JPA 关联关系隔离在服务层内部。 统一异常处理放在 @RestControllerAdvice 中,分别处理参数校验失败、资源不存在、业务冲突和未预期异常。 收益是接口契约清晰、错误格式统一、实体演化不影响外部 API。 代价是增加 DTO 映射代码,且需要明确哪些异常映射到哪些 HTTP 状态码。 适用条件是系统需要对外提供 JSON API,或前后端分离后需要稳定契约。 > 实体深藏服务内, > 契约只露薄薄层。 > 异常各有归处去, > 状态分明不误人。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Java 21 + Maven 配置:pom.xml 引入 spring-boot-starter-web、spring-boot-starter-validation、spring-boot-starter-data-jpa 代码:OwnerResponse、OwnerRequest、ApiOwnerController、GlobalExceptionHandler 启动:mvn spring-boot:run 验证:curl GET /api/owners/1 V1:返回 OwnerResponse JSON,但错误处理不完整 ``` 先确认 pom.xml 包含 Web、Validation 和 JPA 依赖。 ```xml org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation org.springframework.boot spring-boot-starter-data-jpa ``` 创建 OwnerResponse DTO,只暴露必要字段。 ```java public record OwnerResponse( Long id, String firstName, String lastName, String address, String city, String telephone ) { } ``` 创建 ApiOwnerController,返回 DTO 而不是实体。 ```java @RestController @RequestMapping("/api/owners") public class ApiOwnerController { private final OwnerService ownerService; public ApiOwnerController(OwnerService ownerService) { this.ownerService = ownerService; } @GetMapping("/{id}") public OwnerResponse getOwner(@PathVariable Long id) { Owner owner = ownerService.findOwnerById(id); return new OwnerResponse( owner.getId(), owner.getFirstName(), owner.getLastName(), owner.getAddress(), owner.getCity(), owner.getTelephone() ); } } ``` 启动应用。 ```bash mvn spring-boot:run ``` 验证正常查询。 ```bash curl -i http://localhost:8080/api/owners/1 ``` 示例输出: ```text HTTP/1.1 200 Content-Type: application/json {"id":1,"firstName":"George","lastName":"Franklin","address":"110 W. Liberty St.","city":"Madison","telephone":"6085551023"} ``` V1 成功返回 JSON,但此时如果传入非数字 ID,会触发 500;如果 ID 不存在,也会因为 Service 抛异常而返回 500。 > 初版接口已成形, > 正常路径走得通。 > 只是错误无人管, > 五百乱象隐其中。 # 06、排查 ```text 诊断树 ├── 参数类型错误 → 怀疑 MethodArgumentTypeMismatchException → 检查默认异常处理 → 证据:返回 500 → 根因:未捕获该异常 ├── ID 不存在 → 怀疑 Service 抛 NoSuchElementException → 检查响应 → 证据:返回 500 → 根因:未映射为 404 └── 业务校验失败 → 怀疑 Service 抛 IllegalStateException → 检查响应 → 证据:返回 500 → 根因:未映射为 409 ``` 第一个问题是传入非数字 ID。 ```bash curl -i http://localhost:8080/api/owners/abc ``` 示例输出: ```text HTTP/1.1 500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/api/owners/abc"} ``` 怀疑是 MethodArgumentTypeMismatchException 没有被处理。 检查日志,看到异常堆栈: ```text Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long' ``` 根因是 Spring 默认把类型转换失败当作未预期异常,返回 500。 第二个问题是 ID 不存在。 ```bash curl -i http://localhost:8080/api/owners/9999 ``` 示例输出: ```text HTTP/1.1 500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/api/owners/9999"} ``` 怀疑是 Service 抛出的 NoSuchElementException 没有被映射。 检查 Service 实现,发现 findOwnerById 内部使用 Optional.orElseThrow()。 根因是异常直接冒泡到容器,被默认 500 处理。 **错误尝试:** 我一开始在 Controller 里用 try-catch 捕获所有异常并手动构造 ResponseEntity,结果每个方法都重复大量样板代码,而且状态码散落各处,难以统一维护。 > 参数错时五百现, > 资源不在也同面。 > 若在方法里捕尽, > 样板成灾更难辨。 # 07、优化 ```text V1:正常路径可用,错误路径全部 500 证据:curl 参数错误、不存在 ID 均返回 500 V2:引入 @RestControllerAdvice + ProblemDetail,统一映射 400/404/409/500 ``` 创建 GlobalExceptionHandler,集中处理四类异常。 ```java @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ProblemDetail handleTypeMismatch(MethodArgumentTypeMismatchException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.BAD_REQUEST, "参数类型错误: " + ex.getName() ); pd.setTitle("Bad Request"); return pd; } @ExceptionHandler(NoSuchElementException.class) public ProblemDetail handleNotFound(NoSuchElementException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.NOT_FOUND, "Owner 不存在" ); pd.setTitle("Not Found"); return pd; } @ExceptionHandler(IllegalStateException.class) public ProblemDetail handleConflict(IllegalStateException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.CONFLICT, ex.getMessage() ); pd.setTitle("Conflict"); return pd; } @ExceptionHandler(Exception.class) public ProblemDetail handleGeneric(Exception ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部错误" ); pd.setTitle("Internal Server Error"); return pd; } } ``` 同时让 Service 在业务校验失败时抛出 IllegalStateException,而不是返回 null 或布尔值。 这样 Controller 保持简洁,所有错误路径都汇聚到 GlobalExceptionHandler。 > 异常汇聚一处管, > 状态分明各有安。 > 四百四百零九, > 五百不再乱纠缠。 # 08、演进 ```text V2:统一异常处理 + Problem Detail 能力:标准 JSON 契约、稳定错误格式、可扩展异常映射 遗留:校验异常尚未覆盖,请求体校验需后续补充 ``` 用 V1 同一组请求验证 V2。 正常查询: ```bash curl -i http://localhost:8080/api/owners/1 ``` 示例输出: ```text HTTP/1.1 200 Content-Type: application/json {"id":1,"firstName":"George","lastName":"Franklin","address":"110 W. Liberty St.","city":"Madison","telephone":"6085551023"} ``` 参数类型错误: ```bash curl -i http://localhost:8080/api/owners/abc ``` 示例输出: ```text HTTP/1.1 400 Content-Type: application/problem+json {"type":"about:blank","title":"Bad Request","status":400,"detail":"参数类型错误: id","instance":"/api/owners/abc"} ``` ID 不存在: ```bash curl -i http://localhost:8080/api/owners/9999 ``` 示例输出: ```text HTTP/1.1 404 Content-Type: application/problem+json {"type":"about:blank","title":"Not Found","status":404,"detail":"Owner 不存在","instance":"/api/owners/9999"} ``` 收益是前端只需识别 Content-Type 和 status,就能统一解析错误。 代价是 GlobalExceptionHandler 需要持续维护异常映射,避免把所有异常都归为 500。 遗留问题是当前只有路径参数,还没有覆盖请求体校验,例如创建 Owner 时的 @Valid 校验失败。 > 同组请求再验证, > 状态分明各归位。 > 契约稳定前端喜, > 遗留校验待下回。 # 09、洞见 ```text 从现象到判断:Controller 返回对象 ≠ HTTP 响应;异常映射决定 API 成熟度 ``` ## Controller 的 return 只是返回值,不是响应 ```text Controller → return Owner → HandlerAdapter → MessageConverter → HTTP Response ``` 很多人以为 return 语句执行完响应就发出了,实际上 return 只是把对象交给框架。 真正的序列化和响应写入发生在方法返回之后,由 HttpMessageConverter 完成。 这解释了为什么 Controller 里只有一句 return owner,客户端却能收到完整 JSON。 ## 反直觉判断:默认异常处理会把很多客户端错误变成 500 ```text 参数类型错误 → 默认 500 → 实际应为 400 ``` Spring Boot 的默认错误处理对未捕获异常统一返回 500,即使根因是客户端传错参数。 如果不显式映射 MethodArgumentTypeMismatchException,前端会误以为服务器故障。 统一异常处理不是可选优化,而是 JSON API 的基本能力。 ## 反直觉判断:DTO 不是多余代码,而是契约边界 ```text 实体直接序列化 → 关联爆炸/循环引用 → DTO 隔离 → 契约稳定 ``` 直接返回 JPA 实体看似省事,但会把持久化细节泄漏到 API。 DTO 的映射代码虽然增加,但换来的是外部契约与内部模型解耦。 当实体增加字段或调整关联时,API 可以保持稳定。 ## Problem Detail 让错误从“状态码”升级为“可读文档” ```text 错误响应 → title + status + detail + instance → 前端可统一处理 ``` Problem Detail 规范让错误响应包含人类可读的 title、机器可读的 status、具体 detail 和发生路径 instance。 前端可以只写一个错误解析器,而不需要为每个接口单独适配。 > 返回非响应须明辨, > 异常映射定成熟。 > 契约边界靠 DTO, > 错误文档解百忧。 # 10、系统落地 ```text 入口:GET /api/owners/{id} 服务:ApiOwnerController → OwnerService → OwnerRepository 本模块:DTO 映射、统一异常处理、Problem Detail 存储:H2/MySQL 中的 owners 表 未实现:请求体校验、创建/更新 API、分页查询 ``` 原来 Petclinic 只有服务端渲染的 Owner 查询,没有标准 JSON API。 本篇增加了 ApiOwnerController、OwnerResponse DTO 和 GlobalExceptionHandler,使系统具备对外提供 Owner 查询 JSON 接口的能力。 现在前端可以通过 GET /api/owners/{id} 获取结构化数据,并且错误响应统一为 Problem Detail 格式。 下一篇需要补充创建和更新 Owner 的 JSON API,并引入请求体 DTO 与 Bean Validation。 官方文档可参考 Spring 官网 https://spring.io 和 Petclinic 仓库 https://github.com/spring-projects/spring-petclin ic 。 > 旧日视图今成 API, > 查询一路到存储。 > 错误统一契约稳, > 创建更新待下章。 # 11、小结 ```text 三问三答:return 如何变响应;是否新建 ApiController;选错后果 ``` 第一问:Controller 里只写了一句 return owner,为什么最终能变成完整 HTTP JSON Response? 因为 return 只是把对象交给 HandlerAdapter,后续由 MappingJackson2HttpMessageConverter 序列化为 JSON,并写入 HTTP 响应体。 第二问:是继续在现有 Controller 上增加 @ResponseBody,还是单独建立 ApiController? 单独建立 ApiController 更合适,因为服务端渲染和 JSON API 的返回类型、异常策略、契约要求不同,分离后各自演化更清晰。 第三问:如果选错会怎样? 如果直接返回实体会导致关联爆炸或循环引用;如果不做统一异常处理,参数错误和 404 都会变成 500,前端无法区分客户端错误和服务端故障。 当前状态是 Owner 查询 JSON API 已可用,错误响应符合 Problem Detail,但创建、更新和请求体校验尚未实现。 > 一句返回非终点, > 框架接力方成全。 > 分离接口避旧债, > 统一异常保平安。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 @RestController 的方法返回对象后,客户端能收到 JSON? 因为 @RestController 组合了 @ResponseBody,HandlerAdapter 在方法返回后调用 HttpMessageConverter 将返回值序列化为 JSON,再写入 HTTP 响应体。 ## 实战题:为 Petclinic 增加 GET /api/owners?lastName=xxx 的查询接口,返回 List,并处理空结果。 创建 ApiOwnerController 中的查询方法,调用 OwnerService 按 lastName 查询,空结果返回空列表和 200,而不是 404。 ## 排障题:调用 GET /api/owners/abc 返回 500,但日志显示 MethodArgumentTypeMismatchException,如何修复? 在 GlobalExceptionHandler 中增加 @ExceptionHandler(MethodArgumentTypeMismatchException.class),返回 400 和 P roblemDetail,而不是让它冒泡到默认 500 处理。 ## 架构判断题:如果前端要求返回 Owner 的所有 pets 和 visits,是否应该直接返回 Owner 实体? 不应该。应该为这个需求单独设计包含 pets 和 visits 的 DTO,避免实体直接序列化带来的关联爆炸和循环引用,同时保持 API 契约稳定。 > 理解框架接力链, > 实战查询列表添。 > 排障须看异常类, > 架构判断守边界。 # 13、升华 ```text 工程结论:JSON API 的成熟度取决于契约、校验和错误处理,而不是能否返回 JSON ``` 能返回 JSON 只是起点,真正的工程能力在于稳定契约、明确校验和统一错误处理。 Controller 的 return 只是调用链中的一个环节,理解从 HTTP 到 Response 的完整路径,才能在排障时快速定位问题。 下一篇将引入请求体 DTO 和 Bean Validation,把创建 Owner 的 JSON API 落地,并覆盖校验失败的错误映射。 > 能返 JSON 非终点, > 契约校验方为全。 > 调用链中明因果, > 排障定位不茫然。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 基础、安全、数据、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置、容器构建、生产上线 **本篇:** Owner 查询标准 JSON API、DTO、统一异常处理、Problem Detail、HTTP 状态码映射 **下一篇:** Owner 创建与更新 JSON API、请求体 DTO、Bean Validation、校验错误映射

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

《SpringBoot4进阶实战》第13招 数据库升级不能靠ddl-auto

# 数据库迁移:Entity 改字段为何不等于生产库已升级 ```text 旧方案:Hibernate ddl-auto 自动改表 → 生产库结构失控 → 数据丢失与发布事故 ``` 我是老李,在深圳做架构师,常年跟 Spring Boot 应用和 MySQL 打交道。 Petclinic 项目跑得好好的,可每次改个 Entity 字段,测试环境一切正常,一到生产就出幺蛾子。 Java Entity 改一个字段,为什么完全不能代表生产数据库已经安全升级? 选择 ddl-auto、schema.sql 还是 Flyway,哪种方式才能让 Schema 变更可控、可追溯、可回滚? 如果选错迁移方案,百万行数据的表升级失败,你拿什么恢复业务? # 01、故事 ```text 周五 17:50 → 深圳科兴科学园 3 栋 11 层 → 任务是给 Petclinic 的 owners 表加一个 email 字段 → 产品经理催着上线,测试环境一切正常,生产却报错 ``` 周五傍晚,我正准备收拾东西下班,产品经理小跑过来,说下周要给宠物主发营销邮件,owners 表必须加 email 字段。 任务是给 Petclinic 的 owners 表加一个 email 字段,改动不大,Entity 加个属性,数据库加一列,半小时搞定。 可我刚把代码提交,测试环境跑得飞起,生产数据库却直接抛异常,说 owners 表不存在 email 列。 我盯着屏幕,心里发毛:明明 Entity 改了,为什么生产库没跟上? > 代码改一行, > 数据库无响, > 生产报错急, > 谁在管 Schema? # 02、问题 ```text 需求变化:owners 表新增 email 字段 → 冲突:Entity 改了但生产库结构没变 → 后果:应用启动失败,查询报错,业务中断 ``` Petclinic 一直用 Hibernate 的 ddl-auto=update 管理表结构,开发时很方便,加个字段自动建列。 但生产环境我早就把 ddl-auto 设为 none,因为自动改表太危险,可能锁表、误删数据。 结果就是:代码里 Entity 加了 email,生产库还是老结构,应用一启动,Hibernate 校验失败,直接抛异常。 影响范围是全部依赖 owners 表的接口,比如查询宠物主列表、编辑宠物主信息,全部 500。 可验证的验收标准:应用启动时能自动执行数据库迁移,把 owners 表从 V1 升级到 V2,且迁移过程有日志、有版本记录。 > 开发一时爽, > 生产火葬场, > 结构无人管, > 升级全靠撞。 # 03、原理 ```text 核心机制:Schema 版本管理 = 把数据库结构变更当作代码,用版本化脚本顺序执行,记录当前版本,保证可重复、可审计 ``` 先看三种常见方式的职责边界。 Hibernate ddl-auto 是 ORM 层的自动建表工具,适合开发期快速原型,但生产环境必须关闭,因为它无法感知已有数据,可能删列、改类型导致数据丢失。 schema.sql 是 Spring Boot 启动时执行的 SQL 脚本,适合初始化表结构,但它是全量执行,没有版本概念,每次启动都跑一遍,无法增量升级。 Flyway 和 Liquibase 是专门的迁移工具,用版本号管理每个变更脚本,记录已执行版本,只执行未执行的脚本,保证幂等和可追溯。 反直觉判断:很多人以为 schema.sql 能替代 Flyway,其实 schema.sql 每次启动都执行,如果脚本里有 INSERT 语句,会导致重复数据; 而 Flyway 只执行一次,靠版本表记录。 另一个反直觉判断:数据库回滚通常比代码回滚复杂,因为代码回滚是替换文件,数据库回滚要处理数据变更,比如删了一列,回滚要恢复数据,可能涉及大量数据操作。 > 自动建表一时快, > 生产数据埋祸根, > 版本脚本管增量, > 迁移工具定乾坤。 # 04、架构 ```text 输入:Schema 变更需求(加字段、加约束、加索引) → 本模块:Flyway 迁移引擎 → 输出:数据库结构升级到目标版本,版本表记录历史 ``` 引入 Flyway 后,Petclinic 的数据库管理分三层。 第一层是应用代码层,Entity 定义 Java 对象,只负责 ORM 映射,不直接改表。 第二层是迁移脚本层,每个版本一个 SQL 文件,放在 src/main/resources/db/migration 下,Flyway 按版本号顺序执行。 第三层是数据库层,Flyway 创建 flyway_schema_history 表,记录每个脚本的执行状态,保证不重复执行。 边界很清晰:应用启动时,Flyway 先检查版本,执行未执行的脚本,然后 Hibernate 再启动,此时表结构已就绪,不会校验失败。 收益是 Schema 变更可审计、可回滚(通过编写反向脚本)、可协作(多个开发者各自加版本)。 代价是每个变更都要写 SQL 脚本,不能只改 Entity 就完事,增加了开发成本。 适用条件是团队有数据库变更规范,能接受 SQL 脚本作为变更的唯一入口。 > 三层各司其职, > 迁移脚本管版本, > 应用代码管映射, > 数据库稳定运行。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + MySQL 8 + Flyway 9.22 → 配置:添加依赖和配置 → 代码:编写 V1、V2、V3 迁移脚本 → 启动:应用启动时自动执行 → 验证:查询版本表和表结构 ``` 先看当前 Petclinic 的 pom.xml,添加 Flyway 依赖。 ```xml org.flywaydb flyway-core 9.22.3 org.flywaydb flyway-mysql 9.22.3 ``` 在 application.properties 中配置 Flyway,并关闭 Hibernate 的 ddl-auto。 ```properties spring.jpa.hibernate.ddl-auto=none spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration spring.flyway.baseline-on-migrate=true ``` baseline-on-migrate=true 表示如果数据库已有表但没有版本表,Flyway 会创建基线,避免报错。 现在创建第一个迁移脚本 V1__init_schema.sql,初始化 Petclinic 的核心表。 ```sql -- V1__init_schema.sql CREATE TABLE owners ( id INT AUTO_INCREMENT PRIMARY KEY, first_name VARCHAR(30) NOT NULL, last_name VARCHAR(30) NOT NULL, address VARCHAR(255), city VARCHAR(80), telephone VARCHAR(20) ); CREATE TABLE pets ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(30) NOT NULL, birth_date DATE, owner_id INT NOT NULL, CONSTRAINT fk_pets_owners FOREIGN KEY (owner_id) REFERENCES owners(id) ); ``` V2 脚本添加 email 字段,并加唯一约束。 ```sql -- V2__add_email_to_owners.sql ALTER TABLE owners ADD COLUMN email VARCHAR(100); ALTER TABLE owners ADD CONSTRAINT uk_owners_email UNIQUE (email); ``` V3 脚本添加索引,优化按姓氏查询。 ```sql -- V3__add_index_on_owners_last_name.sql CREATE INDEX idx_owners_last_name ON owners(last_name); ``` 启动应用,观察日志。 ```text 示例输出: Flyway Community Edition 9.22.3 Successfully validated 3 migrations Current version of schema: 1 Migrating schema to version 2 Migrating schema to version 3 Successfully applied 2 migrations ``` 验证版本表。 ```sql SELECT version, description, success FROM flyway_schema_history; ``` ```text 示例输出: version | description | success 1 | init schema | 1 2 | add email to owners | 1 3 | add index on owners last | 1 ``` 验证表结构。 ```sql SHOW COLUMNS FROM owners; ``` ```text 示例输出: Field | Type | Null | Key | Default id | int | NO | PRI | NULL first_name | varchar(30) | NO | | NULL last_name | varchar(30) | NO | | NULL address | varchar(255) | YES | | NULL city | varchar(80) | YES | | NULL telephone | varchar(20) | YES | | NULL email | varchar(100) | YES | UNI | NULL ``` 成功标准:应用启动时自动执行迁移,owners 表新增 email 列和唯一约束,版本表记录 V1-V3。 未在当前环境实测,以上为预期结果,需按当前官方文档核验。 > 版本脚本写清楚, > 启动自动执行, > 表结构升级好, > 数据安全有保障。 # 06、排查 ```text 诊断树:迁移失败 → 检查版本表 → 检查 SQL 语法 → 检查权限 → 检查连接 ``` 问题一:应用启动报错,提示 Flyway 迁移失败。 现象:启动日志出现 ERROR,说 Migration V2 failed。 怀疑:SQL 语法错误或约束冲突。 检查:查看详细日志。 ```bash mvn spring-boot:run ``` ```text 示例输出: Migration V2__add_email_to_owners.sql failed SQL State : 23000 Error Code : 1062 Message : Duplicate entry 'john@example.com' for key 'owners.uk_owners_email' ``` 证据:owners 表已有重复 email 数据,导致唯一约束创建失败。 根因:生产数据不干净,直接加唯一约束会失败。 修复:先清理重复数据,再创建约束。 ```sql -- V2__add_email_to_owners.sql 修改版 ALTER TABLE owners ADD COLUMN email VARCHAR(100); UPDATE owners SET email = CONCAT('user', id, '@example.com') WHERE email IS NULL; ALTER TABLE owners ADD CONSTRAINT uk_owners_email UNIQUE (email); ``` 问题二:Flyway 校验失败,提示已应用的迁移被修改。 现象:启动报错,说 Migration checksum mismatch。 怀疑:有人改了已执行的 V1 脚本。 检查:查看版本表。 ```sql SELECT installed_rank, version, checksum FROM flyway_schema_history; ``` 证据:V1 的 checksum 与文件当前不一致。 根因:团队有人改了 V1 脚本,但 V1 已在生产执行过。 修复:禁止修改已执行的脚本,只能新增版本。如果必须改,用 Flyway repair 命令更新校验和。 ```bash mvn flyway:repair ``` **错误尝试:** 我一开始以为 Flyway 失败是数据库连接问题,反复检查连接池配置,浪费了半小时。后来看日志才发现是 SQL 约束冲突,教训是先看错误码和消息,别瞎猜。 > 迁移失败莫慌张, > 日志里面找真相, > 重复数据先清理, > 校验和变要 repair。 # 07、优化 ```text V1:直接加唯一约束 → 证据:生产数据有重复 → V2:先清理数据再加约束,并增加回滚脚本 ``` V1 的迁移脚本太天真,没考虑生产数据质量。 优化方案:在 V2 脚本中增加数据清理步骤,并编写 V2__rollback.sql 用于回滚。 修改后的 V2 脚本。 ```sql -- V2__add_email_to_owners.sql ALTER TABLE owners ADD COLUMN email VARCHAR(100); -- 清理重复数据,保留 id 最小的记录 UPDATE owners o1 JOIN owners o2 ON o1.email = o2.email AND o1.id > o2.id SET o1.email = CONCAT('user', o1.id, '@example.com'); ALTER TABLE owners ADD CONSTRAINT uk_owners_email UNIQUE (email); ``` 同时创建回滚脚本,放在 db/rollback 目录。 ```sql -- V2__rollback.sql ALTER TABLE owners DROP INDEX uk_owners_email; ALTER TABLE owners DROP COLUMN email; ``` Flyway 本身不支持自动回滚,但我们可以通过编写反向脚本,在需要时手动执行。 优化后,迁移脚本更健壮,能处理脏数据,且具备回滚能力。 > 数据质量先保证, > 约束才能建得稳, > 回滚脚本备一手, > 生产升级不慌张。 # 08、演进 ```text V2:Flyway 管理 Schema → 能力:版本化迁移、失败回滚、数据清理 → 遗留:百万数据表的兼容升级策略 ``` 引入 Flyway 后,Petclinic 的 Schema 变更有了版本控制,每次发布都自动执行迁移。 收益是开发、测试、生产环境结构一致,不再出现 Entity 改了但库没改的问题。 代价是每个变更都要写 SQL,且要维护回滚脚本,增加了工作量。 适用场景是任何需要长期演进的数据库应用,尤其是多人协作的项目。 遗留问题是:如果 owners 表已有百万行数据,加字段或加索引时,直接 ALTER TABLE 会锁表,导致业务中断。 用 V1 同一组请求验证:启动应用,查询 owners 列表,返回正常,说明迁移成功。 ```http GET /api/owners ``` ```text 示例输出: HTTP/1.1 200 OK [{"id":1,"firstName":"George","lastName":"Franklin","email":"george@example.com"}] ``` > 版本管理已就绪, > 百万数据待处理, > 锁表风险要规避, > 兼容升级需策略。 # 09、洞见 ```text 从现象到判断:Entity 改字段只是代码层变化,数据库结构必须通过迁移脚本显式变更,否则生产环境必然不一致 ``` ## 代码与数据库是两套生命周期 ```text 代码:编译 → 打包 → 发布 → 回滚(秒级) 数据库:结构变更 → 数据迁移 → 验证 → 回滚(分钟到小时级) ``` 代码和数据库的变更速度不同步,代码可以快速迭代,但数据库结构变更涉及数据安全,必须谨慎。 反直觉判断:很多人以为改 Entity 就等于改数据库,实际上 Entity 只是映射,数据库结构不会自动跟随。 ## 数据库回滚比代码回滚复杂得多 ```text 代码回滚:替换 jar 包 → 重启应用 → 完成(分钟级) 数据库回滚:执行反向 SQL → 数据恢复 → 验证一致性 → 可能丢失新数据(小时级) ``` 代码回滚是替换文件,数据库回滚要处理数据变更,比如删了一列,回滚要恢复数据,但新写入的数据可能无法恢复。 反直觉判断:Flyway 不支持自动回滚,因为自动回滚可能造成数据丢失,所以通常用 forward 修复,而不是 backward 回滚。 ## 迁移脚本是团队契约 ```text 开发 A:V2 加字段 开发 B:V3 加索引 生产:按顺序执行 V1→V2→V3 ``` 迁移脚本是团队协作的契约,每个版本只能追加,不能修改已执行的脚本,否则校验和会失败。 ## 生产环境必须用迁移工具 ```text ddl-auto=update:开发方便,生产危险 schema.sql:初始化可以,增量不行 Flyway:版本化,可追溯,适合生产 ``` 生产环境必须用 Flyway 或 Liquibase,因为只有它们能保证 Schema 变更的可控性。 > 代码库是代码, > 数据库是数据, > 迁移脚本连两者, > 版本管理保平安。 # 10、系统落地 ```text 入口:Petclinic 应用启动 → 服务:Flyway 迁移引擎 → 本模块:Schema 版本管理 → 存储:MySQL 数据库 → 未实现:百万数据表的在线迁移策略 ``` 原来 Petclinic 用 Hibernate ddl-auto 管理表结构,开发方便但生产不可控。 本篇增加了 Flyway 依赖和配置,创建了 V1-V3 迁移脚本,实现了 Schema 版本管理。 现在 Petclinic 启动时会自动执行迁移,表结构变更可追溯、可回滚。 下一篇将处理百万数据表的兼容升级,比如用 Expand/Contract 模式,先加字段(Expand),再迁移数据,最后删旧字段(Contract)。 官方文档:Spring Boot 参考文档、Flyway 官方文档。 > 版本管理已落地, > 百万数据待挑战, > Expand 再 Contract, > 在线升级不中断。 # 11、小结 ```text 三问三答:Entity 改字段不等于数据库升级;选择 Flyway 而非 ddl-auto;选错会导致生产事故 ``` 第一问:Java Entity 改一个字段,为什么不能代表生产数据库已经安全升级? 因为 Entity 只是 Java 对象,数据库结构不会自动跟随,必须通过迁移脚本显式变更。 第二问:选择 ddl-auto、schema.sql 还是 Flyway? 生产环境必须用 Flyway,因为它有版本记录、可重复执行、可回滚,而 ddl-auto 危险,schema.sql 无版本。 第三问:选错迁移方案会有什么后果? 选错会导致生产库结构不一致,应用启动失败,甚至数据丢失,回滚复杂且耗时。 当前状态:Petclinic 已引入 Flyway,Schema 变更纳入版本管理,启动自动迁移。 > 三问三答已清晰, > 迁移工具选 Flyway, > 版本脚本管变更, > 生产升级有保障。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Hibernate ddl-auto=update 不适合生产环境? 因为 ddl-auto=update 会在启动时自动修改表结构,可能删除列、改变数据类型,导致数据丢失,且无法控制执行顺序,没有版本记录。 ## 实战题:给 Petclinic 的 visits 表添加一个 visit_date 索引,并创建 V4 迁移脚本。 创建 V4__add_index_on_visits_visit_date.sql。 ```sql CREATE INDEX idx_visits_visit_date ON visits(visit_date); ``` 启动应用,验证版本表新增 V4。 ## 排障题:Flyway 迁移失败,提示 Duplicate column name 'email',如何排查? 先查看版本表,确认 V2 是否已执行。 ```sql SELECT version, success FROM flyway_schema_history; ``` 如果 V2 已成功,但表里没有 email 列,可能是有人手动删了列,导致 Flyway 认为已执行。 修复:手动添加列,或用 Flyway repair 更新状态。 ## 架构判断题:百万数据表加索引,直接 CREATE INDEX 会锁表,如何设计兼容升级? 使用 Expand/Contract 模式:先创建索引(用 ALGORITHM=INPLACE, LOCK=NONE),然后逐步迁移数据,最后删除旧索引。 > 作业四题练一练, > 理解实战加排障, > 架构判断想清楚, > 迁移技能更上一层。 # 13、升华 ```text 工程结论:数据库迁移是生产级应用的必备能力,版本化脚本是唯一可靠的方式,Entity 只是代码层,不能替代数据库变更管理 ``` 从 Petclinic 的实战中,我们验证了 Flyway 的价值:它让 Schema 变更像代码一样可版本化、可审计、可回滚。 但真正的挑战在百万数据表的在线迁移,需要结合 Expand/Contract 模式、在线 DDL 和 CI/CD 流程。 下一篇将深入探讨如何让迁移进入 CI/CD,以及如何设计零停机升级。 > 迁移工具已就位, > 百万数据待处理, > CI/CD 集成好, > 生产升级零停机。 **系列项目:** Spring Petclinic 生产级演进,服务对象是宠物诊所业务。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 安全、数据访问、事务管理、远程调用、数据库迁移基础(Flyway 版本管理)。 **本篇:** 将 Petclinic Schema 纳入 Flyway 版本管理,建立 V1→V3 迁移链,处理脏数据与回滚脚本。 **下一篇:** 百万数据表的 Expand/Contract 兼容升级与 CI/CD 集成。

资源下载

更多资源
Nacos

Nacos

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

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

优质分享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等操作系统。

用户登录
用户注册