# 实战：主库故障与 DCS 干扰

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

---

本节把前六节变成三条可重复、但风险边界不同的证据链：

```text
managed online drill
  controlled Patroni service stop
  -> process fence
  -> automatic candidate selection
  -> client token reconciliation
  -> old member rejoin
  -> planned baseline restore

offline decision drill
  random DCS/network symptom packet
  -> evidence request
  -> stop line
  -> no live DCS/network mutation

disposable PostgreSQL lab
  real timeline divergence
  -> pg_rewind
  -> fresh pg_basebackup
  -> marker/streaming validation
  -> exact cleanup
```

小节标题中的“随机注入主机、网络或 DCS 症状”指从 blind scenario library 随机抽取
症状；正式 online mutation 只有可自动复位的进程 fence。单 etcd、watchdog off 的共享
沙箱不具备安全、真实地证明不对称网络分区和 DCS quorum 的条件。

## 33.7.1 随机注入主机、网络或 DCS 症状 {#item-33-7-1}

### 先读合同

- 环境、动作和验收：
  [`requirements.json`](/labs/ch33/requirements.json)
- 五类失败域与六个 DCS scenario：
  [`failure-model.json`](/labs/ch33/failure-model.json)
- 人类可读安全边界：
  [`lab-contract.md`](/labs/ch33/lab-contract.md)
- managed 与 disposable 拓扑：
  [`topology.mmd`](/labs/ch33/topology.mmd)
- 33 个反例：
  [`negative-cases.json`](/labs/ch33/negative-cases.json)

静态检查不连接远端：

```bash
static/labs/ch33/task.sh lint
```

它检查合同、failure model、负例集合与 15 个 hash-bound source files。只读现场快照：

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

`capture` 投影：

```text
three Patroni members
role / state / timeline / lag / tags
dynamic ttl / loop_wait / retry_timeout
pause / synchronous / failsafe / rewind / slot policy
per-node service / postmaster / REST
per-node system identifier / recovery / LSN
sender / receiver state
```

它不读取 inventory，不修改数据库、DCS、服务或路由。

### 完整演练 guard

```bash
export PG36_EVIDENCE_DIR="$(
  mktemp -d "${TMPDIR:-/tmp}/pg36-ch33.XXXXXX"
)"
export PG36_CH33_INVENTORY=/absolute/private/inventory.yml
export PG36_CH33_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH33_NONPRODUCTION=true
export PG36_CH33_PRODUCTION_DATA=false
export PG36_CH33_PRODUCTION_TRAFFIC=false
export PG36_CH33_CONFIRM=FENCE_FAILOVER_REJOIN_REBUILD_CH33

static/labs/ch33/task.sh drill:failover
```

inventory 必须为 mode 0600。`private_client_service.py` 从中只提取 fixture 用户密码，
生成一次性 mode-0600 libpq service file；密码不会进入 evidence，退出时 exact private
directory 被删除。

任一 guard 不符，runner 在 mutation 前以 77 退出。evidence directory 已非空也拒绝，
避免把两次 run 混成一份报告。

### online fault 为什么选 service stop

runner 运行：

```text
systemctl stop patroni on pg-test-1
```

这是明确、可复位的 L2 动作。它不是：

```text
kill -9 Patroni while postmaster may remain writable
power off a host
freeze storage
iptables asymmetric partition
stop the single etcd
delete a DCS key
```

`systemctl stop` 返回后还不算 fence。runner 单独检查：

```text
service_active=false
postmaster_alive=false
patroni_rest_reachable=false
```

只有 fence 成立，后续候选才可被接受。

### 随机 DCS scenario

同一 run 用系统随机源从六个 scenario 选一个。本次抽到：

```text
primary-isolated-from-dcs-and-one-replica
```

blind observation：

> incumbent primary 失去 DCS，同时不能联系 failsafe set 中一名 replica。

正确决策：

```text
require demotion or external fence of old primary
then establish DCS authority on surviving side
then validate candidate WAL/timeline
only then accept promotion
```

evidence 保存 `decision_only=true`、
`live_dcs_fault_injected=false`、`live_network_partition_injected=false` 和
`leader_key_deleted=false`。教材不把桌面推理冒充在线故障结果。

## 33.7.2 保护旧主、选择候选、测量 RTO/RPO {#item-33-7-2}

### fixture 与客户端

runner 在 `test.pg36_ch33` 创建 exact marker schema：

```sql
run_marker(run_id, external_dispatch_enabled=false)
write_probe(
    run_id,
    attempt_no,
    token UNIQUE,
    client_sent_at,
    committed_at
)
```

客户端每 200 ms：

```text
token = run_id + attempt_no
INSERT ... RETURNING commit time, LSN, timeline
autocommit
target_session_attrs=read-write
```

网络/连接异常记录 `outcome=unknown`，不把错误字符串或密码写入 evidence。探针在 fault
前必须已有 acknowledgement，且在 topology stable 后还要有新 timeline acknowledgement，
否则不能测量切换窗口。

### 候选在运行时产生

preflight：

```text
pg-test-1  primary  running    timeline 17
pg-test-2  replica  streaming  timeline 17
pg-test-3  replica  streaming  timeline 17
pause=false
maximum_lag_on_failover=1 MiB
synchronous_mode=false
failsafe_mode=true
use_pg_rewind/use_slots=true
```

合同只声明 eligible set：

```text
{pg-test-2, pg-test-3}
```

没有指定 expected winner。正式结果：

```text
selected candidate  pg-test-3
failed topology     pg-test-3 primary, pg-test-2 streaming
old pg-test-1       process-fenced
timeline            17 -> 18
```

开发 runner 的早期版本曾硬编码 `pg-test-2`，真实运行立即暴露了错误。本章把这次失败
转成负例 `evidence.failed.leader=...`：validator 必须接受任一 eligible winner，并拒绝
把未观察候选写成事实。

### 时序

正式公开证据
[`failover-run.json`](/labs/ch33/failover-run.json)：

| 阶段 | 观测 |
|---|---:|
| `systemctl stop patroni` | 1.582 s |
| action start → process fence | 1.817 s |
| action start → Patroni topology stable | 4.536 s |
| old primary start → streaming | 2.527 s |
| planned switchover back | 2.832 s |
| maximum client acknowledgement gap | 6.212 s |

为什么 client gap 大于 control-plane stable：

```text
200 ms sampling resolution
connection failure detection
PgBouncer/HAProxy health convergence
new connection establishment
PostgreSQL promotion/recovery
client retry schedule
```

因此 4.536 秒是 observer 看到 Patroni stable 的控制面时间；6.212 秒是 synthetic client
连续两个成功 acknowledgement 的端到端缺口。二者都不是完整业务 RTO，后者仍未包含
事故发现、人工确认和所有应用恢复。

### timeline 归属而不是伪造 IP

服务从 HAProxy/PgBouncer 通过 Unix socket 回源，PostgreSQL 的
`inet_server_addr()` 返回 NULL。runner 没有把 NULL 强行替换成配置 IP，而是：

```text
client returned timeline
+ same observer's Patroni phase
= backend authority attribution
```

结果：

```text
old timeline acknowledged   18
new timeline acknowledged  112
```

这套归属只在切换窗口 timeline 唯一前进、baseline restore 在 probe 结束后才执行的合同
内成立。若窗口内多次切换，应再加入 server identity 或 commit audit。

### unknown outcome 对账

```text
attempts                       160
acknowledged                   130
unknown                         30
persisted rows                 130
acknowledged missing             0
duplicate token                  0
unreconciled unknown              0
```

对每个 unknown，runner 按 `(run_id, attempt_no, token)` 查询新主，得到 committed once
或 absent。本次 30 个都 absent。若某个 committed once，它也不是失败；只有无法分类
才是 unresolved。

### 怎样写 RPO

错误写法：

```text
RPO = 0
```

本次正确写法：

> 在 160 次 synthetic idempotent INSERT、异步 `pg-test` 和该服务路径条件下，130 个
> 客户端已确认 token 全部在新历史中存在一次；30 个 unknown 全部对账，已知 fixture
> 数据损失为 0。没有证明任意生产事务或复合故障下的零 RPO。

若应用没有 idempotency key/ledger，客户端 acknowledgement 与数据库 WAL 位置之间就
缺少可对账身份，RPO 只能保持 unknown。

### 旧主归队与基线恢复

runner 启动 `pg-test-1` 后要求：

```text
pg-test-3 primary running
pg-test-1 replica streaming
pg-test-2 replica streaming
pg-test-1 pg_is_in_recovery()=true
```

然后才执行 planned switchover：

```text
leader     pg-test-3
candidate  pg-test-1
final      pg-test-1 primary, two streaming replicas
timeline   18 -> 19
```

若任一阶段失败，recovery handler 先启动由本 run 停止的服务，再读取**实际**唯一 leader；
只在三成员健康后从该 runtime leader 切回 `pg-test-1`。它不假设 winner 必为某节点。

## 33.7.3 重建旧主并验证时间线、端点和业务写入 {#item-33-7-3}

### 为什么另做 disposable lab

managed 旧主在 controlled stop 后没有分叉，Patroni 可以直接让它跟随新 timeline；这
不能证明 `pg_rewind`。真实 managed reinit 又会删除副本 PGDATA，超出本章授权。

所以 runner 在 `pg-test-3` 创建：

```text
/tmp/pg36-ch33-rebuild-<exact-run-id>/
  A/
  B/
  C/
  sock-A/
  sock-B/
  sock-C/
  .pg36-ch33-owned
```

全部 `listen_addresses=''`、Unix socket mode 0700，不加入 Patroni/DCS/代理/备份。

### 分叉状态机

```text
initdb A --data-checksums
create base marker
pg_basebackup -R A -> B
start B streaming

stop A
promote B
write new-primary
stop B

start A alone
write old-primary-divergent
stop A

restart B alone
write after-divergence
pg_rewind target=A source=B -R
start A streaming from B

pg_basebackup -R B -> C
start C streaming from B
```

每次开始另一分叉写入前先停止当前 primary，因此
`concurrent_divergent_primaries=false`。这避免为了演示 rewind 真制造并发双主。

### rewind 验收

```text
PostgreSQL                       18.6
same system identifier          true
timeline diverged               true
pg_rewind                       245.343 ms
A pg_is_in_recovery             true
A receiver_status               streaming
A markers:
  base                          present
  new-primary                   present
  after-divergence              present
  old-primary-divergent         absent
```

`pg_rewind` 快，是因为临时数据极小且本地缓存/磁盘路径很短。它不代表 TB 级集群
rewind 时间；生产还受修改页面比例、WAL/archive、tablespace、同步与存储影响。

### full rebuild 验收

```text
fresh pg_basebackup             228.115 ms
C pg_is_in_recovery             true
C receiver_status              streaming
C accepted markers             all present
temporary instances after      none
exact root after               absent
managed PGDATA touched         false
```

两个时延不可拿来比较“rewind 一定比 basebackup 慢/快”：样本极小，basebackup 初始源、
checkpoint 与缓存条件不同。实验要证明的是两条机制都能构造正确 follower，以及失败时
有明确 fallback。

### 33 个反例

[`validate.py`](/labs/ch33/validate.py) 对真实 evidence 逐个变异：

```text
production data/traffic permission opened
managed reinit or DCS/network mutation enabled
watchdog claim opened in watchdog-off sandbox
DCS member count or synchronous mode 被伪造
failure domain / scenario / fence invariant missing
preflight two primaries / pause / split system id / excessive lag
wrong fault host / no service stop
service still active / postmaster still alive
wrong selected leader / old primary writable / timeline unchanged
old primary did not rejoin
ack missing / duplicate / unresolved unknown
rewind system id split / divergent marker remains
basebackup not streaming
temporary root remains
production gate approved
```

正式结果：

```text
declared counterexamples rejected  33
live evidence mutants rejected     33
source files hash-bound            15
secret scan                        passed
production_ch33_gate               pending
```

这不证明代码没有 bug，但能防止“只检查工具 exit 0”“清理失败仍通过”“沙箱结果冒充生产”
等结构性错误。

### 复核现有 bundle

```bash
export PG36_EVIDENCE_DIR=/absolute/private/ch33-evidence
static/labs/ch33/task.sh verify
static/labs/ch33/task.sh review

# 或一次完成，不产生在线 mutation
static/labs/ch33/task.sh all
```

`all` 只重建 validation/public summary 并扫描 evidence，不停止服务、不连接 secret
inventory、不触发 failover/rebuild。

### 最终边界

正式证据能够支持：

- controlled process fence 先于候选接受；
- Patroni 从 eligible set 自动选择唯一新主；
- endpoint 上的 synthetic token 可完整对账；
- 旧主以 streaming replica 归队；
- planned switchover 恢复教学基线；
- 同源分叉可用 `pg_rewind` 收敛；
- rewind 不适用时 fresh base backup 能构造 follower；
- exact fixture/root 被清理。

不能支持：

- 主机断电、内核 hang、存储损坏的等价性；
- 真实不对称网络分区下没有脑裂；
- 单 etcd 代表生产 DCS quorum；
- watchdog fencing 已验证；
- 异步复制的生产零 RPO；
- managed `reinit` 的工时与存储集成；
- 6.212 秒是生产 RTO SLO。

因此公开证据的最终决策始终是：

```text
controlled failover/rejoin/rebuild mechanism demonstrated
production approval = null
production_ch33_gate = pending
```

---

[上一节：切换与重建 runbook](../06/) · [返回本章目录](../) · [下一章：过载保护与资源故障判型——李代桃僵](/overload-resource-incidents/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
