一次 CDN 故障带走了四个功能
某内容社区在晚高峰遇到 CDN 证书过期。监控上只有一个红色指标:静态资源 5xx 上升。用户侧的表现却分散在四个地方:首页白屏,下单页样式错乱,订单记录空白,消息中心红点不出现。埋点功能正常,因为埋点 SDK 不经过 CDN。
值班同学第一反应是「CDN 挂了,换域名」。半小时后 CDN 恢复,问题却没有完全消失:远程配置仍然拿不到,首页继续使用旧快照。复盘时才发现,远程配置也挂在 CDN 上,而订单页与消息中心都依赖远程配置里的开关。真正的问题不是 CDN,而是没人画过依赖图,也没人算过一张证书能影响多少功能。
这次事故留下三个问题:功能依赖哪些东西,哪些依赖失效会跨过功能边界,爆炸半径能不能在上线前算出来。
依赖图:功能与依赖是两层节点
端上架构图常按「页面」画:首页、商详、订单、消息。这种图画不出故障传播,因为故障不认页面。更有用的画法只有两层节点:功能依赖哪些依赖,依赖又依赖哪些依赖。
| 层 | 节点 | 边的含义 | 例子 |
|---|---|---|---|
| 功能层 | 用户可感知的功能 | 功能需要某个依赖才能工作 | 下单支付需要订单服务 |
| 依赖层 | 服务、SDK、存储、配置 | 依赖运行需要另一个依赖 | 订单服务需要 API 网关与订单主库 |
本节使用的依赖图如下。redundant 表示这个依赖是否有多活、降级或本地替代;dependsOn 是它运行时需要的上游。
| 依赖 | 故障域 | 类型 | 冗余 | 上游依赖 |
|---|---|---|---|---|
dns | network | service | 是 | 无 |
cdn | network | service | 是 | dns |
api-gateway | backend | service | 否 | dns |
order-api | backend | service | 否 | api-gateway、order-db |
order-db | storage | storage | 否 | 无 |
remote-config | config | config | 是 | cdn |
payment-sdk | thirdparty | thirdparty | 否 | 无 |
push-sdk | thirdparty | thirdparty | 是 | 无 |
analytics-sdk | thirdparty | sdk | 是 | 无 |
功能只直接声明自己用到的依赖:
| 功能 | 直接依赖 |
|---|---|
浏览首页 browse | cdn、remote-config |
下单支付 checkout | api-gateway、order-api、payment-sdk |
订单记录 order-history | api-gateway、order-api |
消息通知 notification | push-sdk、remote-config |
行为埋点 analytics | analytics-sdk |
注意 order-api 同时依赖 api-gateway 与 order-db。这意味着网关故障与主库故障会通过同一条路径抵达订单功能,但它们的修复手段完全不同。依赖图的价值不是画得漂亮,而是把「谁会跟着谁挂」摊开。
故障域按共享失效原因划分
故障域不是团队边界,也不是服务边界,而是共享同一个失效原因的节点集合。同一个机房、同一个 DNS 服务商、同一个第三方 SDK 账号、同一份配置下发通道,都应该落进同一个域。
| 故障域 | 成员 | 共享的失效原因 |
|---|---|---|
network | dns、cdn | DNS 服务商、证书、边缘节点 |
backend | api-gateway、order-api | 同一套集群与发布通道 |
storage | order-db | 主库实例、磁盘、备份策略 |
config | remote-config | 配置中心与缓存链路 |
thirdparty | payment-sdk、push-sdk、analytics-sdk | 外部厂商的可用性与限流 |
按团队划分故障域会得到一个舒服但错误的结论:每个团队都「只有一个域」,所以每个团队都觉得自己不是单点。把它改成按失效原因划分,跨团队的共享依赖才会浮出来。
爆炸半径:受影响功能比例
爆炸半径不是「影响了几个接口」,而是「用户可感知的功能坏了多少」。用功能做分母,得到的比例可以直接和发布门槛对话。
依赖失效时,先沿依赖边向下游传播,再收集直接或间接挂在这些依赖上的功能:
| 失效依赖 | 受影响依赖 | 受影响功能 | 比例 |
|---|---|---|---|
dns | dns、cdn、api-gateway、order-api、remote-config | 首页、下单、订单、通知 | 0.80 |
api-gateway | api-gateway、order-api | 下单、订单 | 0.40 |
order-api | order-api | 下单、订单 | 0.40 |
order-db | order-api、order-db | 下单、订单 | 0.40 |
cdn | cdn、remote-config | 首页、通知 | 0.40 |
remote-config | remote-config | 首页、通知 | 0.40 |
payment-sdk | payment-sdk | 下单 | 0.20 |
push-sdk | push-sdk | 通知 | 0.20 |
analytics-sdk | analytics-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 的传播路径永远看不见。
- 把故障域等同于团队边界,跨团队共享的配置中心与网关会被漏掉。
- 认为「有重试」就是冗余,忽略证书、账号、区域级故障无法靠重试恢复。
- 用受影响接口数代替受影响功能比例,数字很大但无法和发布门槛比较。
- 依赖图出现环仍然上线,故障传播无法排序,排查时互相等待。
- 新增依赖不更新图,三个月后爆炸半径算出来的是旧系统的答案。
- 把上界当成损失预测,忽略降级覆盖率,评审时高估或低估真实影响。
- 单点清单只列不处置,连续多个版本重复出现同一批名字。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。