《互联网产品的客户端架构·规模化与架构演进》5.1 故障域与爆炸半径

端上故障很少只坏一个按钮:一个共享依赖失效,可能同时带走首页、下单与通知。本节把依赖图、故障域、单点与爆炸半径写成可运行的 TypeScript 模型,用受影响功能比例量化一次故障的波及面,并给出评审判据与常见反例,让「影响面多大」从口头争论变成可计算的数字。

一次 CDN 故障带走了四个功能

某内容社区在晚高峰遇到 CDN 证书过期。监控上只有一个红色指标:静态资源 5xx 上升。用户侧的表现却分散在四个地方:首页白屏,下单页样式错乱,订单记录空白,消息中心红点不出现。埋点功能正常,因为埋点 SDK 不经过 CDN。

值班同学第一反应是「CDN 挂了,换域名」。半小时后 CDN 恢复,问题却没有完全消失:远程配置仍然拿不到,首页继续使用旧快照。复盘时才发现,远程配置也挂在 CDN 上,而订单页与消息中心都依赖远程配置里的开关。真正的问题不是 CDN,而是没人画过依赖图,也没人算过一张证书能影响多少功能。

这次事故留下三个问题:功能依赖哪些东西,哪些依赖失效会跨过功能边界,爆炸半径能不能在上线前算出来。

依赖图:功能与依赖是两层节点

端上架构图常按「页面」画:首页、商详、订单、消息。这种图画不出故障传播,因为故障不认页面。更有用的画法只有两层节点:功能依赖哪些依赖,依赖又依赖哪些依赖。

层节点边的含义例子
功能层用户可感知的功能功能需要某个依赖才能工作下单支付需要订单服务
依赖层服务、SDK、存储、配置依赖运行需要另一个依赖订单服务需要 API 网关与订单主库

本节使用的依赖图如下。redundant 表示这个依赖是否有多活、降级或本地替代;dependsOn 是它运行时需要的上游。

依赖故障域类型冗余上游依赖
dnsnetworkservice是无
cdnnetworkservice是dns
api-gatewaybackendservice否dns
order-apibackendservice否api-gateway、order-db
order-dbstoragestorage否无
remote-configconfigconfig是cdn
payment-sdkthirdpartythirdparty否无
push-sdkthirdpartythirdparty是无
analytics-sdkthirdpartysdk是无

功能只直接声明自己用到的依赖:

功能直接依赖
浏览首页 browsecdn、remote-config
下单支付 checkoutapi-gateway、order-api、payment-sdk
订单记录 order-historyapi-gateway、order-api
消息通知 notificationpush-sdk、remote-config
行为埋点 analyticsanalytics-sdk

注意 order-api 同时依赖 api-gateway 与 order-db。这意味着网关故障与主库故障会通过同一条路径抵达订单功能,但它们的修复手段完全不同。依赖图的价值不是画得漂亮,而是把「谁会跟着谁挂」摊开。

故障域按共享失效原因划分

故障域不是团队边界,也不是服务边界,而是共享同一个失效原因的节点集合。同一个机房、同一个 DNS 服务商、同一个第三方 SDK 账号、同一份配置下发通道,都应该落进同一个域。

故障域成员共享的失效原因
networkdns、cdnDNS 服务商、证书、边缘节点
backendapi-gateway、order-api同一套集群与发布通道
storageorder-db主库实例、磁盘、备份策略
configremote-config配置中心与缓存链路
thirdpartypayment-sdk、push-sdk、analytics-sdk外部厂商的可用性与限流

按团队划分故障域会得到一个舒服但错误的结论:每个团队都「只有一个域」,所以每个团队都觉得自己不是单点。把它改成按失效原因划分,跨团队的共享依赖才会浮出来。

爆炸半径:受影响功能比例

爆炸半径不是「影响了几个接口」,而是「用户可感知的功能坏了多少」。用功能做分母,得到的比例可以直接和发布门槛对话。

依赖失效时,先沿依赖边向下游传播,再收集直接或间接挂在这些依赖上的功能:

失效依赖受影响依赖受影响功能比例
dnsdns、cdn、api-gateway、order-api、remote-config首页、下单、订单、通知0.80
api-gatewayapi-gateway、order-api下单、订单0.40
order-apiorder-api下单、订单0.40
order-dborder-api、order-db下单、订单0.40
cdncdn、remote-config首页、通知0.40
remote-configremote-config首页、通知0.40
payment-sdkpayment-sdk下单0.20
push-sdkpush-sdk通知0.20
analytics-sdkanalytics-sdk埋点0.20

dns 的比例最高,不是因为它最重要,而是因为它位于依赖链的根部。把它标红之后,团队才会去问:DNS 有没有多服务商?证书续期有没有自动告警?这些问题在「CDN 故障」的标题下永远不会被提出来。

比例也有边界。它只统计「功能是否依赖」,不统计「依赖失效时功能是否已经降级」。一个已经有本地快照的首页,真实影响可能低于 0.80。所以爆炸半径是上界,不是损失预测。上界适合做架构评审,损失预测需要叠加降级覆盖率,那是 5.2 的内容。

单点:非冗余且影响多个功能

单点的判定需要两个条件同时成立:依赖没有冗余,且它的失效会带走多个功能。只满足一个都不够。

依赖非冗余受影响功能数是否单点
api-gateway是2是
order-api是2是
order-db是2是
payment-sdk是1低于阈值,不是
dns否4有冗余,不是

阈值默认是「至少影响两个功能」,可以按版本调整。阈值调低会把所有非冗余依赖都列出来,适合做一次全面盘点;阈值调高则只保留最危险的那几个,适合放进发布门禁。

「有重试」不算冗余。重试只能覆盖瞬时错误,覆盖不了证书过期、账号被封、区域性故障。真正的冗余需要独立的失效域:多服务商、多可用区、本地快照,三者至少有一个。

把模型写成代码

把上面的图、域、单点与比例写成纯函数,评审时就不需要手工数格子:

// file: src/stability/fault-domain.ts
// tier: L1
// verify: node --test test/stability/fault-domain.test.ts

export type DependencyKind = "service" | "sdk" | "storage" | "config" | "thirdparty";

export type Dependency = {
  id: string;
  title: string;
  domain: string;
  kind: DependencyKind;
  redundant: boolean;
  dependsOn: string[];
};

export type Feature = { id: string; title: string; dependsOn: string[] };

export type BlastRadius = { dependencyId: string; dependencies: string[]; features: string[]; ratio: number };

export type FaultDomain = { id: string; title: string; dependencies: string[] };

const DOMAIN_TITLES: Record<string, string> = {
  network: "网络与分发",
  backend: "业务后端",
  storage: "数据存储",
  config: "配置与实验",
  thirdparty: "第三方 SDK",
};

export const DEPENDENCIES: Dependency[] = [
  { id: "dns", title: "域名解析", domain: "network", kind: "service", redundant: true, dependsOn: [] },
  { id: "cdn", title: "静态资源 CDN", domain: "network", kind: "service", redundant: true, dependsOn: ["dns"] },
  { id: "api-gateway", title: "API 网关", domain: "backend", kind: "service", redundant: false, dependsOn: ["dns"] },
  { id: "order-api", title: "订单服务", domain: "backend", kind: "service", redundant: false, dependsOn: ["api-gateway", "order-db"] },
  { id: "order-db", title: "订单主库", domain: "storage", kind: "storage", redundant: false, dependsOn: [] },
  { id: "remote-config", title: "远程配置", domain: "config", kind: "config", redundant: true, dependsOn: ["cdn"] },
  { id: "payment-sdk", title: "支付 SDK", domain: "thirdparty", kind: "thirdparty", redundant: false, dependsOn: [] },
  { id: "push-sdk", title: "推送 SDK", domain: "thirdparty", kind: "thirdparty", redundant: true, dependsOn: [] },
  { id: "analytics-sdk", title: "埋点 SDK", domain: "thirdparty", kind: "sdk", redundant: true, dependsOn: [] },
];

export const FEATURES: Feature[] = [
  { id: "browse", title: "浏览首页", dependsOn: ["cdn", "remote-config"] },
  { id: "checkout", title: "下单支付", dependsOn: ["api-gateway", "order-api", "payment-sdk"] },
  { id: "order-history", title: "订单记录", dependsOn: ["api-gateway", "order-api"] },
  { id: "notification", title: "消息通知", dependsOn: ["push-sdk", "remote-config"] },
  { id: "analytics", title: "行为埋点", dependsOn: ["analytics-sdk"] },
];

export function dependencyById(id: string, dependencies: Dependency[] = DEPENDENCIES): Dependency | undefined {
  return dependencies.find((dependency) => dependency.id === id);
}

export function featureById(id: string, features: Feature[] = FEATURES): Feature | undefined {
  return features.find((feature) => feature.id === id);
}

export function directDependents(depId: string, dependencies: Dependency[] = DEPENDENCIES): string[] {
  return dependencies.filter((dependency) => dependency.dependsOn.includes(depId)).map((dependency) => dependency.id);
}

export function affectedDependencies(depId: string, dependencies: Dependency[] = DEPENDENCIES): string[] {
  const affected = new Set<string>([depId]);
  const queue = [depId];
  while (queue.length > 0) {
    const current = queue.shift() as string;
    for (const dependency of dependencies) {
      if (!affected.has(dependency.id) && dependency.dependsOn.includes(current)) {
        affected.add(dependency.id);
        queue.push(dependency.id);
      }
    }
  }
  return dependencies.filter((dependency) => affected.has(dependency.id)).map((dependency) => dependency.id);
}

export function dependentsOf(
  depId: string,
  dependencies: Dependency[] = DEPENDENCIES,
  features: Feature[] = FEATURES,
): string[] {
  const affected = new Set(affectedDependencies(depId, dependencies));
  return features.filter((feature) => feature.dependsOn.some((id) => affected.has(id))).map((feature) => feature.id);
}

export function blastRadius(
  depId: string,
  dependencies: Dependency[] = DEPENDENCIES,
  features: Feature[] = FEATURES,
): BlastRadius | undefined {
  if (!dependencies.some((dependency) => dependency.id === depId)) {
    return undefined;
  }
  const affectedFeatures = dependentsOf(depId, dependencies, features);
  return {
    dependencyId: depId,
    dependencies: affectedDependencies(depId, dependencies),
    features: affectedFeatures,
    ratio: features.length === 0 ? 0 : affectedFeatures.length / features.length,
  };
}

export function faultDomains(dependencies: Dependency[] = DEPENDENCIES): FaultDomain[] {
  const ids: string[] = [];
  for (const dependency of dependencies) {
    if (!ids.includes(dependency.domain)) {
      ids.push(dependency.domain);
    }
  }
  return ids.map((id) => ({
    id,
    title: DOMAIN_TITLES[id] ?? id,
    dependencies: dependencies.filter((dependency) => dependency.domain === id).map((dependency) => dependency.id),
  }));
}

export function domainBlastRadius(
  domainId: string,
  dependencies: Dependency[] = DEPENDENCIES,
  features: Feature[] = FEATURES,
): BlastRadius | undefined {
  const domain = faultDomains(dependencies).find((item) => item.id === domainId);
  if (domain === undefined) {
    return undefined;
  }
  const affected = new Set<string>();
  for (const depId of domain.dependencies) {
    for (const id of affectedDependencies(depId, dependencies)) {
      affected.add(id);
    }
  }
  const affectedFeatures = features.filter((feature) => feature.dependsOn.some((id) => affected.has(id))).map((feature) => feature.id);
  return {
    dependencyId: `domain/${domainId}`,
    dependencies: dependencies.filter((dependency) => affected.has(dependency.id)).map((dependency) => dependency.id),
    features: affectedFeatures,
    ratio: features.length === 0 ? 0 : affectedFeatures.length / features.length,
  };
}

export function singlePoints(
  minFeatures = 2,
  dependencies: Dependency[] = DEPENDENCIES,
  features: Feature[] = FEATURES,
): string[] {
  return dependencies
    .filter((dependency) => !dependency.redundant)
    .map((dependency, index) => ({ dependency, index, radius: blastRadius(dependency.id, dependencies, features) as BlastRadius }))
    .filter((item) => item.radius.features.length >= minFeatures)
    .sort((a, b) => b.radius.ratio - a.radius.ratio || a.index - b.index)
    .map((item) => item.dependency.id);
}

export function riskiestDomain(dependencies: Dependency[] = DEPENDENCIES, features: Feature[] = FEATURES): string | undefined {
  let best: { id: string; ratio: number } | undefined;
  for (const domain of faultDomains(dependencies)) {
    const ratio = domainBlastRadius(domain.id, dependencies, features)?.ratio ?? 0;
    if (best === undefined || ratio > best.ratio) {
      best = { id: domain.id, ratio };
    }
  }
  return best?.id;
}

export function sharedDependencies(
  featureA: string,
  featureB: string,
  dependencies: Dependency[] = DEPENDENCIES,
  features: Feature[] = FEATURES,
): string[] {
  const first = featureById(featureA, features);
  const second = featureById(featureB, features);
  if (first === undefined || second === undefined) {
    return [];
  }
  const shared = new Set(first.dependsOn.filter((id) => second.dependsOn.includes(id)));
  return dependencies.filter((dependency) => shared.has(dependency.id)).map((dependency) => dependency.id);
}

export function hasCycle(dependencies: Dependency[] = DEPENDENCIES): boolean {
  const state = new Map<string, number>();
  const visit = (id: string): boolean => {
    const current = state.get(id) ?? 0;
    if (current === 1) return true;
    if (current === 2) return false;
    state.set(id, 1);
    for (const next of dependencyById(id, dependencies)?.dependsOn ?? []) {
      if (visit(next)) return true;
    }
    state.set(id, 2);
    return false;
  };
  return dependencies.some((dependency) => visit(dependency.id));
}

export function unknownReferences(dependencies: Dependency[] = DEPENDENCIES, features: Feature[] = FEATURES): string[] {
  const known = new Set(dependencies.map((dependency) => dependency.id));
  const missing: string[] = [];
  for (const dependency of dependencies) {
    for (const ref of dependency.dependsOn) {
      if (!known.has(ref)) missing.push(`${dependency.id}->${ref}`);
    }
  }
  for (const feature of features) {
    for (const ref of feature.dependsOn) {
      if (!known.has(ref)) missing.push(`${feature.id}->${ref}`);
    }
  }
  return missing;
}

代码里有三个刻意设计:

  • affectedDependencies 用集合防环,依赖图出现环时不会死循环,hasCycle 单独负责报告问题。
  • blastRadius 返回 undefined 而不是空对象,未知依赖必须显式处理,不能被当成「影响为零」。
  • singlePoints 先按比例排序,比例相同再按声明顺序,保证每次构建输出的清单稳定。

测试与真实输出

ℹ tests 22
ℹ suites 0
ℹ pass 22
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 120.647959

测试覆盖了正常路径,也覆盖了未知依赖、无共享依赖、自定义环与未知引用。这些用例不是为了凑数,而是因为依赖图最容易被改坏:新增一个 SDK、把配置挪到另一个域名,都可能在不报错的情况下改变爆炸半径。

判据与反例

判据通过条件失败信号
功能有依赖清单每个上线功能至少列出两个依赖评审时答不出「它靠什么工作」
故障域按失效原因划分同域节点共享至少一个失效原因域的名字是团队名
单点有处置每个单点有冗余、降级或明确接受单点清单连续三个版本不变
半径可计算任一依赖能在分钟级算出比例只能回答「影响挺大」
图无环hasCycle 为 false依赖链无法排序

四个常见反例:

反例一:按页面画依赖图。 页面之间的跳转关系不是故障传播关系。首页跳商详再跳订单,不代表订单服务挂了首页会白屏;反过来,首页与订单可能共享同一个配置依赖,页面图完全画不出来。

反例二:只算直接依赖。 只看 browse 的直接依赖,会漏掉 cdn -> dns 这条链。DNS 故障时首页仍然白屏,但它在直接依赖表里根本不出现。

反例三:把冗余写成「有重试」。 重试次数再多,也跨不过同一个 DNS 服务商。冗余的判据是失效域独立,不是代码里有没有 retry。

反例四:用功能数量代替比例。 「影响了三个功能」在小产品里是灾难,在大产品里可能只是局部。比例才能跨版本、跨产品比较。

小结

故障域与爆炸半径解决的是「影响面多大」这个问题。

  • 依赖图要分两层:功能挂依赖,依赖挂依赖;只画页面画不出传播路径。
  • 故障域按共享失效原因划分,不按团队或服务划分。
  • 爆炸半径用受影响功能比例表示,它是上界,不是损失预测。
  • 单点等于「非冗余」且「影响多个功能」,重试不算冗余。
  • 比例与单点清单要能自动算出来,并且每次构建结果稳定。

有了上界,下一步才是把上界压下来:哪些功能可以降级,哪些开关能切断传播,兜底内容长什么样。5.2 从一次「全量关闭」的事故开始。

阅读导航:上一节:4.3 优化闭环与回归防护 · 下一节:5.2 降级、开关与兜底 。

常见错误与边界

  • 只画功能之间的调用关系,不画依赖之间的上下游,DNS 与 CDN 的传播路径永远看不见。
  • 把故障域等同于团队边界,跨团队共享的配置中心与网关会被漏掉。
  • 认为「有重试」就是冗余,忽略证书、账号、区域级故障无法靠重试恢复。
  • 用受影响接口数代替受影响功能比例,数字很大但无法和发布门槛比较。
  • 依赖图出现环仍然上线,故障传播无法排序,排查时互相等待。
  • 新增依赖不更新图,三个月后爆炸半径算出来的是旧系统的答案。
  • 把上界当成损失预测,忽略降级覆盖率,评审时高估或低估真实影响。
  • 单点清单只列不处置,连续多个版本重复出现同一批名字。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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