11.1 识别 DDL 的四类风险
评审 DDL 时,先把“这条语句通常很快”改写成四个可证伪的问题:
lock: 要什么锁?等多久?拿到后持有多久?队列会挡住谁?
physical work: 扫描、重写、索引构建、WAL、临时空间各是多少?
compatibility: 旧/新读写组合是否都理解中间 schema?
backfill: 如何分批、停止、重入、限速并证明没有跳行?这四类风险互相放大。一个只执行 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 冲突。风险不是简单的:
ACCESS EXCLUSIVE = 一定很慢而是:
impact
= time waiting in lock queue
+ time executing after grant
+ time until transaction commit
+ queue amplification on later sessions即使 catalog change 在取得锁后只需几毫秒,前方一个长查询也可能让它排队。更隐蔽的是 lock queue fairness:
long SELECT holds ACCESS SHARE
→ DDL queues for ACCESS EXCLUSIVE
→ later SELECT may queue behind incompatible waiting DDL
→ one planned change becomes a service-wide convoy因此 DDL 不能在已经做了远程调用、人工确认或大量前置 SQL 的长事务尾部执行。锁一旦取得,会一直持有到事务结束;“语句执行完成”不是“锁已释放”。
用 timeout 表达发布预算
BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';
ALTER TABLE shop_private.ch11_order
ADD COLUMN shipping_code text;
COMMIT;两个 timeout 回答不同问题:
lock_timeout:单次等待锁最多多久;statement_timeout:从命令开始到完成的总预算,包括锁等待。
通常令 lock_timeout < statement_timeout,否则总超时可能先触发,无法区分“没拿到锁”和“拿到后执行过久”。本章用 verbose error 保存 55P03,并在失败后检查列仍不存在。
timeout 不是自动重试许可。若 DDL 已经在队列中造成业务抖动,立即高频重试会持续重建队列。重试前至少重新观察:
root blocker identity and transaction age
queue depth and affected SLI
remaining change window
whether application traffic can be drained or shifted
whether the command is idempotent or has partial external state实测一个“物理上快、锁上失败”的 ADD COLUMN
锁图协调器 打开两个真实 backend:
holder:
BEGIN
LOCK ch11_order IN ACCESS SHARE MODE
waiter:
ALTER TABLE ch11_order ADD COLUMN shipping_code text
lock_timeout=4sobserver 保存:
waiter=pg36-ch11-lock-waiter
blocker=pg36-ch11-lock-holder
wait_event_type=Lock
wait_event=relation
requested_mode=AccessExclusiveLock
granted=false结果:
SQLSTATE=55P03
blocker_edges=1
shipping_code_after_failure=absent
holder_release=COMMIT释放 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:
change metadata, no per-row visit
validation scan:
read every relevant row, keep tuple representation
table rewrite:
write a new physical relation and rebuild affected indexes它们的风险不同:
| 行为 | 主要资源 | 常见后果 |
|---|---|---|
| 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。目录证据:
SELECT
attname,
atthasmissing,
attmissingval
FROM pg_attribute
WHERE attrelid =
'shop_private.ch11_default_probe'::regclass
AND attname = 'fast_flag';本章在 50,000 行表上执行:
ALTER TABLE shop_private.ch11_default_probe
ADD COLUMN fast_flag integer NOT NULL DEFAULT 7;一次 PostgreSQL 18.4 观测:
relfilenode: 20116 → 20116
atthasmissing=true
attmissingval={7}
WAL≈12 KBOID 与 WAL 字节每次都可能变化;稳定关系是 same filenode、missing metadata 存在、WAL 远小于逐行改写。
volatile default 必须逐行求值
ALTER TABLE shop_private.ch11_default_probe
ADD COLUMN volatile_stamp timestamptz
NOT NULL DEFAULT clock_timestamp();clock_timestamp() 是 volatile,同一命令中每行都需要实际值。相同 fixture 的观测:
relfilenode: 20116 → 20129
atthasmissing=false
WAL≈11.5 MB这不是要建立“11.5 MB”阈值,而是证明:
constant/non-volatile default → metadata path
volatile default → per-row rewrite path如果最终值本来就需要按行计算,更安全的路径通常是:
ADD nullable column without default
→ protect new writes
→ bounded backfill
→ validate
→ add desired default for future writes
→ set not nulldefault 只定义未来“省略该列”时写什么,不是历史事实生成器。
类型变更不能只看 cast 是否存在
ALTER COLUMN TYPE 通常会重写表和索引;某些 binary-coercible 或内容不变的转换可避免表重写,但 index、collation、statistics 仍可能变化。发布前至少检查:
all values representable under new type
default expression convertible
CHECK/FK/generated/expression index dependencies
collation and opclass semantics
table + indexes size and free disk
WAL/replica lag budget
old application parameter/result decoding
ANALYZE requirement after change不要把 USING expression 当作无代价转换。它允许更复杂的计算,恰恰意味着要逐行应用,并且不会自动替你正确转换旧 default。
DROP COLUMN 也有物理延迟
DROP COLUMN 通常只在 catalog 中把列标为 dropped,不会立刻缩小 heap。旧 tuple 中空间随后续更新逐渐回收;若强求立即回收,往往需要 rewrite,风险更大。
这还揭示恢复边界:
down: ADD COLUMN old_name ...只能重建一个空壳列,不能恢复已删除值。列删除后的恢复是 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”漏掉了真正危险的组合:
old app + expanded schema
new app + partially backfilled data
old app rollback + switched schema
offline job + almost-contracted 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 为例:
legacy representation:
standard / express / pickup
new representation:
STD / EXP / PUP发布前先回答:
| 情形 | 预期 |
|---|---|
| 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 可能产生额外影响。
跨应用版本重命名通常需要:
add new
→ keep old
→ bridge/dual-write
→ backfill
→ switch reads
→ retire old users
→ drop old“只是改名”描述的是数据库物理工作,不描述 API compatibility。
11.1.4 数据回填的节奏与失败恢复
一条大 UPDATE 的问题不只是锁
UPDATE huge_table
SET new_col = transform(old_col)
WHERE new_col IS NULL;即使 row locks 不阻塞普通读取,它仍可能:
- 生成巨大 WAL;
- 让 replicas 持续落后;
- 延长 transaction 与 crash recovery;
- 产生大量 dead tuples;
- 让 autovacuum、checkpoint 和前台 IO 竞争;
- 在最后一行失败时回滚全部工作;
- 让取消操作也需要长时间 undo/cleanup。
所以“数据库支持事务”不是把所有行放进一个事务的理由。
批次合同
一个可运行的 backfill 至少声明:
stable ordering key
batch size and transaction boundary
predicate selecting only unresolved rows
checkpoint committed with the same batch
sleep/rate or load feedback
lock and statement timeout
maximum batches/time/WAL/lag
restart semantics
new-write protection
completion oracle and mismatch query本章使用 order_id keyset,而不是逐页增长的 OFFSET:
WHERE order_id > :last_order_id
AND shipping_code IS NULL
ORDER BY order_id
LIMIT :batch_size
FOR UPDATE数据更新与:
last_order_id
rows_migrated
batches
phase在同一事务提交。进程在两个 batch 之间退出,已提交批次保留;在一个 batch 内失败,数据与 checkpoint 一起回滚。
checkpoint 不能制造跳行
若配合 SKIP LOCKED,直接把 checkpoint 推到最大 key 可能永久跳过被锁的低 key。选择包括:
- 不跳锁,给每批设置短 lock timeout;
- 维护可回访的 pending ranges;
- 让 checkpoint 只表示连续完成前缀;
- 结束前做独立 unresolved sweep。
本章没有使用 SKIP LOCKED。每批后还断言:
NOT EXISTS (
SELECT 1
FROM ch11_order
WHERE shipping_code IS NULL
AND order_id <= checkpoint.last_order_id
)这是“水位没有越过遗漏行”的机器证据。
受控中止也是成功路径
回填器 的:
--max-batches 2在两批各 5,000 行后返回 75:
phase=backfilling
rows_migrated=10,000
remaining=39,999
resume_from=<exact committed key>再次运行不重建 fixture:
start=backfilling
batches_run=8
total_batches=10
rows_migrated=49,999
remaining=0
mismatches=0
phase=migrated75 不是事故;它表示到达已声明停止线,可以由发布编排在重新检查水位后继续。真正危险的是脚本把“进程退出”与“数据库是否部分提交”混在一起。
本节验收问题
- 每条 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