跳转到主要内容

32 PITR 与误操作恢复——妙手回春

第 21 章已经证明一条基础备份与连续 WAL 可以在隔离实例中恢复;第 31 章又要求事故 响应先保护现场、明确目标,再授权动作。本章把两者接起来,处理一种尤其危险的情况:

PostgreSQL 没有宕机,复制、监控甚至备份都显示正常,但一笔已经提交的事务把业务 数据改错了。

同步副本会忠实重放错误,HA 切换不会让错误消失;物理 PITR 又会把整个 cluster 带回旧状态,连错误之后的合法写入一起拿走。因此“执行恢复命令”只占很小一部分, 真正的工作是:

界定事故
  -> 固定提交边界与事后合法写集
      -> 选择能覆盖目标的 backup + WAL + timeline
          -> 在隔离环境恢复多个候选
              -> 证明目标事务存在或不存在
                  -> 合并目标之后的合法事实
                      -> 控制外部副作用
                          -> 决定提取、修补或整库切换

本章正式实验故意恢复两个候选。第一个使用默认的 inclusive XID 语义,错误事务被重放, 所以必须否决;第二个使用 exclusive XID,安全事务存在而错误事务不存在,才可作为历史 正确状态。实验随后证明一个更重要的反例:直接切到第二个候选会丢掉目标之后 100 笔合法 写入,只有经过审计对账,最终业务状态才完整。

学习完成标准

完成本章后,读者应能:

  1. 区分误更新、误删除、误 DDL、批处理越界和外部副作用事故;
  2. 写出影响对象、错误事务、持续写入、事后合法写与业务真相来源;
  3. 在逻辑补偿、从恢复副本提取对象、整库 PITR 之间作出有条件的选择;
  4. 正确比较 time、XID、LSN、name、immediate 与 end-of-WAL 目标;
  5. 解释 recovery_target_inclusive 为什么会决定错误事务是否被保留;
  6. 理解 XID 按事务开始分配而按提交顺序恢复,不能把数字大小当提交顺序;
  7. 根据 backup lineage 与 timeline history 选择 currentlatest 或明确 timeline;
  8. 把时区、时钟偏差、日志延迟和事务时间语义写入目标误差预算;
  9. 使用 Pigsty pig pitr --plan 审阅真实恢复计划,并区分 managed restore 与 side restore;
  10. 在不覆盖原集群、不接入 Patroni、不开放 TCP、不回写 archive 的环境中恢复候选;
  11. 用 PostgreSQL 日志、recovery 配置、控制信息与 SQL 共同判断恢复进度;
  12. 以对象 manifest、关键交易和跨表不变量验证数据,而不是只看实例能启动;
  13. 用条件写入把历史正确值与目标之后的合法增量合并,并在不匹配时整批回滚;
  14. 隔离 outbox、定时任务、CDC、邮件、支付和 webhook,防止恢复副本制造第二次事故;
  15. 区分“历史候选正确”“对账完成”“服务可切换”和“生产已批准”;
  16. 输出一份可复核的 target、backup、WAL、timeline、验证、清理与决策证据包。

这不是“把时间拨回去”

物理 PITR 的输入和输出可以写成:

C(T,τ)=B(Tb)+W(TbT,τ) C(T,\tau)=B(T_b)+W(T_b \rightarrow T,\tau)

其中:

  • $B$ 是结束于目标之前的某份物理基础备份;
  • $W$ 是从该备份一致点开始的连续 WAL;
  • $T$ 是恢复目标;
  • $\tau$ 是所选 timeline;
  • $C$ 是整个 PostgreSQL cluster 在该历史分支上的一致状态。

这个公式没有说 $C$ 就是现在应交付给用户的业务状态。若错误提交点为 $T_e$,当前时刻 为 $T_n$,则还存在两类信息:

good_before   在 Te 之前且应保留的事实
bad_at        在 Te 提交的错误事务及其副作用
good_after    在 Te 之后仍然合法的提交

exclusive PITR 可以得到 good_before,但不会自动得到 good_after;外部支付、邮件或消息 也不在物理数据目录里。最终状态通常更接近:

Sfinal=Sexclusive before TeΔgood afterexternal reconciliation S_{\text{final}} = S_{\text{exclusive before }T_e} \oplus \Delta_{\text{good after}} \oplus \text{external reconciliation}

这里的 $\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 开发沙箱:

target             pg36-l2-vagrant/pg-test
source             pg-test-1, managed primary
restore host       pg-test-3, live replica remains unchanged
PostgreSQL         18.6
Pig                1.5.1
pgBackRest         2.59.0
fixture            5,000 accounts
random victims     1,000 contiguous accounts
safe-before        +100 cents to every victim
damage             set 1,000 balances to zero + 1,000 pending outbox rows
good-after         +700 cents to first 100 victims
target             damage XID from source audit
restore            exact fresh full backup, timeline=current
isolation          custom -D, no restart, Unix socket only, archive off

正式观测:

fresh full backup                    2.090 s
backup logical / repository delta    42,355,954 / 5,751,304 bytes
pgBackRest check                     0.630 s
target identification               0.183 s
inclusive plan / restore             0.219 s / 3.040 s
inclusive start -> promoted/validate 0.961 s / 0.340 s
inclusive damage present             true
inclusive accepted                   false
exclusive plan / restore             0.214 s / 2.974 s
exclusive start -> promoted/validate 0.939 s / 0.503 s
exclusive safe present               true
exclusive damage/post present        false / false
exclusive accepted                   true
raw exclusive legitimate writes lost 100
reconciled legitimate writes kept    100
reconciliation                      0.200 s
conditional rows repaired            1,000
wrong outbox rows canceled            1,000
external dispatch                         0
fixture data loss after reconcile         0
source -> candidate timeline          11 -> 12

最终还证明:

source Patroni topology unchanged    true
managed PGDATA touched               false
DCS / route changed                  false / false
TCP listener created                 false
fixture schema removed               true
side restore roots removed           true
fresh backup retained                true
backup or WAL deleted                false
declared counterexamples rejected    32
live evidence mutants rejected       32
source files hash-bound              12
production_ch32_gate                 pending

这些时间只是 42.4 MB 合成沙箱的一次观测,不能外推生产 RTO。实验没有切业务路由,没有 测试并发交错事务、缺失 WAL、加密密钥丢失、区域故障、真实支付补偿,也没有批准生产 操作。

阅读前后关系

本章目录

32.1 先界定误操作

32.2 恢复目标与时间线

32.3 隔离恢复策略

32.4 执行恢复并观察进度

32.5 数据验证与安全回切

32.6 实战:随机恢复目标演练

权威参考

PostgreSQL:

Pigsty:


上一章:事件分级、现场保护与应急决策——枕戈待旦 · 返回下卷导读 · 下一章:故障切换与集群重建——力挽狂澜 · 查看全书目录 · 查看索引中心

32.1 先界定误操作

误操作事故最容易犯的第一个错误,是把“有人说删错了”直接翻译成一个 PITR 时间。事故 描述是业务语言,恢复目标是 WAL 历史上的精确边界,中间至少还缺对象、事务、提交、 后续写入和外部副作用五层证据。

先写事故合同,再碰恢复命令:

affected truth       哪些业务事实错了
bad transaction      哪一笔或哪一组提交制造错误
first/last bad       错误开始和结束边界
continuing mutation  错误作业是否仍在运行
good after           错误之后哪些合法事实必须保留
external effects     数据库之外已经发生了什么
authority            谁能判定最终业务真相

若其中关键项仍是 unknown,正确动作通常是围住继续伤害、保存证据和并行恢复候选,而不是 在原集群上赌一个时间点。

32.1.1 错误 UPDATE、DELETE、DROP 与批处理

先按状态变化分类

同一句“数据没了”,恢复语义可能完全不同:

类型 典型例子 首要边界 常见恢复方向
单笔 DML UPDATE 漏写 WHERE 一笔 commit 前后 逻辑修补或对象提取
多笔 DML 客户端 autocommit 循环删除 第一笔与最后一笔坏提交 多候选、批量对账
事务型 DDL DROP TABLEDROP SCHEMA DDL commit 与依赖范围 从隔离 PITR 提取对象
粗粒度操作 TRUNCATE、分区 detach/drop 对象与关联表 对象提取或整库恢复
后台批处理 调度器持续重算错误价格 作业开始、每次 commit、停止时刻 先停作业,再找完整坏窗
权限/配置 错误 GRANT、参数或路由变更 数据是否真的变化 配置回退,不一定 PITR
外部副作用 outbox 已发、支付已扣 数据库 commit 与外部确认 数据恢复 + 业务补偿

PostgreSQL 的许多 DDL 具有事务性,未提交时可以 ROLLBACK;一旦已经提交,就不能回到 原会话补发 ROLLBACK。PITR 的作用不是“撤销 SQL”,而是从较早基础备份重放 WAL, 在指定提交边界之前或之后构造另一份完整历史。

这一区别会直接改变调查方式:

single transaction
  -> 找到 commit identity
  -> 验证 before / after 两个候选

autocommit batch
  -> 找到 first bad commit
  -> 找到 last bad commit
  -> 判断中间是否夹杂合法事务

external side effect
  -> 找数据库提交
  -> 找 message/payment idempotency identity
  -> 恢复数据后仍要外部对账

“执行成功”不说明影响正确

应用需要把下面这些信息当作审计事件,而不是只记录 SQL 文本:

change_id / job_id / request_id
application actor and database role
transaction id or commit-correlated identity
object and business-key range
expected rows / actual rows
old/new aggregate or manifest
started_at / committed_at in UTC
idempotency key and external event ids

UPDATE 1000000 的 row count 能提示范围异常,却不能说明哪一百万行本来应该改变; statement log 能说明服务端收到什么文本,却可能没有 bind 参数;WAL 是物理恢复事实, 不是自动生成的业务审计表。恢复能力必须在事故前就布置可关联的应用、数据库和外部系统 身份。

批处理不是“一笔大事务”的同义词

考虑一个每 1,000 行提交一次的脚本:

10:00:01  batch 1 commit  bad
10:00:03  user order      good
10:00:04  batch 2 commit  bad
10:00:06  refund          good
10:00:07  batch 3 commit  bad

恢复到 batch 1 之前会同时丢掉两笔合法写;恢复到 batch 3 之后又保留全部错误。此时没有 一个单独 PITR 点能直接得到最终正确业务状态。需要先取得历史正确对象,再按业务身份重放 或合并合法变化。

因此发现坏批次后,第一步是阻断相同 mutation source

  • 禁用精确的 scheduler job,而不是停掉所有 scheduler;
  • 吊销或冻结产生错误的 service role,而不是随意改全局 HBA;
  • 对受影响业务 key 加写围栏,而不是默认让全站停写;
  • 记录围栏生效前最后一笔提交,不能把“点击停止”当成“已经停止”。

停止动作、实际停止证据和最后坏提交是三件事。

复制不会替你保留“正确过去”

流复制和同步复制的目标是复制已经提交的 WAL。误更新只要正常提交,副本也会正确地把 它重放。以下判断都不成立:

replica lag = 0       -> 副本数据一定正确
three replicas        -> 一定存在错误之前的副本
fail over             -> 误更新会消失
backup job succeeded  -> 一定能定位到正确边界

若团队希望保留延迟副本,必须把延迟窗口、监控、promotion 防误触和 WAL 保留纳入独立 设计;它也不能替代基础备份、连续归档和恢复演练。

32.1.2 影响对象、开始时间、结束时间和持续写入

建立事故包络

把事故范围写成一个六维包络:

I=(O,K,[Tf,Tl],X,A,E) I=(O,K,[T_f,T_l],X,A,E)
  • $O$:database、schema、table、partition、sequence、large object 等对象集合;
  • $K$:受影响业务 key 或谓词;
  • $[T_f,T_l]$:第一笔到最后一笔坏提交的区间;
  • $X$:已确认或候选 transaction identity;
  • $A$:坏提交之后必须保留的合法写集合;
  • $E$:消息、支付、邮件、搜索索引、缓存等外部副作用。

不要把“发现时间”写进 $T_l$。四个时刻应分开:

t_start    错误动作开始
t_commit   某笔错误事务真正提交
t_detect   人或监控发现异常
t_fence    错误来源已被证明停止

若一个作业持续运行,则 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

INSERT INTO ops.change_audit (
    change_id, stage, xid, observed_lsn, details
)
VALUES (
    :'change_id',
    'before-dangerous-change',
    pg_current_xact_id(),
    pg_current_wal_lsn(),
    jsonb_build_object(
        'predicate', :'reviewed_predicate',
        'expected_rows', :expected_rows
    )
);

这不是让 DBA 在事故后凭空制造 XID,而是展示可恢复性如何进入变更协议。若事前没有 审计,必须用日志、应用请求、业务 key、WAL 工具和候选恢复交叉定位,并扩大不确定区间。

把事后合法写入作为一等对象

很多恢复票只写“删除 1,000 行”,没有写错误之后发生的 100 笔合法订单。应单独建立 good-after manifest

identity         order_id / ledger_id / event_id
commit evidence  audit row / immutable ledger / application event
ordering         depends-on or sequence
payload digest   canonical fields, not secret-bearing raw request
external state   not-sent / sent / acknowledged / compensated
replay method    idempotent insert / conditional update / manual review
owner            business authority

这个集合为空也必须有证据:例如错误后立即完成全局写围栏,且所有 writer、job 与外部 ingress 都被证明停止。“没看到新行”不等于没有合法写。

用上下界表达不确定性

若只能确认错误发生在 10:00:00+0810:00:05+08 之间,不要假装存在一个精确 10:00:03。更安全的候选搜索是:

candidate A  upper bound before suspected error
candidate B  first candidate where damage appears
candidate C  immediately before independently identified commit

每个候选都运行同一组业务探针。目标选择是一个可证伪的搜索过程,而不是一次性猜值。 恢复副本越容易创建,越应该多恢复一个候选,少在原集群上做一次不可逆猜测。

事故包络的完成条件

进入 restore 前至少要能填写:

incident:
  affected_objects: [...]
  affected_business_keys: [...]
  first_bad_commit: known | bounded | unknown
  last_bad_commit: known | bounded | unknown
  mutation_source_fenced: true
  good_after_manifest: evidence-reference
  external_effects: none | bounded | unknown
  business_truth_owner: named-person-or-role
  unresolved_unknowns: [...]

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 不符时回滚。

应使用条件更新,不要盲目写回:

UPDATE app.account AS a
SET balance_cents = r.recovered_balance_cents
                  + r.audited_post_delta_cents,
    status = 'active'
FROM recovery_candidate AS r
WHERE a.account_id = r.account_id
  AND a.balance_cents = r.expected_current_balance_cents
  AND a.status = 'mispriced';

随后检查实际 row count 必须等于 manifest;少一行说明当前状态已被其他事务改变,整笔 修补应回滚并重新调查。

什么时候从恢复副本提取对象

DROP TABLE、误删一个租户或一个时间分区时,常见流程是:

physical PITR full cluster into isolation
  -> validate historical object and dependencies
      -> export selected schema/data
          -> import into quarantine schema
              -> compare current and recovered identities
                  -> merge under write fence

导出前要连同以下对象评审:

  • 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 会同时销毁现场、当前合法 写和比较基准;空间不足不是自动授权覆盖,而是需要事故指挥人明确选择保存哪些证据并 承担什么损失。

五个常见伪方案

在 replica 上查旧值
  错误 WAL 通常已经重放;lag 不是受控历史边界。

对已提交事务执行 ROLLBACK
  ROLLBACK 只能影响当前未提交事务。

从最近 full backup 直接启动
  缺少目标前 WAL 时,它只代表 backup 一致点,不代表事故边界。

恢复到“报警前一分钟”
  报警时间不是 commit 时间,时钟与采集链还有误差。

在原 PGDATA 上反复试 target
  每次都覆盖当前现场,丧失比较和回退能力。

交给下一节的恢复假设

完成本节后,应该得到而不是猜到:

route                  compensate | extract | full-cutover
bad commit             exact identity or bounded interval
inclusive expectation  damage must be present or absent
exclusive expectation  safe facts present, damage absent
good-after             manifest plus replay/merge owner
external effects       fenced and reconciled plan
production cutover     still not authorized

下一节把这些业务边界映射到 PostgreSQL 的 time、XID、LSN、name、inclusive 与 timeline 语义。


返回本章目录 · 下一节:恢复目标与时间线 · 查看全书目录 · 查看索引中心

32.2 恢复目标与时间线

恢复目标由三部分组成:

where to stop     time | xid | lsn | name | immediate | end-of-WAL
which history     timeline
what to do there  pause | promote | shutdown

少写任何一项,工具都会替你选默认值;默认值可能技术上合理,却不一定符合这次事故。 特别是 recovery_target_inclusive 默认是 onrecovery_target_timeline 默认是 latestrecovery_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”,不是“回到事故前”。

选择顺序可以写成:

reviewed named point exists?
  yes -> name
  no  -> exact bad commit XID independently audited?
           yes -> XID, then prove inclusive/exclusive
           no  -> exact WAL boundary independently derived?
                    yes -> LSN, then prove nearby transaction set
                    no  -> bounded UTC commit interval?
                             yes -> time, restore multiple candidates
                             no  -> continue evidence collection

time:人最容易理解,也最容易被时钟骗

时间目标接受 timestamp with time zone 语义。生产票据应写完整偏移:

2026-07-30 03:09:12.483+00
2026-07-30 11:09:12.483+08

不要只写:

03:09
yesterday 11:09
2026-07-30 11:09:12

Pig 可以为不带时区的输入补当前本地时区,但自动补全是 CLI 解析行为,不是事故证据。 执行计划应把目标归一化成完整时间戳,再由操作者对照日志、server timezone 和原始来源 复核。

时间目标配合 recovery_target_inclusive

inclusive=on   包含 commit timestamp 恰好等于目标的事务
inclusive=off  停在它之前

真实系统中多个事务可能共享很接近的提交时间;日志时间还有格式精度和采集延迟。时间目标 更适合表达候选区间,而不是把一条聊天消息的分钟级时间强行当成精确提交点。

XID:本章实验的目标,但不是天然全序号

XID 在事务开始时顺序分配,事务可以按不同顺序结束:

XID 500 begins
XID 501 begins
XID 501 commits
XID 500 commits

官方定义是:恢复那些在目标事务之前提交的事务,并根据 inclusive 决定是否包含目标 事务;不能用 xid < target 推断“更早提交” (recovery_target_xid)。

本章实验故意串行制造三笔事务:

safe-before -> damage -> post-target

所以 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:最清晰,但必须提前创建

SELECT pg_create_restore_point(
    'before_price_rebuild_change_20260730'
);

命名恢复点适合重大 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 会产生新历史

原历史:

timeline 11
  A ---- B ---- damage ---- C ---- D

恢复到 damage 之前并 promote:

timeline 11
  A ---- B ---- damage ---- C ---- D
          \
timeline 12
            B' ---- E ---- F

新 timeline 防止恢复后生成的 WAL 覆盖旧历史。PostgreSQL 会产生 timeline history 文件,记录从哪条历史、哪个位置分叉;它们会像 WAL 一样归档,且是多分支恢复选择所必需 的证据 (Timelines)。

因此 timeline ID 不是“恢复次数”或“谁更新”,而是 WAL 历史分支身份。看到 timeline 变大也不能单独断言一次未授权 failover;要读 history、DCS、日志和变更记录。

currentlatest 的参照点

PostgreSQL 的定义非常具体:

current  沿基础备份创建时所在的 timeline 恢复
latest   沿 archive 中可找到的最新 timeline 恢复;默认值
N        沿明确的十进制 timeline
0xN      沿明确的十六进制 timeline

latest 对持续追随新 primary 的 standby 很有用,但在多次事故演练后,archive 里可能 已有后来创建的实验分支。若本次目标是原事故历史,盲用 latest 可能沿错分支。本章正式 实验显式使用 current,把恢复限定在 fresh backup 所属的 timeline 11;候选 promote 后各自在隔离目录进入新 timeline,但 archive_mode=off,不把实验分支推回 source repository。

复杂 re-recovery 必须画出 lineage:

backup label
backup start / stop LSN
backup timeline
target timeline
parent timeline and switchpoint
required history files
required WAL min/max
why latest/current/explicit is chosen

“仓库里有这个 WAL 文件名”仍不够。WAL 文件名前八位是 timeline 的十六进制表示;同一 逻辑 segment 位置在不同 timeline 上可属于不同历史。

“刚刚之前”是 exclusive 语义

time、XID、LSN 的 recovery_target_inclusive 默认是 on

on   stop just after target
off  stop just before target

如果目标 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 时,最稳妥的做法通常不是在事故群里投票,而是:

same backup
same target
same source timeline
same validation probes
candidate A inclusive
candidate B exclusive

然后让数据回答。候选差异应聚焦目标事务;如果两者还有其他意外差异,说明 backup、 timeline、并发提交或验证合同仍未被理解。

32.2.3 时钟、时区和证据误差

PostgreSQL 有不止一个“现在”

在一笔长事务中:

SELECT
    current_timestamp,
    statement_timestamp(),
    clock_timestamp();
  • current_timestamp / transaction_timestamp() 固定为事务开始时刻;
  • statement_timestamp() 是当前语句开始时刻;
  • clock_timestamp() 返回实际墙钟,会在语句执行中变化。

若审计列默认使用 current_timestamp,长事务提交时记录的值可能接近事务开始,不是 commit 时刻。日志行时间、应用 request time、消息 broker time 又分别来自不同进程。 “按 audit.created_at 恢复”之前必须先确认这个列的时间语义。

建立时钟证据

恢复票据至少记录:

PostgreSQL TimeZone and log_timezone
database host UTC time and synchronization state
application host UTC time
collector/queue timestamp semantics
client-provided timestamps and trust level
log timestamp precision
known NTP offset or uncertainty
conversion rule used by CLI

使用完整 UTC 或 numeric offset,不使用歧义缩写。官方建议 recovery time 使用数值偏移 或完整 zone name;缩写是否可用取决于更早加载的 timezone_abbreviations

把误差写进搜索区间

设:

  • $t_o$ 是观察到的时间;
  • $\epsilon_c$ 是时钟偏差;
  • $\epsilon_l$ 是日志/采集延迟;
  • $\epsilon_s$ 是时间戳精度与语义误差。

则候选区间至少为:

[to(ϵc+ϵl+ϵs),to+(ϵc+ϵl+ϵs)] [t_o-(\epsilon_c+\epsilon_l+\epsilon_s), 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 还会补“今天”。这些便捷格式 适合交互,不适合冻结事故目标。

生产票据应保存:

raw source timestamp
source timezone
normalized UTC timestamp
normalization tool/version
operator-reviewed target
inclusive/exclusive
candidate validation result

目标选择验收表

检查 通过证据 失败动作
一个目标 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 隔离恢复策略

隔离恢复的目标不是“找一台空机器”,而是让恢复候选同时满足:

cannot overwrite source evidence
cannot join source HA control plane
cannot receive application traffic
cannot emit unintended external effects
cannot contaminate source WAL archive
can still read the exact backup/WAL it needs
can be identified, validated, stopped and deleted exactly

这六条不是同一个开关。listen_addresses='' 能关闭 TCP 入站,不会自动阻止逻辑订阅、 HTTP 扩展或脚本向外连接;custom PGDATA 不接入 Patroni,也不会自动得到只读 repository 凭据。隔离要逐层证明。

32.3.1 不覆盖仍可取证的原集群

原集群同时是现场、差异源和回退资产

即使原数据已经错误,它仍保存:

  • 错误之后的合法提交;
  • 当前业务 key、version 与外部事件身份;
  • 未归档 WAL 和最近 commit 线索;
  • session、job、slot、subscription 与路由状态;
  • 与恢复候选进行差异比较的基准;
  • 如果目标判断错误,重新选择候选所需的证据。

因此默认拓扑应是:

live source --------------------------> preserved
    |                                      |
    +--> backup/WAL --> candidate A        +--> good-after audit
                   \--> candidate B        +--> current-state manifest

而不是:

live source PGDATA --overwrite--> guessed target

PostgreSQL 官方恢复流程也建议在空间允许时先复制整个 cluster data directory 与 tablespace;至少保存可能尚未归档的 pg_walRecovering Using a Continuous Archive Backup)。 对仍在线的 source,不应把这理解成随意复制活动 PGDATA;应通过受支持的备份、存储快照 或停机证据流程完成。

先决定 source 的运行状态

三种常见状态:

source 状态 何时选择 必须控制
继续服务 影响局部,可围住错误对象 bad writer、受影响 key、事后写审计
只读/全局写围栏 影响广,good-after 难追踪 所有入口、后台作业、长事务
停止并保全 完整性未知或仍快速恶化 WAL、内存外证据、启动权限

“source 继续服务”不能写成“什么都不做”。至少要冻结错误 job/service identity,记录围栏 时间,保存合法写 manifest。反之,也不要因为要做 PITR 就自动停全站;停写是业务可用性 决策,必须由影响范围与对账能力支撑。

保存事实,不保存秘密扩散

事故证据包可以记录:

cluster and member identity
system identifier relation or protected digest
timeline and LSN
backup label and archive range
effective recovery parameters
object/row manifests and business-key digests
UTC action timeline
source-file and evidence hashes

不应把这些内容直接放进公开工单:

SCRAM verifiers
TLS private keys
cloud/repository credentials
raw connection URIs
unredacted business rows
unbounded query text or bind values

私有证据目录需要最小权限、保留期限与访问日志;公开摘要只保留足以复核结论的环境轮廓、 计数、状态和散列。

任何覆盖动作都要单独授权

managed PGDATA restore 至少会:

  1. 停止或绕开 HA 生命周期;
  2. 替换当前物理数据;
  3. 改变 timeline 与可继续恢复的路径;
  4. 使旧节点与 DCS/其他成员产生身份冲突风险;
  5. 让回到当前状态依赖另一条恢复路径。

它是独立的高风险状态迁移,不应因为 side restore 验证通过就自动获批。本章实验从不 执行 managed restore,也不把 side candidate 注册为 Patroni member。

32.3.2 选择备份、WAL 与目标环境

备份必须早于目标并能走到目标

对候选 backup $B_i$,最低条件是:

stop(Bi)<Ttarget \operatorname{stop}(B_i) < T_{\text{target}}

并且从 backup 一致点到 target 的所有必需 WAL 与 timeline history 都可读。选择最近的 合格 backup 通常减少重放量,但不能只按 label 字符串或“最新”按钮决定:

stanza identity matches source
database system lineage matches
PostgreSQL major and block/checksum properties compatible
backup status has no error
full/diff/incr dependencies all retained
backup stop point is before target
archive min/max and history cover target timeline
tablespace mapping is available
repository key and credentials are usable

本章正式实验在 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,而不是让工具临场猜

恢复票据应保存:

stanza             pg-test
repo               1
backup label       20260730-030910F
backup type        full
backup stop        timestamp + LSN + WAL
target             XID/time/LSN/name
inclusive          true/false
timeline           current/latest/N + rationale
target action      pause/promote/shutdown

运行 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 与文件系统语义要正确。

恢复关键参数常包括:

max_connections
max_worker_processes
max_wal_senders
max_prepared_transactions
max_locks_per_transaction

如果恢复主机当前配置比 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 覆盖:

listen_addresses=''
unix_socket_directories=<private-root>/socket
unix_socket_permissions=0700
hba_file=<private-root>/pg_hba.restore.conf
ssl=off
shared_preload_libraries=''
primary_conninfo=''
primary_slot_name=''
archive_mode=off

private HBA:

local all postgres peer
local all all      reject

这些设置适合本章沙箱,不是所有生产恢复的通用模板。例如需要业务验证账号时,应新建 一次性、最小权限的验证入口,而不是重新开放原应用角色。

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 写权限,单靠操作人员承诺不是权限隔离。

控制数据库内的自动执行器

恢复实例中可能存在:

pg_cron / pgAgent jobs
logical subscriptions
background worker extensions
LISTEN/NOTIFY consumers
outbox relay
trigger-driven HTTP calls
foreign data wrappers
application sidecars and local service units

应在启动前列出它们,并从三层阻断:

  1. 网络层:默认拒绝出站;
  2. 进程层:不启动 application/relay/agent,按评审禁用 background workers;
  3. 数据层:把 outbox/queue 置隔离状态,使用幂等键和 sandbox endpoint。

本章只证明 synthetic fixture 的 external_dispatch_count=0,没有宣称通用 egress 隔离 已经通过。读者把实验迁移到真实系统时,必须补这一门。

Pigsty 的 managed 与 side restore 边界

当前 pig pitr 有两种生命周期:

managed data directory
  may stop Patroni
  ensures PostgreSQL is stopped
  restores managed PGDATA
  may start PostgreSQL
  leaves Patroni stopped
  does not rejoin HA or switch traffic

custom -D side restore
  requires pre-created postgres-owned directory
  requires --no-restart
  does not stop Patroni
  does not manage default PostgreSQL service
  operator starts isolated postmaster manually

完整语义见 pig pitr。这里最重要的不是记选项,而是从 plan 核对实际 boundary。路径是否为 side restore 由有效 managed PGDATA 与解析后的路径决定, 不是简单看字符串里有没有 /pg/data

正式实验先执行:

pig pitr \
  -s pg-test -r 1 \
  -b "$BACKUP_LABEL" \
  --xid "$DAMAGE_XID" \
  --target-action=promote \
  --target-timeline=current \
  -D "$CANDIDATE/data" \
  --no-restart \
  --plan \
  -o json \
  -- \
  --archive-mode=off

plan 必须明确:

boundary       pitr:side-restore
data directory exact candidate path
service lifecycle not-managed
backup set     exact reviewed label
target         exact source-audited XID
timeline       current
confirmation   required

结构化执行不会交互询问;只有审批完成后才使用 --yes。不要把 --yes 写进没有 guard、 没有 exact target、会指向 managed PGDATA 的通用脚本。

隔离验收

启动候选后,同时从 OS 与 SQL 两侧检查:

OS:
  no TCP listener on candidate port
  private socket exists and mode is 0700
  postmaster.pid belongs to exact candidate
  managed Patroni process remains unchanged

SQL:
  cluster_name identifies candidate
  listen_addresses is empty
  archive_mode is off
  ssl is off for private socket-only lab
  shared_preload_libraries is empty in this fixture
  pg_is_in_recovery() eventually becomes false

停止时只允许:

pg_ctl -D <exact-marker-root>/data stop
verify PID and socket absent
delete <exact-marker-root>

宽泛 pkill postgres、按端口杀进程、删除未解析变量路径都不属于可接受清理。


上一节:恢复目标与时间线 · 返回本章目录 · 下一节:执行恢复并观察进度 · 查看全书目录 · 查看索引中心

32.4 执行恢复并观察进度

一次 PITR 至少有四个不同的“完成”:

restore copy complete
  -> PostgreSQL first accepts a connection
      -> configured recovery target is reached
          -> candidate data and business invariants pass

pig pitr --no-restart 成功只证明 pgBackRest restore 阶段完成;pg_ctl -w start 成功 可能只代表 hot standby 已可读;pg_is_in_recovery() = false 证明 recovery 已结束, 仍不证明目标业务状态正确。工具状态必须映射到这条状态机。

32.4.1 从已验证备份克隆恢复目标

冻结一份可重放参数包

执行前把变量写入私有 evidence,而不是临场从 shell history 猜:

stanza
repository number
backup label
source system-lineage relation
backup stop LSN/time/timeline
target type and exact normalized value
inclusive/exclusive
target timeline
target action
candidate root
PostgreSQL binary/version
recovery-critical settings
source file hashes
operator, reviewer and authorization

秘密只保存引用或受控位置,不复制进 JSON。参数包要能回答“同一份证据能否重放同一个 候选”,但不能变成凭据泄漏包。

先验证 repository 和 target 可达性

只读预检:

pgbackrest --stanza=pg-test --repo=1 --output=json info
pgbackrest --stanza=pg-test check

检查:

  • 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 只接受形如:

/data/pg36-ch32-restore/run_YYYYMMDDTHHMMSSZ_random/inclusive
/data/pg36-ch32-restore/run_YYYYMMDDTHHMMSSZ_random/exclusive

的 exact path。目录必须:

owner       postgres:postgres
mode        0700
symlink     false
preexisting false
managed PGDATA relation different

Pig 要求 custom -D 预先存在、归 DBSU 所有;它不会代为创建,因为在 destructive classification、owner 检查和 plan 输出之前必须先知道具体目标路径。

先 plan,后 execute

inclusive 候选:

sudo -iu postgres pig pitr \
  -s pg-test -r 1 \
  -b "$BACKUP_LABEL" \
  --xid "$DAMAGE_XID" \
  --target-action=promote \
  --target-timeline=current \
  -D "$INCLUSIVE_ROOT/data" \
  --no-restart \
  --plan \
  -o json \
  -- \
  --archive-mode=off \
  --spool-path="$INCLUSIVE_ROOT/spool" \
  --log-path="$INCLUSIVE_ROOT/log"

审阅 plan 后,在受 guard 约束的自动化中把 --plan 换成 --yes。exclusive 候选只增加:

--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 结果包括:

boundary/effective data dir  exact side path
side_restore                 true
managed data dir             /pg/data
patroni_stopped              false
postgres_restarted           false
backup_set                   exact fresh full
target_type                  xid
target_value                 source-audited XID
exclusive                    true or false
target_action                promote
target_timeline              current

patroni_active=true 在这里是好事:它描述 restore host 上原有 live replica 的 Patroni 没有被 side restore 停掉;隔离 candidate 尚未启动。不能把它误读成“候选已由 Patroni 管理”。

文件恢复失败时

若 pgBackRest restore 失败:

  1. 保留 plan、stdout/stderr、candidate marker 与 repository snapshot;
  2. 不启动部分恢复目录;
  3. 判断是 backup、WAL、key、权限、空间、tablespace 还是版本问题;
  4. 停止任何 exact candidate postmaster;
  5. 只删除当前 marker 所有的 candidate;
  6. 修复原因后开启新 run,不覆盖旧 evidence。

managed restore 失败时,Patroni 可能已停止且 PGDATA 可能只恢复了一部分;不能因失败而 直接重启 Patroni。side restore 则不应改变 managed 生命周期,这正是事故调查优先使用 它的原因。

32.4.2 监控 WAL 重放、目标达成与启动状态

手工启动时覆盖危险继承配置

正式 runner 使用 PostgreSQL 18 的 pg_ctl 启动 custom PGDATA,并通过 -o 传入:

config_file=<candidate>/data/postgresql.conf
hba_file=<candidate>/pg_hba.restore.conf
listen_addresses=''
port=55433
unix_socket_directories=<candidate>/socket
unix_socket_permissions=0700
ssl=off
archive_mode=off
primary_conninfo=''
primary_slot_name=''
shared_preload_libraries=''
logging_collector=off
cluster_name=pg36-ch32-inclusive|exclusive

以及 source 的五项 recovery-critical maxima。完整、安全转义与路径验证见 exercise.py,不要从上面的概念清单拼接未审阅 shell。

第一条连接可能发生在 recovery 中

hot_standby=on,PostgreSQL 达到一致状态后可以开放只读查询,而后继续重放 WAL。 因此:

pg_ctl -w start

-w 只等待 server ready,不保证 target_action=promote 已完成。第 21 章正式实验 已经实际观察到:

first connection  recovery=true, transaction_read_only=true
later             recovery=false, writable=true

本章 runner 继续轮询:

SELECT json_build_object(
    'in_recovery', pg_is_in_recovery(),
    'transaction_read_only',
        current_setting('transaction_read_only')::boolean,
    'last_replay_lsn', pg_last_wal_replay_lsn(),
    'replay_paused', pg_is_wal_replay_paused()
);

只有 pg_is_in_recovery() = false 才表示 promote action 已完成。随后仍先运行只读业务 探针;“可写”只是候选能力,不是允许应用写入。

分解恢复时延

用一个总秒数会掩盖瓶颈。本章分开测:

target_identification_ms
plan_ms
restore_copy_ms
start_to_first_connection_ms
start_to_promoted_ms
candidate_validation_ms
reconciliation_ms

关系:

$$ 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 复核工具状态

三个观察面互相校验

文件/配置面

PG_VERSION
backup_label or backup metadata
postgresql.conf / include chain
postgresql.auto.conf
recovery.signal
standby.signal
tablespace links
postmaster.pid / postmaster.opts

现代 PostgreSQL 通过 recovery.signal 进入 targeted recovery;若同时有 standby.signal,standby mode 优先。pgBackRest 会把 recovery 设置写入有效配置, 操作者应读 effective result,不能只看手写模板。

日志面

关注事件顺序,而不是只搜 ready

selected timeline / restore command
redo starts at ...
restored log file ...
consistent recovery state reached
recovery stopping before|after target transaction/time/LSN
redo done
new timeline selected/created
database system is ready to accept connections
fatal target not reached or WAL unavailable

日志时间必须与第 32.2 节的时区证据绑定。敏感业务值、凭据和原始查询不应未经筛选进入 公开 evidence。

SQL/控制面

SELECT
    pg_is_in_recovery(),
    pg_is_wal_replay_paused(),
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn(),
    pg_last_xact_replay_timestamp();

SELECT * FROM pg_control_checkpoint();
SELECT * FROM pg_control_recovery();
SELECT * FROM pg_control_system();

在 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 取目标, 再由两个候选证明包含关系。

候选状态表

恢复完成后先填:

candidate:
  backup_label: exact
  target:
    type: xid
    value_source: source-audit
    inclusive: true | false
    timeline: current
    action: promote
  runtime:
    in_recovery: false
    archive_mode: off
    tcp_listener: false
    private_socket: true
    patroni_managed: false
  data:
    safe_present: true | false
    damage_present: true | false
    post_target_present: true | false
  decision: accepted | rejected | blocked

下一节才把 accepted candidate 与当前 source、good-after manifest 和外部系统放在一起, 决定提取、修补或切换。


上一节:隔离恢复策略 · 返回本章目录 · 下一节:数据验证与安全回切 · 查看全书目录 · 查看索引中心

32.5 数据验证与安全回切

恢复候选能启动,只证明 WAL 把物理文件带到一个一致状态;错误事务不在候选里,只证明 一个必要条件;把这个候选切给用户,还要回答:

目标之前应该保留的事实是否完整?
目标之后的合法提交去了哪里?
当前 source 是否已发生新的变化?
数据库外的消息、支付、邮件和索引是什么状态?
切换后应用是否会重复执行历史工作?
如果判断错了,怎样回到切换前状态?

验证应当从“便宜、宽泛”逐渐走向“昂贵、业务特定”,每一层都能独立阻断下一层。

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

前一层通过不能推出后一层。例如:

PostgreSQL ready            != target reached
target reached              != target selected correctly
row count equal             != row contents equal
table digest equal          != payment provider agrees
application login succeeds  != background jobs are safe

先做对象 manifest

对象 manifest 不应只列 table name:

SELECT
    n.nspname,
    c.relname,
    c.relkind,
    c.relpersistence,
    c.relispartition,
    c.reltuples::bigint AS estimated_rows
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = ANY (ARRAY['app', 'ledger'])
ORDER BY 1, 2;

还要覆盖:

  • 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 却完全不同:

candidate A  account 1 = 100, account 2 = 200
candidate B  account 1 = 200, account 2 = 100

更有用的 manifest:

exact count
min/max business key
sum and bounded numeric aggregates
count by status/tenant/time bucket
ordered or commutative digest over canonical fields
null/duplicate/orphan counts
critical identity allow/deny probes

对大表按稳定业务 key 分桶,分别保存 count/sum/digest;失败时可以缩小范围,也避免一个 全表 string_agg 占用巨大内存。digest 输入要固定:

column order
NULL representation
timestamp timezone and precision
numeric scale
text encoding/collation
row ordering
hash algorithm/version

否则“摘要不同”可能只是序列化不同。

关键交易采用双向探针

每个候选至少要有:

must exist      target 前已提交的安全事实
must not exist  错误事务制造的事实
must not exist  target 后提交、尚未合并的事实

本章正式实验:

探针 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。

跨表不变量比单表摘要更接近业务

示例:

account.balance
  = opening balance + sum(posted ledger)

order.total
  = sum(order_line.amount) + tax - discount

inventory opening + receipts - reservations - shipments
  = inventory closing

one settled payment
  -> exactly one immutable ledger posting
  -> at most one externally acknowledged charge

把不变量写成可返回违反数量的 SQL:

SELECT count(*) AS violations
FROM app.account AS a
JOIN (
    SELECT account_id, sum(amount_cents) AS ledger_balance
    FROM ledger.entry
    WHERE state = 'posted'
    GROUP BY account_id
) AS l USING (account_id)
WHERE a.balance_cents <> l.ledger_balance;

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 final=RG \text{source final} = R \oplus G

并且只有在当前 source 仍符合预期错误前像 $D$ 时才允许写。流程:

export R with business keys and digest
export G from independent audit/ledger
derive expected current preimage D
start scoped write fence
BEGIN
  verify exact key set and current preimage
  conditionally write R + G
  cancel/mark bad outbox rows
  assert affected row counts
  assert business invariants
COMMIT
release fence after external validation

任何 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 与冲突

对象提取后常见遗漏:

rows restored, sequence still behind
historical key now reused by a new row
unique key conflicts with post-target write
foreign key points to newer version
trigger re-emits side effect
generated column expression changed

处理 sequence 不能只 setval(max(id))

  • 其他 partition/table 是否共享 sequence;
  • cache 中是否还有已分配值;
  • post-target 是否使用更高 ID;
  • 应保留 monotonically increasing 还是允许 gap;
  • application 是否把 ID 大小误当业务顺序。

冲突必须交给业务规则决定:保留当前、保留历史、合并字段、生成新 identity 或人工审阅。 数据库工具不能替 owner 发明真相。

整库切换前先重放 good-after

若选择整库 candidate,至少:

  1. 在 source 建立并证明全局写围栏;
  2. 冻结最后 source commit/high-water mark;
  3. 把 $G$ 按依赖与幂等顺序应用到 candidate;
  4. 对数据库和外部系统重新对账;
  5. 重新运行对象、数据、业务与应用验证;
  6. 排空/重建连接池;
  7. 以明确 endpoint 切换,不依赖 DNS 猜测;
  8. 观察错误率、延迟、backlog 与不变量;
  9. 保留 source,不立即 rewind/delete;
  10. 达到观察窗和审批后才处理旧拓扑。

pig pitr 不会自动完成 Patroni rejoin、VIP、HAProxy/PgBouncer 切换或应用 smoke。它是 恢复编排工具,不是 cluster/service recovery controller。相关平台动作要结合 第 22 章第 33 章 的角色、路由和重建合同。

回切之后的回退边界

切到 candidate 并恢复写入后,会出现 candidate-only commits。此时回 source 也不再是 “切回连接串”,而需要反向合并:

before first candidate-only write
  -> technical route rollback may be possible

after first candidate-only write
  -> fence
  -> identify candidate-only commits
  -> reverse reconcile
  -> external compensation
  -> then decide route

这与第 30 章升级的“第一笔目标独占写”边界相同。把观察窗口只写成 30 分钟不够,还要 记录第一笔独占提交。

32.5.3 防止恢复环境向外重复发送消息和支付

数据库回到过去,外部世界不会

假设:

10:00 order committed
10:01 payment provider charged
10:02 email sent
10:03 search index updated
10:05 database restored to 09:59

数据库里订单消失,并不会自动退款、收回邮件或删除搜索文档。若恢复实例又重放同一 outbox,还可能再次扣款或发送。

因此恢复状态是一个分布式对账问题:

Sbusiness=SPostgreSQLSbrokerSpaymentSsearchSnotification S_{\text{business}} = S_{\text{PostgreSQL}} \Join S_{\text{broker}} \Join S_{\text{payment}} \Join S_{\text{search}} \Join S_{\text{notification}}

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 至少有:

event_id          globally stable
aggregate_id
aggregate_version
event_kind
payload_digest
created_at
dispatch_state
external_ack_id
idempotency_key

恢复时把事件分为:

already acknowledged externally
  -> do not resend; rebuild local state from ack

pending and still semantically valid
  -> send once under same idempotency key

created by bad transaction
  -> cancel/tombstone; never dispatch

unknown
  -> quarantine for owner review

不要直接删除坏 outbox 行。保留 canceled 状态和 incident/change ID,才能证明为什么没 有发送,并避免后续任务把“缺失”解释成尚未生成。

本章正式实验在 damage 事务内生成 1,000 条 pending fixture outbox;外部 dispatcher 根本不存在。对账事务精确把这 1,000 行变为 canceled,要求 row count 完全匹配, 最终 pending=0external_dispatch=0

支付必须以外部账本为准

支付场景禁止只根据恢复后的 payment.status 决定重扣或退款。至少比对:

merchant order id
provider transaction id
idempotency key
authorization / capture / refund state
amount and currency
provider event sequence
database immutable ledger

若数据库显示“未支付”但 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 的目的,是证明:

wrong target can be rejected
right historical boundary can be recovered
post-target legitimate writes can be reconciled
fixture side effects can be canceled
source topology can remain healthy

它没有真实应用、payment provider、CDC、DNS/VIP 或 production owner,因而不具备服务 切换前提。business_cutover_performed=falseproduction_ch32_gate=pending 是实验通过条件,不是未完成项。


上一节:执行恢复并观察进度 · 返回本章目录 · 下一节:实战:随机恢复目标演练 · 查看全书目录 · 查看索引中心

32.6 实战:随机恢复目标演练

本节把前五节压成一次可重放实验。它不把 target 写死在脚本里,也不只恢复一个“预期会 成功”的候选,而是要求:

source audit independently yields damage XID
inclusive candidate must prove damage is present and be rejected
exclusive candidate must prove damage is absent and be accepted
raw exclusive candidate must expose post-target data loss
reconciliation must preserve every audited legitimate write
all cleanup and safety claims must survive adversarial mutation

实验会创建并删除一次性 synthetic schema,创建两个 side-restore 目录后精确删除,并 保留一份新 full backup。它只适用于已经确认的开发沙箱;guard 字符串不是生产授权。

32.6.1 在隐藏时间窗内注入误更新

先读四份合同

静态检查不连接远端:

static/labs/ch32/task.sh lint

只读预检可在一个独立新目录运行:

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch32-capture.XXXXXX"
)"
static/labs/ch32/task.sh capture

它验证:

target identity and three-member Patroni topology
pg-test-1 is sole running primary
pg-test-2/3 are streaming replicas
PostgreSQL major and cluster_name
fixture schema absent
restore prefix empty and port free
repository readable
Pig pitr required flags present
source and upstream evidence hashes

capture 的在线 mutation 为零,不创建 backup,不恢复目录,也不改变 Patroni/DCS/路由。

完整演练需要新的私有 evidence directory

export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch32.XXXXXX"
)"
export PG36_CH32_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH32_NONPRODUCTION=true
export PG36_CH32_PRODUCTION_DATA=false
export PG36_CH32_PRODUCTION_TRAFFIC=false
export PG36_CH32_CONFIRM=RANDOM_XID_PITR_RECONCILE_CH32

static/labs/ch32/task.sh drill:pitr

任一 guard 缺失,runner 在 source mutation 前退出。evidence directory 已非空也会退出, 避免把新 run 混进旧证据。正式实现见:

fixture 是一份可独立计算的账

test.pg36_ch32 有 5,000 个账户:

account_id       1 .. 5000
opening balance  100000 + account_id cents
status           active

opening total:

$$ 5000 \times 100000

  • \frac{5000 \times 5001}{2} =512{,}502{,}500 $$

runner 用系统随机源选择连续 1,000 个 victim。目标账户范围、run ID 与事故时刻只在本次 私有证据中确定,恢复命令没有预置 XID 或固定时间。

四笔状态和一份 fresh backup

顺序:

base transaction
  create 5,000 accounts and run marker
  write base audit

fresh full backup

safe-before transaction
  +100 cents to all 1,000 victims
  insert 1,000 safe ledger rows
  write safe XID audit

damage transaction
  set all 1,000 victim balances to zero
  status = mispriced
  create 1,000 fixture-only pending outbox rows
  write damage XID audit in the same transaction

post-target transaction
  +700 cents to first 100 victims
  insert 100 legitimate ledger rows
  write post-target XID audit

damage 和 outbox 在同一事务中,因此 inclusive candidate 要么同时看到二者,要么实验 失败。外部 dispatcher 不存在,external_dispatch_enabled=false 写入 audit 与合同。

注意 source 在 damage 后、修补前的当前值:

first 100 victims    balance=700, status=mispriced
other 900 victims   balance=0,   status=mispriced

而正确历史值应是 opening + 100,前 100 个还要再加合法的 700。这个设计使“直接写回 recovered value”必然丢数据,逼迫操作者处理 good-after。

target 来自 source,不来自注入代码常量

runner 在三笔事务完成后,单独查询:

pg36_ch32.incident_audit
  where run_id = exact marker
    and stage = damage

取得 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 观察:

base audit                   present
safe-before audit            present
safe ledger rows             1,000
damage audit with target XID present
mispriced victim rows        1,000
pending wrong outbox         1,000
post-target audit/ledger     absent / 0
decision                     rejected

这证明默认 inclusive XID 语义停在目标事务之后。若 validator 只检查 pig_result.success 而没有检查 damage 数据,这个错误候选会看起来完全健康。

exclusive 候选才是历史正确边界

base audit                   present
safe-before audit            present
safe ledger rows             1,000
damage audit                 absent
pending wrong outbox         0
post-target audit/ledger     absent / 0
victims active               1,000
victim balance sum           deterministic opening + 100 each
decision                     accepted

source timeline 为 11;两个隔离候选沿 current 重放并 promote 到 timeline 12。因为 archive_mode=off,两个实验分支不会进入共享 repository。

accepted 的含义是“可作为历史正确数据源”,不是“可以直接切流”。它明确缺少 100 笔 post-target ledger。

从 candidate 提取,再与 source audit 合并

exclusive candidate 导出 1,000 行:

account_id
recovered_balance_cents
status=active

私有 evidence 只保存 count 和导出集合 SHA-256。source 独立导出 100 条 post-target ledger,要求 key 集合正好等于预期 100 个账户,每条 delta 都是 700。

runner 构造 typed temporary table:

account_id
recovered_balance_cents
post_delta_cents
expected_current_balance_cents

一笔事务中先验证 1,000 行 current preimage:

first 100 expected current balance = 700
remaining 900 expected current balance = 0
all status = mispriced

完全匹配后才执行:

new balance = recovered balance + audited post delta
status = active
pending wrong outbox -> canceled

条件更新或 outbox row count 不是 1,000,事务抛错并回滚。

最终 manifest 独立可算

safe 增量:

1000×100=100,000 1000 \times 100 = 100{,}000

post-target 合法增量:

100×700=70,000 100 \times 700 = 70{,}000

最终总额:

512,502,500+100,000+70,000=512,672,500 512{,}502{,}500 + 100{,}000 + 70{,}000 =512{,}672{,}500

正式结果:

accounts                    5,000
total balance               512,672,500 cents
victims active              1,000
victims mispriced           0
safe ledger                 1,000
post-target ledger          100
pending wrong outbox        0
canceled wrong outbox       1,000
external dispatch           0

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

共同阶段:

target identification        0.183 s
fresh full backup            2.090 s
pgBackRest check             0.630 s
reconciliation               0.200 s
backup logical size          42,355,954 bytes
repository delta              5,751,304 bytes

这是本地 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

本次没有测量:

incident detection
human target investigation
production approval
large-scale validation
business-owner reconciliation
application deployment/config change
pool drain and traffic switch
cache/search rebuild
observation window

因此公开 evidence 固定:

timings_are_sandbox_observations = true
production_rto_claimed = false

生产演练应测 end-to-end:

t0 incident declared
t1 mutation fenced
t2 target approved
t3 candidate engine ready
t4 candidate business-valid
t5 good-after/external reconcile complete
t6 service traffic restored
t7 observation window exited

分别报告阶段时长与 critical path,不能只取最快的 restore copy

版本恢复与版本迁移是两项工时

物理 backup 要由兼容的 PostgreSQL major 与 extension library 启动。若事故时生产已经 升级,而目标 backup 属于旧 major,runbook 可能需要:

acquire exact old server binaries
acquire matching extension libraries
recreate locale/collation runtime
restore and validate on old major
export selected data
transform/import into current major
validate cross-version semantics

这段工时要独立记录,不能用“目标主机已经装了新版 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 输出恢复证据和备份体系改进项

私有证据包

preflight-evidence.json
exercise-manifest.json
source-before.json
fixture.json
backup.json
inclusive-plan.json
inclusive-recovery.json
exclusive-plan.json
exclusive-recovery.json
reconciliation.json
source-after.json
cleanup.json
negative-report.json
validation-report.json
public-summary.json
review.txt

目录为 0700,文件为 0600。review 拒绝 SCRAM verifier、private key、clear password、 credential URI 和 raw system identifier。公开摘要只保留版本、规模、计数、时间、 候选决策、安全结论和 gate。

对完成的同一证据目录,可以离线重跑:

export PG36_EVIDENCE_DIR=/private/existing/ch32-run
static/labs/ch32/task.sh verify
static/labs/ch32/task.sh review

all 也只执行 verify + review,不会再次创建 fixture、backup 或 restore。修改 12 个 实验源文件中的任何一个后,旧 preflight/source hash 会失败;应开启新 run,不应给旧 证据重新盖章。

32 个反例不是装饰

validator 将每个完整 evidence 深拷贝后实际注入变异,要求全部被拒绝:

authority:
  production data allowed
  managed PGDATA / Patroni mutation allowed
  backup expiration allowed

target:
  time replaces audited XID
  exclusive disabled
  timeline changes to latest
  archive mode enabled
  TCP listener enabled

preflight:
  target/primary/topology changed
  source or upstream hash changed

recovery:
  damage XID or backup label changed
  archive coverage false
  inclusive damage missing or candidate accepted
  exclusive damage/post present or safe missing

reconciliation:
  repaired rows = 999
  post-target write lost
  external dispatch = 1

cleanup/governance:
  route changed
  restore root remains
  production gate approved

正式 run:

declared counterexamples rejected  32 / 32
live evidence mutants rejected     32 / 32
source files hash-bound            12 / 12
secret material                    absent

从一次成功演练生成改进 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 场景和通过证据。只写“加强监控”无法改变下一次 恢复结果。

终局结论

本章正式证据支持:

exact audited XID targeting demonstrated
inclusive/exclusive boundary demonstrated
isolated Pig side restore demonstrated
post-target reconciliation demonstrated
exact fixture/root cleanup demonstrated
source HA preservation demonstrated

它不支持:

production cutover approved
worst-case archive RPO met
production RTO met
external side effects compensated
cross-major restore completed
regional disaster recovery completed

所以最终决策保持:

business_cutover_performed = false
production_ch32_gate = pending

下一章切换响应目标:当数据本身正确,但 writer、timeline 或节点拓扑失去唯一性时,怎样 完成主从切换、fencing、pg_rewind 与故障节点重建。


上一节:数据验证与安全回切 · 返回本章目录 · 下一章:故障切换与集群重建——力挽狂澜 · 查看全书目录 · 查看索引中心