11.1 识别 DDL 的四类风险
评审 DDL 时,先把“这条语句通常很快”改写成四个可证伪的问题:
这四类风险互相放大。一个只执行 5 ms 的 catalog change,如果在锁队列中等待 20 分钟,就不是 5 ms 变更;一个逻辑兼容的 nullable column,如果回填制造持续 WAL 和副本延迟,也不是无风险变更。
11.1.1 锁等级与持锁时间
先分开 lock mode、wait time 与 hold time
PostgreSQL 的多数 ALTER TABLE 子命令在没有特别说明时请求 ACCESS EXCLUSIVE。它与普通查询取得的 ACCESS SHARE 冲突。风险不是简单的:
而是:
即使 catalog change 在取得锁后只需几毫秒,前方一个长查询也可能让它排队。更隐蔽的是 lock queue fairness:
因此 DDL 不能在已经做了远程调用、人工确认或大量前置 SQL 的长事务尾部执行。锁一旦取得,会一直持有到事务结束;“语句执行完成”不是“锁已释放”。
用 timeout 表达发布预算
两个 timeout 回答不同问题:
lock_timeout:单次等待锁最多多久;statement_timeout:从命令开始到完成的总预算,包括锁等待。
通常令 lock_timeout < statement_timeout,否则总超时可能先触发,无法区分“没拿到锁”和“拿到后执行过久”。本章用 verbose error 保存 55P03,并在失败后检查列仍不存在。
timeout 不是自动重试许可。若 DDL 已经在队列中造成业务抖动,立即高频重试会持续重建队列。重试前至少重新观察:
实测一个“物理上快、锁上失败”的 ADD COLUMN
锁图协调器 打开两个真实 backend:
observer 保存:
结果:
释放 holder 后才进入真正 expand。这个实验同时证明三件事:
- nullable
ADD COLUMN的 physical work 很小; - 它仍需强锁;
- timeout failure 没有把 schema 留在“也许改了一半”的状态。
lock 计划最少写到对象级
一次变更说明至少列出:
| 对象 | 命令 | 主要 lock | 预计持有 | blocker 来源 | 超时后动作 |
|---|---|---|---|---|---|
| order | ADD COLUMN | AccessExclusive | catalog-only | long SELECT/xact | abort, observe, retry |
| order | VALIDATE CHECK | ShareUpdateExclusive | scan duration | DDL/vacuum family | pause backfill or reschedule |
| order | CREATE INDEX CONCURRENTLY | multi-phase lighter table locks | build duration | concurrent DDL/snapshots | inspect INVALID |
| partition parent | ATTACH | ShareUpdateExclusive | catalog + validation | maintenance DDL | retain standalone child |
| attached child | ATTACH | AccessExclusive | validation window | readers/loaders | postpone attach |
表格中的 lock mode 必须以目标 PostgreSQL 大版本的官方文档和演练为准,不能从另一条相似命令推断。
11.1.2 表重写、全表扫描与 WAL 放大
catalog-only、scan 与 rewrite 是三件事
常见物理行为可粗分为:
它们的风险不同:
| 行为 | 主要资源 | 常见后果 |
|---|---|---|
| catalog-only | strong but short lock | lock queue |
| scan | read IO, buffer churn, CPU | latency/replica read contention |
| rewrite | read + write IO, WAL, disk, index work | lag, disk pressure, long lock |
| backfill UPDATE | WAL, dead tuples, index maintenance | autovacuum debt, bloat, lag |
pg_relation_size() 不变不能证明没有扫描;relfilenode 不变也只排除某些 rewrite,不能证明命令便宜。反过来,relfilenode 改变是强烈的 rewrite 证据。
constant default 的 metadata fast path
PostgreSQL 11 起,新增带 non-volatile default 的列可以把一次计算结果保存在 column metadata 中,不必立刻重写每个旧 tuple。目录证据:
本章在 50,000 行表上执行:
一次 PostgreSQL 18.6 观测:
OID 与 WAL 字节每次都可能变化;稳定关系是 same filenode、missing metadata 存在、WAL 远小于逐行改写。
volatile default 必须逐行求值
clock_timestamp() 是 volatile,同一命令中每行都需要实际值。相同 fixture 的观测:
这不是要建立“11.5 MB”阈值,而是证明:
如果最终值本来就需要按行计算,更安全的路径通常是:
default 只定义未来“省略该列”时写什么,不是历史事实生成器。
类型变更不能只看 cast 是否存在
ALTER COLUMN TYPE 通常会重写表和索引;某些 binary-coercible 或内容不变的转换可避免表重写,但 index、collation、statistics 仍可能变化。发布前至少检查:
不要把 USING expression 当作无代价转换。它允许更复杂的计算,恰恰意味着要逐行应用,并且不会自动替你正确转换旧 default。
DROP COLUMN 也有物理延迟
DROP COLUMN 通常只在 catalog 中把列标为 dropped,不会立刻缩小 heap。旧 tuple 中空间随后续更新逐渐回收;若强求立即回收,往往需要 rewrite,风险更大。
这还揭示恢复边界:
只能重建一个空壳列,不能恢复已删除值。列删除后的恢复是 forward repair、从权威源重建或 restore/PITR,而不是把 DDL 方向反过来。
11.1.3 新旧应用版本的兼容窗口
schema 不是瞬时切换
滚动发布至少存在这些组合:
| application | read path | write path | database phase |
|---|---|---|---|
| old | old column | old column only | expanded |
| new shadow | old response + compare new | dual write | migrating |
| new primary | new column | dual write | validated/switched |
| rolled-back old | old column | old column only | switched rollback window |
只测试“新 application + 新 schema”漏掉了真正危险的组合:
兼容窗口要按最旧仍可能运行的 artifact定义,不按“主服务已经 100%”定义。consumer 包括:
- web/API replicas;
- queue workers 与 cron;
- ETL/CDC/sink;
- BI 与 ad-hoc SQL;
- migration/repair scripts;
- 失败后可能回滚的上一版;
- 长连接中仍缓存旧 prepared statement 的进程。
兼容矩阵先于 DDL
以 shipping_method → shipping_code 为例:
发布前先回答:
| 情形 | 预期 |
|---|---|
| old insert omits code | database derives code |
| old update changes method | code follows method |
| new dual-write consistent pair | accept |
| new dual-write mismatched pair | reject 23514 |
| new read sees legacy null | fallback or do not switch |
| old rollback after switch | still reads/writes successfully |
本章用一个 temporary BEFORE trigger 作为单一映射 authority,再用命名 CHECK 闭合表示一致性。触发器不是默认答案;它只是本例在多 writer 共存期内比“应用连续发两条独立 UPDATE”更可审计。
rename 往往不是兼容变更
直接 RENAME COLUMN old TO new:
- 对数据库本身是 catalog change;
- 对仍引用 old 的 SQL 是立即破坏;
- 对
SELECT *、row decoder、ORM metadata、prepared statement 可能产生额外影响。
跨应用版本重命名通常需要:
“只是改名”描述的是数据库物理工作,不描述 API compatibility。
11.1.4 数据回填的节奏与失败恢复
一条大 UPDATE 的问题不只是锁
即使 row locks 不阻塞普通读取,它仍可能:
- 生成巨大 WAL;
- 让 replicas 持续落后;
- 延长 transaction 与 crash recovery;
- 产生大量 dead tuples;
- 让 autovacuum、checkpoint 和前台 IO 竞争;
- 在最后一行失败时回滚全部工作;
- 让取消操作也需要长时间 undo/cleanup。
所以“数据库支持事务”不是把所有行放进一个事务的理由。
批次合同
一个可运行的 backfill 至少声明:
本章使用 order_id keyset,而不是逐页增长的 OFFSET:
数据更新与:
在同一事务提交。进程在两个 batch 之间退出,已提交批次保留;在一个 batch 内失败,数据与 checkpoint 一起回滚。
checkpoint 不能制造跳行
若配合 SKIP LOCKED,直接把 checkpoint 推到最大 key 可能永久跳过被锁的低 key。选择包括:
- 不跳锁,给每批设置短 lock timeout;
- 维护可回访的 pending ranges;
- 让 checkpoint 只表示连续完成前缀;
- 结束前做独立 unresolved sweep。
本章没有使用 SKIP LOCKED。每批后还断言:
这是“水位没有越过遗漏行”的机器证据。
受控中止也是成功路径
回填器 的:
在两批各 5,000 行后返回 75:
再次运行不重建 fixture:
75 不是事故;它表示到达已声明停止线,可以由发布编排在重新检查水位后继续。真正危险的是脚本把“进程退出”与“数据库是否部分提交”混在一起。
本节验收问题
- 每条 DDL 的 lock mode、等待预算、持锁到何时是否明确;
- 是否考虑等待 DDL 对后来查询造成的队列放大;
- physical work 是 catalog、scan、rewrite、index build 还是 backfill;
- WAL、额外磁盘、replica lag 和 autovacuum 债务是否有预算;
- old/new/rollback/offline consumer 的兼容矩阵是否完整;
- backfill 的 ordering、batch、checkpoint 与停止线是否可执行;
- checkpoint 能否跳过 locked/gapped rows;
- timeout 后如何判定未变、部分变或需要 forward repair;
- 动态 OID/毫秒是否被误写为跨环境 golden;
- contract 是否与 expand 被错误地塞进同一发布窗口。
任一高影响问题没有答案时,这条 DDL 仍是设计草案,不是可执行变更。
参考资料
- PostgreSQL 18:ALTER TABLE locks and notes
- PostgreSQL 18:Modifying Tables
- PostgreSQL 18:Explicit Locking
返回本章目录 · 下一节:Expand–Migrate–Contract · 查看全书目录 · 查看索引中心