跳转到主要内容

11 守正出奇:模式变更与安全发布

PostgreSQL 能在事务中执行许多 DDL,但“能回滚”不等于“能在线发布”。一次模式变更能否安全进入生产,至少同时取决于:

logical compatibility
  × lock mode and lock duration
  × scan/rewrite/WAL/resource cost
  × old/new application coexistence
  × resumable data migration
  × observable switch and rollback window
  × explicit contract gate

所以本章不把 ALTER TABLE 写成命令速查。我们把变更拆成一个有状态、有停止线、有证据的发布协议:

legacy
  → expand       add backward-compatible shape
  → migrate      bounded, restartable backfill
  → validate     prove old and new rows satisfy invariants
  → switch       move traffic while retaining rollback shape
  → observe      prove old readers/writers have disappeared
  → contract     remove old semantics only after a separate gate

任何箭头失败,都先问“当前已提交状态是什么、服务是否仍兼容、下一步是重试、暂停、回退流量还是前滚修复”,而不是下意识运行一份机械 down.sql

本章目标

完成本章后,读者应当能够:

  • 从 lock、scan、rewrite、WAL 与 application compatibility 五个维度评审 DDL;
  • 说明元数据 fast path 为什么仍会等待 ACCESS EXCLUSIVE
  • lock_timeout 把无限排队变成可识别的 55P03
  • 区分 non-volatile constant default 与 volatile default 的物理行为;
  • 识别类型变更、约束验证、列删除和表重写的不同恢复语义;
  • 设计 expand–migrate–validate–switch–contract 状态机;
  • 为旧写入、新双写和影子读取定义兼容矩阵;
  • 用 keyset 批次、原子 checkpoint 与停止线实现可中断回填;
  • 解释为什么双写需要单一一致性权威,不能依赖两个独立写调用;
  • 使用 NOT VALID 先保护新写入,再用 VALIDATE CONSTRAINT 检查历史行;
  • 在 PostgreSQL 14–17 与 18 之间正确处理 NOT NULL 目录差异;
  • CREATE INDEX CONCURRENTLY 放在事务块外,并复用第 9 章的失败回收纪律;
  • 预建可证明的 CHECK,降低 ATTACH PARTITION 的验证扫描风险;
  • 说明 ATTACH 对父表和子表的实际锁,以及 default partition 的额外边界;
  • 正确使用 PostgreSQL 14+ 的 DETACH PARTITION CONCURRENTLY
  • 在 Pigsty 中隔离发布连接,观察锁、WAL、复制、磁盘、连接和延迟;
  • 区分 PostgreSQL 原生证据、Pigsty 平台证据与应用发布证据;
  • 拒绝在旧依赖或观察窗口证据缺失时执行 contract;
  • SAFE-MIGR-006DEFAULT-VERS-010 产出 v0.6 candidate evidence。

实验边界

实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;主体发布路径面向 PostgreSQL 14–18。所有可重建对象都位于 shop_private,以 ch11_ 开头并带固定 marker,不修改 shop.* 业务表:

ch11_order                 50,000-row legacy order fixture
ch11_migration_state       monotonic phase and backfill checkpoint
ch11_default_probe         constant/volatile default A/B
ch11_default_probe_result  physical and WAL evidence
ch11_event                 range-partitioned parent
ch11_event_2025q1          preloaded standalone attach candidate

订单发布的最终自动验收故意停在:

phase=switched
shipping_code NOT NULL and validated
shipping_method retained
compatibility bridge retained
contract not executed

这是设计结果,不是“少做一步”。本地脚本能证明目录、数据和 SQLSTATE,不能证明生产中的旧容器、离线作业、BI 查询和临时脚本已经退出,也不能把几秒钟等待冒充约定的回退观察期。

下载资产:

本章目录

11.1 识别 DDL 的四类风险

11.2 Expand–Migrate–Contract

11.3 索引与约束的在线化路径

11.4 在线分区化

11.5 数据回填与流量切换

11.6 发布窗口中的平台观察

11.7 实战:无中断演进订单模式

实测摘要

一次 PostgreSQL 18.6 全量验收得到:

risk:
  ACCESS SHARE holder + ADD COLUMN
    → waiter requests AccessExclusiveLock
    → SQLSTATE 55P03
    → shipping_code remains absent

default:
  50,000 rows + constant 7
    → same relfilenode / atthasmissing=true / WAL ≈ 12 KB
  50,000 rows + clock_timestamp()
    → new relfilenode / atthasmissing=false / WAL ≈ 11.5 MB

compatibility:
  old insert → standard/STD
  old update → express/EXP
  new dual write → pickup/PUP
  mismatch → 23514 / named constraint identity

backfill:
  initial legacy nulls=49,999
  two × 5,000 batches → exit 75 / remaining 39,999
  resume eight batches → migrated 49,999 / remaining 0
  checkpoint total=10 batches / last_order_id=50,000

constraints:
  pair CHECK NOT VALID → VALIDATE
  non-null CHECK NOT VALID → VALIDATE → SET NOT NULL
  SET NOT NULL relfilenode unchanged
  PG18 relation NOT NULL appears in pg_constraint and pg_attribute

partition:
  ATTACH parent=ShareUpdateExclusiveLock
  ATTACH child=AccessExclusiveLock
  validated bound CHECK present before attach
  DETACH CONCURRENTLY outside transaction / rows retained=20,000
  child relfilenode unchanged / final reattached

release:
  phase=switched / contract=P3612 refused
  old column and bridge retained / worker=0
  business checksum=f8a7bfae59c6d16cd323abecfefe1014

WAL 字节、filenode OID、PID、毫秒和具体 lock backend 都不是跨环境 golden。稳定结论是 fast/rewrite 的相对关系、锁边、SQLSTATE、单调状态、批次原子性、零遗漏、数据保留和最终兼容边界。

章节验收

  1. 变更说明同时回答 lock、scan/rewrite、WAL/space/lag 与兼容性;
  2. lock_timeout 小于发布允许排队时间,statement_timeout 留出执行预算;
  3. 55P03 被当作“未取得发布条件”,不是盲目无限重试;
  4. constant/volatile default 的物理差异有目录与 WAL 证据;
  5. expand 对所有仍受支持的旧版本保持可读写;
  6. 双写只有一个一致性 authority,并有命名约束反例;
  7. backfill 使用有界 keyset batch,不用巨型 OFFSET;
  8. 数据更新与 checkpoint 同事务提交;
  9. 受控中止能从精确水位继续,不能跳过低 key 遗留行;
  10. NOT VALID 不被误写成“暂不执行约束”;
  11. VALIDATE CONSTRAINT 的扫描和 lock budget 单独规划;
  12. PostgreSQL 18 的关系级 NOT NULL catalog 差异已隔离;
  13. concurrent index 命令位于事务块外,失败残留有 exact cleanup;
  14. ATTACH 候选列定义完全匹配父表且 bound CHECK 已验证;
  15. default partition 和 subpartition 的额外扫描边界已评审;
  16. DETACH CONCURRENTLY 只在 PostgreSQL 14+ 且事务块外使用;
  17. Pigsty 发布连接、应用流量和只读分析入口职责分离;
  18. 锁、WAL、replication lag、disk、connection 与 SLI 同窗观察;
  19. application deployment 与 database migration 各有独立 identity;
  20. contract 需要旧读写依赖清零与真实观察期证据;
  21. reset 的错误 token、错误 target 和 active-worker 反例全部拒绝;
  22. 最终业务 checksum 不变,v0.6 仍是 candidate 而非已发布 baseline。

下一章 ch12《一气呵成:从数据库契约到后端服务》 将从数据库状态机继续走向 driver、连接池、应用发布和端到端验收。

参考资料


上一章:顾此失彼:并发控制与隔离异常 · 返回上卷导读 · 下一章:一气呵成:从数据库契约到后端服务 · 查看全书目录 · 查看索引中心

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=4s

observer 保存:

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。这个实验同时证明三件事:

  1. nullable ADD COLUMN 的 physical work 很小;
  2. 它仍需强锁;
  3. 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.6 观测:

relfilenode: 20116 → 20116
atthasmissing=true
attmissingval={7}
WAL≈12 KB

OID 与 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 null

default 只定义未来“省略该列”时写什么,不是历史事实生成器。

类型变更不能只看 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_methodshipping_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=migrated

75 不是事故;它表示到达已声明停止线,可以由发布编排在重新检查水位后继续。真正危险的是脚本把“进程退出”与“数据库是否部分提交”混在一起。

本节验收问题

  1. 每条 DDL 的 lock mode、等待预算、持锁到何时是否明确;
  2. 是否考虑等待 DDL 对后来查询造成的队列放大;
  3. physical work 是 catalog、scan、rewrite、index build 还是 backfill;
  4. WAL、额外磁盘、replica lag 和 autovacuum 债务是否有预算;
  5. old/new/rollback/offline consumer 的兼容矩阵是否完整;
  6. backfill 的 ordering、batch、checkpoint 与停止线是否可执行;
  7. checkpoint 能否跳过 locked/gapped rows;
  8. timeout 后如何判定未变、部分变或需要 forward repair;
  9. 动态 OID/毫秒是否被误写为跨环境 golden;
  10. contract 是否与 expand 被错误地塞进同一发布窗口。

任一高影响问题没有答案时,这条 DDL 仍是设计草案,不是可执行变更。

参考资料


返回本章目录 · 下一节:Expand–Migrate–Contract · 查看全书目录 · 查看索引中心

11.2 Expand–Migrate–Contract

Expand–Migrate–Contract 的价值不在三个英文词,而在于它强迫团队承认:数据库 schema 与 application fleet 不能原子切换。

本书把它细化为六个可查询阶段:

legacy
  → expanded
  → backfilling
  → migrated
  → validated
  → switched
  → contract

每个阶段都要定义入口条件、允许的读写版本、完成证据、失败语义与下一步。阶段名不是 deployment 日志的一行字符串,而是 release protocol。

11.2.1 先扩展兼容结构

Expand 的判定标准

一个 expand 变更应满足:

old application can still read
old application can still write
new application can discover/use the new shape
existing rows need not already satisfy the final invariant
new writes cannot create unbounded new migration debt
operation fits a measured lock budget

常见 expand:

  • 添加 nullable column;
  • 添加新表或新 relation;
  • 添加不改变旧调用结果的新函数参数/overload;
  • 添加兼容 view;
  • 增加 NOT VALID 的 CHECK/FK;
  • 建立 concurrent index;
  • 安装临时 bridge trigger;
  • 扩展 enum-like catalog,而非立即删除旧值。

常见非 expand:

  • rename/drop old column;
  • 直接 SET NOT NULL
  • 收窄 type/length/range;
  • 删除旧 enum/catalog value;
  • 改变函数返回 shape;
  • 改变旧字段语义但保留同名;
  • 让旧 writer 因新约束立即失败。

“DDL 能在旧代码旁边执行”不等于逻辑兼容;要实际运行最旧受支持版本的 read/write contract。

本章的兼容扩展

初始表只有:

shipping_method text NOT NULL

expand 事务做:

ALTER TABLE shop_private.ch11_order
    ADD COLUMN shipping_code text;

CREATE TRIGGER ch11_order_shipping_bridge
BEFORE INSERT OR UPDATE
ON shop_private.ch11_order
FOR EACH ROW
EXECUTE FUNCTION shop_private.ch11_sync_shipping_code();

ALTER TABLE shop_private.ch11_order
    ADD CONSTRAINT ch11_order_shipping_pair_consistent
    CHECK (...)
    NOT VALID;

这三部分承担不同职责:

组件 职责
nullable new column 让历史行暂时可表示
bridge trigger 让旧 writer 不再制造 NULL,并集中映射规则
pair CHECK NOT VALID 拒绝新/更新行的表示不一致,不扫描旧行

NOT VALID 不是“约束关闭”。约束加入后,新插入或更新的行仍被检查;只有已有行暂时没做全表验证。

bridge 必须是临时且单一的 authority

本例映射:

standard ↔ STD
express  ↔ EXP
pickup   ↔ PUP

旧 application 只提供 shipping_method,trigger 派生 code。新 application 在共存期 dual-write;若 pair 不一致,trigger/constraint 以命名 23514 拒绝。

为什么不让 application 连续执行:

UPDATE ... SET shipping_method = ...;
UPDATE ... SET shipping_code = ...;

因为两个 statement 之间可能:

  • transaction 被取消;
  • 进程断连;
  • 第二条被 retry/skip;
  • 另一个 writer 介入;
  • 第一条提交而第二条未提交。

若两条在同一 transaction,可以保证原子性,但仍存在多个 application 实现映射漂移的问题。短期 database bridge 把映射收敛为一个 authority;长期则应收缩回一个 canonical representation,避免永久双写。

Expand 也需要版本 identity

本章 state row:

migration_id=shipping-code-v1
phase=legacy

expand 在同一事务中:

add objects
  + install compatibility
  + update phase=expanded

若 DDL rollback,phase 也 rollback。下一次执行会先检查:

  • migration identity 是否正确;
  • 当前 phase 是否恰为 legacy
  • new column 是否确实不存在;
  • table marker 是否匹配。

它选择“前置拒绝”而不是无条件 IF NOT EXISTSIF NOT EXISTS 只能证明同名对象存在,不能证明 type、default、owner、constraint 与 function body 是期望版本。

11.2.2 分批迁移、双读校验与切换

Migrate 不等于一条 UPDATE

迁移阶段包含三个并行事实:

new writes remain compatible and complete
historical debt monotonically decreases
read comparison proves semantic equivalence

若只做 backfill,而旧 writer 继续写 NULL,remaining count 永远追不上;若只保护新写入,却不做 shadow comparison,可能把错误映射完整填满全表。

本章的次序:

expanded:
  old/new application probes
  build temporary partial index for unresolved rows

backfilling:
  keyset batches + atomic checkpoint
  controlled stop and resume

migrated:
  remaining NULL=0
  mapping mismatch=0

validated:
  pair CHECK validated
  non-null CHECK validated
  column SET NOT NULL

switched:
  new reads authoritative
  old column and bridge retained for rollback window

双读不是向用户返回两个结果

shadow read 的基本结构:

primary result = currently trusted representation
shadow result  = candidate representation
compare normalized semantics
emit mismatch metric/log with stable identity
return only primary result

它要回答:

  • 全量还是采样;
  • 采样是否覆盖 tenant/value/time buckets;
  • 如何归一化 NULL、时区、排序、rounding;
  • mismatch 是否含敏感数据;
  • 谁处理 mismatch;
  • mismatch=0 要持续多久;
  • shadow query 的额外负载预算。

本例可以在数据库内做精确比较:

SELECT count(*) AS mismatches
FROM shop_private.ch11_order
WHERE shipping_code IS DISTINCT FROM
      CASE shipping_method
          WHEN 'standard' THEN 'STD'
          WHEN 'express'  THEN 'EXP'
          WHEN 'pickup'   THEN 'PUP'
      END;

IS DISTINCT FROM 让 NULL 也进入确定的相等语义。生产业务的等价关系可能跨服务或包含版本化规则,不能只比较文本。

先切写还是先切读

常见安全次序是:

1 protect new writes at database boundary
2 deploy code capable of reading both
3 turn on new/dual write
4 backfill and validate
5 shadow new read
6 switch primary read
7 observe
8 disable old write compatibility
9 contract old representation

“先切写再切读”让 new representation 逐步变新鲜,便于读比较;但具体顺序仍取决于:

  • 新值能否从旧值无损派生;
  • old writer 是否仍可能运行;
  • new writer 是否能继续提供 old representation;
  • read fallback 是否会掩盖 migration debt;
  • rollback 时旧应用能否理解新写入。

不要把模式当教条,应把每个箭头写进兼容矩阵。

Switch 是流量动作,不是 DDL

本章 switch.sql 在数据库内只能模拟:

  • mismatch=0;
  • 新表示可作为 read authority;
  • 切换后旧 writer 仍能写;
  • 新 writer 继续 dual-write;
  • state 进入 switched

真实 switch 通常是 application flag、deployment、routing 或 query version 的改变。它需要自己的:

release identity
owner
start/end time
traffic percentage
SLI guard
rollback command
database migration identity

不要用 schema_version=42 代替 application rollout 证据,也不要用“应用已发布”代替数据库 catalog postcheck。

11.2.3 观察稳定后再收缩旧结构

Contract 是新的独立发布

Contract 删除的是兼容空间:

  • drop old column/table/function;
  • drop bridge trigger;
  • remove fallback read;
  • tighten type/range;
  • remove old index/API;
  • revoke old privilege;
  • delete old catalog values。

它不应和 expand 放在同一个 maintenance window。否则旧 application 一旦仍在运行,expand 提供的兼容立刻被 contract 撤销,整个模式失去意义。

contract 的入口条件至少包括:

database phase=switched
new representation complete and validated
old read traffic=0
old write traffic=0
offline/BI/ETL dependency inventory cleared
old prepared statements/connections aged out
rollback observation window elapsed
backup/PITR posture current
exact target and owner approved
forward repair documented

“观察一周”必须可验证

时间长度本身不够。需要观测对象:

old column read counter or query family
old write path/application version
bridge trigger invocation count
fallback-read count
mismatch count
old deployment replica count
offline job last success and next schedule
database errors for unknown old/new columns

如果没有区分旧/新路径的 telemetry,“观察一周无报警”不能证明旧依赖为零。

同时要考虑低频 consumer。一个月只跑一次的财务作业不会在七天窗口出现;依赖 inventory 和 owner 确认仍不可省略。

本地 suite 为什么拒绝 contract

contract-gate.sql 要求三个独立输入:

action token:
  CONTRACT_CH11_AFTER_OBSERVATION

exact target:
  pg36_shop/shop_private/ch11_order/shipping_method

external observation evidence:
  legacy-readers=0;legacy-writers=0;rollback-window=elapsed

task.sh all 故意不提供。稳定结果:

psql exit=3
SQLSTATE=P3612
phase=switched
shipping_method exists
bridge exists

这样全自动 CI 不会因为“测试跑完”而获得删除旧语义的权力。若有人在 disposable fixture 上显式满足 gate,可以演练真正 DROP;生产审批、证据和权限仍是另一条边界。

Contract 后没有免费回滚

删除旧列之后:

application rollback to old binary

往往已不再可行。可选恢复:

  • 前滚部署兼容修复;
  • 从 new representation 重建 old 值(仅当转换可逆且规则仍在);
  • 从外部权威源 reconciliation;
  • 从 backup/PITR 恢复到另一个环境并提取数据;
  • 全库恢复,接受明确 RPO/RTO 与其他数据影响。

所以 contract 是 destructive semantic change,即使 DROP COLUMN 物理上很快。

状态机的单调性

本章不提供 phase=validated → phase=expanded 的数据库 down path。回退流量时:

database stays expanded/validated/switched-compatible
application read path returns to old representation
new writer may continue dual-write
issue is repaired forward

这种“应用回退、数据库不倒退”通常比反向 DDL 更可靠。数据库状态可以暂时更宽松,只要:

  • 两种表示继续一致;
  • 新写入不积累债务;
  • owner 和 expiry 明确;
  • 后续 forward path 仍可执行。

发布状态表

可把每阶段写成以下审查表:

Phase 允许版本 写入 authority 完成证据 失败后
legacy old old baseline checksum redesign
expanded old + new bridge/dual catalog + compatibility cases retry/forward repair
backfilling old + new bridge/dual checkpoint + watermarks pause/resume
migrated old + new bridge/dual remaining=0, mismatch=0 repair anomalies
validated old + new constraints convalidated, attnotnull fix and revalidate
switched old rollback + new dual SLI + shadow match route reads back
contract new only new dependency zero + observation forward repair/restore

阶段必须由事实推动,不由“脚本跑到了第几行”推动。

本节验收问题

  1. expand 是否对最旧受支持 writer/readers 真正兼容;
  2. new write protection 是否在 backfill 前建立;
  3. mapping/dual-write 是否只有一个一致性 authority;
  4. migration identity 与 phase 是否同 DDL 原子提交;
  5. shadow comparison 的语义、采样和 owner 是否明确;
  6. switch 的 application release identity 是否独立留证;
  7. rollback 是流量回退还是数据库反向 DDL;
  8. contract 是否是独立发布、独立授权和独立窗口;
  9. 低频/offline consumer 是否进入依赖清单;
  10. contract 后丢失语义时是否诚实声明 forward repair/restore。

Expand–Migrate–Contract 不是让发布变慢;它是把原本隐含、同时发生且不可诊断的风险,拆成可以停止和验证的阶段。

参考资料


上一节:识别 DDL 的四类风险 · 返回本章目录 · 下一节:索引与约束的在线化路径 · 查看全书目录 · 查看索引中心

11.3 索引与约束的在线化路径

索引和约束的“在线化”不是无锁,而是把:

build / enforce new rows / scan old rows / publish identity

拆到不同阶段,缩短最强锁的持续时间,并让失败状态可识别。每种对象支持的拆分方式不同;不能把 NOT VALIDCONCURRENTLYUSING INDEX 当成通用后缀。

11.3.1 CREATE INDEX CONCURRENTLY 的阶段与失败残留

Concurrent build 解决什么

普通 CREATE INDEX 会阻止表上的写入。CREATE INDEX CONCURRENTLY 允许普通 insert/update/delete 继续,但代价是:

  • 多阶段目录状态;
  • 至少两次 table scan;
  • 等待影响旧 snapshot 的 transaction;
  • 更多总工作量和更长 elapsed time;
  • 每表同一时刻只能有一个 concurrent build;
  • 不能在 transaction block 中运行;
  • expression/predicate evaluation 仍可能失败;
  • 失败可能留下 INVALID index。

所以 CONCURRENTLY 的意思是“降低对普通写的阻塞”,不是“免费后台任务”。

先复用第 9 章的候选纪律

模式发布中的 index 也必须先回答:

query shape and parameterization
operator / collation / opclass
before/after plan and result identity
index size and build WAL
write/HOT cost
replica and disk watermarks
failure cleanup identity
retention or removal phase

本章不重复第 9 章的全套收益评估,只把一个 temporary partial index 应用于回填:

CREATE INDEX CONCURRENTLY
    ch11_order_shipping_missing_idx
ON shop_private.ch11_order (order_id)
WHERE shipping_code IS NULL;

它绑定:

WHERE order_id > checkpoint
  AND shipping_code IS NULL
ORDER BY order_id
LIMIT batch_size

随着 backfill 完成,predicate 集合缩到零;它是 migration acceleration object,不是永久 schema。

为什么必须是独立入口

online-index.sh 让 psql 在同一 session 中依次执行:

SET ROLE
SET lock_timeout
SET statement_timeout
CREATE INDEX CONCURRENTLY

每个 --command 是独立 top-level command,避免把 concurrent build 塞进 implicit multi-statement transaction。下面写法会失败:

BEGIN;
CREATE INDEX CONCURRENTLY ...;
COMMIT;

migration framework 如果默认“每个 migration 自动包事务”,需要为这类命令声明 non-transactional phase;不能偷偷关闭整套 framework 的事务保护。

失败后查 catalog,不按文件名猜

SELECT
    index_class.relname,
    index_catalog.indisready,
    index_catalog.indisvalid,
    index_catalog.indisunique,
    pg_get_indexdef(index_catalog.indexrelid),
    pg_get_expr(
        index_catalog.indpred,
        index_catalog.indrelid
    ) AS predicate
FROM pg_index AS index_catalog
JOIN pg_class AS index_class
  ON index_class.oid = index_catalog.indexrelid
WHERE index_catalog.indrelid =
      'shop_private.ch11_order'::regclass;

失败的 concurrent index:

  • 可能仍占磁盘;
  • 可能给写入带来维护开销;
  • 若是 unique build,某些阶段甚至可能开始施加 uniqueness;
  • 不会被 planner 当作正常 valid index。

处理顺序:

capture SQLSTATE/stderr and catalog
  → identify exact schema/index/table/definition
  → decide repair/rebuild/drop
  → DROP INDEX CONCURRENTLY exact_name
  → verify catalog absence

不要运行模糊 DROP INDEX IF EXISTS some_name 后声称“已清理”;同名跨 schema、错误定义和并发新建都需要防护。

从 unique index 快速接成约束

对非分区普通表,可以先:

CREATE UNIQUE INDEX CONCURRENTLY candidate_uidx
ON account (tenant_id, external_ref);

验证 valid 后:

ALTER TABLE account
    ADD CONSTRAINT account_external_ref_key
    UNIQUE USING INDEX candidate_uidx;

第二步通常是短 catalog operation。边界:

  • 必须是 unique B-tree;
  • 使用默认排序;
  • 不能是 expression index;
  • 不能是 partial index;
  • PRIMARY KEY 还要求列 NOT NULL,否则可能触发扫描;
  • 当前不能用该语法直接给 partitioned table 添加约束;
  • 转换后 index 由 constraint 拥有,drop constraint 会连带 drop index。

先 concurrent build 再 attach,不消除第二步的锁预算,只缩短需要强锁时做的工作。

11.3.2 NOT VALIDVALIDATE CONSTRAINT 与验证扫描

NOT VALID 的精确定义

对支持的 CHECK/FK(PostgreSQL 18 还扩展到关系级 NOT NULL),ADD ... NOT VALID

does not scan all pre-existing rows at ADD time
does enforce the constraint for future INSERT/UPDATE rows
records convalidated=false

它不是:

constraint disabled
validation optional forever
available to UNIQUE/PRIMARY KEY
no locks

本章 expand 后:

ch11_order_shipping_pair_consistent
  contype=c
  convalidated=false

pg_attribute.shipping_code
  attnotnull=false

旧行可以 shipping_code IS NULL,但新/更新行不能产生错误 pair。

为什么 validation 能与 DML 共存

ALTER TABLE shop_private.ch11_order
    VALIDATE CONSTRAINT
    ch11_order_shipping_pair_consistent;

PostgreSQL 扫描旧行时,新/更新行已经由 constraint enforcement 保护,因此 validation 使用 SHARE UPDATE EXCLUSIVE,不需要像直接 ADD valid constraint 那样长期阻止普通更新。

这仍然是全表读取:

  • 会消耗 IO/buffer/CPU;
  • 与某些 DDL、VACUUM family 操作冲突;
  • 可能造成 replica/存储侧压力;
  • 遇到历史坏值会失败;
  • 需要单独 statement_timeout 与发布水位。

convalidated=true 是完成证据;“命令返回成功”之外还应保存:

SELECT
    conname,
    contype,
    convalidated,
    pg_get_constraintdef(oid, true)
FROM pg_constraint
WHERE conrelid = '...'::regclass;

非空的跨版本路径

PostgreSQL 14–17 的通用做法:

ALTER TABLE orders
    ADD CONSTRAINT orders_new_col_nn
    CHECK (new_col IS NOT NULL)
    NOT VALID;

ALTER TABLE orders
    VALIDATE CONSTRAINT orders_new_col_nn;

ALTER TABLE orders
    ALTER COLUMN new_col SET NOT NULL;

当一个 valid CHECK 已经证明列无 NULL,SET NOT NULL 可以避免再做一次全表扫描;执行时让该 CHECK 保持存在。

本章实测:

pair CHECK      false → true
non-null CHECK  false → true
attnotnull      false → true
SET NOT NULL relfilenode 19911 → 19911

same filenode 说明没有 table rewrite;官方保证与 valid CHECK 共同支持“无需重复验证扫描”的判断。它仍需要短时强锁,不能省略 lock budget。

PostgreSQL 18 的目录差异

PostgreSQL 17 及以前,relation column 的 NOT NULL 主要表示在:

pg_attribute.attnotnull

pg_constraintcontype='n' 主要用于 domain。PostgreSQL 18 把 relation NOT NULL 也提升为完整 constraint:

pg_constraint.contype='n'
pg_constraint.conrelid=<table>
named NOT NULL
convalidated state
inheritance/enforcement metadata

本章 PG18.6 验收同时看到:

ch11_order_shipping_code_not_null | n | true
pg_attribute.shipping_code        | a | true

因此跨 14–18 的 catalog checker 必须 version-gate:

PG14–17:
  require attnotnull=true
  do not require relation contype=n

PG18:
  require attnotnull=true
  require exactly one validated relation NOT NULL constraint

不要因为 PG18 新语法支持 NOT NULL NOT VALID 就把它直接写进声明支持 PG14–18 的无条件 migration。通用主体仍可使用 CHECK → VALIDATE → SET NOT NULL。

NOT VALID 失败恢复

若 validation 发现坏值:

constraint remains present and not valid
new/updated rows remain protected
old violations remain queryable

这通常比直接 ADD valid constraint 后整个发布卡住更可控。修复流程:

  1. 保存 violation query 与 stable row identity;
  2. 暂停或限速 backfill;
  3. 修复历史数据;
  4. 再次验证 zero violations;
  5. 重跑 VALIDATE CONSTRAINT
  6. convalidated
  7. 才进入 switch。

不应为了让 validation 通过而随手 drop constraint;那会重新打开新债务入口。

11.3.3 默认值、非空与类型变更的版本边界

默认值按 volatility 与目标版本判断

对于:

ALTER TABLE t ADD COLUMN c type DEFAULT expression;

先回答:

expression volatility?
evaluated once or per row?
does target PG support metadata missing value?
is value a real historical fact?
will old application explicitly send NULL?
does default need removal after migration?

non-volatile constant fast path 在当前支持范围内可用,但强锁仍在。volatile expression 会逐行更新;不同 extension/function 还要确认 volatility declaration 是否真实,不能为追求 fast path 把非 immutable 函数伪装成 immutable。

default 与 NOT NULL 的组合

新增:

ADD COLUMN flag integer NOT NULL DEFAULT 7

对 constant default 可以很快,但业务语义仍可能错误:

  • 所有旧行真的都是 7 吗;
  • 未来调用方省略时真的应为 7 吗;
  • 旧 application 显式传 NULL 会不会失败;
  • 7 是临时 backfill 值还是长期 default;
  • 是否需要区分“未知”与“默认”。

物理 fast path 不能替代领域建模。若历史值需要从旧数据计算,应 nullable expand + backfill,而不是给所有历史 tuple 伪造同一个事实。

类型变更的四条路径

路径 适用 主要风险
in-place ALTER TYPE 小表/可证明无重写转换 lock、依赖、plan/statistics
new column + backfill 可双表示、需转换 coexistence、WAL、contract
new table + dual write 大结构变化/新 key consistency、cutover
logical copy/CDC 极大表/跨系统 ordering、lag、reconciliation

选型不只看表大小。还看 write rate、转换是否可逆、FK/unique、业务 key、partition、可用窗口和 rollback。

Precheck 必须在写前失败

text → integer 例子:

SELECT id, old_value
FROM source
WHERE old_value !~ '^[0-9]+$'
   OR old_value::numeric >
      2147483647;

真正 migration 仍要处理 precheck 与执行之间的竞态:

  • 先加兼容 CHECK 约束;
  • 暂停旧 writer;
  • 在同一受控 transaction 再检查;
  • 或把所有写引到能验证新范围的路径。

还要检查:

default
generated column
views/functions
expression/partial indexes
foreign keys
statistics and extended statistics
logical replication publications/subscribers
driver parameter/result type decoding

成功后重新 ANALYZE,并验证 query plans 与 driver contract;不能只比较 information_schema.columns

版本矩阵写进 artifact

每个 migration repository 应维护至少:

minimum supported major
maximum validated major
version-specific syntax
catalog assertion branch
feature introduced version
known semantic differences

本章:

能力 PG14 PG15 PG16 PG17 PG18
constant-default metadata path
CHECK/FK NOT VALID
valid CHECK helps SET NOT NULL
relation NOT NULL in pg_constraint
named/relation NOT NULL NOT VALID
DETACH PARTITION CONCURRENTLY

“最低 PG14”意味着代码必须先在 PG14 parser/catalog 上成立;不能只在 PG18 运行后根据结果猜兼容。

本节验收问题

  1. concurrent index 是否真正位于 transaction block 外;
  2. build 前后的 query、write、size/WAL 证据是否完整;
  3. failure 是否检查 indisvalid/indisready 并精确清理;
  4. partial migration index 是否有明确 drop phase;
  5. NOT VALID 是否被正确解释为“新写入已执行”;
  6. validation scan 的 IO、lock 与 timeout 是否独立预算;
  7. CHECK → VALIDATE → SET NOT NULL 次序是否跨 PG14–18;
  8. PG18 relation NOT NULL catalog 是否 version-gated;
  9. default 的 volatility 与历史语义是否都评审;
  10. ALTER TYPE 是否检查数据、依赖、driver 和 statistics;
  11. catalog fast path 是否被误写成“零锁零风险”;
  12. dynamic cost/timing 是否只作观测,不作跨环境常数。

参考资料


上一节:Expand–Migrate–Contract · 返回本章目录 · 下一节:在线分区化 · 查看全书目录 · 查看索引中心

11.4 在线分区化

本书对分区采用渐进式教学:

ch04: decide whether partitioning is justified
ch11: rehearse attach/detach as a safe schema release
ch28: operate the full partition lifecycle

本节不推翻 ch04 对订单模型“暂不分区”的 ADR。我们使用独立事件 fixture,学习当证据门已经通过后,如何把预装数据表挂入/摘出分区层次,并把 lock、scan、version 和 recovery 语义说清楚。

11.4.1 从 ch04 的分区 ADR 选择迁移路径

先确认旧决定为什么失效

ch04 分区决策门 要求至少出现一类真实证据:

  • retention/归档需要按整批数据处理;
  • relation/index 规模已形成可测瓶颈;
  • 代表性查询能稳定携带 partition key,并证明 pruning 收益;
  • 热冷分层、备份或维护窗口需要独立 physical units。

进入 ch11 时,不应把“学会 ATTACH”误解为“现在应该分区”。先更新 ADR:

old evidence
new evidence and timestamp
candidate key and partition count
unique/PK/FK implications
NULL and row-movement semantics
query/pruning measurements
retention and operational owner
migration and rollback strategy

如果新证据仍不足,正确结论可以继续是 not-now

Regular table 不能原地变成 partitioned table

迁移通常需要新结构。常见路径:

A. new parent + new partitions
   → copy/backfill
   → catch up writes
   → switch logical name/view

B. prepare an existing regular table
   → prove it fits one bound
   → ATTACH PARTITION

C. new partitioned table + logical replication/CDC
   → reconcile
   → cut over

路径 B 适合:

  • 已经按边界离线装载的一批数据;
  • 归档表重新加入 logical parent;
  • 新周期分区先单独 load/validate;
  • 迁移时把一段现有数据转换成 child。

它不是把任意一张巨大表“瞬间变成分区表”。父/子 column shape、constraints、indexes、owner、storage 与数据 bound 都要准备。

Key、unique 与 FK 必须先重做语义

PostgreSQL partitioned table 的 unique/primary key 必须包含全部 partition key columns,且 key 不能含 expression。因为 uniqueness 最终由各 child 的本地 index 执行,数据库要从 partition routing 推导跨 child 不重复。

如果旧合同是:

UNIQUE(order_no)

placed_at 分区后机械改为:

UNIQUE(order_no, placed_at)

不再保证 order_no 全局唯一。两个不同月份可以重复 order number。可选设计:

  • 改 API identity 为复合键,明确语义变化;
  • 保留未分区 global registry;
  • 选择不同 partition key;
  • 接受只在 partition 内唯一;
  • 不分区。

外键、idempotency key、upsert conflict target 和 driver 参数都会受影响。没有解决这些逻辑问题时,在线 DDL 技巧没有意义。

本章 fixture 的范围

事件表没有 global unique/FK,只演练 range bound:

CREATE TABLE shop_private.ch11_event (
    event_id bigint NOT NULL,
    occurred_at timestamptz NOT NULL,
    payload text NOT NULL
) PARTITION BY RANGE (occurred_at);

独立候选 ch11_event_2025q1 有 20,000 行。数据都落在:

[2025-01-01 00:00:00+00, 2025-04-01 00:00:00+00)

这个简化让实验精确关注 attach/detach;它不是完整生产事件模型。

11.4.2 预建约束、ATTACH 与扫描规避

ATTACH 的两种验证路径

命令:

ALTER TABLE shop_private.ch11_event
ATTACH PARTITION shop_private.ch11_event_2025q1
FOR VALUES FROM ('2025-01-01 00:00:00+00')
             TO ('2025-04-01 00:00:00+00');

PostgreSQL 必须证明所有 child row 满足隐含 partition bound。若没有可用证明,它会在持有 child ACCESS EXCLUSIVE 时扫描该表。

预先建立并验证匹配 CHECK:

ALTER TABLE shop_private.ch11_event_2025q1
ADD CONSTRAINT ch11_event_2025q1_bound
CHECK (
    occurred_at >=
        timestamptz '2025-01-01 00:00:00+00'
    AND occurred_at <
        timestamptz '2025-04-01 00:00:00+00'
);

官方文档建议这样让系统跳过 attach validation scan。注意:

  • constraint 必须 valid;
  • expression 必须足以让系统证明相同 bound;
  • time zone/type/cast/NULL 语义要一致;
  • child columns 必须与 parent 完全匹配;
  • child 若自身 partitioned,递归 subpartition 也要考虑;
  • attach 仍会取得锁,预建 CHECK 不等于 lock-free。

实测 ATTACH 的锁

partition_lab.py 在一个 transaction 中执行 ATTACH 后故意暂不 commit,observer 查询 holder 的 pg_locks

PostgreSQL 18.6 结果:

Relation Granted mode
ch11_event parent ShareUpdateExclusiveLock
ch11_event_2025q1 child AccessExclusiveLock

这与 PostgreSQL 18 ALTER TABLE 文档一致:

parent: SHARE UPDATE EXCLUSIVE
attached table: ACCESS EXCLUSIVE
default partition (if any): ACCESS EXCLUSIVE

预验证 CHECK 降低的是持有 child 强锁期间的扫描工作,不改变 child 需要强锁这一事实。若 child 仍被报表长读,ATTACH 仍可能等锁或阻塞。

本地证据能证明到哪里

实验保存:

bound CHECK convalidated=true before attach
child row count=20,000
exact ATTACH lock modes
child relfilenode unchanged
parent count=20,000 after attach
pg_inherits edge=1
relispartition=true

relfilenode 未变说明数据没有 copy/rewrite,但不能单独证明“没有 validation scan”。“匹配 valid CHECK 可避免扫描”来自官方语义;运行证据证明我们确实提供了该 CHECK 和目标 bound。

不要用一个极快 elapsed time 声称 scan 被证明跳过:数据可能在 cache 中、表可能太小、计时噪声也可能掩盖差异。

Default partition 的隐藏扫描

如果 parent 已有 DEFAULT partition,添加一个新显式 range 时,PostgreSQL 还必须证明 DEFAULT 中没有属于新 range 的行。没有排除性 CHECK 时会:

scan default partition
while holding ACCESS EXCLUSIVE on it

若 DEFAULT 自身 partitioned,会递归检查。发布计划要么:

  • 预先为 DEFAULT 添加排除新 range 的 valid CHECK;
  • 先迁出该 range 的行;
  • 在可接受窗口完成扫描;
  • 重新设计是否需要 DEFAULT。

只优化待 attach child 而忘记 DEFAULT,是常见的“测试环境快、生产突然锁住”原因。

Partitioned index 的在线路径不同

不能直接对 partitioned parent 使用:

CREATE INDEX CONCURRENTLY ON partitioned_parent (...);

官方推荐的渐进路径:

CREATE INDEX ON ONLY parent          # invalid parent index
  → CREATE INDEX CONCURRENTLY child1
  → ALTER INDEX parent_idx ATTACH PARTITION child1_idx
  → repeat for every child
  → parent index becomes valid when complete

每个 child index 要验证定义、opclass、collation、validity 与 ownership。对 unique/PK 还要满足 partition key 规则。生产实施还要把这套检查扩展到全部现存分区,并验证新增分区不会漏建索引。

11.4.3 DETACH、并发能力与锁等级必须按版本说明

普通 DETACH 与 CONCURRENTLY

ALTER TABLE parent
DETACH PARTITION child;

普通形式对 parent 请求 ACCESS EXCLUSIVE。PostgreSQL 14 引入:

ALTER TABLE parent
DETACH PARTITION child CONCURRENTLY;

并发形式对 parent 使用较低的 SHARE UPDATE EXCLUSIVE,内部有两个 transaction:

  1. parent 与 child 取得 SHARE UPDATE EXCLUSIVE,标记正在 detach 并 commit;
  2. 等待使用该 partitioned table 的旧 transaction 结束;
  3. 再取得 parent SHARE UPDATE EXCLUSIVE 和 child ACCESS EXCLUSIVE
  4. 完成 detach,并建立等价 CHECK。

这解释了两个边界:

  • 它仍可能等待长 transaction;
  • 最终仍要短时取得 child ACCESS EXCLUSIVE

“CONCURRENTLY”不是不等待、不锁或固定秒数完成。

事务与结构限制

DETACH PARTITION ... CONCURRENTLY

  • 不能在 transaction block 中运行;
  • parent 有 DEFAULT partition 时不允许;
  • interrupted/pending detach 可能需要 FINALIZE
  • 目标版本必须 PostgreSQL 14+;
  • FK、subpartition 和 concurrent activity 仍需按目标版本复核。

因此 migration runner 需要像 concurrent index 一样提供独立 non-transactional command。

本章的 Python runner 用两个 psql top-level command:

SET ROLE pg36_owner
ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY

它们在同一 session 执行,但 DETACH 不在显式或 implicit multi-statement transaction 内。

版本事实不能倒推

本书支持 PG14–18,因此该语法在主体矩阵中可用。若维护 PG13:

CONCURRENTLY / FINALIZE unavailable

不能运行时 fallback 成普通 DETACH 后仍报告相同 lock semantics。应:

  • 明确跳过并报告 unsupported;
  • 选择有维护窗的 ordinary detach;
  • 或先升级目标 major。

本章 manifest 保存 server_version_num=180006,review 要求 >=140000;它不把 18.6 的 timing 当成 PG14–17 保证。

11.4.4 产出一次可回退的分区化发布

完整演练

实验状态:

before:
  child standalone
  valid bound CHECK
  rows=20,000
  parent rows=0
  relispartition=false

attach:
  capture parent/child locks before commit
  commit
  parent rows=20,000
  relispartition=true

detach concurrently:
  top-level PG14+ command
  child rows=20,000
  parent rows=0
  relispartition=false
  filenode unchanged

rollback rehearsal:
  verify data/bound still valid
  reattach

final:
  parent rows=20,000
  child attached
  filenode unchanged throughout

这里“回退”是结构性 reattach,因为 detach 后没有允许 child 与 parent 的写路径发生分叉。若 detach 后:

  • child 单独接收写入;
  • parent 在同一 bound 又建立新 partition;
  • constraint 被修改;
  • indexes/privileges/schema 发生漂移;

reattach 就不再是机械 undo,需要 reconciliation 和新的 lock/scan 评审。

发布 runbook

生产 attach 前:

1 verify target database/primary/role/search_path
2 verify parent/child identity and exact column shape
3 freeze or control writers to standalone child
4 validate bound CHECK and count violations=0
5 build/validate child indexes and constraints
6 handle DEFAULT exclusion
7 measure relation/index size and active transactions
8 set lock/statement timeout
9 observe service SLI, WAL, lag, disk
10 ATTACH
11 verify pg_inherits, relispartition, rows, pruning and privileges
12 keep standalone recovery plan until observation completes

detach 前:

1 identify PG major and DEFAULT restriction
2 decide ordinary vs concurrently
3 check long transactions/snapshots
4 define routing after detach
5 execute top-level command
6 inspect pending/final state
7 verify row count and new CHECK
8 archive/copy/drop only as separate destructive action

Detach 不等于删除

DETACH 保留 table 和数据,适合:

  • 归档前 COPY/backup;
  • 低频报表;
  • 数据压缩/汇总;
  • 独立验证;
  • 在明确边界下重新 attach。

DROP TABLE partition 则删除数据对象。不要在同一个“一键 retention”里把 detach、archive verification 和 drop 混成无法暂停的动作。

API 不应暴露 child identity

应用继续访问 logical parent:

SELECT ...
FROM event
WHERE occurred_at >= $1
  AND occurred_at < $2;

不要让业务 URL、job payload 或 ORM model 绑定 event_2025q1。否则每次 attach/detach 都变成 application contract 变更,物理生命周期无法独立演进。

本节验收问题

  1. ch04 ADR 的进入门是否真的被新证据触发;
  2. partition key 是否同时满足 lifecycle、query、NULL 与稳定性;
  3. global unique/PK/FK 语义是否重新设计;
  4. regular→partitioned 是否有新结构和切换路径;
  5. candidate child column shape 是否与 parent 完全一致;
  6. matching bound CHECK 是否 valid 且可被系统证明;
  7. ATTACH 的 parent/child/default locks 是否按目标版本写明;
  8. DEFAULT partition 的排除扫描是否处理;
  9. partitioned index 是否使用 per-child concurrent build/attach;
  10. DETACH CONCURRENTLY 是否 PG14+ 且位于 transaction block 外;
  11. pending detach/FINALIZE 与长 transaction 是否进入故障预案;
  12. detach 后写入是否可能让 reattach 失去可逆性;
  13. archive verification 与 destructive drop 是否分开授权;
  14. 应用是否只依赖 logical parent。

参考资料


上一节:索引与约束的在线化路径 · 返回本章目录 · 下一节:数据回填与流量切换 · 查看全书目录 · 查看索引中心

11.5 数据回填与流量切换

在线模式发布中,DDL 往往只占几秒,回填和流量切换却持续数小时或数天。真正的控制回路是:

measure
  → commit one bounded batch
  → observe database + replica + application watermarks
  → continue / slow / pause
  → verify semantic equality
  → switch a bounded traffic slice
  → observe / rollback traffic / advance

批量工作不能只追求“尽快跑完”;它要让前台服务始终优先,并让任意停止点都可解释。

11.5.1 批次、限速、水位与断点续跑

先选择稳定遍历方式

不要对不断变化的大表使用:

ORDER BY id
OFFSET 5000000
LIMIT 5000;

OFFSET 会反复扫描/跳过前缀,成本随进度增长;并发 insert/delete 还可能让页边界移动。优先 keyset:

WHERE id > :last_id
  AND new_value IS NULL
ORDER BY id
LIMIT :batch_size

ordering key 应:

  • 稳定不变;
  • 有确定顺序;
  • 可索引;
  • 能表达连续完成前缀;
  • 不因分区切换或业务更新而重排。

若没有单调 key,可以使用 range table、work queue 或显式 claim records;不要用 ctid 作为跨 transaction checkpoint,因为 UPDATE/VACUUM/rewrites 会改变它。

一批的事务边界

本章每批:

BEGIN
  lock migration state row
  select next unresolved key range
  lock target rows
  update derived representation
  assert no unresolved row <= new watermark
  update checkpoint/phase
COMMIT

关键不变量:

data batch committed ⇔ checkpoint committed

如果先提交数据、后写外部 checkpoint,进程可能重复处理;如果先推进 checkpoint、后提交数据,可能永久跳过。将 checkpoint 放在同一 PostgreSQL transaction 最简单。

跨系统 backfill 无法原子提交时,需要 idempotent sink、outbox/inbox、source offset 与 reconciliation,而不是假装有单库事务。

Batch size 不是常数

5000 只是本地 fixture 的演示值。生产 batch 由目标约束:

batch transaction duration
rows and bytes changed
WAL bytes/sec
replica replay lag
disk and IO latency
autovacuum/dead tuples
lock wait and user latency
connection pool saturation
remaining window

可采用反馈式控制:

if user latency or lag above stop threshold:
    stop after current commit
elif above slow threshold:
    reduce batch / increase sleep
elif safely below target for several windows:
    cautiously increase

不要在 transaction 内 sleep;commit 后再限速,让 locks、snapshot 和 transaction resources 及时释放。

三层水位

至少区分:

水位 例子 用途
progress last key, rows done, remaining 续跑与 ETA
safety WAL rate, lag, disk, lock wait, SLI pause/slow
correctness nulls, mismatches, constraint status switch gate

progress 变快不能覆盖 safety 超线;remaining=0 也不能覆盖 mismatch>0。

ETA 只能是动态估计:

remaining rows / recent safe throughput

如果 workload、row width、cache 或 replication 变化,早期吞吐不能外推全程。

SKIP LOCKED 的 checkpoint 陷阱

假设:

rows 1..100
row 20 locked
batch SKIP LOCKED selects 1..19,21..51
checkpoint=max(id)=51

下次 id > 51,row 20 永久遗漏。解决方式:

  • 不跳锁,短 lock_timeout 后暂停;
  • checkpoint 只推进连续完成前缀;
  • 记录 skipped ranges 并回访;
  • 使用 claim/work table;
  • 最终做 full unresolved sweep。

本章选择“不跳锁 + 连续前缀 assertion”。第 10 章 SKIP LOCKED 适合可替代任务队列,不应机械搬到所有 backfill。

受控中止与重入

第一次运行:

./backfill.py \
  --service pg36-admin \
  --batch-size 5000 \
  --max-batches 2 \
  --output backfill-interrupted.json

结果:

exit=75
start.phase=expanded
end.phase=backfilling
batches_run=2
rows_migrated=10,000
remaining_nulls=39,999
committed_batches_preserved=true

第二次没有 --max-batches

start.phase=backfilling
start.last_order_id=<previous end>
batches_run=8
end.phase=migrated
end.rows_migrated=49,999
end.batches=10
remaining_nulls=0
mismatches=0

review 比较两份 JSON 的 exact checkpoint relationship,而不要求动态时间或 PID 相同。

11.5.2 影子读、双写的风险与一致性验证

双写为什么危险

双写可能指:

  1. 同一数据库 transaction 内写两列/两表;
  2. 两个独立 database transaction;
  3. 数据库 + cache;
  4. 数据库 A + 数据库 B;
  5. 数据库 + message/API。

只有第一种天然共享 PostgreSQL 原子性。其他都需要:

idempotency
ordering
retry semantics
outbox/inbox or log authority
reconciliation
partial failure handling

“应用会同时写两边”没有说明断连、timeout、retry、reordering 和版本漂移。

同库双表示也会漂移

即使在一行内:

shipping_method='express'
shipping_code='STD'

如果每个 application 都独立实现 mapping,版本差异或 bug 仍会制造矛盾。本章用:

  • temporary trigger 派生 old-only write;
  • new application dual-write;
  • named CHECK 拒绝 mismatch;
  • shadow query 比较全量;
  • final NOT NULL;

组成 closed loop。

负向 case:

shipping_method=express
shipping_code=STD

必须:

SQLSTATE=23514
constraint=ch11_order_shipping_pair_consistent
row absent after subtransaction rollback

只断言“插入失败”不够;错误可能来自 unique、权限、语法或其他 constraint。

Trigger bridge 的代价

bridge trigger 是迁移工具,不是永久隐藏层。它会:

  • 增加每次写入成本;
  • 改变显式 NULL 的语义;
  • 影响 COPY/replication/repair paths;
  • 让 application 看不见数据库自动补值;
  • 增加 function/owner/search_path/privilege 审查面;
  • 在 contract 时形成依赖。

本章 function 是 invoker,不需要 SECURITY DEFINER。若必须 definer,继续遵守 SAFE-DEFR-004:固定可信 search_path、受控 owner、撤销 PUBLIC EXECUTE、schema-qualify object。

bridge 必须有:

owner
installation phase
invocation telemetry
removal gate
expiration
test for old/new/mismatch

否则临时双写会变成多年永久复杂度。

Shadow read 的三种精度

模式 优点 风险
database exact count 全量、简单 可能昂贵,只覆盖库内等价
application synchronous compare 贴近真实 decoder 增加请求延迟/负载
async sampled compare 对前台影响小 采样偏差、时序差

对于大表,可以分 bucket:

WHERE id >= :lo
  AND id < :hi
  AND old_normalized IS DISTINCT FROM new_normalized

保存:

range
snapshot/watermark
mapping version
row count
mismatch count
sample identities
query duration/buffers

如果比较在不同 snapshot 或跨异步系统执行,要区分真实不一致与复制/传输 lag。

Read fallback 会隐藏债务

新代码常写:

if new_value is null:
    derive from old_value

这有利于早期兼容,但如果没有 fallback counter 和 expiry:

  • backfill 漏行不会暴露;
  • new write bug 会被掩盖;
  • contract 前无法证明依赖清零;
  • 读取可能永久使用旧语义。

进入 switch 前要做到:

remaining null=0
mismatch=0
constraint valid
fallback count=0 for observation window

然后再删除 fallback,最后才删除 old representation。

11.5.3 何时暂停、回退或前滚

三种动作不是同义词

pause:
  stop creating more change after current safe commit

rollback traffic:
  route reads/writes back to a compatible application path
  while database remains expanded

forward repair:
  keep monotonic schema state and fix data/code/config ahead

数据库 schema 在 expand 后通常不需要立刻 down。只要 old path 仍兼容,可以先回退 application traffic,再修复 new path。

Stop conditions 要量化

发布前定义,例如:

any unexpected SQLSTATE or constraint identity
lock wait > 2s or queue depth > N
p95/p99 exceeds agreed budget for M windows
replica replay lag > threshold
WAL retention/disk free crosses threshold
checkpoint fails to advance
mismatch > 0
unexpected null debt increases
worker retry/error rate > threshold
primary role changes/failover begins
monitoring blind spot

数值必须来自实际 SLO/capacity,不应从本章本地数字照抄。停止条件还要指定:

  • 谁有权 pause;
  • 当前 batch 是否允许 commit;
  • 如何防止 orchestrator 自动重启;
  • 下一次 resume 需要哪些复核;
  • incident/change owner 如何交接。

选择 rollback traffic

适合:

database expanded and backward compatible
new read/write shows application regression
old path still receives complete old representation
no contract executed

动作:

stop rollout/flag
route to old version
keep bridge/dual write
capture new-path evidence
repair forward

若 new writer 已经产生 old application 无法理解的数据,即使 old column 存在也不能安全 rollback;这必须在兼容矩阵提前证明。

选择 forward repair

适合:

  • backfill 中少量 deterministic bad rows;
  • constraint validation 发现历史 violation;
  • migration 已提交且 down 会丢失更多语义;
  • schema 已扩展,旧路径仍正常;
  • contract 尚未发生。

例如:

constraint convalidated=false
new rows still enforced
bad historical rows identified

保留 constraint,修数据,再 validate,通常优于 drop constraint 退回无保护状态。

何时需要 restore/PITR

只有当:

  • destructive contract 已丢失不可重建语义;
  • 大范围错误写入无法可靠 reconciliation;
  • catalog/physical corruption;
  • forward repair 风险高于恢复;

才进入 restore/PITR 决策。它不是轻量 undo。必须明确:

restore target and isolation
RPO/RTO
other databases/tenants impact
replay/cutover plan
post-restore reconciliation

多数模式发布问题应在 contract 之前被兼容窗口和 gate 截住。

Decision table

证据 动作
safety waterline crossed, current batch healthy commit batch, pause
lock timeout before DDL state change observe blocker, reschedule/retry
backfill process exits between batches resume from checkpoint
validation finds old bad rows keep NOT VALID, repair forward
new application SLI regress, old path compatible rollback traffic
mismatch or null debt grows stop switch, fix writer/bridge
old dependency still observed block contract
destructive loss already occurred forward reconstruct or restore

变更窗口结束不等于强行完成

如果窗口将结束而只完成 60% backfill,安全结果可以是:

phase=backfilling
old/new compatibility retained
checkpoint durable
temporary index retained and documented
owner + next window + expiry recorded

强行扩大 batch、取消 timeout 或在监控盲区继续,只是把进度指标置于数据与服务安全之上。

本节验收问题

  1. keyset ordering 是否稳定,checkpoint 是否表示连续完成前缀;
  2. batch data 与 checkpoint 是否同事务;
  3. size/rate/sleep 是否由 safety watermarks 调节;
  4. SKIP LOCKED 是否可能制造永久遗漏;
  5. controlled exit 与 unexpected failure 是否可区分;
  6. 双写的原子性范围和 authority 是否明确;
  7. mismatch 反例是否核对 SQLSTATE 与 constraint identity;
  8. trigger/fallback 是否有 owner、telemetry、expiry 和 removal gate;
  9. shadow comparison 是否处理 snapshot/lag/normalization;
  10. pause、traffic rollback、forward repair 是否分别定义;
  11. stop conditions 是否量化并绑定 owner;
  12. 窗口结束时是否允许安全停在中间 phase。

上一节:在线分区化 · 返回本章目录 · 下一节:发布窗口中的平台观察 · 查看全书目录 · 查看索引中心

11.6 发布窗口中的平台观察

数据库 catalog 只能回答“当前对象是什么、谁在等谁”;发布平台还要回答“哪条流量进入哪里、影响面多大、过去几分钟发生了什么、是否仍在安全水位内”。

一次发布观察闭环:

change identity + UTC window
  → exact Pigsty service / database / role / application_name
  → PostgreSQL live catalog
  → Prometheus/Grafana historical trend
  → application release + SLI/error evidence
  → pause/switch/contract decision

仪表盘不是 DDL 正确性的替代品;SQL postcheck 也不是容量与用户影响的替代品。

11.6.1 从服务入口隔离实验流量

Pigsty 的四类默认服务

Pigsty v4.5 的默认 PostgreSQL 服务:

Service Port 默认路径 典型用途
primary 5433 HAProxy → primary pool 生产读写
replica 5434 HAProxy → replica pool 生产只读
default 5436 HAProxy → primary PostgreSQL DBA、ETL、直接写
offline 5438 HAProxy → offline/replica PostgreSQL OLAP、离线、交互

端口与 selector 来自 pg_default_services / pg_services 配置,以目标集群的当前 Pigsty Service 文档 和 inventory 为准。

模式变更不应随便借用 production application pool:

  • session-level DDL 设置可能被 pool 复用污染;
  • transaction pooling 可能不支持所需 session 语义;
  • maintenance traffic 与用户请求难以区分;
  • pool timeout 与 migration timeout 可能互相覆盖;
  • 连接数/排队会混入应用 SLI。

通常使用受控 DBA/default direct service 或专用 migration service;仍必须确认它路由到 writable primary。

“连到 5436”仍不是目标证明

连接后第一条证据:

SELECT
    current_database(),
    session_user,
    current_user,
    current_setting('server_version'),
    pg_is_in_recovery(),
    current_schemas(false),
    current_setting('application_name');

还要验证:

  • cluster/instance identity;
  • expected schema version;
  • object owner/marker;
  • target relation OID/definition;
  • transaction pooling 是否绕过;
  • connection string 没有展开 secret;
  • role 能力只覆盖本次 change。

错误 primary 上的正确 DDL 仍是事故;正确 primary 上的错误 database 也是。

本章 service file 只保存受控连接 identity,脚本使用:

service=pg36-admin
application_name=pg36-ch11-<phase>

manifest 记录 service name,不展开密码/URI。

用 application_name 切出发布流量

每个 phase 使用稳定前缀:

pg36-ch11-lock-holder
pg36-ch11-lock-waiter
pg36-ch11-backfill
pg36-ch11-index-build
pg36-ch11-partition-attach
pg36-ch11-partition-detach

这样可以在:

SELECT
    pid,
    backend_start,
    application_name,
    state,
    xact_start,
    query_start,
    wait_event_type,
    wait_event,
    left(query, 300)
FROM pg_stat_activity
WHERE application_name LIKE 'pg36-ch11-%';

区分 phase,保存 live evidence,并让 reset 在 worker 存活时 fail closed。

application_name 是可伪造 label,不是 authentication/authorization。取消 session 前仍要组合:

database + pid + backend_start + role + client + application + query identity

防止 PID reuse 或同名前缀误伤。

隔离流量不等于隔离资源

专用 service/role/application_name 改善 attribution 和权限边界,却仍共享:

  • primary CPU/IO/buffer;
  • WAL 与 replication;
  • disk;
  • autovacuum;
  • lock manager;
  • checkpoint;
  • network;
  • replicas 的 replay。

因此 L1 演练服务不能证明生产容量。若要在生产 shadow/backfill,必须设置实际资源和 SLI 水位。

不要为一次 DDL 临时改平台配置

例如为了让 backfill 更快,随手全局修改:

max_wal_size
checkpoint_timeout
autovacuum
statement_timeout
work_mem
max_parallel_maintenance_workers

会把一个 schema change 变成 schema + config 联合 change,扩大变量和恢复面。优先使用 session/local setting;确需 config change 时,独立变更单、独立 evidence 和独立复位。

11.6.2 观察锁、复制延迟、WAL 与资源水位

从发布 SLI 开始

发布期间先看用户影响:

request success/error rate
p50/p95/p99 latency
timeout/retry rate
queue depth
connection acquisition latency
business invariant errors

再解释数据库侧原因。一个 WAL spike 如果不影响 SLO 且在预算内,可能可接受;一个没有明显 CPU spike 的 lock convoy 也可能让 tail latency 立即恶化。

Pigsty dashboard 路线

Pigsty v4.5 当前文档列出的相关面板:

PGSQL Activity:
  sessions, load, active/idle, locks

PGSQL Xacts:
  transaction rate/time, rollback, lock

PGCAT Locks:
  live activity and lock waits from catalog

PGSQL Query / PGCAT Query:
  affected query family, calls, latency, plan/stat context

PGSQL Persist:
  WAL, checkpoint, archive, IO, XID

PGSQL Replication:
  physical/logical replication, slots, pub/sub

PGSQL Service / Proxy / PgBouncer:
  routing, backend health, queue and pool

PGSQL Instance / NODE dashboards:
  CPU, memory, disk, filesystem, IO, network

名称与布局会升级,以当前 Pigsty Dashboard 文档 为准,不把截图坐标或 panel ID 写进长期 runbook。

锁观察必须落回 exact graph

历史曲线回答:

when did lock waits rise?
how many sessions and how long?
which database/cluster?
did user latency rise together?

live catalog 回答:

SELECT
    waiter.pid AS waiter_pid,
    waiter.backend_start AS waiter_epoch,
    waiter.application_name AS waiter_app,
    waiter.wait_event_type,
    waiter.wait_event,
    blocker.pid AS blocker_pid,
    blocker.backend_start AS blocker_epoch,
    blocker.application_name AS blocker_app,
    blocker.xact_start,
    blocker.state
FROM pg_stat_activity AS waiter
CROSS JOIN LATERAL unnest(
    pg_blocking_pids(waiter.pid)
) AS edge(blocker_pid)
LEFT JOIN pg_stat_activity AS blocker
  ON blocker.pid = edge.blocker_pid;

对 DDL 还要查 relation lock:

SELECT
    activity.application_name,
    lock.relation::regclass,
    lock.mode,
    lock.granted,
    lock.waitstart
FROM pg_locks AS lock
JOIN pg_stat_activity AS activity
  ON activity.pid = lock.pid
WHERE lock.relation IS NOT NULL;

看到 AccessExclusiveLock 不能直接杀 session;先确定 holder、waiter、队列和业务影响。

WAL 观察不是只看一个 counter

模式变更会影响:

WAL generation rate
WAL directory size
archive throughput/failure
replication slot retained WAL
replica receive/replay lag
checkpoint frequency and write pressure
network throughput
backup/PITR window

单节点本章用 pg_current_wal_insert_lsn() 前后差作为相对 A/B;生产需时间序列。counter 应使用 rate/increase,并注意 restart/reset epoch。还要区分:

  • generated WAL;
  • sent/received WAL;
  • replay progress;
  • slot restart_lsn retention;
  • archive queue。

主库 lag 为零不代表 replica 已应用;bytes lag 与 time lag也不能互换。

Replication lag 的停止线

backfill/validation/CREATE INDEX 可让 replica:

  • replay 落后;
  • read query 与 replay 冲突;
  • WAL 堆积占盘;
  • failover 后 RPO/RTO 风险增加。

停止条件应同时绑定:

max replay bytes/time lag
max retained WAL / minimum disk free
replica read SLI
archive success
failover readiness policy

如果发布时 primary failover,默认暂停 change,重新验证:

new primary identity
committed schema phase
checkpoint state
concurrent index/partition intermediate state
worker ownership
application routing

不能假设脚本连接自动漂移后可从上一行继续。

资源水位与 workload attribution

至少保存:

维度 观察 解释
CPU instance/node usage transform/index build CPU
IO read/write latency/throughput scan/rewrite/checkpoint
disk data/WAL/temp free rewrite double space/retention
memory buffer/cache/OS pressure scan cache displacement
connection app/admin/pool queues migration contention
vacuum dead tuples/xmin/age backfill cleanup debt
query user + migration families regression attribution

发布 connection 的 application_name、UTC window 和 query identity 让这些图能与具体 phase 对齐。

指标盲点也是停止条件

如果 exporter、Grafana、logs、application telemetry 任一关键链路不可用:

cannot observe ≠ no impact

对于高风险 rewrite/backfill/contract,应暂停,而不是在盲区继续。监控恢复后先补采 live state,再决定 resume。

11.6.3 配置变更与模式变更分别留证

三种 identity

一次完整发布至少有:

application release:
  image/git SHA, rollout/flag, version population

database migration:
  migration ID, source checksum, phase, schema post-state

platform/config change:
  inventory commit, rendered diff, apply task, runtime value

它们相互引用,但不能共享一个模糊“release-2026-07-29”后失去可归因性。

Database evidence

本章 evidence:

manifest.txt
preflight.txt
setup/expand/validate/switch outputs
lock-attempt.stderr
lock-graph.csv
default-catalog.csv
constraint-before.csv
constraint-after.csv
backfill-interrupted.json
backfill-resumed.json
partition-summary.json
contract-gate.stderr
verify.txt
model-verify-after.txt
review.txt

manifest 保存:

  • UTC capture time;
  • action/service;
  • psql/Python/server version;
  • database/user/recovery;
  • source SHA-256。

它不保存 secret,也不把 dynamic PID/filenode/timing 当 golden。

Pigsty config evidence

如果本次确实改变 pg_services、PostgreSQL 参数或监控配置,另存:

inventory/config source commit
target cluster/instance selector
rendered before/after diff
validation/lint output
exact Ansible tag/command
changed vs restarted instances
pg_settings source/sourcefile/pending_restart
service routing/health postcheck
rollback/reapply command

不要只保存 SHOW parameter。runtime value 不能证明它来自哪个 inventory version,也不能说明 restart 后会不会保持。

反过来,config repo diff 也不能证明 runtime 已生效;两边都要。

Schema migration 不应修改不可变历史

baseline-v0.6-proposal.json 不改写 v0.1 baseline,而是:

base checksum
  + dependency v0.5 checksum
  + SAFE-MIGR-006 statement change
  + DEFAULT-VERS-010 runtime check change
  + ch11 evidence paths
  + promotion conditions

同理,生产 migration 文件一旦发布,不应原地改内容后保留同 migration ID。若修复:

  • 发布新 migration identity;
  • 说明依赖和前置 phase;
  • 保留旧 artifact/checksum;
  • fresh install 继续由同一权威链生成或验证。

Change log 与 evidence package

建议目录:

change-id/
  intent.md
  approvals/
  application/
  database/
  platform/
  observations/
  decision-log.md
  final-review.json

decision log 记录:

timestamp UTC
observer/decision owner
current phase
watermarks
continue/slow/pause/switch/contract
reason and evidence links
next review time

这样事故复盘能回答“当时基于什么证据继续”,而不是只有最后成功/失败。

敏感信息与保留期

SQL、query parameter、client address、logs、application payload 可能包含:

  • customer identifiers;
  • tokens/credentials;
  • financial/personal data;
  • internal topology。

证据包要:

redact secrets and payload
retain stable hashes/IDs when enough
apply access control
declare retention and deletion
preserve chain of custody for incidents

不要为了“完整证据”把 password-bearing URI、PGPASSWORD 或完整用户 payload 写入 artifact。

Final review 的职责边界

自动 review 可以确认:

files exist
checksums/dependencies match
SQLSTATE and catalog relationships
checkpoint monotonicity
data checksums and cleanup

人/发布系统仍要确认:

production SLI acceptable
replication/disk within budget
old consumers truly zero
observation window elapsed
contract authority granted

让工具拒绝它不能证明的 contract,是质量而不是自动化不完整。

本节验收问题

  1. 发布使用哪个 Pigsty service、port、pool/direct path;
  2. 连接后是否再次验证 database/role/primary/schema;
  3. 每个 phase 是否有独立 application_name;
  4. service 隔离与资源隔离是否被正确区分;
  5. user SLI、lock、WAL、lag、disk、pool 是否同窗观察;
  6. lock metric 是否能落回 exact blocker graph;
  7. failover 后是否默认暂停并重新发现 state;
  8. 监控盲点是否是明确停止条件;
  9. application、database、platform identity 是否分离;
  10. config source diff 与 runtime postcheck 是否都有;
  11. migration/source artifacts 是否 checksum 固定且不可变;
  12. evidence 是否脱敏、有权限和保留期;
  13. 自动 review 是否诚实拒绝无法证明的生产事实。

参考资料


上一节:数据回填与流量切换 · 返回本章目录 · 下一节:实战:无中断演进订单模式 · 查看全书目录 · 查看索引中心

11.7 实战:无中断演进订单模式

本节把模式发布做成一条可重复、可中止、可复核的证据链:

target guard
  → legacy fixture
  → physical-risk A/B
  → lock failure with unchanged catalog
  → compatible expand
  → old/new writer matrix
  → concurrent temporary index
  → controlled backfill interruption
  → exact resume
  → validate and SET NOT NULL
  → switch while retaining rollback shape
  → contract refusal
  → partition attach/detach rehearsal
  → model checksum and proposal review

实验不把生产“无中断”承诺缩成一次本地脚本成功。它证明机制和状态关系;真实服务 SLI、复制水位与旧依赖清零仍需在目标 Pigsty 发布窗口完成。

11.7.1 加字段、回填、建约束与新旧版本共存

确认 disposable L1

准备权限受控的 service file:

export PGSERVICEFILE=/absolute/private/path/pg_service.conf
export PGSERVICE=pg36-admin

psql -X -w \
  --dbname='service=pg36-admin application_name=pg36-ch11-preflight' \
  --command="
    SELECT
        current_database(),
        session_user,
        current_user,
        current_setting('server_version'),
        pg_is_in_recovery();
  "

只在已确认可写、可重建的 L1/本地目标继续。本章脚本再次验证:

database=pg36_shop
writable primary
effective role=pg36_owner
search_path=pg_catalog,shop_private
ch04-v1 marker
ch11 object marker

无 service file、错误 database、recovery target、role/path/model 不符都会 fail closed。

全量入口:

cd static/labs/ch11
export PG36_EVIDENCE_DIR="$PWD/evidence/ch11/all-$(date -u +%Y%m%dT%H%M%SZ)"
./task.sh all

可独立运行:

setup | risk | expand | backfill | validate
partition | verify | review | reset | all

riskvalidate 会重建专用 fixture;verify 只检查当前完整状态。

Legacy fixture

setup.sql 建立 50,000 行:

CREATE TABLE shop_private.ch11_order (
    order_id bigint PRIMARY KEY,
    order_ref text NOT NULL UNIQUE,
    shipping_method text NOT NULL,
    created_at timestamptz NOT NULL,
    payload text NOT NULL,
    CONSTRAINT ch11_order_shipping_method_check
      CHECK (
        shipping_method IN
          ('standard', 'express', 'pickup')
      )
);

以及单行 state:

migration_id=shipping-code-v1
phase=legacy
source_rows=50,000
target_rows=NULL
rows_migrated=0
batches=0
last_order_id=0

shipping_code 必须不存在。setup 只在所有已存在同名对象 marker 匹配时才重建,防止误删用户对象。

先测 default/rewrite 风险

default-probe.sql 对另一张 50,000 行 disposable table 做 A/B:

ADD fast_flag integer NOT NULL DEFAULT 7
ADD volatile_stamp timestamptz
    NOT NULL DEFAULT clock_timestamp()

一次 raw result:

fast:
  before_filenode=20116
  after_filenode=20116
  atthasmissing=true
  attmissingval={7}
  WAL=12088

volatile:
  after_filenode=20129
  atthasmissing=false
  WAL=11488328

review 只要求:

before_filenode == fast_filenode
fast_filenode != volatile_filenode
fast atthasmissing
not volatile atthasmissing
volatile WAL > fast WAL
row_count=50,000

具体 OID/WAL 随环境变化。

Expand 前先让锁预算失败

run_lock_case.py

holder:
  ACCESS SHARE on ch11_order

waiter:
  ADD COLUMN shipping_code text
  requests ACCESS EXCLUSIVE
  lock_timeout=4s

observer:
  one pg_blocking_pids edge

结果:

lock=55P03
requested=AccessExclusiveLock
blocker_edges=1
shipping_code remains absent
holder released by COMMIT

只有这条 negative path 通过,才执行 expand.sql

phase legacy required
ADD nullable shipping_code
install bridge function/trigger
ADD pair CHECK NOT VALID
phase=expanded in same transaction

这验证 timeout 后可安全重新评估并重试,而不是声称所有 55P03 都可以自动循环。

旧/新版本共存

compatibility.sql 运行四条合同:

old insert:
  method=standard, code omitted
  → standard/STD

old update:
  existing legacy row method express
  → express/EXP

new insert:
  method=pickup, code=PUP
  → pickup/PUP

bad dual write:
  method=express, code=STD
  → 23514
  → constraint=ch11_order_shipping_pair_consistent
  → row absent

此时:

total rows=50,002
legacy NULLs=49,999
pair constraint convalidated=false
attnotnull=false

其中一条旧行已经被 old update 顺带填充,两个新 insert 都由 bridge/dual write 提供 code,所以 backfill target 不是 50,000,而是 49,999。审查器固定这个关系,防止统计口径漂移。

Temporary partial index

online-index.sh 在 transaction block 外:

CREATE INDEX CONCURRENTLY
    ch11_order_shipping_missing_idx
ON shop_private.ch11_order (order_id)
WHERE shipping_code IS NULL;

build 后验证:

indisready=true
indisvalid=true
indpred=(shipping_code IS NULL)
indrelid=exact ch11_order

backfill 完成并验证后,再 exact:

DROP INDEX CONCURRENTLY
    shop_private.ch11_order_shipping_missing_idx;

最终 verify 要求该 migration-only index 不存在。

Backfill 与约束收紧

第一次:

batch size=5,000
max batches=2
exit=75
rows migrated=10,000
remaining=39,999
phase=backfilling

续跑:

eight more batches
total migrated=49,999
total batches=10
last_order_id=50,000
remaining=0
mismatches=0
phase=migrated

validate.sql

ALTER TABLE ch11_order
VALIDATE CONSTRAINT
    ch11_order_shipping_pair_consistent;

ALTER TABLE ch11_order
ADD CONSTRAINT ch11_order_shipping_code_nn
CHECK (shipping_code IS NOT NULL)
NOT VALID;

ALTER TABLE ch11_order
VALIDATE CONSTRAINT
    ch11_order_shipping_code_nn;

ALTER TABLE ch11_order
ALTER COLUMN shipping_code SET NOT NULL;

PostgreSQL 18.6 目录:

ch11_order_shipping_pair_consistent | c | valid
ch11_order_shipping_code_nn         | c | valid
ch11_order_shipping_code_not_null   | n | valid
pg_attribute.shipping_code          | attnotnull=true

SET NOT NULL 前后 relfilenode 相同。PG14–17 review 分支不要求 relation contype=n

Switch 保留回退形状

switch.sql 先做 full shadow comparison,再分别运行一个 old/new writer:

shadow mismatches=0
old writer after switch succeeds
new writer remains dual-write
phase=switched
shipping_method retained
bridge retained

最终总行数:

50,000 baseline
+2 coexistence
+2 switch probes
=50,004

读取可以切到 shipping_code,但数据库继续支持旧 application rollback。

11.7.2 注入锁等待和回填中断,验证中止与恢复

Evidence 不是最终一句 PASS

一次全量目录:

manifest.txt
preflight.txt
setup.txt

default-probe.txt
default-catalog.csv

lock-holder.stdout/stderr
lock-attempt.stdout/stderr
lock-graph.csv
lock-summary.json

expand.txt
index-build.txt
compatibility.txt
constraint-before.csv

backfill-interrupted.json
backfill-resumed.json

validate.txt
constraint-after.csv
index-drop.txt
switch.txt

contract-gate.stdout/stderr/exit

partition-prepare.txt
partition-attach-holder.txt
partition-detach.txt
partition-summary.json

verify.txt
model-verify-after.txt
review.txt

raw evidence 保存动态值,review 比较稳定关系。

锁注入的 happens-before

协调器不是用 sleep 1 猜时序:

  1. interactive holder 完成 LOCK ... ACCESS SHARE
  2. holder 输出 readiness marker;
  3. 才启动 DDL waiter;
  4. observer 循环直到捕获 exact blocker edge;
  5. waiter 以 55P03 结束;
  6. 检查 column count=0;
  7. 向 holder 发送 COMMIT,不是强杀 backend。

这固定关键顺序而不固定 PID/时间。异常退出时 finally 只处理自己启动的 child process,并尝试 rollback。

回填中止的原子边界

--max-batches=2 在第二次 commit 后由 client 主动退出,模拟:

  • maintenance window 到达停止线;
  • safety waterline 要求暂停;
  • orchestrator 有计划让出资源。

它证明 between-batch restart。每个 batch 内:

target updates
checkpoint updates
phase updates

同事务,因此 disconnect/error 时一起 rollback。

审查器比较:

resume.start.last_order_id
  == interrupted.end.last_order_id

resume.end.rows_migrated
  == target_rows

resume.end.remaining_nulls
  == 0

resume.end.last_order_id
  == 50,000

还要求 mismatches=0 与 total batches=10,不能仅看 phase 字符串。

Contract 是故意失败的验收项

全量流程运行:

psql --file=contract-gate.sql

但不提供 token/target/observation。期望:

exit=3
SQLSTATE=P3612
message=contract refused

随后 final verify 要求:

release=switched/contract:not-executed
compatibility=legacy-column+bridge-retained

如果 contract 意外成功,verify.sql 会因 old column/bridge 缺失而失败。负向 gate 与正向 post-state 双重闭合。

分区实验的可逆边界

partition_lab.py

prepare standalone child + valid CHECK
  → ATTACH inside held transaction
  → observer captures SUE + AX
  → COMMIT
  → DETACH CONCURRENTLY in top-level command
  → verify rows and filenode retained
  → reattach

一次结果:

server_version_num=180006
before child rows=20,000 / parent=0
after attach child is partition / parent=20,000
after detach child standalone / rows=20,000 / parent=0
final reattached / parent=20,000
same child filenode across all states

elapsed ms 被记录,但不参与 PASS。

最终数据库验收

verify.sql 要求:

active pg36-ch11 workers=0
release phase=switched
checkpoint=49,999 rows / 10 batches / last id 50,000
orders=50,004
NULL/mismatch=0
pair + nn CHECK valid
attnotnull=true
old column + bridge present
temporary index absent
default A/B relationship intact
partition attached / 20,000 rows

再运行 ch05 model verify:

relation_checksum=
  f8a7bfae59c6d16cd323abecfefe1014

证明专用实验没有改变 shop.* 业务基线。

Reset 与负向安全

all 保留 fixture 供人工复核。删除属于 R2:

export PG36_RESET_TOKEN=RESET_CH11_RELEASE_LAB
export PG36_RESET_TARGET=pg36_shop/shop_private/ch11
./task.sh reset

reset 前要求:

  • action token exact;
  • database/schema/chapter target exact;
  • 所有同名 relation/function marker exact;
  • 没有活跃 pg36-ch11-* worker。

成功:

status=ok
reset_target=pg36_shop/shop_private/ch11
remaining_ch11_relations=0
remaining_ch11_functions=0
business checksum unchanged

错误 token、错误 target 或 active worker 必须 psql exit=3 且 state 保持。

11.7.3 把发布证据与新规则追加到规约

Review summary

一次 PostgreSQL 18.6 审查:

status=ok
risk=lock:55P03/schema-unchanged/
     default:metadata-vs-rewrite
wal=fast:12088/volatile:11488328
compatibility=old+new/mismatch:23514/contract:P3612
backfill=interrupt:2x5000/
         resume:49999-in-10/remaining:0
constraints=not-valid->valid->not-null/
            catalog:pg18-pg_constraint+pg_attribute
partition=attach:SUE+AX/
          detach-concurrently:180006/
          retained:20000
proposal=0.1.0->0.6.0/
         SAFE-MIGR-006+DEFAULT-VERS-010/
         depends-on-v0.5
final=switched/contract:not-executed/
      checksum:f8a7bfae59c6d16cd323abecfefe1014

WAL 数字仅为该次观测;关系和规则才是 golden。

SAFE-MIGR-006 的 v0.6 change

baseline-v0.6-proposal.json 提议把原 statement:

类型收窄、列删除、表重写或约束收紧前完成
precheck、timeout、兼容、恢复和 post-state;
contract 与 application rollback 共同设计。

收紧为:

跨应用版本 DDL 必须划分:
  expand / migrate / validate / switch / contract

执行前验证 target,并声明:
  lock + statement timeout
  compatibility window
  scan/rewrite/WAL/replica-lag budget
  batch and stop conditions
  restart semantics
  post-state

contract only after:
  old readers/writers zero
  real observation evidence

lost semantics:
  forward repair or restore
  never an empty-shell down migration

这不是把所有小 DDL bureaucratize。scope 是跨 application version 或 destructive/high-impact change;低风险变更仍按实际风险分级。

DEFAULT-VERS-010 的 runtime check

statement 不改,新增 check:

every migration phase has:
  stable migration identity
  monotonic queryable state

interruption:
  resumes from committed checkpoint
  cannot skip unresolved data

本章证据:

  • phase 与 DDL 同事务;
  • checkpoint 与 batch 同事务;
  • two-batch interruption;
  • exact resume;
  • zero unresolved before watermark;
  • final catalog/data checksum。

Proposal 依赖链

v0.6 保存:

immutable v0.1 canonical checksum
v0.5 proposal canonical checksum
rule_changes
evidence paths
promotion conditions

review.py 每次重算 canonical JSON checksum。依赖 artifact 改动后 checksum 不匹配,v0.6 fail;不能静默引用一个同名但内容已变的 proposal。

它仍是 candidate。晋升条件:

  1. ch11 suite 在 PG14–18 compatibility matrix 通过;
  2. PG18 NOT NULL catalog 分支与 PG14–17 分支都实测;
  3. 至少一个真实 Pigsty HA 发布窗口保存 lock/WAL/lag/disk/tail evidence;
  4. application owner 证明旧 read/write 清零;
  5. 约定 rollback observation window 真实经过;
  6. v0.2–v0.5 依赖先晋升;
  7. 再发布不可变 baseline v0.6 artifact。

本地 PASS 不冒充这些条件已经成立。

从实验模板迁移到真实 change

替换 fixture 时,不要只改 table name。必须重新设计:

domain mapping and reversibility
old/new application versions
row ordering and partitioning
batch size and watermarks
temporary/permanent indexes
constraint identity
Pigsty service/role
replica and disk thresholds
switch/rollback mechanism
consumer inventory
contract evidence and authority

保留 harness 的结构:

context guard
negative lock case
compatibility matrix
controlled interruption
catalog before/after
final checksum/invariants
safe reset for disposable rehearsals
candidate rule evidence

本章最终交付

本章完成后,读者不应只会写:

ALTER TABLE ...;

而应能提交一个 change package:

intent + risk classification
version compatibility matrix
state machine
SQL/migration artifacts
backfill/checkpoint code
Pigsty observation plan
stop/rollback/forward-repair decisions
raw evidence + automated review
contract gate
post-state and governance proposal

这才是“安全发布”可复用的工程能力。

本节验收问题

  1. 全量任务能否从 clean fixture 重复运行;
  2. default A/B 是否保存 physical 与 WAL 关系;
  3. lock case 是否有 exact edge、55P03 与 unchanged catalog;
  4. old/new/mismatch compatibility cases 是否全部通过;
  5. temporary index 是否事务块外 build/drop;
  6. interrupted/resumed JSON 的 checkpoint 是否连续;
  7. constraints before/after 与 PG version branch 是否正确;
  8. switch 后 old rollback shape 是否仍在;
  9. contract 是否因缺证以 P3612 拒绝;
  10. partition ATTACH locks 和 DETACH boundary 是否实测;
  11. worker、temporary object 与 business checksum 是否闭合;
  12. reset 是否双 token、marker、active-worker 三重保护;
  13. v0.6 checksum/dependency/rule IDs 是否由 review 验证;
  14. candidate 是否诚实保留生产 promotion conditions。

参考资料


上一节:发布窗口中的平台观察 · 返回本章目录 · 下一章:一气呵成:从数据库契约到后端服务 · 查看全书目录 · 查看索引中心