跳转到主要内容

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 语义。


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