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,而是:
$$ \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,至少:
- 在 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 也不再是 “切回连接串”,而需要反向合并:
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,还可能再次扣款或发送。
因此恢复状态是一个分布式对账问题:
$$ 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=0、external_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=false 与
production_ch32_gate=pending 是实验通过条件,不是未完成项。
上一节:执行恢复并观察进度 · 返回本章目录 · 下一节:实战:随机恢复目标演练 · 查看全书目录 · 查看索引中心