跳至内容
32.5 数据验证与安全回切

32.5 数据验证与安全回切

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

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

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

32.5.1 行数、摘要、关键交易与跨表不变量

五层验证金字塔

层次问题示例证据
引擎cluster 能否稳定完成 recoverylog、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 后提交、尚未合并的事实

本章正式实验:

探针inclusiveexclusive
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,至少:

  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,还可能再次扣款或发送。

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

$$ 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恢复后主动连 publisheregress deny、禁用 subscription worker
CDC connector从旧 LSN 重发独立 connector identity,不接 candidate
cron/job到点重新跑批scheduler 不启动、job 状态清单
payment API重复 charge/refundprovider 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 变绿。

服务切换的最终门

通过条件
candidateexact target/timeline,safe/bad/post 探针符合预期
database对象、manifest、约束、索引与跨表不变量通过
deltagood-after 身份完整,重放/合并无缺失
externalpayment/message/search/notification 已对账
isolationjob/connector/route 在审批前仍被围住
topologywriter 唯一,Patroni/DCS 计划经过评审
clientpool drain、endpoint、transaction retry 与 smoke 明确
rollback第一笔新独占写边界与反向对账方案明确
authorityDBA、业务 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 是实验通过条件,不是未完成项。


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

最后更新于