守正出奇:模式变更与安全发布
11 守正出奇:模式变更与安全发布
PostgreSQL 能在事务中执行许多 DDL,但“能回滚”不等于“能在线发布”。一次模式变更能否安全进入生产,至少同时取决于:
所以本章不把 ALTER TABLE 写成命令速查。我们把变更拆成一个有状态、有停止线、有证据的发布协议:
任何箭头失败,都先问“当前已提交状态是什么、服务是否仍兼容、下一步是重试、暂停、回退流量还是前滚修复”,而不是下意识运行一份机械 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-006与DEFAULT-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.* 业务表:
订单发布的最终自动验收故意停在:
这是设计结果,不是“少做一步”。本地脚本能证明目录、数据和 SQLSTATE,不能证明生产中的旧容器、离线作业、BI 查询和临时脚本已经退出,也不能把几秒钟等待冒充约定的回退观察期。
下载资产:
- 实验合同
- 上下文 guard
- legacy fixture
- 默认值 fast path / rewrite A/B
- 默认值目录证据
- 锁预算失败语句
- 锁图协调器
- 兼容扩展 DDL
- 事务块外并发索引入口
- 旧/新应用兼容反例
- 可续跑回填器
- 约束目录快照
- 验证与非空收紧
- 流量切换探针
- contract 拒绝门
- 分区候选准备
- 分区锁与并发摘除实验
- 最终状态验收
- 双 token reset
- 证据审查器
- v0.6 规则提案
- 任务入口
本章目录
11.1 识别 DDL 的四类风险
11.2 Expand–Migrate–Contract
11.3 索引与约束的在线化路径
- 11.3.1
CREATE INDEX CONCURRENTLY的阶段与失败残留 - 11.3.2
NOT VALID、VALIDATE CONSTRAINT与验证扫描 - 11.3.3 默认值、非空与类型变更的版本边界
11.4 在线分区化
- 11.4.1 从 ch04 的分区 ADR 选择迁移路径
- 11.4.2 预建约束、
ATTACH与扫描规避 - 11.4.3
DETACH、并发能力与锁等级必须按版本说明 - 11.4.4 产出一次可回退的分区化发布
11.5 数据回填与流量切换
11.6 发布窗口中的平台观察
11.7 实战:无中断演进订单模式
实测摘要
一次 PostgreSQL 18.6 全量验收得到:
WAL 字节、filenode OID、PID、毫秒和具体 lock backend 都不是跨环境 golden。稳定结论是 fast/rewrite 的相对关系、锁边、SQLSTATE、单调状态、批次原子性、零遗漏、数据保留和最终兼容边界。
章节验收
- 变更说明同时回答 lock、scan/rewrite、WAL/space/lag 与兼容性;
lock_timeout小于发布允许排队时间,statement_timeout留出执行预算;- 55P03 被当作“未取得发布条件”,不是盲目无限重试;
- constant/volatile default 的物理差异有目录与 WAL 证据;
- expand 对所有仍受支持的旧版本保持可读写;
- 双写只有一个一致性 authority,并有命名约束反例;
- backfill 使用有界 keyset batch,不用巨型 OFFSET;
- 数据更新与 checkpoint 同事务提交;
- 受控中止能从精确水位继续,不能跳过低 key 遗留行;
NOT VALID不被误写成“暂不执行约束”;VALIDATE CONSTRAINT的扫描和 lock budget 单独规划;- PostgreSQL 18 的关系级
NOT NULLcatalog 差异已隔离; - concurrent index 命令位于事务块外,失败残留有 exact cleanup;
ATTACH候选列定义完全匹配父表且 bound CHECK 已验证;- default partition 和 subpartition 的额外扫描边界已评审;
DETACH CONCURRENTLY只在 PostgreSQL 14+ 且事务块外使用;- Pigsty 发布连接、应用流量和只读分析入口职责分离;
- 锁、WAL、replication lag、disk、connection 与 SLI 同窗观察;
- application deployment 与 database migration 各有独立 identity;
- contract 需要旧读写依赖清零与真实观察期证据;
- reset 的错误 token、错误 target 和 active-worker 反例全部拒绝;
- 最终业务 checksum 不变,v0.6 仍是 candidate 而非已发布 baseline。
下一章 ch12《一气呵成:从数据库契约到后端服务》 将从数据库状态机继续走向 driver、连接池、应用发布和端到端验收。
参考资料
- PostgreSQL 18:ALTER TABLE
- PostgreSQL 18:Modifying Tables
- PostgreSQL 18:Table Partitioning
- PostgreSQL 18 Release Notes:NOT NULL constraints
- PostgreSQL 14 Release Notes:DETACH PARTITION CONCURRENTLY
- Pigsty:PostgreSQL Service
- Pigsty:PostgreSQL Dashboards
上一章:顾此失彼:并发控制与隔离异常 · 返回上卷导读 · 下一章:一气呵成:从数据库契约到后端服务 · 查看全书目录 · 查看索引中心
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
11.2 Expand–Migrate–Contract
Expand–Migrate–Contract 的价值不在三个英文词,而在于它强迫团队承认:数据库 schema 与 application fleet 不能原子切换。
本书把它细化为六个可查询阶段:
每个阶段都要定义入口条件、允许的读写版本、完成证据、失败语义与下一步。阶段名不是 deployment 日志的一行字符串,而是 release protocol。
11.2.1 先扩展兼容结构
Expand 的判定标准
一个 expand 变更应满足:
常见 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。
本章的兼容扩展
初始表只有:
expand 事务做:
这三部分承担不同职责:
| 组件 | 职责 |
|---|---|
| nullable new column | 让历史行暂时可表示 |
| bridge trigger | 让旧 writer 不再制造 NULL,并集中映射规则 |
| pair CHECK NOT VALID | 拒绝新/更新行的表示不一致,不扫描旧行 |
NOT VALID 不是“约束关闭”。约束加入后,新插入或更新的行仍被检查;只有已有行暂时没做全表验证。
bridge 必须是临时且单一的 authority
本例映射:
旧 application 只提供 shipping_method,trigger 派生 code。新 application 在共存期 dual-write;若 pair 不一致,trigger/constraint 以命名 23514 拒绝。
为什么不让 application 连续执行:
因为两个 statement 之间可能:
- transaction 被取消;
- 进程断连;
- 第二条被 retry/skip;
- 另一个 writer 介入;
- 第一条提交而第二条未提交。
若两条在同一 transaction,可以保证原子性,但仍存在多个 application 实现映射漂移的问题。短期 database bridge 把映射收敛为一个 authority;长期则应收缩回一个 canonical representation,避免永久双写。
Expand 也需要版本 identity
本章 state row:
expand 在同一事务中:
若 DDL rollback,phase 也 rollback。下一次执行会先检查:
- migration identity 是否正确;
- 当前 phase 是否恰为
legacy; - new column 是否确实不存在;
- table marker 是否匹配。
它选择“前置拒绝”而不是无条件 IF NOT EXISTS。IF NOT EXISTS 只能证明同名对象存在,不能证明 type、default、owner、constraint 与 function body 是期望版本。
11.2.2 分批迁移、双读校验与切换
Migrate 不等于一条 UPDATE
迁移阶段包含三个并行事实:
若只做 backfill,而旧 writer 继续写 NULL,remaining count 永远追不上;若只保护新写入,却不做 shadow comparison,可能把错误映射完整填满全表。
本章的次序:
双读不是向用户返回两个结果
shadow read 的基本结构:
它要回答:
- 全量还是采样;
- 采样是否覆盖 tenant/value/time buckets;
- 如何归一化 NULL、时区、排序、rounding;
- mismatch 是否含敏感数据;
- 谁处理 mismatch;
- mismatch=0 要持续多久;
- shadow query 的额外负载预算。
本例可以在数据库内做精确比较:
IS DISTINCT FROM 让 NULL 也进入确定的相等语义。生产业务的等价关系可能跨服务或包含版本化规则,不能只比较文本。
先切写还是先切读
常见安全次序是:
“先切写再切读”让 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 的改变。它需要自己的:
不要用 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 的入口条件至少包括:
“观察一周”必须可验证
时间长度本身不够。需要观测对象:
如果没有区分旧/新路径的 telemetry,“观察一周无报警”不能证明旧依赖为零。
同时要考虑低频 consumer。一个月只跑一次的财务作业不会在七天窗口出现;依赖 inventory 和 owner 确认仍不可省略。
本地 suite 为什么拒绝 contract
contract-gate.sql 要求三个独立输入:
task.sh all 故意不提供。稳定结果:
这样全自动 CI 不会因为“测试跑完”而获得删除旧语义的权力。若有人在 disposable fixture 上显式满足 gate,可以演练真正 DROP;生产审批、证据和权限仍是另一条边界。
Contract 后没有免费回滚
删除旧列之后:
往往已不再可行。可选恢复:
- 前滚部署兼容修复;
- 从 new representation 重建 old 值(仅当转换可逆且规则仍在);
- 从外部权威源 reconciliation;
- 从 backup/PITR 恢复到另一个环境并提取数据;
- 全库恢复,接受明确 RPO/RTO 与其他数据影响。
所以 contract 是 destructive semantic change,即使 DROP COLUMN 物理上很快。
状态机的单调性
本章不提供 phase=validated → phase=expanded 的数据库 down path。回退流量时:
这种“应用回退、数据库不倒退”通常比反向 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 |
阶段必须由事实推动,不由“脚本跑到了第几行”推动。
本节验收问题
- expand 是否对最旧受支持 writer/readers 真正兼容;
- new write protection 是否在 backfill 前建立;
- mapping/dual-write 是否只有一个一致性 authority;
- migration identity 与 phase 是否同 DDL 原子提交;
- shadow comparison 的语义、采样和 owner 是否明确;
- switch 的 application release identity 是否独立留证;
- rollback 是流量回退还是数据库反向 DDL;
- contract 是否是独立发布、独立授权和独立窗口;
- 低频/offline consumer 是否进入依赖清单;
- contract 后丢失语义时是否诚实声明 forward repair/restore。
Expand–Migrate–Contract 不是让发布变慢;它是把原本隐含、同时发生且不可诊断的风险,拆成可以停止和验证的阶段。
参考资料
上一节:识别 DDL 的四类风险 · 返回本章目录 · 下一节:索引与约束的在线化路径 · 查看全书目录 · 查看索引中心
11.3 索引与约束的在线化路径
索引和约束的“在线化”不是无锁,而是把:
拆到不同阶段,缩短最强锁的持续时间,并让失败状态可识别。每种对象支持的拆分方式不同;不能把 NOT VALID、CONCURRENTLY 和 USING 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 仍可能失败;
- 失败可能留下
INVALIDindex。
所以 CONCURRENTLY 的意思是“降低对普通写的阻塞”,不是“免费后台任务”。
先复用第 9 章的候选纪律
模式发布中的 index 也必须先回答:
本章不重复第 9 章的全套收益评估,只把一个 temporary partial index 应用于回填:
它绑定:
随着 backfill 完成,predicate 集合缩到零;它是 migration acceleration object,不是永久 schema。
为什么必须是独立入口
online-index.sh 让 psql 在同一 session 中依次执行:
每个 --command 是独立 top-level command,避免把 concurrent build 塞进 implicit multi-statement transaction。下面写法会失败:
migration framework 如果默认“每个 migration 自动包事务”,需要为这类命令声明 non-transactional phase;不能偷偷关闭整套 framework 的事务保护。
失败后查 catalog,不按文件名猜
失败的 concurrent index:
- 可能仍占磁盘;
- 可能给写入带来维护开销;
- 若是 unique build,某些阶段甚至可能开始施加 uniqueness;
- 不会被 planner 当作正常 valid index。
处理顺序:
不要运行模糊 DROP INDEX IF EXISTS some_name 后声称“已清理”;同名跨 schema、错误定义和并发新建都需要防护。
从 unique index 快速接成约束
对非分区普通表,可以先:
验证 valid 后:
第二步通常是短 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 VALID、VALIDATE CONSTRAINT 与验证扫描
NOT VALID 的精确定义
对支持的 CHECK/FK(PostgreSQL 18 还扩展到关系级 NOT NULL),ADD ... NOT VALID:
它不是:
本章 expand 后:
旧行可以 shipping_code IS NULL,但新/更新行不能产生错误 pair。
为什么 validation 能与 DML 共存
PostgreSQL 扫描旧行时,新/更新行已经由 constraint enforcement 保护,因此 validation 使用 SHARE UPDATE EXCLUSIVE,不需要像直接 ADD valid constraint 那样长期阻止普通更新。
这仍然是全表读取:
- 会消耗 IO/buffer/CPU;
- 与某些 DDL、VACUUM family 操作冲突;
- 可能造成 replica/存储侧压力;
- 遇到历史坏值会失败;
- 需要单独
statement_timeout与发布水位。
convalidated=true 是完成证据;“命令返回成功”之外还应保存:
非空的跨版本路径
PostgreSQL 14–17 的通用做法:
当一个 valid CHECK 已经证明列无 NULL,SET NOT NULL 可以避免再做一次全表扫描;执行时让该 CHECK 保持存在。
本章实测:
same filenode 说明没有 table rewrite;官方保证与 valid CHECK 共同支持“无需重复验证扫描”的判断。它仍需要短时强锁,不能省略 lock budget。
PostgreSQL 18 的目录差异
PostgreSQL 17 及以前,relation column 的 NOT NULL 主要表示在:
pg_constraint 中 contype='n' 主要用于 domain。PostgreSQL 18 把 relation NOT NULL 也提升为完整 constraint:
本章 PG18.6 验收同时看到:
因此跨 14–18 的 catalog checker 必须 version-gate:
不要因为 PG18 新语法支持 NOT NULL NOT VALID 就把它直接写进声明支持 PG14–18 的无条件 migration。通用主体仍可使用 CHECK → VALIDATE → SET NOT NULL。
NOT VALID 失败恢复
若 validation 发现坏值:
这通常比直接 ADD valid constraint 后整个发布卡住更可控。修复流程:
- 保存 violation query 与 stable row identity;
- 暂停或限速 backfill;
- 修复历史数据;
- 再次验证 zero violations;
- 重跑
VALIDATE CONSTRAINT; - 查
convalidated; - 才进入 switch。
不应为了让 validation 通过而随手 drop constraint;那会重新打开新债务入口。
11.3.3 默认值、非空与类型变更的版本边界
默认值按 volatility 与目标版本判断
对于:
先回答:
non-volatile constant fast path 在当前支持范围内可用,但强锁仍在。volatile expression 会逐行更新;不同 extension/function 还要确认 volatility declaration 是否真实,不能为追求 fast path 把非 immutable 函数伪装成 immutable。
default 与 NOT NULL 的组合
新增:
对 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 例子:
真正 migration 仍要处理 precheck 与执行之间的竞态:
- 先加兼容 CHECK 约束;
- 暂停旧 writer;
- 在同一受控 transaction 再检查;
- 或把所有写引到能验证新范围的路径。
还要检查:
成功后重新 ANALYZE,并验证 query plans 与 driver contract;不能只比较 information_schema.columns。
版本矩阵写进 artifact
每个 migration repository 应维护至少:
本章:
| 能力 | 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 运行后根据结果猜兼容。
本节验收问题
- concurrent index 是否真正位于 transaction block 外;
- build 前后的 query、write、size/WAL 证据是否完整;
- failure 是否检查
indisvalid/indisready并精确清理; - partial migration index 是否有明确 drop phase;
NOT VALID是否被正确解释为“新写入已执行”;- validation scan 的 IO、lock 与 timeout 是否独立预算;
- CHECK → VALIDATE → SET NOT NULL 次序是否跨 PG14–18;
- PG18 relation NOT NULL catalog 是否 version-gated;
- default 的 volatility 与历史语义是否都评审;
- ALTER TYPE 是否检查数据、依赖、driver 和 statistics;
- catalog fast path 是否被误写成“零锁零风险”;
- dynamic cost/timing 是否只作观测,不作跨环境常数。
参考资料
- PostgreSQL 18:CREATE INDEX
- PostgreSQL 18:ALTER TABLE
- PostgreSQL 18 Release Notes
- PostgreSQL 17:pg_constraint
- PostgreSQL 18:pg_constraint
上一节:Expand–Migrate–Contract · 返回本章目录 · 下一节:在线分区化 · 查看全书目录 · 查看索引中心
11.4 在线分区化
本书对分区采用渐进式教学:
本节不推翻 ch04 对订单模型“暂不分区”的 ADR。我们使用独立事件 fixture,学习当证据门已经通过后,如何把预装数据表挂入/摘出分区层次,并把 lock、scan、version 和 recovery 语义说清楚。
11.4.1 从 ch04 的分区 ADR 选择迁移路径
先确认旧决定为什么失效
ch04 分区决策门 要求至少出现一类真实证据:
- retention/归档需要按整批数据处理;
- relation/index 规模已形成可测瓶颈;
- 代表性查询能稳定携带 partition key,并证明 pruning 收益;
- 热冷分层、备份或维护窗口需要独立 physical units。
进入 ch11 时,不应把“学会 ATTACH”误解为“现在应该分区”。先更新 ADR:
如果新证据仍不足,正确结论可以继续是 not-now。
Regular table 不能原地变成 partitioned table
迁移通常需要新结构。常见路径:
路径 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 不重复。
如果旧合同是:
按 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:
独立候选 ch11_event_2025q1 有 20,000 行。数据都落在:
这个简化让实验精确关注 attach/detach;它不是完整生产事件模型。
11.4.2 预建约束、ATTACH 与扫描规避
ATTACH 的两种验证路径
命令:
PostgreSQL 必须证明所有 child row 满足隐含 partition bound。若没有可用证明,它会在持有 child ACCESS EXCLUSIVE 时扫描该表。
预先建立并验证匹配 CHECK:
官方文档建议这样让系统跳过 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 文档一致:
预验证 CHECK 降低的是持有 child 强锁期间的扫描工作,不改变 child 需要强锁这一事实。若 child 仍被报表长读,ATTACH 仍可能等锁或阻塞。
本地证据能证明到哪里
实验保存:
relfilenode 未变说明数据没有 copy/rewrite,但不能单独证明“没有 validation scan”。“匹配 valid CHECK 可避免扫描”来自官方语义;运行证据证明我们确实提供了该 CHECK 和目标 bound。
不要用一个极快 elapsed time 声称 scan 被证明跳过:数据可能在 cache 中、表可能太小、计时噪声也可能掩盖差异。
Default partition 的隐藏扫描
如果 parent 已有 DEFAULT partition,添加一个新显式 range 时,PostgreSQL 还必须证明 DEFAULT 中没有属于新 range 的行。没有排除性 CHECK 时会:
若 DEFAULT 自身 partitioned,会递归检查。发布计划要么:
- 预先为 DEFAULT 添加排除新 range 的 valid CHECK;
- 先迁出该 range 的行;
- 在可接受窗口完成扫描;
- 重新设计是否需要 DEFAULT。
只优化待 attach child 而忘记 DEFAULT,是常见的“测试环境快、生产突然锁住”原因。
Partitioned index 的在线路径不同
不能直接对 partitioned parent 使用:
官方推荐的渐进路径:
每个 child index 要验证定义、opclass、collation、validity 与 ownership。对 unique/PK 还要满足 partition key 规则。生产实施还要把这套检查扩展到全部现存分区,并验证新增分区不会漏建索引。
11.4.3 DETACH、并发能力与锁等级必须按版本说明
普通 DETACH 与 CONCURRENTLY
普通形式对 parent 请求 ACCESS EXCLUSIVE。PostgreSQL 14 引入:
并发形式对 parent 使用较低的 SHARE UPDATE EXCLUSIVE,内部有两个 transaction:
- parent 与 child 取得
SHARE UPDATE EXCLUSIVE,标记正在 detach 并 commit; - 等待使用该 partitioned table 的旧 transaction 结束;
- 再取得 parent
SHARE UPDATE EXCLUSIVE和 childACCESS EXCLUSIVE; - 完成 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:
它们在同一 session 执行,但 DETACH 不在显式或 implicit multi-statement transaction 内。
版本事实不能倒推
本书支持 PG14–18,因此该语法在主体矩阵中可用。若维护 PG13:
不能运行时 fallback 成普通 DETACH 后仍报告相同 lock semantics。应:
- 明确跳过并报告 unsupported;
- 选择有维护窗的 ordinary detach;
- 或先升级目标 major。
本章 manifest 保存 server_version_num=180006,review 要求 >=140000;它不把 18.6 的 timing 当成 PG14–17 保证。
11.4.4 产出一次可回退的分区化发布
完整演练
实验状态:
这里“回退”是结构性 reattach,因为 detach 后没有允许 child 与 parent 的写路径发生分叉。若 detach 后:
- child 单独接收写入;
- parent 在同一 bound 又建立新 partition;
- constraint 被修改;
- indexes/privileges/schema 发生漂移;
reattach 就不再是机械 undo,需要 reconciliation 和新的 lock/scan 评审。
发布 runbook
生产 attach 前:
detach 前:
Detach 不等于删除
DETACH 保留 table 和数据,适合:
- 归档前
COPY/backup; - 低频报表;
- 数据压缩/汇总;
- 独立验证;
- 在明确边界下重新 attach。
DROP TABLE partition 则删除数据对象。不要在同一个“一键 retention”里把 detach、archive verification 和 drop 混成无法暂停的动作。
API 不应暴露 child identity
应用继续访问 logical parent:
不要让业务 URL、job payload 或 ORM model 绑定 event_2025q1。否则每次 attach/detach 都变成 application contract 变更,物理生命周期无法独立演进。
本节验收问题
- ch04 ADR 的进入门是否真的被新证据触发;
- partition key 是否同时满足 lifecycle、query、NULL 与稳定性;
- global unique/PK/FK 语义是否重新设计;
- regular→partitioned 是否有新结构和切换路径;
- candidate child column shape 是否与 parent 完全一致;
- matching bound CHECK 是否 valid 且可被系统证明;
- ATTACH 的 parent/child/default locks 是否按目标版本写明;
- DEFAULT partition 的排除扫描是否处理;
- partitioned index 是否使用 per-child concurrent build/attach;
- DETACH CONCURRENTLY 是否 PG14+ 且位于 transaction block 外;
- pending detach/FINALIZE 与长 transaction 是否进入故障预案;
- detach 后写入是否可能让 reattach 失去可逆性;
- archive verification 与 destructive drop 是否分开授权;
- 应用是否只依赖 logical parent。
参考资料
- PostgreSQL 18:Table Partitioning
- PostgreSQL 18:ALTER TABLE ATTACH/DETACH
- PostgreSQL 14 Release Notes:Concurrent Detach
上一节:索引与约束的在线化路径 · 返回本章目录 · 下一节:数据回填与流量切换 · 查看全书目录 · 查看索引中心
11.5 数据回填与流量切换
在线模式发布中,DDL 往往只占几秒,回填和流量切换却持续数小时或数天。真正的控制回路是:
批量工作不能只追求“尽快跑完”;它要让前台服务始终优先,并让任意停止点都可解释。
11.5.1 批次、限速、水位与断点续跑
先选择稳定遍历方式
不要对不断变化的大表使用:
OFFSET 会反复扫描/跳过前缀,成本随进度增长;并发 insert/delete 还可能让页边界移动。优先 keyset:
ordering key 应:
- 稳定不变;
- 有确定顺序;
- 可索引;
- 能表达连续完成前缀;
- 不因分区切换或业务更新而重排。
若没有单调 key,可以使用 range table、work queue 或显式 claim records;不要用 ctid 作为跨 transaction checkpoint,因为 UPDATE/VACUUM/rewrites 会改变它。
一批的事务边界
本章每批:
关键不变量:
如果先提交数据、后写外部 checkpoint,进程可能重复处理;如果先推进 checkpoint、后提交数据,可能永久跳过。将 checkpoint 放在同一 PostgreSQL transaction 最简单。
跨系统 backfill 无法原子提交时,需要 idempotent sink、outbox/inbox、source offset 与 reconciliation,而不是假装有单库事务。
Batch size 不是常数
5000 只是本地 fixture 的演示值。生产 batch 由目标约束:
可采用反馈式控制:
不要在 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 只能是动态估计:
如果 workload、row width、cache 或 replication 变化,早期吞吐不能外推全程。
SKIP LOCKED 的 checkpoint 陷阱
假设:
下次 id > 51,row 20 永久遗漏。解决方式:
- 不跳锁,短
lock_timeout后暂停; - checkpoint 只推进连续完成前缀;
- 记录 skipped ranges 并回访;
- 使用 claim/work table;
- 最终做 full unresolved sweep。
本章选择“不跳锁 + 连续前缀 assertion”。第 10 章 SKIP LOCKED 适合可替代任务队列,不应机械搬到所有 backfill。
受控中止与重入
第一次运行:
结果:
第二次没有 --max-batches:
review 比较两份 JSON 的 exact checkpoint relationship,而不要求动态时间或 PID 相同。
11.5.2 影子读、双写的风险与一致性验证
双写为什么危险
双写可能指:
- 同一数据库 transaction 内写两列/两表;
- 两个独立 database transaction;
- 数据库 + cache;
- 数据库 A + 数据库 B;
- 数据库 + message/API。
只有第一种天然共享 PostgreSQL 原子性。其他都需要:
“应用会同时写两边”没有说明断连、timeout、retry、reordering 和版本漂移。
同库双表示也会漂移
即使在一行内:
如果每个 application 都独立实现 mapping,版本差异或 bug 仍会制造矛盾。本章用:
- temporary trigger 派生 old-only write;
- new application dual-write;
- named CHECK 拒绝 mismatch;
- shadow query 比较全量;
- final NOT NULL;
组成 closed loop。
负向 case:
必须:
只断言“插入失败”不够;错误可能来自 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 必须有:
否则临时双写会变成多年永久复杂度。
Shadow read 的三种精度
| 模式 | 优点 | 风险 |
|---|---|---|
| database exact count | 全量、简单 | 可能昂贵,只覆盖库内等价 |
| application synchronous compare | 贴近真实 decoder | 增加请求延迟/负载 |
| async sampled compare | 对前台影响小 | 采样偏差、时序差 |
对于大表,可以分 bucket:
保存:
如果比较在不同 snapshot 或跨异步系统执行,要区分真实不一致与复制/传输 lag。
Read fallback 会隐藏债务
新代码常写:
这有利于早期兼容,但如果没有 fallback counter 和 expiry:
- backfill 漏行不会暴露;
- new write bug 会被掩盖;
- contract 前无法证明依赖清零;
- 读取可能永久使用旧语义。
进入 switch 前要做到:
然后再删除 fallback,最后才删除 old representation。
11.5.3 何时暂停、回退或前滚
三种动作不是同义词
数据库 schema 在 expand 后通常不需要立刻 down。只要 old path 仍兼容,可以先回退 application traffic,再修复 new path。
Stop conditions 要量化
发布前定义,例如:
数值必须来自实际 SLO/capacity,不应从本章本地数字照抄。停止条件还要指定:
- 谁有权 pause;
- 当前 batch 是否允许 commit;
- 如何防止 orchestrator 自动重启;
- 下一次 resume 需要哪些复核;
- incident/change owner 如何交接。
选择 rollback traffic
适合:
动作:
若 new writer 已经产生 old application 无法理解的数据,即使 old column 存在也不能安全 rollback;这必须在兼容矩阵提前证明。
选择 forward repair
适合:
- backfill 中少量 deterministic bad rows;
- constraint validation 发现历史 violation;
- migration 已提交且 down 会丢失更多语义;
- schema 已扩展,旧路径仍正常;
- contract 尚未发生。
例如:
保留 constraint,修数据,再 validate,通常优于 drop constraint 退回无保护状态。
何时需要 restore/PITR
只有当:
- destructive contract 已丢失不可重建语义;
- 大范围错误写入无法可靠 reconciliation;
- catalog/physical corruption;
- forward repair 风险高于恢复;
才进入 restore/PITR 决策。它不是轻量 undo。必须明确:
多数模式发布问题应在 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,安全结果可以是:
强行扩大 batch、取消 timeout 或在监控盲区继续,只是把进度指标置于数据与服务安全之上。
本节验收问题
- keyset ordering 是否稳定,checkpoint 是否表示连续完成前缀;
- batch data 与 checkpoint 是否同事务;
- size/rate/sleep 是否由 safety watermarks 调节;
SKIP LOCKED是否可能制造永久遗漏;- controlled exit 与 unexpected failure 是否可区分;
- 双写的原子性范围和 authority 是否明确;
- mismatch 反例是否核对 SQLSTATE 与 constraint identity;
- trigger/fallback 是否有 owner、telemetry、expiry 和 removal gate;
- shadow comparison 是否处理 snapshot/lag/normalization;
- pause、traffic rollback、forward repair 是否分别定义;
- stop conditions 是否量化并绑定 owner;
- 窗口结束时是否允许安全停在中间 phase。
上一节:在线分区化 · 返回本章目录 · 下一节:发布窗口中的平台观察 · 查看全书目录 · 查看索引中心
11.6 发布窗口中的平台观察
数据库 catalog 只能回答“当前对象是什么、谁在等谁”;发布平台还要回答“哪条流量进入哪里、影响面多大、过去几分钟发生了什么、是否仍在安全水位内”。
一次发布观察闭环:
仪表盘不是 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”仍不是目标证明
连接后第一条证据:
还要验证:
- 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,脚本使用:
manifest 记录 service name,不展开密码/URI。
用 application_name 切出发布流量
每个 phase 使用稳定前缀:
这样可以在:
区分 phase,保存 live evidence,并让 reset 在 worker 存活时 fail closed。
application_name 是可伪造 label,不是 authentication/authorization。取消 session 前仍要组合:
防止 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 更快,随手全局修改:
会把一个 schema change 变成 schema + config 联合 change,扩大变量和恢复面。优先使用 session/local setting;确需 config change 时,独立变更单、独立 evidence 和独立复位。
11.6.2 观察锁、复制延迟、WAL 与资源水位
从发布 SLI 开始
发布期间先看用户影响:
再解释数据库侧原因。一个 WAL spike 如果不影响 SLO 且在预算内,可能可接受;一个没有明显 CPU spike 的 lock convoy 也可能让 tail latency 立即恶化。
Pigsty dashboard 路线
Pigsty v4.5 当前文档列出的相关面板:
名称与布局会升级,以当前 Pigsty Dashboard 文档 为准,不把截图坐标或 panel ID 写进长期 runbook。
锁观察必须落回 exact graph
历史曲线回答:
live catalog 回答:
对 DDL 还要查 relation lock:
看到 AccessExclusiveLock 不能直接杀 session;先确定 holder、waiter、队列和业务影响。
WAL 观察不是只看一个 counter
模式变更会影响:
单节点本章用 pg_current_wal_insert_lsn() 前后差作为相对 A/B;生产需时间序列。counter 应使用 rate/increase,并注意 restart/reset epoch。还要区分:
- generated WAL;
- sent/received WAL;
- replay progress;
- slot
restart_lsnretention; - archive queue。
主库 lag 为零不代表 replica 已应用;bytes lag 与 time lag也不能互换。
Replication lag 的停止线
backfill/validation/CREATE INDEX 可让 replica:
- replay 落后;
- read query 与 replay 冲突;
- WAL 堆积占盘;
- failover 后 RPO/RTO 风险增加。
停止条件应同时绑定:
如果发布时 primary failover,默认暂停 change,重新验证:
不能假设脚本连接自动漂移后可从上一行继续。
资源水位与 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 任一关键链路不可用:
对于高风险 rewrite/backfill/contract,应暂停,而不是在盲区继续。监控恢复后先补采 live state,再决定 resume。
11.6.3 配置变更与模式变更分别留证
三种 identity
一次完整发布至少有:
它们相互引用,但不能共享一个模糊“release-2026-07-29”后失去可归因性。
Database evidence
本章 evidence:
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 参数或监控配置,另存:
不要只保存 SHOW parameter。runtime value 不能证明它来自哪个 inventory version,也不能说明 restart 后会不会保持。
反过来,config repo diff 也不能证明 runtime 已生效;两边都要。
Schema migration 不应修改不可变历史
baseline-v0.6-proposal.json 不改写 v0.1 baseline,而是:
同理,生产 migration 文件一旦发布,不应原地改内容后保留同 migration ID。若修复:
- 发布新 migration identity;
- 说明依赖和前置 phase;
- 保留旧 artifact/checksum;
- fresh install 继续由同一权威链生成或验证。
Change log 与 evidence package
建议目录:
decision log 记录:
这样事故复盘能回答“当时基于什么证据继续”,而不是只有最后成功/失败。
敏感信息与保留期
SQL、query parameter、client address、logs、application payload 可能包含:
- customer identifiers;
- tokens/credentials;
- financial/personal data;
- internal topology。
证据包要:
不要为了“完整证据”把 password-bearing URI、PGPASSWORD 或完整用户 payload 写入 artifact。
Final review 的职责边界
自动 review 可以确认:
人/发布系统仍要确认:
让工具拒绝它不能证明的 contract,是质量而不是自动化不完整。
本节验收问题
- 发布使用哪个 Pigsty service、port、pool/direct path;
- 连接后是否再次验证 database/role/primary/schema;
- 每个 phase 是否有独立 application_name;
- service 隔离与资源隔离是否被正确区分;
- user SLI、lock、WAL、lag、disk、pool 是否同窗观察;
- lock metric 是否能落回 exact blocker graph;
- failover 后是否默认暂停并重新发现 state;
- 监控盲点是否是明确停止条件;
- application、database、platform identity 是否分离;
- config source diff 与 runtime postcheck 是否都有;
- migration/source artifacts 是否 checksum 固定且不可变;
- evidence 是否脱敏、有权限和保留期;
- 自动 review 是否诚实拒绝无法证明的生产事实。
参考资料
- Pigsty:Service/Access
- Pigsty:PostgreSQL Dashboards
- Pigsty:PostgreSQL Metrics
- PostgreSQL 18:Monitoring Database Activity
- PostgreSQL 18:Viewing Locks
上一节:数据回填与流量切换 · 返回本章目录 · 下一节:实战:无中断演进订单模式 · 查看全书目录 · 查看索引中心
11.7 实战:无中断演进订单模式
本节把模式发布做成一条可重复、可中止、可复核的证据链:
实验不把生产“无中断”承诺缩成一次本地脚本成功。它证明机制和状态关系;真实服务 SLI、复制水位与旧依赖清零仍需在目标 Pigsty 发布窗口完成。
11.7.1 加字段、回填、建约束与新旧版本共存
确认 disposable L1
准备权限受控的 service file:
只在已确认可写、可重建的 L1/本地目标继续。本章脚本再次验证:
无 service file、错误 database、recovery target、role/path/model 不符都会 fail closed。
全量入口:
可独立运行:
risk 到 validate 会重建专用 fixture;verify 只检查当前完整状态。
Legacy fixture
setup.sql 建立 50,000 行:
以及单行 state:
shipping_code 必须不存在。setup 只在所有已存在同名对象 marker 匹配时才重建,防止误删用户对象。
先测 default/rewrite 风险
default-probe.sql 对另一张 50,000 行 disposable table 做 A/B:
一次 raw result:
review 只要求:
具体 OID/WAL 随环境变化。
Expand 前先让锁预算失败
结果:
只有这条 negative path 通过,才执行 expand.sql:
这验证 timeout 后可安全重新评估并重试,而不是声称所有 55P03 都可以自动循环。
旧/新版本共存
compatibility.sql 运行四条合同:
此时:
其中一条旧行已经被 old update 顺带填充,两个新 insert 都由 bridge/dual write 提供 code,所以 backfill target 不是 50,000,而是 49,999。审查器固定这个关系,防止统计口径漂移。
Temporary partial index
online-index.sh 在 transaction block 外:
build 后验证:
backfill 完成并验证后,再 exact:
最终 verify 要求该 migration-only index 不存在。
Backfill 与约束收紧
第一次:
续跑:
PostgreSQL 18.6 目录:
SET NOT NULL 前后 relfilenode 相同。PG14–17 review 分支不要求 relation contype=n。
Switch 保留回退形状
switch.sql 先做 full shadow comparison,再分别运行一个 old/new writer:
最终总行数:
读取可以切到 shipping_code,但数据库继续支持旧 application rollback。
11.7.2 注入锁等待和回填中断,验证中止与恢复
Evidence 不是最终一句 PASS
一次全量目录:
raw evidence 保存动态值,review 比较稳定关系。
锁注入的 happens-before
协调器不是用 sleep 1 猜时序:
- interactive holder 完成
LOCK ... ACCESS SHARE; - holder 输出 readiness marker;
- 才启动 DDL waiter;
- observer 循环直到捕获 exact blocker edge;
- waiter 以 55P03 结束;
- 检查 column count=0;
- 向 holder 发送
COMMIT,不是强杀 backend。
这固定关键顺序而不固定 PID/时间。异常退出时 finally 只处理自己启动的 child process,并尝试 rollback。
回填中止的原子边界
--max-batches=2 在第二次 commit 后由 client 主动退出,模拟:
- maintenance window 到达停止线;
- safety waterline 要求暂停;
- orchestrator 有计划让出资源。
它证明 between-batch restart。每个 batch 内:
同事务,因此 disconnect/error 时一起 rollback。
审查器比较:
还要求 mismatches=0 与 total batches=10,不能仅看 phase 字符串。
Contract 是故意失败的验收项
全量流程运行:
但不提供 token/target/observation。期望:
随后 final verify 要求:
如果 contract 意外成功,verify.sql 会因 old column/bridge 缺失而失败。负向 gate 与正向 post-state 双重闭合。
分区实验的可逆边界
一次结果:
elapsed ms 被记录,但不参与 PASS。
最终数据库验收
verify.sql 要求:
再运行 ch05 model verify:
证明专用实验没有改变 shop.* 业务基线。
Reset 与负向安全
all 保留 fixture 供人工复核。删除属于 R2:
reset 前要求:
- action token exact;
- database/schema/chapter target exact;
- 所有同名 relation/function marker exact;
- 没有活跃
pg36-ch11-*worker。
成功:
错误 token、错误 target 或 active worker 必须 psql exit=3 且 state 保持。
11.7.3 把发布证据与新规则追加到规约
Review summary
一次 PostgreSQL 18.6 审查:
WAL 数字仅为该次观测;关系和规则才是 golden。
SAFE-MIGR-006 的 v0.6 change
baseline-v0.6-proposal.json 提议把原 statement:
收紧为:
这不是把所有小 DDL bureaucratize。scope 是跨 application version 或 destructive/high-impact change;低风险变更仍按实际风险分级。
DEFAULT-VERS-010 的 runtime check
statement 不改,新增 check:
本章证据:
- phase 与 DDL 同事务;
- checkpoint 与 batch 同事务;
- two-batch interruption;
- exact resume;
- zero unresolved before watermark;
- final catalog/data checksum。
Proposal 依赖链
v0.6 保存:
review.py 每次重算 canonical JSON checksum。依赖 artifact 改动后 checksum 不匹配,v0.6 fail;不能静默引用一个同名但内容已变的 proposal。
它仍是 candidate。晋升条件:
- ch11 suite 在 PG14–18 compatibility matrix 通过;
- PG18 NOT NULL catalog 分支与 PG14–17 分支都实测;
- 至少一个真实 Pigsty HA 发布窗口保存 lock/WAL/lag/disk/tail evidence;
- application owner 证明旧 read/write 清零;
- 约定 rollback observation window 真实经过;
- v0.2–v0.5 依赖先晋升;
- 再发布不可变 baseline v0.6 artifact。
本地 PASS 不冒充这些条件已经成立。
从实验模板迁移到真实 change
替换 fixture 时,不要只改 table name。必须重新设计:
保留 harness 的结构:
本章最终交付
本章完成后,读者不应只会写:
而应能提交一个 change package:
这才是“安全发布”可复用的工程能力。
本节验收问题
- 全量任务能否从 clean fixture 重复运行;
- default A/B 是否保存 physical 与 WAL 关系;
- lock case 是否有 exact edge、55P03 与 unchanged catalog;
- old/new/mismatch compatibility cases 是否全部通过;
- temporary index 是否事务块外 build/drop;
- interrupted/resumed JSON 的 checkpoint 是否连续;
- constraints before/after 与 PG version branch 是否正确;
- switch 后 old rollback shape 是否仍在;
- contract 是否因缺证以 P3612 拒绝;
- partition ATTACH locks 和 DETACH boundary 是否实测;
- worker、temporary object 与 business checksum 是否闭合;
- reset 是否双 token、marker、active-worker 三重保护;
- v0.6 checksum/dependency/rule IDs 是否由 review 验证;
- candidate 是否诚实保留生产 promotion conditions。
参考资料
上一节:发布窗口中的平台观察 · 返回本章目录 · 下一章:一气呵成:从数据库契约到后端服务 · 查看全书目录 · 查看索引中心