# 数据验证与安全回切

LLMS 索引： [llms.txt](/llms.txt)

---

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

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

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

## 32.5.1 行数、摘要、关键交易与跨表不变量 {#item-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 |

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

```text
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：

```sql
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 却完全不同：

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

更有用的 manifest：

```text
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 输入要固定：

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

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

### 关键交易采用双向探针

每个候选至少要有：

```text
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。

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

示例：

```text
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：

```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 提取差异、逻辑补回或切换整个服务 {#item-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$ 时才允许写。流程：

```text
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 与冲突

对象提取后常见遗漏：

```text
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 章](/connection-pooling-routing/) 与
[第 33 章](/failover-rebuild/) 的角色、路由和重建合同。

### 回切之后的回退边界

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

```text
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 防止恢复环境向外重复发送消息和支付 {#item-32-5-3}

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

假设：

```text
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 至少有：

```text
event_id          globally stable
aggregate_id
aggregate_version
event_kind
payload_digest
created_at
dispatch_state
external_ack_id
idempotency_key
```

恢复时把事件分为：

```text
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` 决定重扣或退款。至少比对：

```text
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 的目的，是证明：

```text
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` 是实验通过条件，不是未完成项。

---

[上一节：执行恢复并观察进度](../04/) · [返回本章目录](../) · [下一节：实战：随机恢复目标演练](../06/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
