# 演练矩阵

> **module**: ops/backup-and-dr
> **doc_type**: drill matrix
> **status**: Active
> **owner**: lijian
> **upstream_docs**: [01-prd.md](./01-prd.md)
> **last_verified**: 2026-05-14
>
> **事实源**：把 issue [#342](http://43.130.59.228/FFAIWorkspace/workspace/issues/342) 三条验收条款（A / B / C）拆成可执行验证步骤。**通过 = 备份算完成；不通过 = 备份不算完成**（核心不变量）。
>
> 具体的命令级 runbook 维护在 [`../10-backup-strategy.md`](../10-backup-strategy.md)；本文档定义"验证什么 + 怎么算通过"。

---

## 演练总原则

1. **演练失败 = 备份不算完成**，立刻禁用对应 cron（`crontab -e -u backup-hub` 注释行）；定位修复 → 端到端重跑 → 才能恢复 cron
2. **不在原机器上演练作弊**：必须用**第二台机器**（沙箱 / 临时 VM / 备机），证明从备份+空数据 dir 能起得来
3. **演练记录公开**：每次演练在 [#342](http://43.130.59.228/FFAIWorkspace/workspace/issues/342) 评论下记日期 + 矩阵结果（与 [#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310) 现有做法一致）
4. **dual-check**：自动化校验 + 至少一项独立人工核验（如 SQL count + 业务关键页打开看一眼）

---

## §A — 验收条款 A：4h 内空白 VM 整体恢复

> "**空白 VM 起，按 runbook 从备份恢复整个 production**，4h 内业务可用 + 数据完整"
> 对应 Phase 1 退出条件。

### A.1 起始状态约束

- 一台**全新**腾讯云 VM（与 production 同区域可，但**不能复用原生产机**）
- OS image 是 standard Ubuntu，**未装** PG / Node / pm2 / Nginx / pgbackrest
- 演练者只有 ssh root + 备份枢纽访问凭据
- **数据源约束**：必须用枢纽**异地副本**恢复（不能假定本地 repo 还能用）；RPO 上界 = 上次 pull 时点（详见 [01-prd.md §1.2](./01-prd.md#12-成功指标issue-验收条款) 验收 A 前提）

### A.0 恢复顺序（按 [01-prd.md §3.2](./01-prd.md#32-恢复优先级分级t0t1t2t3) 分级，T0 必须最先）

| Tier | 范围 | 必达时限（4h 内） |
|---|---|---|
| **T0** | offsite-1 枢纽机重建（含从 FFAI prod 交叉备份拉回 keys）| ≤ 30 min |
| **T1** | FFAI prod DB + Secrets + 附件 | ≤ 2 h |
| **T2** | FFAI UAT、AIxC prod、Gitea（并行） | ≤ 1 h |
| **T3** | AIxC UAT | 剩余时间，**允许延后到次日** |

单机演练时也按此顺序声明跑（即使只恢复 1 个 tier，明确标 "本次演练范围 = T1"），让 runbook 在多机受灾时直接可用。

### A.2 验证步骤（全部 ✅ 才算 A 通过）

| 步骤 | 通过判据 | 时限 |
|---|---|---|
| A.2.1 装基础软件（PG 16 + pgbackrest + Node + pm2 + Nginx） | apt 全部 ✅ + `pg_isready` + `pgbackrest version` | ≤ 30 min |
| A.2.2 写 pgbackrest.conf 指向枢纽 repo，跑 `pgbackrest restore` | restore 退出码 0，data dir 文件总数 ≈ 备份 manifest 数 | ≤ 60 min（与 DB 大小相关） |
| A.2.3 起 PG，确认 `pg_is_in_recovery() = false` 且 `SELECT version()` 与原生产一致 | 字符串完全匹配 | ≤ 5 min |
| A.2.4 从枢纽拉 secrets/configs tarball 解压到 `/srv/apps/ffworkspace/` | 文件清单与 manifest 一致 | ≤ 10 min |
| A.2.5 软链 `.env` / `certs/` 按 [`../07-env-and-secrets.md § 1`](../07-env-and-secrets.md) 重建 | `ls -la` 软链不断 | ≤ 5 min |
| A.2.6 起 backend + frontend（pm2 start） | `curl http://localhost/api/health` 返回 200 | ≤ 10 min |
| A.2.7 业务关键页人工核验 | 登录 itadmin → 看待办 / 看组织架构 / 看工单列表 → 数据齐全 | ≤ 15 min |
| A.2.8 Temporal worker 验证 | worker 起来，发一个测试 signal，workflow 走完 | ≤ 15 min |
| A.2.9 i18n 双语切换 | zh-CN ↔ en-US 切换无 missing key | ≤ 5 min |
| A.2.10 dual-check：SQL count vs 原生产快照 | 关键表 count 误差 ≤ 一天（24h dump 节奏内允许） | ≤ 10 min |

**总时限：4h**。沙箱实测 PG 恢复部分 < 30s（29.5MB DB），主要时间在装软件 + 业务起服务 + 验证。

### A.3 频率

- **Phase 1 退出**：跑 1 次完整 A 全通
- **常规节奏**：每**季度** 1 次完整 A（与 §B 月度轮转交叉）

---

## §B — 验收条款 B：月度自动 drill ≥ 3 次无 false failure

> "**每月自动 drill 跑 ≥ 3 次以上无 false failure**"
> 对应 Phase 3 退出条件（自动化稳定后）。

### B.1 自动 drill 范围

每月 13 号（与 Gitea 现有节奏对齐）自动跑**按 source 轮转**的浅层 drill：

| 月份 ending | 当月演练 source | 已自动化标记 |
|---|---|---|
| 01 / 04 / 07 / 10 | Gitea 实例 | ✅（已有，[#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310)） |
| 02 / 05 / 08 / 11 | FFAI prod DB + Secrets/Configs | P1 实施时落 |
| 03 / 06 / 09 / 12 | AIxC prod DB + Temporal worker | P1 实施时落 |

### B.2 浅层 drill 步骤（每月跑这套）

| 步骤 | 通过判据 |
|---|---|
| B.2.1 `pgbackrest verify` 当月对应 source | manifest sha256 全通过 |
| B.2.2 sidecar 容器 restore latest 到 tmpfs | restore 退出码 0 |
| B.2.3 起临时 PG（127.0.0.1 only + iptables 防火墙）| `pg_isready` |
| B.2.4 关键表 count + 抽样 SHA256 与上一次 drill 对比 | 增长合理（不爆减 / 不停滞）|
| B.2.5 teardown：杀 PG + 删 tmpfs | 进程 + 文件零残留 |
| B.2.6 在 [#342](http://43.130.59.228/FFAIWorkspace/workspace/issues/342) 评论自动记录结果 | issue API 200 |

### B.3 通过判据

- 连续 **3 次月度 drill** 全部 ✅
- "false failure" 定义：drill 失败但实际备份是好的（说明是 drill 脚本本身的 bug）。一旦发现 false failure，必须**修脚本 → 重新计数 3 次**

### B.4 失败处理

- B drill 失败 ≠ true failure：先排查 drill 脚本 vs 备份本身。
- 若是备份本身坏：禁用 cron + 紧急人工 dump + 修复后端到端重跑 §A 子集

---

## §C — 验收条款 C：误删 30min 内恢复到误删前 10s

> "**任意员工误删一个关键表 → 30 分钟内由运维操作恢复到误删前 10 秒钟的状态**"
> 对应 Phase 2 退出条件（PITR 启用后）。

### C.1 模拟设计

- 演练日选定一张**测试用表**（演练专用，非生产业务表）
- 写入 100 条标记数据 → 等 30s（确保 WAL 已 push）→ `DROP TABLE`
- 计时开始

### C.2 演练步骤（全部 ✅ 且**总耗时 ≤ 30 min** 才算 C 通过）

| 步骤 | 通过判据 | 时限累计 |
|---|---|---|
| C.2.1 冻结相关写入（pg_hba 临时拒绝 + reload） | 新连接被拒；现有连接 graceful close | ≤ 2 min |
| C.2.2 从 audit_log / PG log 找到 DROP 时间戳 T_disaster | 时间戳精确到秒 | ≤ 3 min |
| C.2.3 在 standby/spare 机器上跑 `pgbackrest restore --type=time --target='T_disaster - 10s' --target-action=pause` | restore 退出码 0 | ≤ 10 min |
| C.2.4 起 PG，连进去 SELECT 验证误删表回来了 + 数据完整 | row count = 100；最新一行 timestamp < T_disaster - 10s | ≤ 5 min |
| C.2.5 `SELECT pg_promote();` 退出 recovery（**不是** `pg_wal_replay_resume()`——后者只是恢复暂停的 WAL 重放，到达 target 后不会 promote）| `pg_is_in_recovery() = false` | ≤ 2 min |
| C.2.6 把应用连接串切到 spare：**当前唯一可行方式 = 在 spare 上改 `.env` 的 `DATABASE_URL` → pm2 restart backend**（未来若引入 pgbouncer dual-host / DNS-switch，本步骤升级；当前生产无此基础设施）| curl `/api/health` 200 + 业务关键页能打开 | ≤ 8 min |

**总时限：30 min**——步骤累加 2+3+10+5+2+8 = **正好 30 min，零 slack**。任何一步小延迟 → 整体 fail。issue 是硬约束，缓解办法：演练前 **pre-stage** spare 机（pgbackrest 已装、`pgbackrest.conf` 已写、`.env` 模板已就绪、pm2 ecosystem 已配），让 30 min 全用在数据恢复 + 验证，而不是装环境。沙箱实测 restore + replay = 1-2s（小数据集），生产规模 buffer 在数据恢复段充足；切流段（C.2.6）才是真正卡点。

### C.3 验证

- 误删的表回来了（行数 + 最新行 timestamp 在 T_disaster - 10s 之前）
- 旧主库下线归档（不杀，留 forensics）
- 自动写一条 incident 时间线到 `docs/ops/incidents/`（不在 PRD 范围，演练时人工补）

### C.4 频率

- **Phase 2 退出**：跑 1 次完整 C 全通
- **常规节奏**：每**季度** 1 次（与 §A 交叉错开）

### C.5 超时 fallback（30 min 零 slack 的现场处置）

C.2 总时限 2+3+10+5+2+8 = 30 min 零 slack，任一步骤延迟整体即 fail。**演练**层面 fail 无生产损失，但**真实误删**触发本流程时超时意味着数据继续不可用——必须事前定好降级路径，避免现场临时拍板。

**触发点 / 处置矩阵**：

| 触发点 | 现场动作 | 决策权 |
|---|---|---|
| C.2.3 restore ≥ 8 min 仍未完成（10 min 时限的 80%）| **不再等**：若 PITR 路径明显不可行（WAL 链路断裂、archive 残缺），跳过 C.2.3-C.2.6，转 **最近一次 daily full backup 恢复**（接受丢失 ≤ 24h，沿用 PRD §1.2 异地 RPO 上限），同时向业务方报 SLA 重议 | 当班运维 + tech-lead 二人确认 |
| C.2.6 切流 ≥ 6 min 仍未通过 health check（8 min 时限的 75%）| **不再硬切**：保留 spare 在 read-only 模式供取数，**当前主库继续承担在线写**（误删只影响 1 张表，其他业务不阻断）；走"局部数据回填"路径——把 spare 上的误删表 `pg_dump` 出来，在主库手工 `INSERT ... ON CONFLICT DO NOTHING`，整体放弃 30min SLA | tech-lead + 业务方确认丢失边界 |
| 总耗时 ≥ 25 min 仍未到 C.2.4 | **触发 SLA 重议**：通知 issue #342 提出方（chentao.jia），从"30min 恢复"降级为"4h 恢复（沿用 §A 异地 RPO）"，避免现场强行赶时间引入二次事故 | 当班运维主动上报 |

**关键不变量**：

- 任何时候**禁止**直接在原主库手工 `UNDROP` 或撕 `pg_class` 元数据——只走 spare → 取数 → 回填路径
- 旧主库**不杀**（留 forensics），即使被绕过也保持运行
- 真实事故触发 C.5 时**必须**写 incident 报告到 `docs/ops/incidents/`，记录降级步骤、丢失边界、是否调用了 §A daily backup 路径

**演练**层面（非真实事故）：超时即 fail，分析根因（pre-stage 不到位 / 脚本不熟 / 工具链卡点）→ 写到下一轮演练 retro，**不接受**演练中临时降级把 fail 改成 pass。

---

## 历次演练记录

> 沿用 [`../10-backup-strategy.md § 4.3`](../10-backup-strategy.md) 格式。

| 日期 | 演练类型 | source/范围 | 结果 | issue 评论链接 |
|---|---|---|---|---|
| 2026-05-13 | §B 浅层 | Gitea | ✅ 4/4 repos HEAD SHA 全等 | [#310 #2948](http://43.130.59.228/FFAIWorkspace/workspace/issues/310#issuecomment-2948) |
| (待 P1) | §A 完整 | 全维度 | TBD | TBD |
| (待 P2) | §C 误删 | 测试表 | TBD | TBD |

---

## 不通过的处理

任何一项 §A / §B / §C 不通过：

1. **立刻禁用** 对应 cron（`crontab -e -u backup-hub` 注释对应段）
2. 在 issue [#342](http://43.130.59.228/FFAIWorkspace/workspace/issues/342) 评论记录"演练失败 + 时间 + 失败步骤 + 已禁用 cron"
3. 定位是 dispatcher / dump / rsync / restore / 应用层哪一段
4. 修复 → 端到端重跑该演练章节 → 全 ✅ 才能解禁 cron
