PITR 与误操作恢复——妙手回春
32 PITR 与误操作恢复——妙手回春
第 21 章已经证明一条基础备份与连续 WAL 可以在隔离实例中恢复;第 31 章又要求事故 响应先保护现场、明确目标,再授权动作。本章把两者接起来,处理一种尤其危险的情况:
PostgreSQL 没有宕机,复制、监控甚至备份都显示正常,但一笔已经提交的事务把业务 数据改错了。
同步副本会忠实重放错误,HA 切换不会让错误消失;物理 PITR 又会把整个 cluster 带回旧状态,连错误之后的合法写入一起拿走。因此“执行恢复命令”只占很小一部分, 真正的工作是:
本章正式实验故意恢复两个候选。第一个使用默认的 inclusive XID 语义,错误事务被重放, 所以必须否决;第二个使用 exclusive XID,安全事务存在而错误事务不存在,才可作为历史 正确状态。实验随后证明一个更重要的反例:直接切到第二个候选会丢掉目标之后 100 笔合法 写入,只有经过审计对账,最终业务状态才完整。
学习完成标准
完成本章后,读者应能:
- 区分误更新、误删除、误 DDL、批处理越界和外部副作用事故;
- 写出影响对象、错误事务、持续写入、事后合法写与业务真相来源;
- 在逻辑补偿、从恢复副本提取对象、整库 PITR 之间作出有条件的选择;
- 正确比较 time、XID、LSN、name、immediate 与 end-of-WAL 目标;
- 解释
recovery_target_inclusive为什么会决定错误事务是否被保留; - 理解 XID 按事务开始分配而按提交顺序恢复,不能把数字大小当提交顺序;
- 根据 backup lineage 与 timeline history 选择
current、latest或明确 timeline; - 把时区、时钟偏差、日志延迟和事务时间语义写入目标误差预算;
- 使用 Pigsty
pig pitr --plan审阅真实恢复计划,并区分 managed restore 与 side restore; - 在不覆盖原集群、不接入 Patroni、不开放 TCP、不回写 archive 的环境中恢复候选;
- 用 PostgreSQL 日志、recovery 配置、控制信息与 SQL 共同判断恢复进度;
- 以对象 manifest、关键交易和跨表不变量验证数据,而不是只看实例能启动;
- 用条件写入把历史正确值与目标之后的合法增量合并,并在不匹配时整批回滚;
- 隔离 outbox、定时任务、CDC、邮件、支付和 webhook,防止恢复副本制造第二次事故;
- 区分“历史候选正确”“对账完成”“服务可切换”和“生产已批准”;
- 输出一份可复核的 target、backup、WAL、timeline、验证、清理与决策证据包。
这不是“把时间拨回去”
物理 PITR 的输入和输出可以写成:
其中:
- $B$ 是结束于目标之前的某份物理基础备份;
- $W$ 是从该备份一致点开始的连续 WAL;
- $T$ 是恢复目标;
- $\tau$ 是所选 timeline;
- $C$ 是整个 PostgreSQL cluster 在该历史分支上的一致状态。
这个公式没有说 $C$ 就是现在应交付给用户的业务状态。若错误提交点为 $T_e$,当前时刻 为 $T_n$,则还存在两类信息:
exclusive PITR 可以得到 good_before,但不会自动得到 good_after;外部支付、邮件或消息
也不在物理数据目录里。最终状态通常更接近:
这里的 $\oplus$ 不是盲目覆盖,而是带身份、顺序、幂等键和前置状态检查的业务合并。
从事故到交付的七道门
| 决策门 | 必须回答 | 缺失时 |
|---|---|---|
| 事故门 | 哪些对象错、哪笔提交错、错误是否仍继续 | 只保护现场,不恢复 |
| 真相门 | 历史正确值与事后合法事实分别来自哪里 | 升级业务 owner |
| 血缘门 | 哪份 backup 结束于目标前,WAL 是否连续,timeline 是哪条 | 阻断 restore |
| 隔离门 | 是否保留原集群、关闭路由与外部副作用 | 不启动候选 |
| 目标门 | inclusive/exclusive 的可证伪预期是什么 | 至少恢复两个候选 |
| 数据门 | manifest、不变量与副作用对账是否通过 | 不切流 |
| 服务门 | 写围栏、路由、观察窗、回退与 owner 是否齐备 | production gate=pending |
严重度不能替代这些门。即便是 SEV1,也不能因为“时间紧”就跳过 timeline 或外部副作用 判断;即便只错一行,也可能因为已经触发支付而需要跨系统对账。
与第 21 章的分工
| 第 21 章 | 本章 |
|---|---|
| 从损失场景设计备份体系 | 从已发生的误操作界定恢复边界 |
| 证明 full backup + WAL 可恢复 | 证明目标事务能被精确包含或排除 |
| 使用预先创建的 named restore point | 从 source audit 独立取得真实 damage XID |
| 验证 keep 存在、discard 不存在 | 验证 safe、damage、post-target 三类提交 |
| 保留停止后的恢复目录 | 验证后删除两个一次性候选 |
| 不处理事后合法写入 | 实际对账并保留 100 笔合法写 |
两章共同坚持:repository status=ok 不是恢复证明,第一条查询成功也不是恢复完成,
恢复工具成功更不是业务可用。
正式实验与结论边界
参考实验运行于已确认的四节点 Pigsty 开发沙箱:
正式观测:
最终还证明:
这些时间只是 42.4 MB 合成沙箱的一次观测,不能外推生产 RTO。实验没有切业务路由,没有 测试并发交错事务、缺失 WAL、加密密钥丢失、区域故障、真实支付补偿,也没有批准生产 操作。
阅读前后关系
本章目录
32.1 先界定误操作
32.2 恢复目标与时间线
32.3 隔离恢复策略
32.4 执行恢复并观察进度
32.5 数据验证与安全回切
32.6 实战:随机恢复目标演练
权威参考
PostgreSQL:
- Continuous Archiving and Point-in-Time Recovery
- Recovery Target Settings
- Backup Control Functions
- Recovery Information Functions
- Control Data Functions
Pigsty:
上一章:事件分级、现场保护与应急决策——枕戈待旦 · 返回下卷导读 · 下一章:故障切换与集群重建——力挽狂澜 · 查看全书目录 · 查看索引中心
32.1 先界定误操作
误操作事故最容易犯的第一个错误,是把“有人说删错了”直接翻译成一个 PITR 时间。事故 描述是业务语言,恢复目标是 WAL 历史上的精确边界,中间至少还缺对象、事务、提交、 后续写入和外部副作用五层证据。
先写事故合同,再碰恢复命令:
若其中关键项仍是 unknown,正确动作通常是围住继续伤害、保存证据和并行恢复候选,而不是 在原集群上赌一个时间点。
32.1.1 错误 UPDATE、DELETE、DROP 与批处理
先按状态变化分类
同一句“数据没了”,恢复语义可能完全不同:
| 类型 | 典型例子 | 首要边界 | 常见恢复方向 |
|---|---|---|---|
| 单笔 DML | UPDATE 漏写 WHERE |
一笔 commit 前后 | 逻辑修补或对象提取 |
| 多笔 DML | 客户端 autocommit 循环删除 | 第一笔与最后一笔坏提交 | 多候选、批量对账 |
| 事务型 DDL | DROP TABLE、DROP SCHEMA |
DDL commit 与依赖范围 | 从隔离 PITR 提取对象 |
| 粗粒度操作 | TRUNCATE、分区 detach/drop |
对象与关联表 | 对象提取或整库恢复 |
| 后台批处理 | 调度器持续重算错误价格 | 作业开始、每次 commit、停止时刻 | 先停作业,再找完整坏窗 |
| 权限/配置 | 错误 GRANT、参数或路由变更 |
数据是否真的变化 | 配置回退,不一定 PITR |
| 外部副作用 | outbox 已发、支付已扣 | 数据库 commit 与外部确认 | 数据恢复 + 业务补偿 |
PostgreSQL 的许多 DDL 具有事务性,未提交时可以 ROLLBACK;一旦已经提交,就不能回到
原会话补发 ROLLBACK。PITR 的作用不是“撤销 SQL”,而是从较早基础备份重放 WAL,
在指定提交边界之前或之后构造另一份完整历史。
这一区别会直接改变调查方式:
“执行成功”不说明影响正确
应用需要把下面这些信息当作审计事件,而不是只记录 SQL 文本:
UPDATE 1000000 的 row count 能提示范围异常,却不能说明哪一百万行本来应该改变;
statement log 能说明服务端收到什么文本,却可能没有 bind 参数;WAL 是物理恢复事实,
不是自动生成的业务审计表。恢复能力必须在事故前就布置可关联的应用、数据库和外部系统
身份。
批处理不是“一笔大事务”的同义词
考虑一个每 1,000 行提交一次的脚本:
恢复到 batch 1 之前会同时丢掉两笔合法写;恢复到 batch 3 之后又保留全部错误。此时没有 一个单独 PITR 点能直接得到最终正确业务状态。需要先取得历史正确对象,再按业务身份重放 或合并合法变化。
因此发现坏批次后,第一步是阻断相同 mutation source:
- 禁用精确的 scheduler job,而不是停掉所有 scheduler;
- 吊销或冻结产生错误的 service role,而不是随意改全局 HBA;
- 对受影响业务 key 加写围栏,而不是默认让全站停写;
- 记录围栏生效前最后一笔提交,不能把“点击停止”当成“已经停止”。
停止动作、实际停止证据和最后坏提交是三件事。
复制不会替你保留“正确过去”
流复制和同步复制的目标是复制已经提交的 WAL。误更新只要正常提交,副本也会正确地把 它重放。以下判断都不成立:
若团队希望保留延迟副本,必须把延迟窗口、监控、promotion 防误触和 WAL 保留纳入独立 设计;它也不能替代基础备份、连续归档和恢复演练。
32.1.2 影响对象、开始时间、结束时间和持续写入
建立事故包络
把事故范围写成一个六维包络:
- $O$:database、schema、table、partition、sequence、large object 等对象集合;
- $K$:受影响业务 key 或谓词;
- $[T_f,T_l]$:第一笔到最后一笔坏提交的区间;
- $X$:已确认或候选 transaction identity;
- $A$:坏提交之后必须保留的合法写集合;
- $E$:消息、支付、邮件、搜索索引、缓存等外部副作用。
不要把“发现时间”写进 $T_l$。四个时刻应分开:
若一个作业持续运行,则 t_fence 之前仍可能出现新坏提交,事故窗口是移动的。先建立写
围栏并确认它生效,才有稳定的恢复问题。
从多层证据收敛,而不是信一个时间戳
| 证据层 | 能回答 | 不能单独回答 |
|---|---|---|
| 工单/聊天 | 人声称何时做了什么 | 实际 commit 边界 |
| 应用审计 | request、actor、业务 key、idempotency | 数据库是否提交 |
| PostgreSQL log | session、statement、error、duration | 未记录的参数与业务正确性 |
| 审计表 | run、stage、XID、LSN、对象摘要 | 外部系统是否消费 |
| 当前表状态 | 现在受影响多少 | 历史旧值 |
| backup/WAL catalog | 可恢复血缘与覆盖范围 | 哪个业务状态正确 |
| 外部账本 | 支付、消息、邮件结果 | 数据库内部完整状态 |
pg_stat_activity 适合回答“现在什么还在运行”,不是历史审计;累计统计也不能还原某笔
事务。对关键批处理,最好在同一事务里写入最小 incident_audit:
这不是让 DBA 在事故后凭空制造 XID,而是展示可恢复性如何进入变更协议。若事前没有 审计,必须用日志、应用请求、业务 key、WAL 工具和候选恢复交叉定位,并扩大不确定区间。
把事后合法写入作为一等对象
很多恢复票只写“删除 1,000 行”,没有写错误之后发生的 100 笔合法订单。应单独建立
good-after manifest:
这个集合为空也必须有证据:例如错误后立即完成全局写围栏,且所有 writer、job 与外部 ingress 都被证明停止。“没看到新行”不等于没有合法写。
用上下界表达不确定性
若只能确认错误发生在 10:00:00+08 到 10:00:05+08 之间,不要假装存在一个精确
10:00:03。更安全的候选搜索是:
每个候选都运行同一组业务探针。目标选择是一个可证伪的搜索过程,而不是一次性猜值。 恢复副本越容易创建,越应该多恢复一个候选,少在原集群上做一次不可逆猜测。
事故包络的完成条件
进入 restore 前至少要能填写:
若 mutation_source_fenced=false,先回第 31 章处理现场;若
good_after_manifest=unknown,可以并行恢复候选,但不能批准覆盖或切流。
32.1.3 逻辑补偿、对象恢复与整库 PITR 的选择
三种路线解决不同问题
PostgreSQL 连续归档的物理恢复只能恢复整个 database cluster,不是按 database 或 table 选择性恢复 (官方说明)。 “只恢复一张表”通常意味着先把整套物理副本恢复到隔离环境,再从中逻辑提取对象。
| 路线 | 适合条件 | 主要风险 | 验收重点 |
|---|---|---|---|
| 原地逻辑补偿 | 影响 key 精确、逆变换明确、当前值可条件匹配 | 覆盖并发合法写 | affected-row preimage 与业务不变量 |
| 隔离 PITR 后对象提取 | 只需部分对象的历史值,原服务需继续 | FK、sequence、触发器与跨表遗漏 | 完整依赖与合并 manifest |
| 隔离 PITR 后整库切换 | 影响广、catalog 级破坏、无法可靠逐项修补 | 丢失目标后合法写与外部状态 | 写围栏、delta replay、路由与回退 |
选择依据不是“哪条命令最熟”,而是:
$$ \text{total risk} = \text{unknown blast radius}
- \text{good-after loss}
- \text{merge error}
- \text{external inconsistency}
- \text{cutover risk} $$
什么时候优先逻辑补偿
例如误把一组账户余额减去固定值,且满足:
- 受影响 account ID 由不可变 change ID 精确列出;
- 每行当前版本仍等于错误后的预期值;
- 目标之后的合法变化有独立 ledger;
- 没有不可逆外部副作用,或已经有补偿协议;
- 修补能放在一笔受控事务中,并在 row count 不符时回滚。
应使用条件更新,不要盲目写回:
随后检查实际 row count 必须等于 manifest;少一行说明当前状态已被其他事务改变,整笔 修补应回滚并重新调查。
什么时候从恢复副本提取对象
误 DROP TABLE、误删一个租户或一个时间分区时,常见流程是:
导出前要连同以下对象评审:
- sequence 当前值与 identity;
- foreign key 的父子行;
- partition、index、constraint、trigger、policy;
- large object 与外部文件;
- extension 类型、collation 与函数依赖;
- logical replication identity 和 downstream 消费位置。
单独 pg_dump -t target 能生成表数据,不代表它已经捕获业务闭包。先定义 closure,再
导出。
什么时候考虑整库切换
下面情况更可能需要整库路线:
- 大范围 schema/catalog 破坏,受影响对象无法可靠枚举;
- 大量相互依赖的数据都被错误转换;
- 目标后写入已被严格围住,或能从独立日志完整重放;
- 业务明确接受目标后的数据损失;
- 原集群已不可信,但恢复候选通过完整引擎与业务验证。
即便如此,也先做 side restore。直接覆盖 managed PGDATA 会同时销毁现场、当前合法 写和比较基准;空间不足不是自动授权覆盖,而是需要事故指挥人明确选择保存哪些证据并 承担什么损失。
五个常见伪方案
交给下一节的恢复假设
完成本节后,应该得到而不是猜到:
下一节把这些业务边界映射到 PostgreSQL 的 time、XID、LSN、name、inclusive 与 timeline 语义。
返回本章目录 · 下一节:恢复目标与时间线 · 查看全书目录 · 查看索引中心
32.2 恢复目标与时间线
恢复目标由三部分组成:
少写任何一项,工具都会替你选默认值;默认值可能技术上合理,却不一定符合这次事故。
特别是 recovery_target_inclusive 默认是 on,recovery_target_timeline 默认是
latest,recovery_target_action 默认是 pause。事故 runbook 应显式记录有效值,
不能把“没有写”当成“没有语义”。
32.2.1 时间、LSN、事务 ID 与命名恢复点
PostgreSQL 每次只接受一个停止目标
PostgreSQL 18 的目标设置如下:
| 目标 | 原生设置 | Pig 参数 | 语义与适用场景 |
|---|---|---|---|
| 最早一致点 | recovery_target='immediate' |
-I |
在线 backup 完成的一致点 |
| 命名点 | recovery_target_name |
--name |
事前调用 pg_create_restore_point() |
| 时间 | recovery_target_time |
-t/--time |
有可靠 commit 时间与时区证据 |
| XID | recovery_target_xid |
--xid |
已审计到目标事务提交身份 |
| LSN | recovery_target_lsn |
--lsn |
已定位 WAL 边界 |
| WAL 末尾 | 不设置较早目标 | -d/--default |
尽可能恢复到 archive 末端 |
原生配置最多只能设置一个 immediate/name/time/xid/lsn 目标,否则启动报错
(Recovery Target Settings)。
end-of-WAL 是“重放所有可用 WAL”,不是“回到事故前”。
选择顺序可以写成:
time:人最容易理解,也最容易被时钟骗
时间目标接受 timestamp with time zone 语义。生产票据应写完整偏移:
不要只写:
Pig 可以为不带时区的输入补当前本地时区,但自动补全是 CLI 解析行为,不是事故证据。 执行计划应把目标归一化成完整时间戳,再由操作者对照日志、server timezone 和原始来源 复核。
时间目标配合 recovery_target_inclusive:
真实系统中多个事务可能共享很接近的提交时间;日志时间还有格式精度和采集延迟。时间目标 更适合表达候选区间,而不是把一条聊天消息的分钟级时间强行当成精确提交点。
XID:本章实验的目标,但不是天然全序号
XID 在事务开始时顺序分配,事务可以按不同顺序结束:
官方定义是:恢复那些在目标事务之前提交的事务,并根据 inclusive 决定是否包含目标
事务;不能用 xid < target 推断“更早提交”
(recovery_target_xid)。
本章实验故意串行制造三笔事务:
所以 XID target 可无歧义地证明 inclusive/exclusive 差别。生产中若存在并发事务,仍要 从候选数据库查询业务 manifest,明确哪些并发提交被包含;XID 只是停止条件,不是业务 正确性的替代品。
不要依赖表行的 xmin 作为通用事故审计:
xmin是行版本创建者,不直接表达删除者或完整事务影响;- UPDATE 会产生新行版本;
- 一个事务可影响许多表,也可能回滚;
- XID 会 wraparound,显示和比较需要理解 epoch;
- freeze、复制与逻辑导出语义都可能让简单推断失效。
可控变更应在同一事务里记录 pg_current_xact_id() 与业务 change ID。
LSN:精确 WAL 位置,不自动等于业务提交
LSN 是 WAL 字节位置,适合:
- 从日志或工具精确得到 commit record 附近边界;
- 关联 archive segment 与 replay 进度;
- 在两个候选之间做技术二分;
- 证明 WAL 已覆盖到目标之后。
但 pg_current_wal_lsn() 是观察时的当前插入位置,不是自动返回“当前事务未来的 commit
record LSN”。在事务内部随手记一个 LSN,再把它命名为 commit LSN,会造成伪精确。
如需按 LSN 恢复,应说明 LSN 如何获得、对应哪条 WAL 记录,并用候选数据验证。
LSN 同样受 inclusive 控制。它可以告诉 PostgreSQL在何处停,却不能告诉业务“账户余额 是否正确”。
name:最清晰,但必须提前创建
命名恢复点适合重大 DDL、批处理和发布窗口。名称应包含 change ID,创建结果与 LSN 要进 变更证据,并确认对应 WAL 已归档。它不是 bookmark 服务:只有已执行并写入 WAL 的恢复 点才能在未来被找到,也不能用新 backup 恢复到该 backup 结束之前的 named point。
recovery_target_inclusive 只适用于 time、XID 和 LSN,不用于 name。若想表达“危险事务
之前”,应在危险动作前创建 restore point,而不是事后猜 named point 的包含语义。
immediate 与 end-of-WAL
immediate 在基础备份达到一致状态时结束,通常用于验证基础备份本身;它不包含 backup
之后的 WAL 业务变化。end-of-WAL 会尽可能重放全部可用 WAL,适用于主机损失后追到
最新,而不适合排除已经归档的误操作。
目标必须晚于所选基础备份的结束点。若想恢复到 backup 进行期间的更早时刻,需要选择 更早一份基础备份;不是把 target 参数写得更早就能穿越该下界。
32.2.2 timeline 分叉与“恢复到刚刚之前”
PITR 会产生新历史
原历史:
恢复到 damage 之前并 promote:
新 timeline 防止恢复后生成的 WAL 覆盖旧历史。PostgreSQL 会产生 timeline history 文件,记录从哪条历史、哪个位置分叉;它们会像 WAL 一样归档,且是多分支恢复选择所必需 的证据 (Timelines)。
因此 timeline ID 不是“恢复次数”或“谁更新”,而是 WAL 历史分支身份。看到 timeline 变大也不能单独断言一次未授权 failover;要读 history、DCS、日志和变更记录。
current 与 latest 的参照点
PostgreSQL 的定义非常具体:
latest 对持续追随新 primary 的 standby 很有用,但在多次事故演练后,archive 里可能
已有后来创建的实验分支。若本次目标是原事故历史,盲用 latest 可能沿错分支。本章正式
实验显式使用 current,把恢复限定在 fresh backup 所属的 timeline 11;候选 promote
后各自在隔离目录进入新 timeline,但 archive_mode=off,不把实验分支推回 source
repository。
复杂 re-recovery 必须画出 lineage:
“仓库里有这个 WAL 文件名”仍不够。WAL 文件名前八位是 timeline 的十六进制表示;同一 逻辑 segment 位置在不同 timeline 上可属于不同历史。
“刚刚之前”是 exclusive 语义
time、XID、LSN 的 recovery_target_inclusive 默认是 on:
如果目标 XID 正是误更新事务:
| 候选 | damage | 结论 |
|---|---|---|
| inclusive / 默认 | 存在 | 应否决 |
exclusive / -X |
不存在 | 才可能接受 |
这不是文案差别,而是一整笔事务是否存在。本章不只检查配置里写了 exclusive,还在
恢复候选中查询 damage audit、错误状态和 outbox 行,证明错误确实不存在。
target action 决定到点后的生命周期
| action | 到点行为 | 适用 | 注意 |
|---|---|---|---|
pause |
暂停 WAL replay | 希望只读检查目标 | 默认;需判断是否真的 paused |
promote |
结束 recovery,开始接受连接 | 隔离候选需完整验证或导出 | 会创建新 timeline |
shutdown |
到点后停库 | 希望保留精确停点目录 | recovery.signal 仍在,重启前要处理配置 |
官方还规定:设置了目标,但 archive recovery 在到达它之前已经结束,server 会 fatal shutdown。不要把这种失败解释成“恢复到了最接近的位置”;目标没有到达就是阻断。
本章使用 promote,但只在 private Unix socket 上启动。promote 的“接受连接”不等于
接受业务流量,也不等于已重新接入 Patroni。
用两个候选替代一场争论
当团队争论目标是否应 inclusive 时,最稳妥的做法通常不是在事故群里投票,而是:
然后让数据回答。候选差异应聚焦目标事务;如果两者还有其他意外差异,说明 backup、 timeline、并发提交或验证合同仍未被理解。
32.2.3 时钟、时区和证据误差
PostgreSQL 有不止一个“现在”
在一笔长事务中:
current_timestamp/transaction_timestamp()固定为事务开始时刻;statement_timestamp()是当前语句开始时刻;clock_timestamp()返回实际墙钟,会在语句执行中变化。
若审计列默认使用 current_timestamp,长事务提交时记录的值可能接近事务开始,不是
commit 时刻。日志行时间、应用 request time、消息 broker time 又分别来自不同进程。
“按 audit.created_at 恢复”之前必须先确认这个列的时间语义。
建立时钟证据
恢复票据至少记录:
使用完整 UTC 或 numeric offset,不使用歧义缩写。官方建议 recovery time 使用数值偏移
或完整 zone name;缩写是否可用取决于更早加载的 timezone_abbreviations。
把误差写进搜索区间
设:
- $t_o$ 是观察到的时间;
- $\epsilon_c$ 是时钟偏差;
- $\epsilon_l$ 是日志/采集延迟;
- $\epsilon_s$ 是时间戳精度与语义误差。
则候选区间至少为:
这不是统计置信区间,而是操作上的保守边界。区间越宽,越应恢复多个候选或改用独立 XID/name/LSN 证据。
夏令时与本地日期是事故放大器
2026-11-01 01:30:00 在采用夏令时回拨的地区可能出现两次;2026-07-30 作为 Pig
time 输入会补为本地当天 00:00:00;只写 12:00:00 还会补“今天”。这些便捷格式
适合交互,不适合冻结事故目标。
生产票据应保存:
目标选择验收表
| 检查 | 通过证据 | 失败动作 |
|---|---|---|
| 一个目标 | effective config / Pig plan 只有一种 target | 修正冲突参数 |
| target 可达 | backup end < target 且 archive 覆盖 | 选更早 backup 或修复 WAL |
| timeline 正确 | history + backup lineage + explicit rationale | 不启动 |
| inclusive 明确 | 两候选或业务边界证明 | 默认恢复两次 |
| action 明确 | pause/promote/shutdown 与后续步骤一致 | 修正生命周期 |
| 时区明确 | raw + UTC + offset + clock evidence | 扩大候选区间 |
| 业务探针明确 | damage/safe/good-after 预期 | 回第 32.1 节 |
恢复计划至此才具有可执行语义。下一节把它放进一个不会覆盖现场、不会接入真实服务、 不会发送外部消息的隔离环境。
上一节:先界定误操作 · 返回本章目录 · 下一节:隔离恢复策略 · 查看全书目录 · 查看索引中心
32.3 隔离恢复策略
隔离恢复的目标不是“找一台空机器”,而是让恢复候选同时满足:
这六条不是同一个开关。listen_addresses='' 能关闭 TCP 入站,不会自动阻止逻辑订阅、
HTTP 扩展或脚本向外连接;custom PGDATA 不接入 Patroni,也不会自动得到只读 repository
凭据。隔离要逐层证明。
32.3.1 不覆盖仍可取证的原集群
原集群同时是现场、差异源和回退资产
即使原数据已经错误,它仍保存:
- 错误之后的合法提交;
- 当前业务 key、version 与外部事件身份;
- 未归档 WAL 和最近 commit 线索;
- session、job、slot、subscription 与路由状态;
- 与恢复候选进行差异比较的基准;
- 如果目标判断错误,重新选择候选所需的证据。
因此默认拓扑应是:
而不是:
PostgreSQL 官方恢复流程也建议在空间允许时先复制整个 cluster data directory 与
tablespace;至少保存可能尚未归档的 pg_wal
(Recovering Using a Continuous Archive Backup)。
对仍在线的 source,不应把这理解成随意复制活动 PGDATA;应通过受支持的备份、存储快照
或停机证据流程完成。
先决定 source 的运行状态
三种常见状态:
| source 状态 | 何时选择 | 必须控制 |
|---|---|---|
| 继续服务 | 影响局部,可围住错误对象 | bad writer、受影响 key、事后写审计 |
| 只读/全局写围栏 | 影响广,good-after 难追踪 | 所有入口、后台作业、长事务 |
| 停止并保全 | 完整性未知或仍快速恶化 | WAL、内存外证据、启动权限 |
“source 继续服务”不能写成“什么都不做”。至少要冻结错误 job/service identity,记录围栏 时间,保存合法写 manifest。反之,也不要因为要做 PITR 就自动停全站;停写是业务可用性 决策,必须由影响范围与对账能力支撑。
保存事实,不保存秘密扩散
事故证据包可以记录:
不应把这些内容直接放进公开工单:
私有证据目录需要最小权限、保留期限与访问日志;公开摘要只保留足以复核结论的环境轮廓、 计数、状态和散列。
任何覆盖动作都要单独授权
managed PGDATA restore 至少会:
- 停止或绕开 HA 生命周期;
- 替换当前物理数据;
- 改变 timeline 与可继续恢复的路径;
- 使旧节点与 DCS/其他成员产生身份冲突风险;
- 让回到当前状态依赖另一条恢复路径。
它是独立的高风险状态迁移,不应因为 side restore 验证通过就自动获批。本章实验从不 执行 managed restore,也不把 side candidate 注册为 Patroni member。
32.3.2 选择备份、WAL 与目标环境
备份必须早于目标并能走到目标
对候选 backup $B_i$,最低条件是:
并且从 backup 一致点到 target 的所有必需 WAL 与 timeline history 都可读。选择最近的 合格 backup 通常减少重放量,但不能只按 label 字符串或“最新”按钮决定:
本章正式实验在 fixture base 创建后新做一份 full backup,再提交 safe、damage 与 post-target 三笔事务;这样既测试 backup 后 WAL 重放,也避免选到目标之后才完成的 backup。
“archive max 大于目标”只是必要条件
WAL segment 名的范围比较可以证明仓库至少走到某个 segment,却不能单独证明:
- 中间每一个 segment 都存在;
- 对象内容未损坏;
- 所需 timeline history 存在;
- repository key 可用;
- restore command 权限和网络可用;
- 目标记录确实在宣称的 segment 中。
所以先运行 repository check,再实际恢复。测试环境可以主动 pg_switch_wal() 缩短等待,
生产不能把频繁 switch 当作归档性能修复;archive_timeout 太短会生成大量未填满但仍为
完整尺寸的 segment。
固定 exact backup,而不是让工具临场猜
恢复票据应保存:
运行 pig pitr 时使用 -b/--set 指定这份 backup。若 plan 解析出的 effective label、
target、timeline 或 data directory 与票据不一致,应在 restore 前阻断。
目标环境要匹配物理要求
物理恢复不是把 data directory 交给任意 PostgreSQL:
- server major 必须与物理格式匹配;
- 所需 extension shared libraries、locale/collation provider 要可用;
- data checksums、page/block/WAL segment 属性要被理解;
- tablespace 目标必须存在、映射正确且容量足够;
- 恢复关键参数不能低于源端需要;
- OS 用户、owner、mode 与文件系统语义要正确。
恢复关键参数常包括:
如果恢复主机当前配置比 backup 要求低,recovery 可能拒绝启动。正式实验读取 source effective settings,再显式传给隔离 postmaster;这不是把整个 source 配置无审查复制过来。
先算空间,再决定保留策略
至少预算:
$$ \text{space} \ge \text{restored PGDATA}
- \text{tablespaces}
- \text{temporary WAL}
- \text{export/intermediate data}
- \text{safety margin} $$
若同时恢复 inclusive 与 exclusive,可顺序复用主机端口和空间,但应保留各自 manifest 与日志;不要同时启动两个继承相同 identity、port 和外部配置的副本。
空间不足时,禁止以 expire 旧 backup 或删除 archive WAL 作为脚本的隐式副作用。本章
实验会留下新 full backup,因为清理 repository 不在授权范围内。
32.3.3 控制网络、凭据和外部副作用
六层隔离清单
| 层 | 本章正式实验 | 真实生产演练还要考虑 |
|---|---|---|
| 文件 | 随机 marker 下的 custom -D |
独立卷、tablespace、快照权限 |
| 进程 | 手工 pg_ctl,一次只启一个 |
cgroup/container/systemd identity |
| 入站 | listen_addresses='',mode-0700 Unix socket |
host firewall/security group |
| 身份 | private HBA,仅本机 postgres peer |
secrets replacement、least privilege |
| 控制面 | 不加入 Patroni/DCS/HAProxy/PgBouncer | DNS/VIP/controller admission |
| 出站/副作用 | fixture 没有 dispatcher,archive off | egress deny、subscription/job/webhook 隔离 |
最后一行最容易被漏掉。listen_addresses='' 只是不接受 TCP 连接,不会阻止数据库或宿主
进程主动向外连接。真正的取证环境应在网络层默认 deny egress,再为只读 backup/WAL
读取开精确 allowlist。
恢复出来的凭据仍然有效
物理 backup 包含当时的:
- database roles 与 password verifier;
pg_hba.conf、证书引用与部分服务配置;- extension、job、subscription 和 FDW metadata;
- application-owned secret(若错误地存进业务表)。
因此不能让恢复实例监听原端口或加载原 HBA。本章在 command line 覆盖:
private HBA:
这些设置适合本章沙箱,不是所有生产恢复的通用模板。例如需要业务验证账号时,应新建 一次性、最小权限的验证入口,而不是重新开放原应用角色。
archive_mode=off 防止污染 source 历史
隔离 candidate promote 后会产生新 timeline 和新 WAL。若它继承
archive_mode=always/on 与 source repository 写凭据,实验分支可能写入共享 archive,
增加 timeline 选择歧义,甚至与 retention 发生交互。
本章通过 pgBackRest restore 参数和 postmaster command line 双重确认
archive_mode=off。这不影响 restore_command 在 recovery 期间读取所需 WAL;
它只禁止候选成为新的 archive producer。
更严格的设计还应让恢复凭据 repository read-only。因为具备 restore 权限的 host 往往 也配置了 backup 写权限,单靠操作人员承诺不是权限隔离。
控制数据库内的自动执行器
恢复实例中可能存在:
应在启动前列出它们,并从三层阻断:
- 网络层:默认拒绝出站;
- 进程层:不启动 application/relay/agent,按评审禁用 background workers;
- 数据层:把 outbox/queue 置隔离状态,使用幂等键和 sandbox endpoint。
本章只证明 synthetic fixture 的 external_dispatch_count=0,没有宣称通用 egress 隔离
已经通过。读者把实验迁移到真实系统时,必须补这一门。
Pigsty 的 managed 与 side restore 边界
当前 pig pitr 有两种生命周期:
完整语义见
pig pitr。这里最重要的不是记选项,而是从 plan
核对实际 boundary。路径是否为 side restore 由有效 managed PGDATA 与解析后的路径决定,
不是简单看字符串里有没有 /pg/data。
正式实验先执行:
plan 必须明确:
结构化执行不会交互询问;只有审批完成后才使用 --yes。不要把 --yes 写进没有 guard、
没有 exact target、会指向 managed PGDATA 的通用脚本。
隔离验收
启动候选后,同时从 OS 与 SQL 两侧检查:
停止时只允许:
宽泛 pkill postgres、按端口杀进程、删除未解析变量路径都不属于可接受清理。
上一节:恢复目标与时间线 · 返回本章目录 · 下一节:执行恢复并观察进度 · 查看全书目录 · 查看索引中心
32.4 执行恢复并观察进度
一次 PITR 至少有四个不同的“完成”:
pig pitr --no-restart 成功只证明 pgBackRest restore 阶段完成;pg_ctl -w start 成功
可能只代表 hot standby 已可读;pg_is_in_recovery() = false 证明 recovery 已结束,
仍不证明目标业务状态正确。工具状态必须映射到这条状态机。
32.4.1 从已验证备份克隆恢复目标
冻结一份可重放参数包
执行前把变量写入私有 evidence,而不是临场从 shell history 猜:
秘密只保存引用或受控位置,不复制进 JSON。参数包要能回答“同一份证据能否重放同一个 候选”,但不能变成凭据泄漏包。
先验证 repository 和 target 可达性
只读预检:
检查:
- stanza
status.code = 0; - exact backup label 存在且
error=false; - backup type/dependency 与预期一致;
- backup end 在 target 之前;
- archive 与 timeline history 覆盖 target;
- restore host 有足够空间、正确版本和 extension library;
- candidate root 不存在,端口与 socket path 未占用。
pgbackrest check 成功仍不替代 restore。它验证 repository/stanza 的一组条件,不会运行
本章业务探针。
为 side restore 建一次性目录
本章 runner 只接受形如:
的 exact path。目录必须:
Pig 要求 custom -D 预先存在、归 DBSU 所有;它不会代为创建,因为在 destructive
classification、owner 检查和 plan 输出之前必须先知道具体目标路径。
先 plan,后 execute
inclusive 候选:
审阅 plan 后,在受 guard 约束的自动化中把 --plan 换成 --yes。exclusive 候选只增加:
不要同时改变 backup、timeline、target action 或验证探针,否则两个候选无法归因比较。
Pig 的 first-class 参数负责 target、backup、timeline、data directory 与生命周期;
-- 后的原生 pgBackRest 参数不能绕过这些边界。当前 CLI 在 structured output 下要求
显式 --yes 才执行,--plan 是无执行预览路径
(pig pitr Safety Mechanisms)。
读懂 restore 结果
本章正式 run 的 Pig 结果包括:
patroni_active=true 在这里是好事:它描述 restore host 上原有 live replica 的 Patroni
没有被 side restore 停掉;隔离 candidate 尚未启动。不能把它误读成“候选已由 Patroni
管理”。
文件恢复失败时
若 pgBackRest restore 失败:
- 保留 plan、stdout/stderr、candidate marker 与 repository snapshot;
- 不启动部分恢复目录;
- 判断是 backup、WAL、key、权限、空间、tablespace 还是版本问题;
- 停止任何 exact candidate postmaster;
- 只删除当前 marker 所有的 candidate;
- 修复原因后开启新 run,不覆盖旧 evidence。
managed restore 失败时,Patroni 可能已停止且 PGDATA 可能只恢复了一部分;不能因失败而 直接重启 Patroni。side restore 则不应改变 managed 生命周期,这正是事故调查优先使用 它的原因。
32.4.2 监控 WAL 重放、目标达成与启动状态
手工启动时覆盖危险继承配置
正式 runner 使用 PostgreSQL 18 的 pg_ctl 启动 custom PGDATA,并通过 -o 传入:
以及 source 的五项 recovery-critical maxima。完整、安全转义与路径验证见
exercise.py,不要从上面的概念清单拼接未审阅 shell。
第一条连接可能发生在 recovery 中
若 hot_standby=on,PostgreSQL 达到一致状态后可以开放只读查询,而后继续重放 WAL。
因此:
的 -w 只等待 server ready,不保证 target_action=promote 已完成。第 21 章正式实验
已经实际观察到:
本章 runner 继续轮询:
只有 pg_is_in_recovery() = false 才表示 promote action 已完成。随后仍先运行只读业务
探针;“可写”只是候选能力,不是允许应用写入。
分解恢复时延
用一个总秒数会掩盖瓶颈。本章分开测:
关系:
$$ T_{\text{technical}} = T_{\text{identify}}
- T_{\text{plan}}
- T_{\text{copy}}
- T_{\text{replay}}
- T_{\text{validate}} $$
真实 RTO 还要加:
$$ T_{\text{service}} = T_{\text{technical}}
- T_{\text{reconcile}}
- T_{\text{cutover}}
- T_{\text{application validate}}
- T_{\text{approval}} $$
本章没有执行后三项,所以不会从 2.8 秒 restore 推导“生产 RTO 3 秒”。
监控哪些进度
| 阶段 | 观察 | 正常推进 | 阻断 |
|---|---|---|---|
| 文件 restore | pgBackRest log/result | files restored, exit 0 | missing backup/key/space |
| 启动 | postmaster log、PID、socket | startup then consistent state | config/library/tablespace error |
| archive replay | PostgreSQL log、replay LSN | requested WAL advances | repeated missing/corrupt WAL |
| target | log + target-specific data | stop before/after expected commit | target not reached |
| action | recovery functions | paused/promoted/shutdown as declared | unexpected action |
| validation | SQL manifest | expected safe/bad/post sets | any invariant mismatch |
没有通用的“百分比”能准确表示 archive recovery,因为最终 target 与可用 WAL、restore latency、replay workload 都会影响剩余工作。比虚构 73% 更有用的是保存已请求 WAL、 last replay LSN、目标和时间趋势。
target 未达到就是失败
PostgreSQL 明确规定:配置了 recovery target,但 archive recovery 在到达它之前结束, server 会以 fatal error 停止 (recovery_target_action)。
常见原因:
- 所选 backup 本来就在 target 之后;
- 中间 WAL 缺失或不可读;
- timeline 选错;
- target time/XID/LSN 根本不在该历史;
- repository credential 或网络中断;
- target 格式/时区错误。
禁止把 last replay LSN 当成“近似成功点”继续交付。应回到血缘与 target 证据。
32.4.3 用原生文件、日志和 SQL 复核工具状态
三个观察面互相校验
文件/配置面
现代 PostgreSQL 通过 recovery.signal 进入 targeted recovery;若同时有
standby.signal,standby mode 优先。pgBackRest 会把 recovery 设置写入有效配置,
操作者应读 effective result,不能只看手写模板。
日志面
关注事件顺序,而不是只搜 ready:
日志时间必须与第 32.2 节的时区证据绑定。敏感业务值、凭据和原始查询不应未经筛选进入 公开 evidence。
SQL/控制面
在 promote 后,一些 recovery 函数会返回 NULL 或历史最后值,这是状态语义,不是自动
错误。原始 system identifier 应在私有证据中受保护;公开摘要通常只需要
“matches source lineage=true”。
工具结论必须回到原生事实
| 工具说法 | 原生复核 |
|---|---|
| side restore | effective PGDATA 与 managed PGDATA 不同;Patroni 未停 |
| archive off | SHOW archive_mode |
| target promote | pg_is_in_recovery() = false 与日志 |
| exact timeline | backup/history/config 与 control data |
| no TCP | SHOW listen_addresses + OS socket table |
| candidate stopped | exact postmaster.pid/socket absent |
| backup retained | restore 后再次读取 repository catalog |
平台包装减少操作错误,但不会改变 PostgreSQL 的恢复语义。证据同时保留 Pig structured result 与原生复核,才能在 CLI 版本变化或输出解释争议时继续审计。
pg_waldump 的位置
pg_waldump 可在高级调查中识别 WAL record、事务 commit/abort 与 relation 变化,但:
- 需要匹配 PostgreSQL major;
- 输出是低层内部格式,不是业务审计;
- 应分析受保护副本或 archive 文件;
- 不应在运行中的 active
pg_wal上做会干扰现场的操作; - 结果仍需与日志、catalog 和业务 identity 关联。
本章正式 run 不靠 pg_waldump 猜 XID,而是从同一错误事务写入的 source audit 取目标,
再由两个候选证明包含关系。
候选状态表
恢复完成后先填:
下一节才把 accepted candidate 与当前 source、good-after manifest 和外部系统放在一起, 决定提取、修补或切换。
上一节:隔离恢复策略 · 返回本章目录 · 下一节:数据验证与安全回切 · 查看全书目录 · 查看索引中心
32.5 数据验证与安全回切
恢复候选能启动,只证明 WAL 把物理文件带到一个一致状态;错误事务不在候选里,只证明 一个必要条件;把这个候选切给用户,还要回答:
验证应当从“便宜、宽泛”逐渐走向“昂贵、业务特定”,每一层都能独立阻断下一层。
32.5.1 行数、摘要、关键交易与跨表不变量
五层验证金字塔
| 层次 | 问题 | 示例证据 |
|---|---|---|
| 引擎 | cluster 能否稳定完成 recovery | log、control data、pg_is_in_recovery() |
| 对象 | schema 与依赖是否齐全 | catalog manifest、extension、constraint、index |
| 数据 | 行与值是否在目标边界 | count、分桶 aggregate、digest、sample |
| 业务 | 跨表事实是否自洽 | ledger=balance、order=payment、inventory conservation |
| 服务 | 应用与外部系统能否安全接入 | smoke、idempotency、routing、backlog、SLO |
前一层通过不能推出后一层。例如:
先做对象 manifest
对象 manifest 不应只列 table name:
还要覆盖:
- extension 名称与版本;
- column 类型、default、identity、generated expression;
- PK/UK/FK/CHECK/exclusion constraint 及 validated 状态;
- index definition、valid/ready 状态与 predicate;
- trigger、policy、publication/subscription;
- partition bound、sequence ownership/value;
- function/procedure language 与依赖;
- collation provider/version。
对象缺失时,不要先补一个同名空表让检查“变绿”;这会破坏恢复证据。候选应保持可重建, 修复动作进入另一个受控副本。
行数是 smoke,不是证明
下面两个表可以行数与总金额相同,业务 key 却完全不同:
更有用的 manifest:
对大表按稳定业务 key 分桶,分别保存 count/sum/digest;失败时可以缩小范围,也避免一个
全表 string_agg 占用巨大内存。digest 输入要固定:
否则“摘要不同”可能只是序列化不同。
关键交易采用双向探针
每个候选至少要有:
本章正式实验:
| 探针 | inclusive | exclusive |
|---|---|---|
| base audit | 有 | 有 |
| safe-before audit / 1,000 ledger | 有 | 有 |
| damage audit / mispriced rows | 有 | 无 |
| 1,000 pending wrong outbox | 有 | 无 |
| post-target audit / 100 ledger | 无 | 无 |
inclusive 候选因为 damage 存在而否决;exclusive 候选虽然通过历史边界,却也明确缺少 100 笔合法写,所以不能直接替换 source。
跨表不变量比单表摘要更接近业务
示例:
把不变量写成可返回违反数量的 SQL:
violations=0 仍只覆盖这条不变量。正式验收要列出每条 SQL、适用范围、运行时间、快照
时刻与 expected result;不能只写“数据检查通过”。
一致快照与当前变化
在仍有写入的 source 上运行多个验证查询,结果可能来自不同时刻。可以:
- 在短、受控的
REPEATABLE READ READ ONLY事务中取得一致快照; - 按业务 change sequence 或 audit high-water mark 冻结 manifest;
- 对高成本检查使用副本,但记录 replay LSN 与 snapshot 时间;
- 避免长事务拖住 vacuum、xmin 与清理。
候选是静止历史,source 是移动目标。比较报告必须给双方标注 snapshot identity,不能把 跨分钟采集的数字假装成原子快照。
32.5.2 提取差异、逻辑补回或切换整个服务
先决定交付单位
| 交付方式 | 恢复候选提供 | source 保留 | 需要的围栏 |
|---|---|---|---|
| 值/行提取 | 历史正确字段或对象 | 大部分当前状态 | 受影响 key |
| quarantine schema 导入 | 完整对象闭包 | 当前 cluster 与服务 | 合并期间对象写 |
| 整库切换 | 目标前整个 cluster | 仅作为旧现场 | 全局写与流量 |
优先选择最小、可验证的交付单位,但不能为了“小”而漏掉依赖闭包。整库切换看似省去 merge,实际上把 target 后所有变化和服务路由都变成问题。
三集合合并
设:
- $R$:exclusive candidate 的历史正确行;
- $D$:当前 source 中错误后的状态;
- $G$:错误之后的合法增量。
目标不是 source = R,而是:
并且只有在当前 source 仍符合预期错误前像 $D$ 时才允许写。流程:
任何 identity、preimage 或 row count 不匹配都应让事务回滚。这说明 source 在目标定位后 又变化,或 good-after manifest 不完整。
不要把 recovered row 直接当 SQL 文本
安全的数据通道应:
- 使用 typed staging table、
COPY或受控参数; - 校验 schema/version 与 column mapping;
- 对 business key 建唯一约束;
- 保存行数与 digest;
- 不把值拼接进动态 SQL;
- 对敏感字段做最小化与访问控制;
- 在 merge 前再次检查 candidate source hash。
本章 runner 将 1,000 个恢复账户解析成 typed JSON recordset,构造临时表,再进行条件 更新;实验数据是确定性整数,私有证据只公开 row count 与 SHA-256,不公开真实业务值。
sequence、identity 与冲突
对象提取后常见遗漏:
处理 sequence 不能只 setval(max(id)):
- 其他 partition/table 是否共享 sequence;
- cache 中是否还有已分配值;
- post-target 是否使用更高 ID;
- 应保留 monotonically increasing 还是允许 gap;
- application 是否把 ID 大小误当业务顺序。
冲突必须交给业务规则决定:保留当前、保留历史、合并字段、生成新 identity 或人工审阅。 数据库工具不能替 owner 发明真相。
整库切换前先重放 good-after
若选择整库 candidate,至少:
- 在 source 建立并证明全局写围栏;
- 冻结最后 source commit/high-water mark;
- 把 $G$ 按依赖与幂等顺序应用到 candidate;
- 对数据库和外部系统重新对账;
- 重新运行对象、数据、业务与应用验证;
- 排空/重建连接池;
- 以明确 endpoint 切换,不依赖 DNS 猜测;
- 观察错误率、延迟、backlog 与不变量;
- 保留 source,不立即 rewind/delete;
- 达到观察窗和审批后才处理旧拓扑。
pig pitr 不会自动完成 Patroni rejoin、VIP、HAProxy/PgBouncer 切换或应用 smoke。它是
恢复编排工具,不是 cluster/service recovery controller。相关平台动作要结合
第 22 章 与
第 33 章 的角色、路由和重建合同。
回切之后的回退边界
切到 candidate 并恢复写入后,会出现 candidate-only commits。此时回 source 也不再是 “切回连接串”,而需要反向合并:
这与第 30 章升级的“第一笔目标独占写”边界相同。把观察窗口只写成 30 分钟不够,还要 记录第一笔独占提交。
32.5.3 防止恢复环境向外重复发送消息和支付
数据库回到过去,外部世界不会
假设:
数据库里订单消失,并不会自动退款、收回邮件或删除搜索文档。若恢复实例又重放同一 outbox,还可能再次扣款或发送。
因此恢复状态是一个分布式对账问题:
PITR 只直接构造第一项。
启动前列出所有副作用通道
| 通道 | 风险 | 隔离/验证 |
|---|---|---|
| transactional outbox | 历史 pending 再次发送 | relay 不启动、event id 对账 |
| logical subscription | 恢复后主动连 publisher | egress deny、禁用 subscription worker |
| CDC connector | 从旧 LSN 重发 | 独立 connector identity,不接 candidate |
| cron/job | 到点重新跑批 | scheduler 不启动、job 状态清单 |
| payment API | 重复 charge/refund | provider idempotency key + provider ledger |
| email/SMS/webhook | 不可撤回或重复 | sandbox endpoint、dispatch fence |
| cache/search | 历史状态覆盖新状态 | versioned event/rebuild policy |
| FDW/external function | 查询或写远端 | network deny、extension review |
“应用没有连接”不能覆盖内置 background worker、宿主 sidecar 或外部 connector。控制项要 跨数据库、主机、网络和平台。
outbox 的恢复协议
理想 outbox 至少有:
恢复时把事件分为:
不要直接删除坏 outbox 行。保留 canceled 状态和 incident/change ID,才能证明为什么没
有发送,并避免后续任务把“缺失”解释成尚未生成。
本章正式实验在 damage 事务内生成 1,000 条 pending fixture outbox;外部 dispatcher
根本不存在。对账事务精确把这 1,000 行变为 canceled,要求 row count 完全匹配,
最终 pending=0、external_dispatch=0。
支付必须以外部账本为准
支付场景禁止只根据恢复后的 payment.status 决定重扣或退款。至少比对:
若数据库显示“未支付”但 provider 已 capture,正确动作可能是补回本地账,而不是再发 charge。若数据库显示“已退款”但 provider 没有退款,则要执行受审批补偿,而不是只改一 列让 dashboard 变绿。
服务切换的最终门
| 门 | 通过条件 |
|---|---|
| candidate | exact target/timeline,safe/bad/post 探针符合预期 |
| database | 对象、manifest、约束、索引与跨表不变量通过 |
| delta | good-after 身份完整,重放/合并无缺失 |
| external | payment/message/search/notification 已对账 |
| isolation | job/connector/route 在审批前仍被围住 |
| topology | writer 唯一,Patroni/DCS 计划经过评审 |
| client | pool drain、endpoint、transaction retry 与 smoke 明确 |
| rollback | 第一笔新独占写边界与反向对账方案明确 |
| authority | DBA、业务 owner、incident commander 各自签字 |
任一项 unknown 都不能被“数据库已经启动”覆盖。可以选择继续隔离验证、只提取部分数据、 保持 source 服务或升级决策,但不能把 unknown 自动转成 pass。
本章实验为什么不切流
正式 run 的目的,是证明:
它没有真实应用、payment provider、CDC、DNS/VIP 或 production owner,因而不具备服务
切换前提。business_cutover_performed=false 与
production_ch32_gate=pending 是实验通过条件,不是未完成项。
上一节:执行恢复并观察进度 · 返回本章目录 · 下一节:实战:随机恢复目标演练 · 查看全书目录 · 查看索引中心
32.6 实战:随机恢复目标演练
本节把前五节压成一次可重放实验。它不把 target 写死在脚本里,也不只恢复一个“预期会 成功”的候选,而是要求:
实验会创建并删除一次性 synthetic schema,创建两个 side-restore 目录后精确删除,并 保留一份新 full backup。它只适用于已经确认的开发沙箱;guard 字符串不是生产授权。
32.6.1 在隐藏时间窗内注入误更新
先读四份合同
- 环境、fixture 与验收:
requirements.json - 状态机、允许与禁止动作:
recovery-contract.json - 人类可读安全边界:
lab-contract.md - 拓扑与数据流:
topology.mmd
静态检查不连接远端:
只读预检可在一个独立新目录运行:
它验证:
capture 的在线 mutation 为零,不创建 backup,不恢复目录,也不改变 Patroni/DCS/路由。
完整演练需要新的私有 evidence directory
任一 guard 缺失,runner 在 source mutation 前退出。evidence directory 已非空也会退出, 避免把新 run 混进旧证据。正式实现见:
fixture 是一份可独立计算的账
test.pg36_ch32 有 5,000 个账户:
opening total:
$$ 5000 \times 100000
- \frac{5000 \times 5001}{2} =512{,}502{,}500 $$
runner 用系统随机源选择连续 1,000 个 victim。目标账户范围、run ID 与事故时刻只在本次 私有证据中确定,恢复命令没有预置 XID 或固定时间。
四笔状态和一份 fresh backup
顺序:
damage 和 outbox 在同一事务中,因此 inclusive candidate 要么同时看到二者,要么实验
失败。外部 dispatcher 不存在,external_dispatch_enabled=false 写入 audit 与合同。
注意 source 在 damage 后、修补前的当前值:
而正确历史值应是 opening + 100,前 100 个还要再加合法的 700。这个设计使“直接写回 recovered value”必然丢数据,逼迫操作者处理 good-after。
target 来自 source,不来自注入代码常量
runner 在三笔事务完成后,单独查询:
取得 damage XID,再记录当前 LSN 与 required WAL segment。随后执行一次 WAL switch、
pgbackrest check,轮询 repository 直到 archive max 覆盖 required segment。若 XID
缺失、不是数字、与 damage transaction 返回值不一致或 archive 未覆盖,恢复不开始。
这不是说生产必须依赖一张同名 audit 表,而是证明目标身份必须从独立事实导出,并与 fixture/业务身份绑定。
32.6.2 独立定位目标、恢复、验证与回切
两个候选只有一个变量不同
| 参数 | inclusive | exclusive |
|---|---|---|
| backup | 同一 exact fresh full | 同一 exact fresh full |
| target | 同一 damage XID | 同一 damage XID |
| timeline | current |
current |
| action | promote |
promote |
| side directory | marker/inclusive | marker/exclusive |
| archive mode | off |
off |
| 差异 | 默认 inclusive | --exclusive |
两个 plan 都先被保存和校验。两个 postmaster 顺序启动在同一空闲端口,只监听各自 mode-0700 Unix socket;inclusive 停止并删除后才创建 exclusive。
inclusive 候选必须被否决
正式 run 观察:
这证明默认 inclusive XID 语义停在目标事务之后。若 validator 只检查 pig_result.success
而没有检查 damage 数据,这个错误候选会看起来完全健康。
exclusive 候选才是历史正确边界
source timeline 为 11;两个隔离候选沿 current 重放并 promote 到 timeline 12。因为
archive_mode=off,两个实验分支不会进入共享 repository。
accepted 的含义是“可作为历史正确数据源”,不是“可以直接切流”。它明确缺少 100 笔 post-target ledger。
从 candidate 提取,再与 source audit 合并
exclusive candidate 导出 1,000 行:
私有 evidence 只保存 count 和导出集合 SHA-256。source 独立导出 100 条
post-target ledger,要求 key 集合正好等于预期 100 个账户,每条 delta 都是 700。
runner 构造 typed temporary table:
一笔事务中先验证 1,000 行 current preimage:
完全匹配后才执行:
条件更新或 outbox row count 不是 1,000,事务抛错并回滚。
最终 manifest 独立可算
safe 增量:
post-target 合法增量:
最终总额:
正式结果:
runner 最后按 exact run marker 删除 pg36_ch32,停止两个 candidate,确认 socket/PID
消失后删除 exact roots;再次验证 Patroni 仍为一主两从、source system lineage 未变、
fresh backup 仍在 repository。它不执行 application cutover。
32.6.3 测量 RPO/RTO 并记录版本迁移工时
正式时间分解
公开证据
pitr-run.json 记录:
| 阶段 | inclusive | exclusive |
|---|---|---|
| plan | 0.219 s | 0.214 s |
| restore copy | 3.040 s | 2.974 s |
| start → promoted | 0.961 s | 0.939 s |
| candidate validation | 0.340 s | 0.503 s |
共同阶段:
这是本地 ARM64 四虚拟机、42.4 MB 合成数据的一次观测。磁盘缓存、已有 repository、 网络距离、压缩/加密、WAL 数量、表空间、extension 和并发都与生产不同。
一次事故有多种 RPO
| 口径 | 本次结果 | 含义 |
|---|---|---|
| archive RPO | target WAL covered | 没有注入 WAL 缺口 |
| raw exclusive loss | 100 rows | 为排除 damage,故意丢弃 target 后合法写 |
| reconciled fixture loss | 0 rows | 100 条 audited delta 全部合并 |
| external-system loss | 未测试 | 没有真实 payment/message provider |
所以不能只说“RPO=0”。更准确的结论是:
在这份 synthetic fixture、完整 audit 与无外部 dispatch 的条件下,exclusive PITR 原始缺少 100 笔合法写;经过条件对账后,已知 fixture 数据损失为 0。
若 production 没有完整 good-after audit,reconciled RPO 就是 unknown,不会因为 WAL 完整自动变成 0。
restore time 不是 RTO
本次没有测量:
因此公开 evidence 固定:
生产演练应测 end-to-end:
分别报告阶段时长与 critical path,不能只取最快的 restore copy。
版本恢复与版本迁移是两项工时
物理 backup 要由兼容的 PostgreSQL major 与 extension library 启动。若事故时生产已经 升级,而目标 backup 属于旧 major,runbook 可能需要:
这段工时要独立记录,不能用“目标主机已经装了新版 PG”抵消。不能直接拿 PG18 server
启动 PG17 physical PGDATA;跨 major 迁移应使用第 30 章的 pg_upgrade、逻辑迁移或
对象提取路径。
本次 source、backup 与 candidate 都是 PostgreSQL 18.6,没有执行 major migration。
所以本次只证明“同 major 运行时已就绪”的恢复路径,没有测得跨版本迁移工时。生产
报告应写 not required in this scenario,而不是虚构成 0 秒。
建议工时表:
| 工作 | started | ended | active labor | waiting | owner | evidence |
|---|---|---|---|---|---|---|
| old runtime acquire | ||||||
| extension compatibility | ||||||
| physical restore | ||||||
| logical extract/transform | ||||||
| target import | ||||||
| cross-version validation |
32.6.4 输出恢复证据和备份体系改进项
私有证据包
目录为 0700,文件为 0600。review 拒绝 SCRAM verifier、private key、clear password、
credential URI 和 raw system identifier。公开摘要只保留版本、规模、计数、时间、
候选决策、安全结论和 gate。
对完成的同一证据目录,可以离线重跑:
all 也只执行 verify + review,不会再次创建 fixture、backup 或 restore。修改 12 个
实验源文件中的任何一个后,旧 preflight/source hash 会失败;应开启新 run,不应给旧
证据重新盖章。
32 个反例不是装饰
validator 将每个完整 evidence 深拷贝后实际注入变异,要求全部被拒绝:
正式 run:
从一次成功演练生成改进 backlog
不要把结论写成“PITR 已掌握”。按发现分类:
| 类别 | 本次证明 | 下一轮故意测试 |
|---|---|---|
| target | exact serial damage XID | 并发事务与宽时间窗 |
| WAL | target 后 segment 已归档 | 缺一个 WAL、archive 延迟、坏 history |
| repository | 单一 sandbox repo 可读 | lost key、read-only credential、immutable/off-site |
| backup | fresh full | diff/incr dependency 与过期边界 |
| runtime | 同 PG18.6 | 旧 major、缺 extension、collation drift |
| data | 5,000 确定性账户 | 多 TB、tablespace、large object |
| reconcile | 100 audited deltas | 冲突写、未知 delta、人工 exception |
| side effects | fixture dispatcher absent | broker/payment/search sandbox |
| topology | source HA 未变 | 经授权的 managed restore/rejoin |
| service | 未切流 | application smoke、pool drain、观察窗 |
每项改进要有 owner、截止时间、下次 drill 场景和通过证据。只写“加强监控”无法改变下一次 恢复结果。
终局结论
本章正式证据支持:
它不支持:
所以最终决策保持:
下一章切换响应目标:当数据本身正确,但 writer、timeline 或节点拓扑失去唯一性时,怎样
完成主从切换、fencing、pg_rewind 与故障节点重建。
上一节:数据验证与安全回切 · 返回本章目录 · 下一章:故障切换与集群重建——力挽狂澜 · 查看全书目录 · 查看索引中心