36.7 演练与验证
一句话
没演练过的容灾方案等于没有方案;演练的价值不是“证明能切”,而是把 RTO 拆成每一段的实测值,让“人等多久”暴露出来。
先摆三个数字
| 数字 | 含义 | 来源 |
|---|---|---|
| 2–3 倍 | 不演练的多活,切换时长的真实值是文档估算的 2–3 倍 | 36.4 两地三中心与多活 的经验值 |
| 1/5–1/3 | 做过恢复尝试的备份里,第一次恢复失败的比例 | 经验值,口径差异大,只用来看量级 |
| 45% | 第一轮演练能覆盖的潜在缺陷比例(每轮发现率 0.45 时) | 推导,跑一遍「可运行的演练记分卡」可复现 |
这三个数字指向同一件事:容灾方案的可信度不来自设计文档,来自实测。文档里的 RTO 是“所有环节都按预期工作”时的加法;实测 RTO 是“某个环节卡住、某个人不在、某个权限没开”时的加法。两者的差距不是精度问题,是数量级问题。
演练分级:按保真度分,不按成本分
分级的标准是这次演练离“真实切换”有多近,而不是花了多少钱:
| 级别 | 做什么 | 频率 | 需要的人 | 风险 | 能验证什么 | 典型产出 |
|---|---|---|---|---|---|---|
| L1 桌面推演 | 给定剧本,各角色口头推演决策 | 季度 | 指挥、研发、业务、客服 | 0 | 谁拍板、谁通报、通知谁 | 决策树、联系人表 |
| L2 单组件注入 | 杀一个实例、断一个依赖、主库只读 60 秒 | 月 / 季度 | SRE、值班 | 低(有冗余兜底) | 自愈、告警、runbook 命令是否还能跑 | 告警阈值调整、脚本修复 |
| L3 单机房切换(真实流量) | 把一个机房的流量切走,跑 30 分钟后切回 | 半年 | 全部 + 业务方 + 客服 | 中(容量必须 N+1) | 切流耗时、容量够不够、切回来会不会二次故障 | 实测 RTO、容量结论 |
| L4 全链路地域切换 | 整个地域停写、异地接管、数据校验、再切回 | 年 | 全部 + 管理层 | 高(真的在赌) | 端到端 RTO、数据一致性、外部依赖 | 演练记分卡、行动项清单 |
两处关键差别,是 L3/L4 与低级别演练的分水岭:
- L3 开始用真实流量。 L1/L2 用“演练环境”或“单实例”,动作再熟练也不代表真实流量下的 DNS 生效、连接池重连、缓存冷启动能过。不上真实流量的切换演练,验证的是“脚本能跑”,不是“业务能活”。
- L3/L4 必须演回切。 切过去只完成一半:回切时缓存是冷的、复制要追、连接池要重建,故障率往往高于切换本身。只演“切过去”,等于只验证了单程。
“备份成功”不等于“能恢复”
备份是可读性验证:文件存在、大小对、能解开。恢复是可写性验证:库能起、能连、能查、能写、应用能接上。两者之间隔着一整条链路,链路上每一环都可能断。
| 恢复失败的三大原因 | 典型表现 | 演练怎么提前发现 |
|---|---|---|
| 缺权限 | 恢复账号没有建库/建表权限;对象存储的 KMS 密钥没授权给恢复角色;跨账号拉备份被拒 | 用“只有恢复权限”的账号跑一次,不要用管理员账号 |
| 缺依赖 | 备份里只有表和数据:VPC、白名单、证书、密钥、扩展插件、时区、locale 都不在备份里 | 恢复到全新环境(不是“清空表再灌”),并记录所有手工补的步骤 |
| 版本不匹配 | 备份来自 8.0.32,目标集群是 8.0.28;逻辑备份的字符集/排序规则与目标不一致;扩展版本对不上 | 固定“备份版本 → 可恢复版本”矩阵,升级前先做一次跨版本恢复 |
三条共同点:它们都不在备份文件里,而在备份文件之外。 备份工具只对“文件内容”负责,不对“能不能用”负责。所以恢复演练的验收标准必须是“应用能读写”,不是“导入没报错”。
演练的度量:把 RTO 切成六段
实测 RTO 是一个总数,总数不可改进。要改进必须切开,切到“每一段有明确起点、终点、负责人”:
| 阶段 | 起点 | 终点 | 常见占比 | 主要成本在 |
|---|---|---|---|---|
| 检测 | 故障发生 | 有人认领告警 | 2%–5% | 告警质量(见 50.5 告警)、心跳灵敏度(见 35.4 故障检测) |
| 决策 | 认领 | 有人批准切换 | 20%–35% | 授权、审批层级、怕担责 |
| 资源 | 批准 | 备侧算力/存储就绪 | 10%–20% | 扩容速度、镜像预热、配额 |
| 恢复 | 资源就绪 | 数据可用(挂载/回放/校验位点) | 10%–15% | 数据量、binlog 回放、冷启动 |
| 验证 | 数据可用 | 核心链路自检通过 | 5%–15% | 校验脚本是否事先写好 |
| 切流 | 验证通过 | 100% 流量生效 | 15%–25% | DNS TTL、灰度节奏、客户端重连 |
“资源”和“恢复”这两段的长度,直接由备侧形态决定:冷备要从对象存储把数据拉回来,温备只差起服务,热备只差一次 VIP 漂移。三种形态的差别见 36.3 冷备 / 温备 / 热备。
🔑 最贵的一段几乎总是决策,而它不出现在任何技术指标里。监控能看到“数据库挂了”,看不到“没人敢拍板”。所以演练记分卡必须有一列专门记“人工步骤数”和“这一段的等待时间”。
一张可抄的记分卡模板:
| 阶段 | 目标(s) | 实测(s) | 占比 | 人工步骤数 | 失败步骤数 | 判定 |
|---|---|---|---|---|---|---|
| 检测 | 60 | 0 | ||||
| 决策 | 540 | 2(审批) | ||||
| 资源 | 300 | 1 | ||||
| 恢复 | 240 | 0 | ||||
| 验证 | 180 | 1 | ||||
| 切流 | 480 | 0 | ||||
| 合计 | 1800 | 100% | 对照 RTO |
四个核心指标,缺一个就没法比较两轮演练的优劣:实测 RTO(总和)、人工步骤数(人越多越不稳定)、失败步骤数(runbook 跑不通的命令条数)、切换后校验项通过率(数据对不对)。
GameDay 的组织:六步,和一条纪律
| 步 | 动作 | 反例 |
|---|---|---|
| 1 定假设 | 写清“我们相信 X 情况下能在 Y 分钟内恢复” | “演练一下切换”,没有可证伪的假设 |
| 2 定爆炸半径 | 明确这次最多影响多少流量/多少用户 | 一上来切 100% |
| 3 定停止条件 | 错误率、P99、资损阈值的具体数字 + 谁有权喊停 | 只写“情况不对就停” |
| 4 执行 | 按剧本注入,只做演练相关变更 | 顺手发个版,无法归因 |
| 5 记录时间戳 | 每个阶段边界记一个时间 | 事后回忆“大概十来分钟” |
| 6 复盘 | 行动项带 owner、截止、验证方式,方法见 56.6 无指责复盘 | 报告只写“演练圆满成功” |
一条纪律:必须有人专门掐表。 这个人不参与任何操作,只做三件事:
- 独立记录,不采信操作者自报的时间(操作者会说“马上好”,那是主观感受)。
- 统一时基,所有时间戳来自同一个时钟源,否则跨团队的时间线对不上,复盘时先吵半小时谁是先动的。
- 阶段边界由外部可观测事件定义:切流阶段的结束是“监控上 100% 流量打到新机房”,不是“我执行完了切流脚本”。这两者之间可能差一个 DNS TTL。
掐表的人缺位,演练就退化成“大家觉得还挺顺”——而“觉得顺”不能写进 RTO。
安全边界
| 边界 | 做法 | 不做的后果 |
|---|---|---|
| 爆炸半径 | 先 1% 流量 / 单单元 / 单只读副本 | 一次满切,演练变事故 |
| 熔断开关 | 错误率或 P99 越线自动中止,不等人判断 | 人在开会,指标已经崩了 |
| 回滚预案 | 独立于演练脚本,预先验证过,不需要临时申请权限 | 回滚也依赖刚刚被破坏的东西 |
| 干系人通知 | 业务、客服、上下游、值班表逐项确认 | 用户先于公司知道 |
| 时间窗 | 避开大促、结账、重大发布 | 风险叠加,无法归因 |
角色分工与护栏清单见 56.2 故障演练。灾备演练额外加一条:必须提前确认“切回来”的窗口,因为 L3/L4 会占用备侧全量容量,占用越久,真实故障撞上来的概率越高。
演练的收敛规律:什么时候该收
每轮演练能发现的问题会递减,因为容易发现的先被发现。用一个简单的模型描述:潜在缺陷 N 个,每轮抓走其中的比例 p,剩下的下一轮继续抓。
- 第 n 轮发现数 =
N × p × (1−p)^(n−1) - 累计覆盖 =
1 − (1−p)^n
N=60、p=0.45 时跑出来的曲线(「可运行的演练记分卡」的真实运行结果):第 1 轮 27 个、覆盖 45%;第 2 轮 14.9 个、累计 69.8%;第 5 轮 2.5 个、累计 95.0%;第 8 轮 0.4 个、累计 99.2%。
这条曲线的用途是决定还要不要投入:当某轮增量掉到 2 个百分点以下(这里是第 6 轮,2.3pp 已接近临界),说明“这套剧本”已被榨干——继续加轮次只会发现同类问题的变体。这时正确的动作不是加轮次,而是换场景:换故障类型(机房断电换成网络分区)、换爆炸半径(单机房换成地域)、换时间点(白天换成凌晨)。换个剧本,曲线会重新从 45% 开始掉。
反过来,若连续三轮增量都低、却每次都有“数据不一致”的发现,说明问题不在覆盖面,在切换逻辑本身——该回去改代码。
与混沌工程的分工
三件事,一句话各自归位:
- 56.1 混沌工程:验证“日常故障下系统是否保持稳态”,可自动化、天天跑,判定靠指标假设。
- 56.2 故障演练:验证“人、流程、工具能否在时限内恢复”,阶段性、有组织成本,判定靠恢复时间。
- 灾备切换演练:验证“整体切换的正确性”——切得过去、切过去数据对、切得回来。它比前两者多两件事:真实流量的接管和回切。
混沌问“扛不扛得住”,演练问“救不救得回”,灾备演练问“切得过去、切得回来吗”。三者不能互相替代:混沌实验做得再好,也发现不了“没人有权拍板切换”。
可运行的演练记分卡
输入六段实测耗时与目标 RTO,输出总 RTO、各段占比、瓶颈段、是否达标,以及“删掉审批能省多少”;后半段跑缺陷收敛曲线:
package main
import (
"fmt"
"math"
)
type phase struct {
name string
actual int // 实测耗时(秒)
manual int // 其中人工动作占的秒数(审批、手工执行、人工核对)
}
func pad(s string, w int) string {
n := 0
for _, r := range s {
if r > 0x2E80 {
n += 2
} else {
n++
}
}
for n < w {
s += " "
n++
}
return s
}
func main() {
const rto = 1800 // 目标 RTO:30 分钟
ps := []phase{
{"检测(告警到认领)", 45, 0},
{"决策(认领到批准切换)", 620, 480},
{"资源拉起(备机房扩容)", 300, 60},
{"数据恢复(挂载 + 回放 binlog)", 240, 0},
{"验证(5 项校验)", 180, 90},
{"切流(DNS TTL + 灰度到 100%)", 420, 0},
}
total, manualTotal := 0, 0
bottleneck, bnSec := "", 0
for _, p := range ps {
total += p.actual
manualTotal += p.manual
if p.actual > bnSec {
bottleneck, bnSec = p.name, p.actual
}
}
fmt.Println("== 演练记分卡:同城机房整体切换 ==")
fmt.Printf("%s %8s %8s %8s\n", pad("阶段", 30), "实测(s)", "占比", "人工(s)")
for _, p := range ps {
share := float64(p.actual) / float64(total) * 100
fmt.Printf("%s %8d %7.1f%% %8d\n", pad(p.name, 30), p.actual, share, p.manual)
}
fmt.Printf("%s %8d %7.1f%% %8d\n", pad("合计", 30), total, 100.0, manualTotal)
fmt.Printf("\nRTO 目标 %ds,实测 %ds,差 %+ds → %s\n",
rto, total, total-rto, map[bool]string{true: "达标", false: "未达标"}[total <= rto])
fmt.Printf("瓶颈阶段:%s(%ds,占 %.1f%%)\n",
bottleneck, bnSec, float64(bnSec)/float64(total)*100)
// 场景 A:只砍两级审批(决策阶段那 480s)
approval := 480
// 场景 B:把所有人工动作都换成脚本/预授权
fmt.Println("\n== 如果压缩人工环节 ==")
for _, c := range []struct {
name string
cut int
}{
{"只删两级审批", approval},
{"删掉全部人工步骤", manualTotal},
} {
after := total - c.cut
fmt.Printf("%s:%ds → %ds(省 %ds,占原时长 %.1f%%),%s\n",
pad(c.name, 20), total, after, c.cut,
float64(c.cut)/float64(total)*100,
map[bool]string{true: "达标", false: "仍不达标"}[after <= rto])
}
// 缺陷收敛:N 个潜在缺陷,每轮抓走 p,剩下的下一轮继续抓
const N, p = 60.0, 0.45
fmt.Println("\n== 多轮演练的缺陷收敛(N=60,每轮发现率 p=0.45)==")
fmt.Printf("%s %8s %10s %10s\n", pad("轮次", 8), "发现数", "累计覆盖", "本轮增量")
prev := 0.0
for n := 1; n <= 8; n++ {
found := N * p * math.Pow(1-p, float64(n-1))
cov := 1 - math.Pow(1-p, float64(n))
fmt.Printf("%s %8.1f %9.1f%% %9.1fpp\n",
pad(fmt.Sprintf("第 %d 轮", n), 8), found, cov*100, (cov-prev)*100)
prev = cov
}
}
本机运行结果(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):
== 演练记分卡:同城机房整体切换 ==
阶段 实测(s) 占比 人工(s)
检测(告警到认领) 45 2.5% 0
决策(认领到批准切换) 620 34.3% 480
资源拉起(备机房扩容) 300 16.6% 60
数据恢复(挂载 + 回放 binlog) 240 13.3% 0
验证(5 项校验) 180 10.0% 90
切流(DNS TTL + 灰度到 100%) 420 23.3% 0
合计 1805 100.0% 630
RTO 目标 1800s,实测 1805s,差 +5s → 未达标
瓶颈阶段:决策(认领到批准切换)(620s,占 34.3%)
== 如果压缩人工环节 ==
只删两级审批 :1805s → 1325s(省 480s,占原时长 26.6%),达标
删掉全部人工步骤 :1805s → 1175s(省 630s,占原时长 34.9%),达标
== 多轮演练的缺陷收敛(N=60,每轮发现率 p=0.45)==
轮次 发现数 累计覆盖 本轮增量
第 1 轮 27.0 45.0% 45.0pp
第 2 轮 14.9 69.8% 24.8pp
第 3 轮 8.2 83.4% 13.6pp
第 4 轮 4.5 90.8% 7.5pp
第 5 轮 2.5 95.0% 4.1pp
第 6 轮 1.4 97.2% 2.3pp
第 7 轮 0.7 98.5% 1.2pp
第 8 轮 0.4 99.2% 0.7pp
四条读数:
- 实测 1805 秒,比目标 1800 秒多 5 秒,判“未达标”。 差 5 秒看着像噪声,却是结构性的:这套流程没有余量,备机房扩容慢 60 秒就超 1 分钟。差 5 秒和差 300 秒是同类问题——没有 buffer 的 RTO 等于没有 RTO。
- 决策段 620 秒占 34.3%,是瓶颈。 六段里它的技术含量最低(不需要扩容、不需要回放数据),却吃掉三分之一时间。这就是“人等多久”——它不在监控里,只在记分卡里。
- 只删两级审批,总时长 1805 → 1325 秒,一步从“未达标”变“达标”,省 26.6%。 换句话说,“多买一个机房”要花一倍预算,而“把审批从两级降到一级”是零成本。大多数团队的 RTO 优化空间在流程里,不在硬件里。
- 缺陷收敛第 6 轮增量跌到 2.3 个百分点,第 7 轮 1.2。 这时候继续加轮次的边际收益已经很小,该换剧本了。曲线不是用来“证明做得够多”,是用来判断“什么时候该换个方向投”。
反模式
| 反模式 | 后果 |
|---|---|
| 只演练非核心链路 | 切换时最贵的核心链路从未被验证,演练报告“全绿”但主流程切不过去 |
| 只桌面推演,从不上真实流量 | 验证了“大家知道该做什么”,没验证“系统做得到”;DNS、连接池、冷启动全是空白 |
| 不掐表,靠事后回忆 | RTO 是估的,无法比较两轮改进,也无法定位瓶颈段 |
| 不演练回切 | 单程验证,回切时缓存冷启动 + 复制追上,二次故障比第一次更糟 |
| 演练不影响任何人 | 说明流量没切、依赖没断、没有真实用户受影响——这样的演练不可信 |
| 演练报告只写“成功” | 没有失败步骤、没有人等时长、没有行动项,下一轮还是同样的断点 |
| 用管理员账号做恢复演练 | 掩盖了权限缺口,真实恢复时才发现恢复角色没有建表权限 |
| 演练完不复演同一场景 | 无法证明改进有效,行动项烂在 backlog 里 |
关联
自检
Q1 一次演练总时长 1500 秒,RTO 是 1800 秒,能不能判“通过”?
▸ 答案:不能直接判。总时长没超只说明“这次运气好”,还要看两件事:各段有没有超过自己目标的 1.5 倍(单段断点会被更坏的故障放大),以及人工步骤数有没有下降。更要紧的是看余量:1500 对 1800 只留 300 秒,如果瓶颈段是决策(人),下一次换个时间点、换个人值班,波动很容易超过 300 秒。有 buffer 的达标才算达标。而这几段时间靠谁记?靠一个不参与操作的掐表人,用统一时基,阶段边界按监控上的外部事件算,不是按“我执行完了”。
Q2 备份文件能正常解开、导入也没报错,为什么还不能说“恢复成功”?
▸ 答案:解开和导入验证的是“文件可读”,恢复要验证的是“系统可用”。中间还差:库能不能起、账号权限够不够、应用连上后能不能读写、依赖(VPC/证书/密钥/扩展/时区)是否齐、版本是否兼容。验收标准必须是“应用跑通一次核心链路”,不是“导入 0 error”。用管理员账号做恢复演练会把权限缺口整段掩盖掉。
Q3 演练做了六轮,每轮还能发现 2–3 个问题,要不要继续做第七轮?
▸ 答案:看的不是“还能发现几个”,是“增量占整体的比例”。按每轮 45% 的发现率,第六轮的累计覆盖已经到 97%,增量只剩 2.3 个百分点——再演同一套剧本,发现的会是同类问题的变体,边际收益很低。这时该换的是场景(故障类型、爆炸半径、时间点),不是加轮次。但如果这 2–3 个问题里反复出现“数据不一致”,那问题在切换逻辑而不在覆盖面,该回去改代码。
Q4 为什么灾备演练必须包含“回切”,而普通故障演练可以不包含?
▸ 答案:普通故障演练的注入是临时的,删掉注入就恢复了,不存在“回切”这个动作。灾备切换是把流量和数据整体搬到另一侧,搬过去之后系统停在一个新状态上,必须再搬回来才算回到日常——而回切是第二次结构相同的切换,且条件更差:缓存是冷的、复制要追平、连接池要重建、备侧已被占用一段时间。只演单程的方案,等于把最容易出事的那一半留给了真实故障。