Schema Review 与回滚:让数据库变更可证明、可停止、可恢复
一次看似普通的字段重命名,可能同时击穿三个系统边界:旧版本应用仍在读取旧字段,迁移脚本为了改类型重写整张表,回滚人员却只准备了上一版应用镜像。发布开始后,DDL 等待长事务释放锁,连接池持续堆积;新应用已经写入新格式,旧应用回滚后又无法解释这些数据。SQL 最终执行成功,业务仍然失败,因为团队把数据库变更误当成了一条可以独立撤销的命令。
数据库变更治理真正控制的是一段跨版本协议。协议参与者包括数据库引擎、迁移工具、新旧应用、只读副本、数据修复任务、备份系统和执行人员。安全发布不是“SQL 审核通过”,而是这些参与者在整个兼容窗口内都有明确行为,异常时能够停止在已知状态,并用证据证明数据仍然可读、可写、可恢复。
先把一条 DDL 拆成风险
评审者看到 ALTER TABLE 时,不能只判断语法。至少要回答六个问题:数据库会取得什么锁,是否扫描或重写数据,额外占用多少磁盘与日志,主从复制怎样传播,旧版与新版应用能否同时运行,执行到一半后还能否安全撤销。
可以用四级风险模型统一团队语言:
| 等级 | 典型动作 | 首要证据 | 发布约束 |
|---|---|---|---|
| R0 元数据低风险 | 添加可空列、调整注释、增加兼容对象 | 目标引擎的实际执行计划、锁类型、影子库结果 | 仍要设置锁等待上限,禁止把“快”理解为“无锁” |
| R1 有界构建 | 并发建索引、受控约束验证、小批量回填 | 扫描时长、临时空间、复制延迟、取消行为 | 独立任务执行,有暂停条件和容量余量 |
| R2 数据重写 | 改字段类型、重建大索引、全表回填 | 行数与字节量、写放大、日志增长、恢复耗时 | 低峰或在线变更工具,必须灰度并演练恢复 |
| R3 破坏性或跨服务 | 删除列、收紧语义、拆表合表、不可逆清洗 | 跨版本兼容矩阵、原始数据保留、前滚脚本、PITR 演练 | 分阶段发布,删除动作必须晚于兼容窗口 |
风险等级不能只由 SQL 关键字决定。同一个“添加列”,在支持快速元数据变更的引擎上可能是 R0;带易变默认值、触发全表重写或遇到长事务时,可能升为 R2。同一个索引,在空表影子库上几秒完成,面对生产数据分布、并发写入和副本延迟时可能成为持续数小时的资源竞争者。
引擎差异首先体现在失败后能否回到原状态。PostgreSQL 的许多普通 DDL 可以放在事务中,语句失败或事务回滚时结构随事务撤销,但 CREATE INDEX CONCURRENTLY 等命令明确不能放进普通事务块。MySQL 8.x 的 InnoDB atomic DDL 保证受支持的单条 DDL 在数据字典、存储引擎与 binlog 之间原子完成,却不是 transactional DDL;DDL 通常会隐式提交当前事务,不能靠外层 ROLLBACK 撤销。评审模板必须记录目标引擎、存储引擎和具体操作,不能只写“数据库支持事务”。
审查记录因此要保存事实输入,而不是一句“影响较小”:目标引擎及发行线、表行数与占用空间、峰值读写、最长事务年龄、副本拓扑、预计扫描量、可用临时空间、锁等待上限、停止信号、兼容版本集合、恢复点和责任人。动态信息来自执行前采样,不能把上周截图当成本次事实。
change_id: dbchg-synthetic-customer-status
owner: <team-name>
engine: postgresql
objects:
- public.customer_account
risk: R2
compatibility:
old_reader: reads name; ignores status
new_reader: reads name and status; accepts null
writer: dual-compatible
execution:
lock_timeout: 2s
statement_timeout: 15m
batch_rows: 1000
stop_when:
- lock wait exceeds approved budget
- replica replay delay keeps rising across observation windows
- application database errors exceed its release baseline
recovery:
before_contract: stop backfill and keep nullable column
after_new_writes: forward-fix; do not drop new data
evidence:
- shadow schema diff
- old/new application smoke tests
- backup restore drill id这些数值只是合成演示值。生产阈值必须由表规模、事务基线、容量预算和服务 SLO 推导,不能复制成全公司的万能标准。
在隔离数据库跑通第一条变更
下面使用 PostgreSQL 容器建立个人实验。机器需要可用的 Docker Engine,55432 端口不能被占用。镜像标签应替换为团队批准并锁定摘要的 PostgreSQL 发行线;示例密码只允许存在于一次性本地容器,不能复用到共享环境。
$env:POSTGRES_IMAGE = "postgres:17-alpine"
docker run --name schema-review-lab `
-e POSTGRES_USER=reviewer `
-e POSTGRES_PASSWORD=synthetic-only `
-e POSTGRES_DB=appdb `
-p 127.0.0.1:55432:5432 `
-d $env:POSTGRES_IMAGE
docker exec schema-review-lab pg_isready -U reviewer -d appdb健康检查应出现 accepting connections。若容器不断重启,先看 docker logs schema-review-lab;若端口绑定失败,使用 Get-NetTCPConnection -LocalPort 55432 定位占用者,而不是改成对外网卡监听。镜像拉取失败则检查 Docker daemon 的代理和企业根证书,不能用关闭 TLS 校验绕过。
创建最小业务表和两行合成数据:
@'
CREATE TABLE customer_account (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO customer_account(name) VALUES ('Ada'), ('Lin');
'@ | docker exec -i schema-review-lab psql -v ON_ERROR_STOP=1 -U reviewer -d appdb
docker exec schema-review-lab psql -U reviewer -d appdb `
-c "SELECT id, name FROM customer_account ORDER BY id;"预期能看到两行数据。-v ON_ERROR_STOP=1 很重要:没有它,psql 在脚本中遇到 SQL 错误后仍可能继续执行后续语句,让 CI 把部分迁移误判为成功。
用 expand-contract 保住新旧版本兼容
字段重命名不是一条 RENAME COLUMN,而是三个可观测阶段。Parallel Change,也称 expand-contract,把不兼容改变拆成扩展、迁移和收缩;关键价值不是多写几步,而是让每一步都保留至少一条可恢复路径。
扩展阶段只增加旧应用能够忽略的结构:
BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '15s';
ALTER TABLE customer_account ADD COLUMN display_name text;
COMMIT;应用部署顺序是先让写路径同时维护 name 与 display_name,再分批回填历史数据。双写不是永久架构:必须记录失败重试、比较差异和退出条件,否则两个字段会演变成没有权威来源的双主数据。
UPDATE customer_account
SET display_name = name
WHERE id IN (
SELECT id
FROM customer_account
WHERE display_name IS NULL
ORDER BY id
LIMIT 1000
FOR UPDATE SKIP LOCKED
);
SELECT count(*) AS missing_count
FROM customer_account
WHERE display_name IS NULL;
SELECT count(*) AS mismatch_count
FROM customer_account
WHERE display_name IS DISTINCT FROM name;每批提交让锁、WAL 和副本压力保持有界;SKIP LOCKED 允许多个 worker 避开彼此已锁定的行,但也意味着“本批没有取到行”不等于“全表完成”。完成条件必须由独立查询证明 missing_count=0,并在持续双写期间重复确认 mismatch_count=0。
新版本开始读取 display_name 后,仍需保留旧列一个完整兼容窗口。只有旧应用实例、旧消费者、离线任务和报表查询全部退出,新增写入不再依赖旧列,差异指标持续为零,才进入收缩阶段。删除列属于新的高风险变更,不能夹在同一发布单里顺手执行。
反向实验:让锁等待在实验室暴露
“添加可空列很快”只描述正常路径。另开两个终端,可以稳定制造一个持锁事务,并验证 DDL 会在超出等待预算后停止。
终端 A:
docker exec -it schema-review-lab psql -U reviewer -d appdbBEGIN;
UPDATE customer_account SET name = name WHERE id = 1;
-- 保持事务不提交。终端 B:
docker exec schema-review-lab psql -v ON_ERROR_STOP=1 -U reviewer -d appdb `
-c "SET lock_timeout='2s'; ALTER TABLE customer_account ADD COLUMN risk_level integer;"终端 B 应以非零状态退出,并出现 canceling statement due to lock timeout。这条证据证明失败发生在锁获取阶段,不代表 SQL 语法错误。PostgreSQL 的 lock_timeout 只约束等待锁的时间,官方文档也不建议把它作为全实例统一默认值;迁移任务应按会话设置,并让 statement_timeout 大于锁等待预算。
在终端 A 执行 ROLLBACK; 后重试终端 B,列应成功创建。随后查询系统视图,建立“谁阻塞谁”的诊断入口:
SELECT blocked.pid AS blocked_pid,
blocker.pid AS blocker_pid,
blocked.query AS blocked_query,
blocker.query AS blocker_query,
now() - blocker.xact_start AS blocker_transaction_age
FROM pg_stat_activity blocked
JOIN pg_locks blocked_lock ON blocked_lock.pid = blocked.pid AND NOT blocked_lock.granted
JOIN pg_locks blocker_lock
ON blocker_lock.locktype = blocked_lock.locktype
AND blocker_lock.database IS NOT DISTINCT FROM blocked_lock.database
AND blocker_lock.relation IS NOT DISTINCT FROM blocked_lock.relation
AND blocker_lock.page IS NOT DISTINCT FROM blocked_lock.page
AND blocker_lock.tuple IS NOT DISTINCT FROM blocked_lock.tuple
AND blocker_lock.virtualxid IS NOT DISTINCT FROM blocked_lock.virtualxid
AND blocker_lock.transactionid IS NOT DISTINCT FROM blocked_lock.transactionid
AND blocker_lock.classid IS NOT DISTINCT FROM blocked_lock.classid
AND blocker_lock.objid IS NOT DISTINCT FROM blocked_lock.objid
AND blocker_lock.objsubid IS NOT DISTINCT FROM blocked_lock.objsubid
AND blocker_lock.granted
JOIN pg_stat_activity blocker ON blocker.pid = blocker_lock.pid;紧急处置不能默认 pg_terminate_backend。先确认阻塞会话所属应用、事务年龄和业务动作,再由具备授权的值班人员决定等待、取消查询还是终止会话。杀错事务可能把一次可控等待升级为业务回滚和重试风暴。
影子库验证的是可执行语义
SQL parser 只能发现语法问题,发现不了目标发行线、扩展、排序规则、已有对象和历史迁移共同形成的语义冲突。影子库应使用与目标环境相同的引擎系列、扩展和关键参数,从空库按完整迁移历史重建,再与声明的最终结构比较。
下面从实验库导出纯结构并恢复到独立数据库:
docker exec schema-review-lab dropdb --if-exists -U reviewer schema_review_shadow
docker exec schema-review-lab createdb -U reviewer schema_review_shadow
docker exec schema-review-lab pg_dump -U reviewer -d appdb `
--schema-only --no-owner --no-privileges `
--file=/tmp/schema-review.sql
docker exec schema-review-lab psql -v ON_ERROR_STOP=1 `
-U reviewer -d schema_review_shadow `
-f /tmp/schema-review.sql
docker exec schema-review-lab psql -U reviewer -d schema_review_shadow `
-c "SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name='customer_account' ORDER BY ordinal_position;"pg_dump --schema-only 只复制对象定义,不复制数据分布、热点、统计信息和并发负载。它适合验证依赖顺序、函数和约束是否存在,却不能证明大表变更时长或锁影响。这里先在容器内写文件再由 psql -f 读取,避免 Windows PowerShell 把 SQL 当文本解码再编码,损坏非 ASCII 标识符或函数体。恢复 dump 会执行其中的数据库对象定义;来源不可信时必须先检查 SQL,影子库账号也不得拥有生产访问权限。实验结束时还要删除容器内 /tmp/schema-review.sql,或随一次性容器一并销毁。
成熟流水线会同时跑两条路径:从空库依次执行全部 migration,证明新环境可重建;从上一发布结构执行本次增量,证明升级路径成立。两条路径的最终结构经过规范化后必须一致。Atlas 的 dev database 采用相同思想:在临时隔离、匹配目标引擎的真实数据库上计算和校验结构,而不是只解析文本。
name: schema-review
on:
pull_request:
paths:
- "db/migrations/**"
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: reviewer
POSTGRES_PASSWORD: synthetic-ci-only
POSTGRES_DB: shadow
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U reviewer -d shadow"
--health-interval 2s
--health-timeout 3s
--health-retries 20
steps:
- uses: actions/checkout@REPLACE_WITH_APPROVED_COMMIT_SHA
- name: Rebuild and verify schema
env:
DATABASE_URL: postgresql://reviewer:synthetic-ci-only@localhost:5432/shadow
run: ./scripts/schema-verify.shCI 凭据只连接该作业创建的临时数据库,不能复用共享测试库或生产只读账号。Action 必须固定到团队审查过的不可变提交,迁移脚本的输出需屏蔽连接串。影子库作业结束后由运行器销毁;长期共享“影子库”会积累历史状态,反而掩盖缺失 migration。
Online DDL 不等于零影响
在线变更只说明某些阶段允许并发访问,不承诺没有锁、没有 I/O 峰值、没有副本延迟,也不承诺随时可取消。
MySQL InnoDB 的 ALGORITHM=INSTANT 主要修改数据字典;INPLACE 可能仍重建表;COPY 会建立并复制新表。把 ALGORITHM 与 LOCK 写进 SQL,是为了让数据库在达不到预期并发级别时失败,而不是静默退化成更危险的算法:
ALTER TABLE customer_account
ADD COLUMN status varchar(32) NULL,
ALGORITHM=INSTANT;
ALTER TABLE customer_account
ADD INDEX idx_customer_status(status),
ALGORITHM=INPLACE,
LOCK=NONE;能执行 LOCK=NONE 也不表示全程无锁。MySQL 在线 DDL 在准备和提交表定义时仍可能短暂申请排他元数据锁,长事务能够让最后切换一直等待;涉及表重建时还会消耗临时空间、I/O,并可能放大复制延迟。发布前应在目标发行线查询具体操作支持的算法,测量表大小、变更速率、临时空间与副本延迟,不能把另一张小表的耗时外推。
MySQL atomic DDL 不能翻译成“可跟业务 DML 一起回滚”。受支持的 InnoDB DDL 在服务器崩溃时不会留下半套数据字典,但执行 DDL 前后会发生隐式提交,START TRANSACTION; ALTER TABLE ...; ROLLBACK; 不能恢复 DDL 之前的业务事务,也不能撤销已经成功的结构变化。迁移框架即使配置为每文件一个事务,也必须按 MySQL 实际语义判断失败后哪些语句已生效。
gh-ost 通过影子表复制数据并消费 row-based binlog 捕获增量,允许节流、暂停和推迟 cut-over;cut-over 最终仍要取得元数据锁并交换表名,推迟切换只能控制时机,不能保证锁一定及时取得。它不是任意 MySQL 表的通用包装:requirements and limitations 要求 MySQL 5.7 或更高版本、至少一台服务器提供 binlog_format=ROW 且 binlog_row_image=FULL 的日志、足够权限,以及变更前后共有一个不含实际 NULL 的主键或唯一键。外键与触发器不受支持,活动-活动双主也不受支持;活动-被动双主必须显式确认拓扑。使用副本时还要保证源端与副本表结构一致,并满足 log_bin、log_slave_updates 等对应运行模式的日志条件;托管数据库另有平台参数与权限限制。生产前先执行不带 --execute 的 noop,再在副本用 --test-on-replica 演练并核对两张表;真正执行时设置负载、复制延迟、panic flag 和 postpone cut-over 的停止边界。pt-online-schema-change 使用触发器维持影子表增量,若业务已有触发器、外键复杂或写入压力高,风险模型不同。选择工具前要回答:增量从哪里捕获、积压存在哪里、怎样限速、cut-over 需要什么锁、异常遗留哪些表和触发器、谁能执行清理。工具名称不是安全结论。
PostgreSQL CREATE INDEX CONCURRENTLY 避免阻塞普通写入,但会进行多阶段扫描,失败可能留下 INVALID 索引,而且不能放在普通事务块中。迁移框架若默认把整份脚本包进事务,必须为该动作设计独立步骤,并在失败后检查索引状态和删除策略。不同引擎的“online”语义不可互换,云数据库还可能限制高权限命令或提供独有在线变更能力,执行前要在目标租户验证。
回滚、前滚和恢复是三种动作
回滚是撤销尚未被其他参与者依赖的兼容变更;前滚是保留已产生的数据,用新的修复恢复正确语义;恢复是从备份或日志构建另一个一致状态。三者不能写成同一个按钮。
| 现场状态 | 优先动作 | 原因 |
|---|---|---|
| PostgreSQL 可事务化 DDL 在事务内失败且未提交 | 数据库事务回滚 | 变更尚未对其他会话成为稳定协议,但仍需确认没有事务外副作用 |
| MySQL DDL 失败或发布任务中断 | 先检查实际 schema、metadata lock、binlog 与 migration 记录 | atomic DDL 不等于外层事务可回滚,不能凭 runner 退出码推断整文件状态 |
| 新列已提交但应用尚未使用 | 保留或删除兼容列,按锁风险选择 | 无需为了“干净”再次制造高风险 DDL |
| 新旧应用双写中,回填失败 | 停止 worker,从检查点前滚并校验差异 | 直接删新列会丢失新版本写入 |
| 新格式已经成为权威数据 | 修复读取或补偿转换 | 旧应用镜像无法解释新语义 |
| 列或数据已被破坏性删除 | 隔离恢复到新实例,提取所需数据 | 全库原地 PITR 会覆盖事故后合法写入 |
一个 down.sql 只能证明有反向语句,不能证明数据可逆。DROP COLUMN 后再 ADD COLUMN 只能恢复列名,不能恢复值;把自由文本归一化成枚举后,如果没有保存原始值与映射版本,也无法从枚举重建原文;合并重复记录后,若来源主键和合并决策未保留,反向拆分没有事实依据。
不可逆转换要在执行前选择一种数据保护方式:保留旧列直到兼容窗口结束;把原始值写入受控归档表并设置保留期;用带版本的映射记录每行转换;或先创建可恢复快照。保护副本也属于敏感数据,必须继承原表的访问控制、加密、审计和删除策略,不能永久堆在临时 bucket。
PostgreSQL PITR 不是普通 migration 的廉价回滚。时间点恢复依赖可用的基础备份和从该备份开始连续可取的 WAL,恢复目标可以是时间、命名恢复点、LSN 或事务 ID;恢复完成会产生新 timeline。它恢复的是整个数据库集群的物理状态,不会恢复 postgresql.conf、pg_hba.conf 等手工配置,也不能直接只恢复一张表。正确演练是恢复到隔离实例,禁止普通业务连接,验证目标是否真正停在事故前、对象数量和关键业务不变量,再决定提取少量数据还是执行实例切换。仅有 pg_dump --schema-only、WAL 链有缺口、恢复目标早于基础备份可达起点,均不能提供 PITR。
备份能力要用恢复证明。审查证据至少包含最近一次隔离恢复结果、可达到的恢复点、恢复耗时、备份加密与访问主体、WAL 或 binlog 连续性、恢复后校验查询。备份任务显示“成功”但从未恢复过,只能证明文件被写出,不能证明业务可恢复。
数据修复任务必须能中断和重入
大表回填不应作为一条无边界 UPDATE 附在 DDL 后。修复 worker 要保存稳定游标、迁移版本、处理数、跳过数、失败原因和最后心跳;每批在独立事务内完成,以主键或不可变键推进。重试必须幂等,不能依赖 OFFSET,因为并发插入和更新会让分页漂移。
CREATE TABLE migration_checkpoint (
migration_id text PRIMARY KEY,
last_id bigint NOT NULL DEFAULT 0,
processed_rows bigint NOT NULL DEFAULT 0,
failed_rows bigint NOT NULL DEFAULT 0,
state text NOT NULL CHECK (state IN ('running', 'paused', 'completed', 'failed')),
updated_at timestamptz NOT NULL DEFAULT now()
);运行器每轮先锁定自己的 checkpoint,按 id > last_id ORDER BY id LIMIT <batch> 读取,更新业务行和 checkpoint 后一起提交。失败时当前批次整体回滚,下一轮从已提交游标继续。若转换依赖会变化的外部服务或规则,必须固定规则版本并保存输入摘要;否则“重跑”会得到不同结果。
容量预算至少估算:新列或影子表空间、索引构建临时空间、WAL/binlog 写放大、副本重放速度、备份保留增量、回填读取对缓存命中率的影响。节流信号应来自数据库与业务共同观测,例如锁等待年龄、磁盘水位、复制延迟趋势、数据库错误率和核心请求延迟。单看 worker 每秒处理行数,会鼓励它在数据库最繁忙时抢占资源。
跨服务兼容靠矩阵,不靠同时发布
微服务环境不可能保证所有实例同时切换。生产中至少会同时存在上一版应用、当前版应用、异步消费者、定时任务、数据平台查询和只读副本。每个阶段都要列出允许的读写组合:
| 阶段 | 旧写入 | 新写入 | 旧读取 | 新读取 | 可停止位置 |
|---|---|---|---|---|---|
| Expand 后 | 写旧列 | 双写或写旧列 | 读旧列 | 回退读旧列 | 保留新列,应用回退 |
| 回填中 | 写旧列 | 双写 | 读旧列 | 新列为空时回退 | 暂停 worker,修复差异 |
| 读切换后 | 逐步退出 | 双写 | 仍可读旧列 | 读新列 | 应用回退,不删除数据 |
| Contract 前 | 已退出 | 只写新列 | 已退出 | 只读新列 | 重新启用兼容写需有预案 |
| Contract 后 | 不允许 | 只写新列 | 不允许 | 只读新列 | 以前滚和数据恢复为主 |
消息事件和 CDC 也属于兼容矩阵。新增数据库列不会自动进入旧事件,修改枚举语义可能让下游反序列化失败,删除列可能破坏 CDC connector 的 schema。收缩前应从依赖清单、查询审计、仓库搜索和运行观测共同证明旧字段已经无人使用;只搜索主应用代码会漏掉报表、脚本和外部消费者。
灰度不是随机挑几台实例。先选择能够代表真实读写路径、数据分布和副本拓扑的小批次,观察一个完整业务周期内的错误、锁等待、复制和差异指标。停止条件一旦命中,自动化应暂停后续批次并保留现场,不要在脚本里“再试几次”消耗恢复窗口。
审查证据进入 CI,而批准权保持分离
一份可执行的变更包至少包括:不可变 migration、结构差异、风险分级、影子库日志、正反实验、兼容矩阵、数据量与容量采样、执行和停止命令、恢复材料、业务验证查询、owner 与批准记录。SQL 文件、审批单和监控面板必须通过同一个 change_id 关联。
自动门禁适合拒绝确定性风险:无 WHERE 的修复、直接删除对象、未设置锁等待、超大表重写、缺少 owner、迁移文件被改写、影子库失败、最终 schema 漂移、恢复证据过期。人工评审负责判断业务语义、兼容窗口、容量取舍和是否接受不可逆性,不能让 lint 工具代替责任人签字。
权限应按动作拆分:CI 影子库账号只管理临时数据库;生产检查账号只读目录和统计视图;迁移账号只拥有批准对象所需 DDL/DML 权限;备份恢复账号与日常迁移账号分离;终止会话、切换主库和执行 PITR 由受控值班角色掌握。连接凭据通过短期身份或密钥系统注入,日志不得输出 DSN、SQL 参数中的个人数据和完整行样本。
生产执行最好采用双人控制:应用 owner 证明兼容语义,数据库 owner 评估引擎行为和恢复,发布人员执行受审制品,值班人员观察停止条件。高风险变更的批准者不应同时是唯一执行者和唯一证据保管者。紧急变更可以缩短等待窗口,但不能省略变更标识、停止条件、数据保护和事后恢复验证。
从失败现象反推处置路径
DDL 长时间无输出:先查等待事件、阻塞会话和事务年龄。若在等元数据锁,取消 DDL 通常比杀业务事务更稳妥;取消后确认数据库是否留下无效索引、影子表或未完成 migration 记录。
数据库 CPU、I/O 或日志量持续上升:判断动作是否扫描或重写数据,检查临时空间、WAL/binlog 生成与副本重放。暂停可暂停的 worker 或在线变更工具;原生 DDL 是否能安全取消必须按引擎验证,不能假设取消成本为零。
副本延迟持续扩大:停止新增批次,确认 DDL 复制方式、回填事务大小和副本应用瓶颈。读流量如果仍被路由到延迟副本,新旧结构与数据可能同时不一致,应按业务一致性要求回主库或摘除副本。
新版本正常、旧版本回滚后报列不存在:说明 contract 过早或兼容矩阵错误。不要立即在高压现场拼接结构;先恢复兼容对象或前滚应用,并检查还有哪些旧消费者。此次事故的改进项应是把旧版本 smoke 加入每个收缩变更,而不是增加一句“发布前确认”。
回填完成但数据对不上:冻结 contract,按稳定主键定位差异,核对规则版本、双写失败和重试幂等性。若原始值已丢失,进入隔离恢复与数据提取流程;不要用猜测规则覆盖更多行。
清理实验和演练事故
个人实验完成后删除影子库和容器。-v 会同时删除匿名 volume;先用 docker inspect schema-review-lab 确认对象确属本次实验,避免按模糊名称清理共享数据。
docker exec schema-review-lab dropdb --if-exists -U reviewer schema_review_shadow
docker exec schema-review-lab rm -f /tmp/schema-review.sql
docker rm -f -v schema-review-lab
Remove-Item Env:POSTGRES_IMAGE -ErrorAction SilentlyContinue团队演练至少覆盖四种事故:DDL 被长事务阻塞、在线变更在 cut-over 前暂停、回填中途重启、破坏性变更后从隔离恢复实例提取数据。演练通过的标准不是“最终修好了”,而是值班人员能够从告警定位 change_id,在预算内停止扩散,保护事故后合法写入,使用受控权限完成恢复,并留下可复核的时间线和数据不变量。
数据库变更成熟度最终体现在几个不变量上:任何环境都能从 migration 历史重建到同一结构;新旧应用在声明的窗口内可以共存;每个长任务都能暂停、重入和观测;任何破坏性动作之前都有被实际恢复过的数据保护;执行者无法绕过审批扩大权限;团队能明确说出某个失败应回滚、前滚还是隔离恢复。做到这些,Schema Review 才从“看 SQL”升级为真正的架构治理。
上线前逐项确认
目标引擎、发行线、扩展、表规模、数据分布、峰值负载和副本拓扑来自本次采样。DDL 的锁、扫描、重写、临时空间、日志写放大和取消行为已在匹配环境验证。空库重建与上一版增量升级都通过,最终结构差异可解释。
正向路径和稳定失败路径都有命令、预期证据、清理动作,没有把未执行步骤写成成功事实。新旧应用、任务、报表、事件与 CDC 的读写兼容矩阵完整,contract 有可观测退出条件。回填按稳定键分批,checkpoint、幂等、暂停、失败重试和差异校验可以落地。
回滚、前滚和隔离恢复已经分开决策;不可逆转换保留原始事实或具备恢复证据。备份与 PITR 的可达恢复点、连续日志、恢复耗时和访问控制经过隔离演练。迁移、检查、备份、终止会话和批准权限相互分离,凭据不进入仓库与日志。
灰度批次、停止条件、监控位置、owner、值班与事故演练记录都关联同一个变更标识。
