《互联网产品的客户端架构·规模化与架构演进》5.3 故障演练与恢复验证

演练最容易通过的部分是注入,最容易造假的部分是恢复。若没有恢复断言与未通过项记录,「演练成功」只是一句口号。本节把演练场景、注入步骤、恢复断言与报告判定写成可运行的 TypeScript 模型,并说明 incomplete 与 fail 的区别、中止条件与报告评审判据。

一次「演练通过」的假象

某团队做了一次 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。
  • 未通过项没有缺陷链接与负责人,下一轮演练重复同一问题。
  • 场景阶段倒序仍然执行,断言的是故障态而不是恢复态。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查