交付门禁:测试分层、迁移、制品与恢复
一次“构建成功、测试全绿”的发布,仍然可能在三个地方失败:测试证明的根本不是生产依赖、数据库迁移在真实库上被历史表拒绝、回滚时拿到的是另一个二进制。门禁的职责不是让流水线变长,而是把每类失败放在它最便宜、最能定位的阶段挡住,并保留“为什么挡、挡住之后怎么修”的证据。本页用同一份合成账目服务走完这条链:分层测试 → 真实依赖与契约 → Flyway 迁移状态机 → 凭据门禁 → 制品与可重复构建 → 回滚 → 备份恢复,注入一次校验和漂移让流水线真实阻断,再用一组凭据两态测试验证启动门禁。
测试框架层(TestContext 缓存、MockMvc 装配、Boot4 切片)的机制权威在 Spring 测试;治理与审批的经济学在 架构治理与交付效能;迁移的状态机方法论在 零停机 schema 迁移。本页只讲“门禁怎么落地、失败长什么样、修复由谁负责”。
固定版本:JDK 21(Liberica 21.0.11+11)、Boot 4.0.9 / Framework 7.0.10、Flyway 11.14.1(Boot BOM 管理,官方配对 flyway-core + flyway-mysql)、MySQL Community 8.4.7(DBngin,127.0.0.1:3306)、JUnit Jupiter 6.0.3、Mockito 5.20.0。全部观察来自 delivery-gate 实验 的一次完整公开运行(9 个默认测试 + 2 个流水线迁移门禁测试、24 个阶段、exit 0),证据在 labs/delivery-gate/evidence/u63-2026-10-28.json。Maven 依赖/BOM/多模块机制归 U62(未实施),本页只使用单模块骨架与 dependency:list 证据。
一、门禁回答什么问题:从“测试通过”到“可发布证据”
一条请求级的不变量:任何进入制品仓库的构建,必须能回答“它证明了什么、没证明什么、失败时停在哪一步”。这要求门禁由若干证据类型组成,而不是由测试数量组成:
| 证据类型 | 回答的问题 | 失败的典型形态 |
|---|---|---|
| 单元测试 | 逻辑分支与交互契约是否符合预期 | 断言失败(秒级,定位到方法) |
| 集成测试 | 真实数据库/容器下约束、事务、迁移是否成立 | 连接、约束、迁移异常(分钟级) |
| 契约测试 | 消费方依赖的字段/类型是否仍然存在 | 字段缺失、类型变化 |
| 迁移门禁 | 已部署库与本次脚本是否一致 | 校验和漂移、失败迁移阻断 |
| 凭据/配置门禁 | 部署环境是否具备运行前提 | 启动失败(绑定校验) |
| 制品证据 | 二进制是否可重复、可回滚 | 哈希不一致、行为不符 |
| 恢复证据 | 数据丢失后能否回到不变量 | 行数/摘要不符 |
这张表的用法是反过来的:先问“这次改动的风险落在哪一行”,再决定哪些门禁是阻断项、哪些只是记录项。把契约测试当单元测试跑、把迁移脚本当普通代码评审,都会让风险落到它不会被发现的阶段。
本页实验的流水线是本地脚本(run.py),阶段顺序固定:构建 → 单元 → 集成 → 契约 → 凭据 → 迁移 seed → 篡改注入 → 迁移 gate(期望失败)→ 修复 → 重跑全绿。它是教学流水线,不是 GitHub Actions;平台专属能力(缓存、矩阵、审批环境)在证据的 limits 里如实标注未运行。
二、测试分层与替身边界:mock 通过不等于约束成立
实验里的账目表有一个数据库 CHECK 约束:amount_cents >= 0。单元测试用 Mockito 替身替换 LedgerRepository:
LedgerRepository repository = mock(LedgerRepository.class);
when(repository.insert(anyString(), anyLong())).thenReturn(42L);
LedgerService service = new LedgerService(repository);
long id = service.register("acct-negative", -5L); // 负金额,mock 下照样“成功”U63 unitMockPassthrough=PASS amount=-5 mockId=42 dbConstraintNotProven=true负数金额在 mock 层“通过”了,因为替身只回答“你怎么调我”,不回答“数据库是否接受”。同一用例在真实 MySQL 上是:
U63 serviceNegativeRejectedByDb=PASS positiveReadBack=[250,125]真实库拒绝负金额,且拒绝时抛出的类型本身就是一个门禁知识:
U63 bootMigratesAtStartup=PASS historyRows=2 versions=V1,V2 index=idx_ledger_account
checkError=HY000/3819 springTranslation=UncategorizedSQLException实测:MySQL 8.4 的 CHECK 违规是 SQLState HY000、错误码 3819,Spring 的 SQL 异常翻译链没有把它映射进 DataIntegrityViolationException,而是落到 UncategorizedSQLException。把“约束失败”一律按 DataIntegrityViolationException catch 的门禁会漏掉这一类失败——这正是“按异常分类做降级/告警”的团队必须用真实库校准分类表的原因。
分层的责任由此明确:替身层证明交互契约(参数怎么传、异常怎么冒泡),真实依赖层证明约束与传输。两层都要有,但结论不能互相替换。替身层能进最快门禁(每次提交),真实依赖层进较慢门禁(合并前/发布前)。
三、真实依赖与消费者契约
真实依赖测试的最小纪律
本实验对共享本机 MySQL 的做法可以复用为模板:
- 每个测试类只创建独有前缀 + uuid 的一次性库(如
u63_migration_<12位>),结束在@AfterAll里 DROP 自己创建的库,并查询SHOW DATABASES LIKE 'u63_<类前缀>_%'断言为空;入口末尾再对整个u63\_%前缀做一次全局无遗留断言; - 迁移目录可注入(
filesystem:指向临时副本),让“坏迁移/校验和漂移”可以在隔离目录里复现,而不污染仓库脚本; - 不做全局配置变更(不改
sql_mode、不建全局账号、不碰其他库);凭据走环境变量(本机 root 空密码只在合成环境成立)。
共享实例上的测试必须自证清理,否则下一次运行会读到上一次的残留,把失败伪装成通过或反之。
契约测试:三方一致而不是“接口能调通”
消费者驱动契约(CDC)的最小闭环有三方:消费者声明的契约(字段名、类型、必需性)、提供者真实响应、契约检查器。本实验让消费者侧对真实随机端口的 HTTP 响应逐字段核对:
U63 contractRealHttp=PASS status=200 contractVersion=1 version=1.0.0
U63 contractCheckerRejectsMissingField=PASS problems=1第二条刻意删掉一个必需字段后运行同一个检查器——检查器必须报错。没有这个负例,“契约通过”可能只是检查器没检查。契约测试与端到端测试的区别:端到端证明“这一整条链路能跑”,契约证明“消费方依赖的那部分接口形状没变”;前者昂贵且脆弱,后者可以按每次提交跑。
本实验的契约是手写的字段断言,不是 Pact 之类的契约文件体系——真实 Pact/Broker 与跨团队契约发布流未运行,只在正文中说明其角色。
四、迁移:Flyway 历史表与版本状态机
启动时迁移的时点
Boot 的 Flyway 自动配置注册一个 FlywayMigrationInitializer,它在 Bean 初始化阶段就执行迁移——在应用对外提供业务服务之前:
@Override
public void afterPropertiesSet() throws Exception {
if (this.migrationStrategy != null) {
this.migrationStrategy.migrate(this.flyway);
}
else {
try {
this.flyway.migrate();
}
catch (NoSuchMethodError ex) {
// Flyway < 7.0
this.flyway.getClass().getMethod("migrate").invoke(this.flyway);
}
}
}含义:迁移失败 = 上下文创建失败 = 进程起不来(而不是“服务起来了但表结构不对”)。这正是集成测试里 MigrationTest 能直接断言历史表的原因——上下文能创建,说明迁移已完成。同时注意 FlywayMigrationStrategy 提供了替换入口:migrate 之外要加 repair/validate 策略,就在这里注入。
历史表就是状态机
默认位置 classpath:db/migration、默认历史表 flyway_schema_history、validateOnMigrate 默认 true、cleanDisabled 默认 true:
/**
* Whether to automatically call validate when performing a migration.
*/
private boolean validateOnMigrate = true;validateOnMigrate=true 意味着每次 migrate 前先 validate:已应用迁移的校验和与当前脚本不一致、或存在失败迁移,都会在迁移开始前被拒绝。cleanDisabled=true 意味着 clean(清空整个 schema)默认不可用,防止误操作把生产库洗掉——本实验用独立一次性库来获得“干净状态”,而不是打开 clean。
校验命令自身的契约(Flyway 11.14.1):
public void validate() throws FlywayException {
final ValidateResult validateResult = validateWithResult();
if (!validateResult.validationSuccessful) {
throw new FlywayValidateException(validateResult.errorDetails, validateResult.getAllErrorMessages());
}
}迁移进行时发现历史里有失败项
迁移器在计算待执行迁移之前先检查失败迁移,并直接抛出异常阻断:
MigrationInfoImpl[] failed = infoService.failed();
if (failed.length > 0) {
if ((failed.length == 1)
&& (failed[0].getState() == MigrationState.FUTURE_FAILED)
&& ValidatePatternUtils.isFutureIgnored(configuration.getIgnoreMigrationPatterns())) {
LOG.warn("Schema " + schema + " contains a failed future migration to version " + failed[0].getVersion() + " !");
} else {
final boolean inTransaction = failed[0].canExecuteInTransaction();
if (failed[0].getVersion() == null) {
throw new FlywayMigrateException(failed[0], "Schema " + schema + " contains a failed repeatable migration (" + doQuote(failed[0].getDescription()) + ") !", inTransaction, migrateResult);
}
throw new FlywayMigrateException(failed[0], "Schema " + schema + " contains a failed migration to version " + failed[0].getVersion() + " !", inTransaction, migrateResult);
}
}注意这个分支的条件:只有“唯一的失败项是 FUTURE_FAILED 且配置忽略未来迁移”时才降级为警告;其余一律抛异常。也就是说,一个失败迁移会永久阻断该 schema 上的后续迁移,直到有人显式处理。这不是工具在为难你,而是它拒绝在状态未知的库上继续叠变更。
一条真实的失败迁移:注入、阻断、修复、复验
实验在一次性库里用一个坏 SQL 复现:
历史表:V1=SUCCESS,V2=FAILED(scripts 目录里 V2 是 THIS IS NOT VALID SQL)
再次 migrate(默认 validateOnMigrate=true)→ FlywayValidateException:
Validate failed: Migrations have failed validation
Detected failed migration to version 2 (broken)
flyway.repair() → 移除失败行(V1 保留),failure 状态清除
修复脚本(前向修正 V2 内容)→ migrate → validate → V1、V2 都是 SUCCESSU63 failedMigrationBlocked=PASS type=FlywayValidateException message=Validate failed: ... Detected failed migration to version 2 (broken)
U63 failedMigrationRepair=PASS blockedThenRepaired=true revalidated=true注意这里的第一道闸其实是 validate(validateOnMigrate 默认 true),不是前一节摘录的 DbMigrate.failed() 分支——那个分支只在显式关闭 validate-on-migrate(或配置忽略模式)后才会成为第二道闸。实验中再次 migrate 抛出的实际类型是 FlywayValidateException,消息里直接给出“Detected failed migration to version 2”,这也是读者最容易误解的一处:两段源码都在同一条 migrate() 调用链上,但默认配置下先到达的是 validate。
修复的责任划分要写进流程:repair 清的是历史表里的失败记录,不修数据本身;如果失败迁移执行了一半(MySQL 的 DDL 不能回滚,多语句迁移也可能部分生效),要由人工核对已执行语句的实际效果,再决定“回滚数据”还是“前向修复”。repair 之后直接改已发布迁移的内容是校验和漂移的来源——正确做法是修复脚本内容只对尚未发布的迁移生效,已发布的只能新增修正迁移。这一点与 零停机 schema 迁移 的 expand–contract 纪律一致。
五、迁移门禁的三种失败与修复责任
| 失败类型 | 触发条件 | 实际表现(本实验) | 修复责任与边界 |
|---|---|---|---|
| 坏 SQL | 迁移脚本语法/对象错误 | 迁移执行失败,历史表留 success=0 | 开发修脚本;若部分生效,人工核对数据/结构后前向修复 |
| 校验和漂移 | 已应用的迁移文件被改动 | FlywayValidateException: Migration checksum mismatch for migration version 2,退出码 1 | 恢复原文件;新变更用新版本号 |
| 失败迁移阻断 | 历史表存在失败项 | 默认配置下 migrate 先被 validate 拦下:FlywayValidateException + Detected failed migration;关掉 validate-on-migrate 才轮到 DbMigrate 的 contains a failed migration | repair 清记录 + 核对实际状态 + 前向修复 |
第二类是实验注入的真实门禁失败:
Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 2
[ERROR] MigrationGateTest.gate ... FlywayValidate → exit 1门禁的“红”必须是命令的退出码,不是日志里的一行警告。脚本流水线之所以在 migration-gate 停下,是因为 Maven 返回 1;CI 平台同样按退出码判定。把“有 ERROR 日志但退出码 0”当失败、或反过来,都会让门禁形同虚设——本实验的 runner 对每个阶段显式断言 expectedExit。
六、配置与凭据门禁:绑定失败必须挡在监听之前
发布 token 是必填配置(@NotBlank + @ConfigurationProperties 校验)。缺 token 时:
***************************
APPLICATION FAILED TO START
***************************
Binding to target study.delivery.ReleaseProperties failed:
Property: delivery.release.token
Value: ""
Origin: "delivery.release.token" from property source "commandLineArgs"
Reason: 不能为空U63 credentialMissingBlocksStartup=PASS property=delivery.release.token真实 jar 运行时同样:DELIVERY_RELEASE_TOKEN 缺失或为空时进程直接退出(退出码 1),不监听端口。这比“起来了、第一个请求 500”好得多——部署编排可以据此判定发布失败并停止切流。
实验里还有一个值得记住的优先级坑(开发期观察,最终实现保留在代码注释里):第一版测试用 SpringApplicationBuilder.properties(...) 注入 token,结果被 classpath 的 application.properties(delivery.release.token=${DELIVERY_RELEASE_TOKEN:} 默认空串)覆盖,凭据“明明传了却仍绑定失败”。原因是 properties() 设置的是 defaultProperties(最低优先级),而 classpath 配置文件高于它。修正为命令行参数(run("--delivery.release.token=..."),最高优先级)后两态都符合预期。凭据注入点必须明确落在哪一层优先级上;配置来源优先级的完整矩阵见 Boot 配置。
凭据门禁还有两条本实验未覆盖、但必须在真实流水线里落实的规则:仓库与制品中不得出现秘密(扫描 + 人工评审);凭据只在部署环境注入(本实验用环境变量模拟),生产应来自托管密钥服务。
七、流水线阶段、失败注入与修复复验
两次完整尝试的状态(取自 evidence 的 pipelineAttempts):
| 阶段 | attempt 1(注入漂移) | attempt 2(修复后重跑) |
|---|---|---|
| build / unit / integration / contract / credential | passed | passed |
| migration-seed | passed | passed |
| tamper-V2 | injected | — |
| migration-gate | blocked(退出码 1,校验和漂移) | passed |
| 修复 V2 | restored(attempt 1 末尾) | restored |
| 结果 | BLOCKED_AS_INJECTED | GREEN_AFTER_REPAIR |
第一次尝试:... attempt-migration-gate-drift exit=1(期望)
第二次尝试:attempt-migration-gate-repaired exit=0,全阶段 passed这条“注入—阻断—修复—复验”链证明了三件事:门禁会挡住真实缺陷;阻挡点是可定位的(日志里直接给出版本号与 checksum mismatch);修复后重跑通过(而不是绕过门禁或手工改历史表)。修复不改变门禁代码,改变的是迁移脚本——门禁本身没有被“调松”。
流水线设计的两条纪律:阶段顺序按失败成本递增(秒→分钟→需环境),失败的阶段之后的阶段不执行;每次失败留证据(日志路径、退出码、期望值),失败原因不能只存在于当时的终端滚动里。
八、制品、可重复构建与回滚
可重复构建
默认 Maven 打包会把构建时刻写进归档条目时间戳,同一份源码两次构建的 SHA-256 不同——开发期首轮实测确是如此(该次观察发生在公开入口定稿前,没有随证据归档,这里不引用具体哈希)。设置 project.build.outputTimestamp 固定时间戳后:
<project.build.outputTimestamp>2026-10-27T00:00:00Z</project.build.outputTimestamp>同版本重建两次得到同一个哈希:12142e3629ff1a77… 两次一致(evidence 的 reproducibleRebuild=true)。可重复构建是“制品清单可核对”的前提:没有它,你无法回答“这个哈希对应的是不是这份源码”。
回滚是行为差异,不是文件被覆盖
两个修订版用同一命令构建,只有资源过滤注入的版本号不同:
2.0.0 运行:{"version":"2.0.0","contractVersion":1,"capabilities":["ledger","batch-ledger"]}
回滚 1.0.0:{"version":"1.0.0","contractVersion":1,"capabilities":["ledger"]}回滚验证要断言的是用户可见行为回到旧版,而不是“旧 jar 被复制过去了”。本实验用同一数据库连续运行两个版本:数据库结构由迁移保证向后兼容(1.0.0 的代码在 2.0.0 迁移过的库上正常工作),这正是 expand–contract 中“旧版本仍能运行”的最小证据。回滚代码与回滚数据是两个决定:本例只回滚代码,数据库保持向前迁移后的结构;如果 2.0.0 引入了不可逆的删列迁移,回滚 1.0.0 就会失败——见迁移页的 contract 阶段准入。
九、备份与恢复:恢复后要验证什么
实验的恢复链:插入 3 行账目(合计 1275 分)→ mysqldump --single-transaction → DROP TABLE(模拟数据丢失)→ mysql < backup.sql → 核对:
before: COUNT(*)=3, SUM(amount_cents)=1275
afterLoss: 表不存在
after: COUNT(*)=3, SUM(amount_cents)=1275(以上取自 evidence 的 backup-restore 记录字段,不是测试观察行。)
“恢复后能查询”不够,要验证不变量:行数、关键聚合(金额合计)、以及(生产上)业务对账。--single-transaction 只对 InnoDB 表提供一致性快照,且它不包含“备份期间新写入”的数据——恢复点之后的写入必须由 binlog/增量补齐,这是 PITR 的责任。本实验只验证逻辑备份的可恢复性:增量备份、binlog 时间点恢复、异地副本、RTO/RPO 演练均未运行,不能凭本实验宣称备份体系可用。
备份与迁移的关系也要分清:备份保护“误删/损坏”,迁移保护“结构演进”;有备份不代表迁移可以不做 expand–contract,迁移失败时的“从备份恢复”也往往意味着丢失恢复点之后的所有写入。
十、证明边界与架构师变化条件
本页证据的边界逐项列出(与 evidence 的 limits 一致):
- 已运行:真实本机 MySQL 8.4.7 上的 V1/V2 迁移、历史表/校验和、CHECK 约束、失败迁移 repair、真实随机端口 HTTP 契约、缺 token 启动失败、两修订版构建与回滚、同版本可重复构建、mysqldump 恢复。
- 未运行:Docker/Testcontainers(用户未授权 Docker)、真实 CI 平台与制品仓库、签名与供应链证明(SLSA/Sigstore)、生产备份体系(增量/binlog PITR/异地/RTO 演练)、蓝绿与金丝雀(归 U65/U81)、Kubernetes 迁移 Job、生产凭据托管(Vault/KMS)、Maven 多模块与 Gradle(U62)。
- 工具版本注意:Flyway 11.14.1 对 MySQL 8.4 打出“newer than the version Flyway has been verified with(8.1)”警告——工具链版本与数据库版本不是天然匹配,升级任何一侧都要重跑迁移门禁。
架构师在改变条件时要重新回答的问题:把门禁从“每次提交跑单元 + 合并前跑集成”改成“全部每次提交跑”,代价是多少分钟、失败率如何变化;迁移从“启动时自动执行”改成“独立发布阶段执行”,谁拥有失败后的修复窗口、应用与迁移的版本如何配对;备份从“每日全量”改成“全量 + binlog 增量”,恢复点目标与演练频率如何随之改变;契约从手写断言升级到 Pact Broker 后,跨团队版本协商由谁负责。这些都不是工具默认值能替代的决定,且每一条都要求重新验证,不能沿用旧结论。
闭卷验收
本页 15 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。