一次「演练通过」的假象
某团队做了一次 CDN 故障演练。步骤很完整:注入 502,观察首页,十分钟后恢复注入,群里发了一句「指标正常,演练通过」。两周后真实故障发生,首页确实能打开,但展示的是三天前的旧配置,用户看到的价格与活动全部过期。
复盘时把演练记录翻出来,发现它只验证了「页面能打开」,没有验证「数据是不是新的」,也没有验证「恢复后多久生效」。注入步骤做了,恢复步骤也做了,唯独没有恢复断言。
这次演练不是失败,而是没有判据。没有断言的演练只能证明「我们敢注入」,不能证明「系统能恢复」。恢复验证需要三个东西:明确的断言、可观测的指标、以及一个诚实的报告——未通过项必须留在报告里,而不是被「整体正常」盖过去。
演练是四阶段状态机
一次合格的演练按固定阶段推进,每个阶段都要留下证据:
| 阶段 | 目的 | 产物 | 缺失后果 |
|---|---|---|---|
inject | 制造可控故障 | 注入目标、比例、持续时间 | 不知道故障是否真的发生 |
observe | 记录用户侧表现 | 指标、日志、截图 | 只能凭感觉判断 |
recover | 移除故障并等待收敛 | 恢复动作、开始时间 | 系统停在故障态 |
assert | 用断言判定恢复 | 通过项与未通过项 | 演练无法得出结论 |
阶段顺序不能乱。先 assert 再 recover,断言的是故障态;先 observe 再 inject,观察的是健康基线。校验器会把阶段倒序直接判为无效场景,而不是让报告悄悄通过。
场景定义要有边界
一个场景至少回答五个问题:谁负责、影响谁、什么情况下中止、怎么做、怎么判定。
| 字段 | 作用 | 示例 |
|---|---|---|
owner | 谁有权开始与中止 | 客户端稳定性小组 |
blastRadius | 最坏影响哪些功能 | 下单支付 |
abortIf | 什么条件下立即停止 | 资金类错误出现一次 |
steps | 注入、观察、恢复、断言四类步骤 | 注入 800ms 延迟,影响 10% 请求 |
assertions | 可观测的恢复判据 | 恢复后 5 分钟内成功率回到基线 |
本节登记了三个场景:订单接口超时、远程配置不可用、推送 SDK 不可用。它们覆盖了三种不同的恢复路径:接口降级、配置快照、站内兜底。
| 场景 | 爆炸半径 | 中止条件 | 关键断言 |
|---|---|---|---|
api-timeout | 下单支付 | 资金类错误出现一次 | 资金错误为零、5 分钟内恢复 |
config-unavailable | 首页与通知 | 崩溃率或白屏率超限 | 首页可渲染、1 秒内出降级文案 |
push-sdk-down | 消息通知 | 站内通知重复率超 1% | 站内通知可见、重复率不超 1% |
中止条件不是形式主义。演练的价值在于「可控」,没有中止条件的注入等于一次真实事故。条件必须能在监控上直接读到,不能写「情况严重时停止」这种无法执行的句子。
恢复断言怎么写
断言要同时满足三个条件:可观测、有时限、与用户价值相关。缺一个都会退化成「看起来正常」。
| 比较符 | 含义 | 适用例子 |
|---|---|---|
lte | 实际值不超过期望 | 恢复时间不超过 5 分钟 |
gte | 实际值不低于期望 | 降级入口可见率不低于 99% |
eq | 实际值完全等于期望 | 资金类错误数等于 0 |
写断言时有四条规则:
- 断言恢复后的值,不是故障中的值。 「故障时成功率下降到 60%」是现象,不是判据;「恢复后 5 分钟内回到 95%」才是。
- 断言用户可感知的结果。 「进程存活」不等于「用户能用」,优先断言入口可见、内容非空、数据新鲜。
- 给时间窗口。 没有窗口的断言会在恢复瞬间通过,又在几分钟后回落。
- 一项断言只测一个指标。 把「成功率高且错误为零」写进一条,失败时不知道是哪一半坏了。
报告只有三种结论
演练报告不应该用「基本通过」「整体正常」这类词。结论只有三种:
| 结论 | 含义 | 处置 |
|---|---|---|
pass | 场景有效、观测齐全、全部断言通过 | 归档,进入下一轮 |
fail | 场景无效,或至少一项断言不通过 | 建缺陷,修复后重跑 |
incomplete | 场景有效,但缺少观测 | 补采指标后重跑,不能算通过 |
incomplete 是最容易被篡改的一类。缺一个观测,报告可以写成「未采集」,也可以写成「通过」;前者诚实,后者会在真实故障中暴露。代码把缺失观测单独放进 missing,不混进 failed,就是为了让这两种问题有不同的处置。
把模型写成代码
// file: src/stability/fault-drill.ts
// tier: L1
// verify: node --test test/stability/fault-drill.test.ts
export type DrillPhase = "inject" | "observe" | "recover" | "assert";
export type Comparison = "lte" | "gte" | "eq";
export type DrillStep = { id: string; phase: DrillPhase; target: string; detail: string };
export type DrillAssertion = {
id: string;
description: string;
metric: string;
comparison: Comparison;
expected: number;
};
export type DrillScenario = {
id: string;
title: string;
owner: string;
blastRadius: string;
abortIf: string;
steps: DrillStep[];
assertions: DrillAssertion[];
};
export type DrillObservation = { metric: string; value: number };
export type DrillCheck = {
assertionId: string;
description: string;
metric: string;
comparison: Comparison;
expected: number;
actual: number | null;
passed: boolean;
};
export type DrillReport = {
scenarioId: string;
title: string;
verdict: "pass" | "fail" | "incomplete";
passed: boolean;
checks: DrillCheck[];
failed: string[];
missing: string[];
errors: string[];
steps: number;
phases: Record<DrillPhase, number>;
};
export type ReadinessCheck = { id: string; ok: boolean; detail: string };
export type SuiteSummary = {
total: number;
passed: number;
failed: number;
incomplete: number;
passRate: number;
failedScenarios: string[];
};
export const PHASE_ORDER: Record<DrillPhase, number> = { inject: 0, observe: 1, recover: 2, assert: 3 };
export const SCENARIOS: DrillScenario[] = [
{
id: "api-timeout", title: "订单接口超时", owner: "客户端稳定性小组",
blastRadius: "下单支付", abortIf: "资金类错误出现一次,或支付成功率低于 80%",
steps: [
{ id: "s1", phase: "inject", target: "order-api", detail: "注入 800ms 延迟,影响 10% 请求" },
{ id: "s2", phase: "observe", target: "checkout", detail: "观察 10 分钟首屏与支付成功率" },
{ id: "s3", phase: "recover", target: "order-api", detail: "移除注入,等待指标回到基线" },
{ id: "s4", phase: "assert", target: "checkout", detail: "核对降级入口、资金错误与恢复时间" },
],
assertions: [
{ id: "a1", description: "降级后支付入口仍然可见", metric: "checkout.degrade.visible", comparison: "eq", expected: 1 },
{ id: "a2", description: "恢复后 5 分钟内成功率回到基线", metric: "checkout.recovery.minutes", comparison: "lte", expected: 5 },
{ id: "a3", description: "演练期间资金类错误为零", metric: "checkout.money.errors", comparison: "eq", expected: 0 },
],
},
{
id: "config-unavailable", title: "远程配置不可用", owner: "客户端平台组",
blastRadius: "首页与消息通知", abortIf: "崩溃率超过 0.5%,或白屏率超过 1%",
steps: [
{ id: "c1", phase: "inject", target: "remote-config", detail: "阻断配置请求并返回 503" },
{ id: "c2", phase: "observe", target: "browse", detail: "观察启动与首页渲染,记录本地快照命中" },
{ id: "c3", phase: "recover", target: "remote-config", detail: "恢复下发并推送一次全量快照" },
{ id: "c4", phase: "assert", target: "browse", detail: "核对降级内容与恢复时间" },
],
assertions: [
{ id: "ca1", description: "配置不可用时首页仍能渲染", metric: "browse.render.ok", comparison: "eq", expected: 1 },
{ id: "ca2", description: "降级文案在 1 秒内出现", metric: "browse.fallback.ms", comparison: "lte", expected: 1000 },
{ id: "ca3", description: "恢复后 3 分钟内配置生效", metric: "config.recovery.minutes", comparison: "lte", expected: 3 },
],
},
{
id: "push-sdk-down", title: "推送 SDK 不可用", owner: "消息与增长组",
blastRadius: "消息通知", abortIf: "站内通知重复率超过 1%",
steps: [
{ id: "p1", phase: "inject", target: "push-sdk", detail: "模拟 SDK 初始化失败" },
{ id: "p2", phase: "observe", target: "notification", detail: "观察站内通知与未读计数" },
{ id: "p3", phase: "recover", target: "push-sdk", detail: "恢复 SDK 并补拉离线消息" },
{ id: "p4", phase: "assert", target: "notification", detail: "核对站内兜底与恢复时间" },
],
assertions: [
{ id: "pa1", description: "推送失败时站内通知仍可见", metric: "notification.inapp.visible", comparison: "eq", expected: 1 },
{ id: "pa2", description: "恢复后重复通知比例不超过 1%", metric: "notification.duplicate.rate", comparison: "lte", expected: 0.01 },
{ id: "pa3", description: "恢复后 5 分钟内未读计数一致", metric: "notification.recovery.minutes", comparison: "lte", expected: 5 },
],
},
];
export function validateScenario(scenario: DrillScenario): string[] {
const errors: string[] = [];
const stepIds = new Set<string>();
for (const step of scenario.steps) {
if (stepIds.has(step.id)) errors.push(`duplicate step id: ${step.id}`);
stepIds.add(step.id);
}
for (const phase of ["inject", "observe", "recover", "assert"] as DrillPhase[]) {
if (!scenario.steps.some((step) => step.phase === phase)) errors.push(`missing phase: ${phase}`);
}
let last = -1;
for (const step of scenario.steps) {
const order = PHASE_ORDER[step.phase];
if (order < last) {
errors.push(`step out of order: ${step.id}`);
break;
}
last = order;
}
if (scenario.assertions.length === 0) errors.push("no assertions");
const metrics = new Set<string>();
for (const assertion of scenario.assertions) {
if (metrics.has(assertion.metric)) errors.push(`duplicate metric: ${assertion.metric}`);
metrics.add(assertion.metric);
}
if (scenario.owner.trim().length === 0) errors.push("missing owner");
if (scenario.blastRadius.trim().length === 0) errors.push("missing blast radius");
if (scenario.abortIf.trim().length === 0) errors.push("missing abort condition");
return errors;
}
export function compareValue(actual: number, assertion: DrillAssertion): boolean {
switch (assertion.comparison) {
case "lte":
return actual <= assertion.expected;
case "gte":
return actual >= assertion.expected;
case "eq":
return actual === assertion.expected;
}
}
export function readiness(scenario: DrillScenario): ReadinessCheck[] {
return [
{ id: "owner", ok: scenario.owner.trim().length > 0, detail: "演练负责人" },
{ id: "blast-radius", ok: scenario.blastRadius.trim().length > 0, detail: "爆炸半径已声明" },
{ id: "abort", ok: scenario.abortIf.trim().length > 0, detail: "中止条件已声明" },
{ id: "rollback", ok: scenario.steps.some((step) => step.phase === "recover"), detail: "包含恢复步骤" },
{ id: "assertion", ok: scenario.assertions.length > 0, detail: "包含恢复断言" },
];
}
export function isReady(scenario: DrillScenario): boolean {
return readiness(scenario).every((check) => check.ok);
}
export function runDrill(scenario: DrillScenario, observations: DrillObservation[]): DrillReport {
const errors = validateScenario(scenario);
const checks: DrillCheck[] = scenario.assertions.map((assertion) => {
const observation = observations.find((item) => item.metric === assertion.metric);
if (observation === undefined) {
return {
assertionId: assertion.id,
description: assertion.description,
metric: assertion.metric,
comparison: assertion.comparison,
expected: assertion.expected,
actual: null,
passed: false,
};
}
return {
assertionId: assertion.id,
description: assertion.description,
metric: assertion.metric,
comparison: assertion.comparison,
expected: assertion.expected,
actual: observation.value,
passed: compareValue(observation.value, assertion),
};
});
const missing = checks.filter((check) => check.actual === null).map((check) => check.metric);
const failed = checks.filter((check) => check.actual !== null && !check.passed).map((check) => check.description);
const phases: Record<DrillPhase, number> = { inject: 0, observe: 0, recover: 0, assert: 0 };
for (const step of scenario.steps) phases[step.phase] += 1;
let verdict: DrillReport["verdict"];
if (errors.length > 0 || failed.length > 0) {
verdict = "fail";
} else if (missing.length > 0) {
verdict = "incomplete";
} else {
verdict = "pass";
}
return {
scenarioId: scenario.id,
title: scenario.title,
verdict,
passed: verdict === "pass",
checks,
failed,
missing,
errors,
steps: scenario.steps.length,
phases,
};
}
export function summarize(reports: DrillReport[]): SuiteSummary {
const passed = reports.filter((report) => report.verdict === "pass").length;
const failed = reports.filter((report) => report.verdict === "fail").length;
const incomplete = reports.filter((report) => report.verdict === "incomplete").length;
return {
total: reports.length,
passed,
failed,
incomplete,
passRate: reports.length === 0 ? 0 : passed / reports.length,
failedScenarios: reports.filter((report) => report.verdict !== "pass").map((report) => report.scenarioId),
};
}
代码里有四个关键约定:
validateScenario检查阶段齐全、顺序合法、编号与指标不重复、负责人与中止条件非空。readiness把「能不能开始演练」拆成五项可勾选条件,缺一项就不允许注入。runDrill把缺少观测与断言不通过分开记录,分别对应incomplete与fail。summarize按场景统计通过率,并保留未通过场景 id,报告可以直接贴进发布记录。
测试与真实输出
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 114.779708
测试覆盖了三个内置场景的结构校验、缺阶段、阶段倒序、重复编号、重复指标、三种比较符的边界,以及四种运行结果:通过、失败、不完整、无效场景。最后两个用例验证汇总报告不会把不完整算成通过。
判据与反例
| 判据 | 通过条件 | 失败信号 |
|---|---|---|
| 场景可校验 | 四阶段齐全、顺序合法 | 注入后直接写「已恢复」 |
| 有中止条件 | 监控可读、可立即执行 | 条件写「视情况而定」 |
| 断言可观测 | 指标来自真实采集 | 断言写在文档里但没人采集 |
| 恢复有时限 | 每条断言带时间窗口 | 只看恢复瞬间的截图 |
| 报告区分结论 | pass、fail、incomplete 不混用 | 用「基本通过」收尾 |
四个常见反例:
反例一:只注入不恢复。 演练结束时故障仍在,团队把「注入成功」当成「演练成功」。这类演练唯一证明的是注入工具可用。
反例二:用平均值断言。 平均成功率 95% 可能掩盖某个端 60% 的事实。按端、按功能、按关键路径拆开断言,平均值只做辅助。
反例三:没有中止条件。 注入比例从 10% 调到 50%,错误率上升却没人敢停。中止条件要在演练开始前写进场景,而不是现场商量。
反例四:把 incomplete 当 pass。 缺一个观测就补采后重跑,不要用「其他指标都正常」替代。真实故障不会只挑你采集了的指标发生。
小结
故障演练解决的是「恢复是否真的发生」。
- 演练是四阶段状态机:注入、观察、恢复、断言,顺序不能乱。
- 场景必须有负责人、爆炸半径、中止条件与可观测断言。
- 断言要断言恢复后的用户价值,给时间窗口,一项只测一个指标。
- 报告只有 pass、fail、incomplete 三种结论;缺观测不能算通过。
- 未通过项要保留缺陷链接与负责人,下一轮演练必须复跑。
故障域回答「影响多大」,开关与兜底回答「还能做什么」,演练回答「是否真的恢复」。三者合起来,才是一条完整的稳定性闭环。下一章转向工程规模化,从 Monorepo 与包边界开始。
阅读导航:上一节:5.2 降级、开关与兜底 · 下一节:6.1 Monorepo 与包边界 。
常见错误与边界
- 只做注入不做恢复,演练结束时系统仍停在故障态。
- 没有中止条件,错误率上升后现场商量是否停止,错过止损窗口。
- 断言写在文档里却没有对应指标,报告只能靠人工描述。
- 用平均值掩盖单端或单功能的严重退化。
- 断言恢复瞬间的截图,不给时间窗口,几分钟后回落也发现不了。
- 缺少观测时写成「基本通过」,把 incomplete 伪装成 pass。
- 未通过项没有缺陷链接与负责人,下一轮演练重复同一问题。
- 场景阶段倒序仍然执行,断言的是故障态而不是恢复态。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。