一次「全量关闭」把故障放大
某团队的支付通道在晚高峰出现区域性失败。值班同学没有细看影响范围,直接在后台把收银台全局开关关掉。故障很快「消失」了:所有端都不再发起支付请求。
但用户侧的问题变大了。已经下单待支付的用户找不到入口,实验组里本来走备用通道的用户也被一起关掉,客服收到的投诉从「支付失败」变成「无法支付」。更糟的是,这个全局开关同时被三个实验复用,关闭它等于一次性停掉三个正在运行的实验。
复盘时发现两个结构性问题:
- 开关只有「开/关」两种状态,没有层级。全局关闭压过了端级与用户级的例外。
- 开关没有冲突检测。同一个 key 上同时存在全局、端级、实验级规则,谁生效取决于后台的保存顺序。
降级不是把功能关掉,而是在故障发生时选择一条仍然可用的路径。要做到这一点,开关必须先有确定的优先级。
开关的四个层级
| 层级 | 作用范围 | 匹配条件 | 优先级 |
|---|---|---|---|
global | 所有用户与端 | 无条件 | 1 |
endpoint | 单一端形态 | 端匹配 | 2 |
experiment | 实验组 | 用户属于任一目标组 | 3 |
user | 单个用户 | 用户 id 匹配 | 4 |
优先级从低到高是全局、端、实验、用户。越具体的规则越晚被覆盖,也越应该由更接近用户的人负责。这个顺序不是唯一解,但必须是唯一且写死的解;如果每次事故都靠人判断谁大,开关本身就会成为故障源。
同一层级内还需要第二排序键。本节用 priority 数值,大者胜出;数值相同再按规则 id 字典序。三层排序保证同一个上下文永远得到同一个赢家,测试才能稳定。
冲突与优先级
用内置规则跑四个上下文,可以直接看到层级覆盖的结果:
| 上下文 | 命中的规则 | 生效值 |
|---|---|---|
| Android,无用户与实验 | g-cashier-on | 开启 |
| iOS,无用户与实验 | e-ios-off 覆盖全局 | 关闭 |
iOS,实验组 cashier-v2 | x-cashier-v2 覆盖端规则 | 开启 |
iOS,用户 u-1001,同时在实验组 | u-vip-off 覆盖实验与端 | 关闭 |
冲突的定义比「同一个 key 有两条规则」更窄:同一 key、同一层级、目标有交集、开关值相反,才算冲突。不同层级的相反值不是冲突,那是优先级要解决的问题;同层级不同目标的相反值也不是冲突,它们服务于不同人群。
| 场景 | 是否冲突 | 原因 |
|---|---|---|
同层、目标 [web, ios] 与 [ios, android]、值相反 | 是 | 交集 ios 上无法同时成立 |
同层、目标 [web] 与 [ios]、值相反 | 否 | 目标不相交 |
| 不同层、值相反 | 否 | 优先级可以裁决 |
| 全局空目标与同层任意目标、值相反 | 是 | 空目标表示覆盖全部 |
冲突检测应该在发布前跑,而不是在事故中跑。发现冲突时只有两个合法动作:改目标让它们不相交,或者合并成一条规则。靠「后保存的覆盖先保存的」是最坏的选择,因为它把系统行为绑定在操作顺序上。
降级是选择动作,不是隐藏
故障类型与功能重要度共同决定降级动作。只看故障类型会一刀切,只看重要度会忽略可重试性。
| 故障类型 | 可重试 | 说明 |
|---|---|---|
timeout | 是 | 请求超时,稍后可能成功 |
unavailable | 是 | 依赖暂时不可达 |
error | 否 | 业务返回明确错误,重试无意义 |
slow | 否 | 响应过慢,重试会放大拥塞 |
| 功能重要度 | 有缓存 | 有静态兜底 | 无兜底 |
|---|---|---|---|
core | 展示缓存 | 展示静态内容 | 阻塞并说明原因 |
important | 展示缓存 | 展示静态内容 | 可重试则稍后重试,否则隐藏入口 |
optional | 展示缓存 | 展示静态内容 | 隐藏入口 |
核心功能无兜底时选择「阻塞并说明原因」,而不是静默隐藏。静默隐藏会让用户以为功能不存在,客服也无法解释;明确提示至少把「系统有问题」转换成「你可以稍后再试」。
可选功能则相反。一个推荐位、一个皮肤入口,故障时隐藏比展示错误更合适。判据不是「能不能修」,而是「展示错误信息的收益是否大于隐藏」。
兜底内容要写完整
兜底不是一句「加载失败」。它至少包含标题、解释和下一步动作,三者缺一不可。
| 功能 | 标题 | 解释 | 动作 |
|---|---|---|---|
信息流 feed | 暂时无法刷新 | 正在展示上次加载的内容 | 重新加载 |
订单 orders | 订单暂时不可用 | 已提交的订单不会丢失 | 重试 |
余额 wallet | 余额加载失败 | 为保护资金安全,请确认网络 | 重新进入 |
| 未注册功能 | 功能暂不可用 | 网络恢复后会自动重试 | 重试 |
未注册功能必须返回通用文案,而不是空白。空白会让用户以为页面坏了,通用文案至少保留了「稍后重试」的路径。
兜底内容还要和降级动作对齐:选择隐藏入口时不需要文案,选择阻塞或展示缓存时必须有文案。planFallback 把这两个决定绑在一起,避免出现「动作是隐藏、界面却弹了一个错误框」的组合。
把模型写成代码
// file: src/stability/degrade-switch.ts
// tier: L1
// verify: node --test test/stability/degrade-switch.test.ts
export type SwitchScope = "global" | "endpoint" | "experiment" | "user";
export type Endpoint = "web" | "ios" | "android" | "miniprogram" | "desktop";
export type SwitchRule = {
id: string;
key: string;
scope: SwitchScope;
enabled: boolean;
targets: string[];
priority: number;
};
export type SwitchContext = { key: string; endpoint: Endpoint; userId?: string; experimentGroups?: string[] };
export type SwitchConflict = { key: string; scope: SwitchScope; ruleIds: string[]; targets: string[] };
export type SwitchAudit = {
total: number;
enabled: number;
disabled: number;
byScope: Record<SwitchScope, number>;
conflicts: SwitchConflict[];
};
const SCOPE_RANK: Record<SwitchScope, number> = { global: 1, endpoint: 2, experiment: 3, user: 4 };
export const SWITCH_RULES: SwitchRule[] = [
{ id: "g-cashier-on", key: "checkout.cashier", scope: "global", enabled: true, targets: [], priority: 0 },
{ id: "e-ios-off", key: "checkout.cashier", scope: "endpoint", enabled: false, targets: ["ios"], priority: 10 },
{ id: "x-cashier-v2", key: "checkout.cashier", scope: "experiment", enabled: true, targets: ["cashier-v2"], priority: 5 },
{ id: "u-vip-off", key: "checkout.cashier", scope: "user", enabled: false, targets: ["u-1001"], priority: 1 },
{ id: "g-feed-cache", key: "feed.cache", scope: "global", enabled: true, targets: [], priority: 0 },
{ id: "e-miniprogram-feed-off", key: "feed.cache", scope: "endpoint", enabled: false, targets: ["miniprogram"], priority: 0 },
];
export function scopeRank(scope: SwitchScope): number {
return SCOPE_RANK[scope];
}
export function ruleMatches(rule: SwitchRule, context: SwitchContext): boolean {
if (rule.key !== context.key) return false;
switch (rule.scope) {
case "global":
return true;
case "endpoint":
return rule.targets.includes(context.endpoint);
case "user":
return context.userId !== undefined && rule.targets.includes(context.userId);
case "experiment":
return (context.experimentGroups ?? []).some((group) => rule.targets.includes(group));
}
}
function compareRules(a: SwitchRule, b: SwitchRule): number {
const byRank = SCOPE_RANK[b.scope] - SCOPE_RANK[a.scope];
if (byRank !== 0) return byRank;
const byPriority = b.priority - a.priority;
if (byPriority !== 0) return byPriority;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
export function resolveSwitch(rules: SwitchRule[], context: SwitchContext): SwitchRule | undefined {
return rules.filter((rule) => ruleMatches(rule, context)).sort(compareRules)[0];
}
export function isEnabled(rules: SwitchRule[], context: SwitchContext, fallback = false): boolean {
const winner = resolveSwitch(rules, context);
return winner === undefined ? fallback : winner.enabled;
}
function overlapTargets(a: string[], b: string[]): string[] | undefined {
if (a.length === 0 && b.length === 0) return [];
if (a.length === 0) return [...b];
if (b.length === 0) return [...a];
const second = new Set(b);
const shared = a.filter((target) => second.has(target));
return shared.length === 0 ? undefined : shared;
}
export function findConflicts(rules: SwitchRule[]): SwitchConflict[] {
const conflicts: SwitchConflict[] = [];
for (let i = 0; i < rules.length; i += 1) {
for (let j = i + 1; j < rules.length; j += 1) {
const a = rules[i];
const b = rules[j];
if (a.key !== b.key || a.scope !== b.scope || a.enabled === b.enabled) continue;
const targets = overlapTargets(a.targets, b.targets);
if (targets === undefined) continue;
conflicts.push({ key: a.key, scope: a.scope, ruleIds: [a.id, b.id].sort(), targets });
}
}
return conflicts.sort((a, b) => {
if (a.key !== b.key) return a.key < b.key ? -1 : 1;
if (a.scope !== b.scope) return a.scope < b.scope ? -1 : 1;
return 0;
});
}
export function auditSwitches(rules: SwitchRule[] = SWITCH_RULES): SwitchAudit {
const byScope: Record<SwitchScope, number> = { global: 0, endpoint: 0, experiment: 0, user: 0 };
let enabled = 0;
for (const rule of rules) {
byScope[rule.scope] += 1;
if (rule.enabled) enabled += 1;
}
return {
total: rules.length,
enabled,
disabled: rules.length - enabled,
byScope,
conflicts: findConflicts(rules),
};
}
export type FailureKind = "timeout" | "error" | "unavailable" | "slow";
export type Criticality = "core" | "important" | "optional";
export type FallbackKind = "cache" | "static" | "none";
export type DegradeAction = "serve-cache" | "serve-static" | "retry-later" | "hide-entry" | "block-with-message";
export type DegradeInput = { failure: FailureKind; criticality: Criticality; fallback: FallbackKind };
export type DegradePlan = { action: DegradeAction; retryable: boolean; userVisible: boolean; reason: string };
export function chooseDegrade(input: DegradeInput): DegradePlan {
const retryable = input.failure === "timeout" || input.failure === "unavailable";
if (input.fallback === "cache") {
return { action: "serve-cache", retryable, userVisible: true, reason: "cache-fallback" };
}
if (input.fallback === "static") {
return { action: "serve-static", retryable, userVisible: true, reason: "static-fallback" };
}
if (input.criticality === "core") {
return { action: "block-with-message", retryable, userVisible: true, reason: "core-no-fallback" };
}
if (input.criticality === "important" && retryable) {
return { action: "retry-later", retryable, userVisible: true, reason: "important-retryable" };
}
return { action: "hide-entry", retryable, userVisible: false, reason: `${input.criticality}-no-fallback` };
}
export type FallbackContent = { featureId: string; title: string; body: string; actionLabel: string };
export const FALLBACKS: FallbackContent[] = [
{ featureId: "feed", title: "暂时无法刷新", body: "正在展示上次加载的内容,网络恢复后会自动更新。", actionLabel: "重新加载" },
{ featureId: "orders", title: "订单暂时不可用", body: "已提交的订单不会丢失,请稍后重试。", actionLabel: "重试" },
{ featureId: "wallet", title: "余额加载失败", body: "为保护资金安全,请确认网络后重新进入。", actionLabel: "重新进入" },
];
export function fallbackFor(featureId: string): FallbackContent {
const registered = FALLBACKS.find((item) => item.featureId === featureId);
if (registered !== undefined) return registered;
return {
featureId,
title: "功能暂不可用",
body: "网络恢复后会自动重试,你也可以稍后手动刷新。",
actionLabel: "重试",
};
}
export type FeatureFallback = { featureId: string; plan: DegradePlan; content: FallbackContent | undefined };
export function planFallback(input: DegradeInput & { featureId: string }): FeatureFallback {
const plan = chooseDegrade(input);
return {
featureId: input.featureId,
plan,
content: plan.userVisible ? fallbackFor(input.featureId) : undefined,
};
}
代码里有四个关键约定:
ruleMatches只负责匹配,不负责排序;匹配与优先级分开,测试可以分别验证。resolveSwitch先过滤再排序,返回完整规则而不只是布尔值,排查时能看到赢家是谁。findConflicts只看同层相反值,避免把正常的层级覆盖误报成冲突。chooseDegrade先看兜底能力,再看重要度;有缓存时任何故障都优先展示缓存。
测试与真实输出
ℹ tests 27
ℹ suites 0
ℹ pass 27
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 123.109833
测试覆盖了四个层级的匹配、同层优先级、用户与实验覆盖、冲突的交集判定,以及降级动作的每种组合。特别保留了两个边界:没有用户身份时不匹配用户规则;全局空目标视为覆盖全部。
判据与反例
| 判据 | 通过条件 | 失败信号 |
|---|---|---|
| 层级唯一 | 同一上下文只有一个赢家 | 两个人看后台得出不同结论 |
| 优先级写死 | 层级顺序与同层排序都有文档 | 事故中临时约定谁大 |
| 冲突可检测 | 发布前能列出同层相反规则 | 只能靠保存顺序碰运气 |
| 动作有兜底 | 每个阻塞或缓存动作都有文案 | 用户看到空白页 |
| 核心不静默 | 核心功能无兜底时明确提示 | 入口消失且没有解释 |
四个常见反例:
反例一:用实验开关做 kill switch。 实验开关的目标是实验组,关闭它只能救实验组,救不了全量。kill switch 必须独立于实验,并且有明确的 owner 与过期时间。
反例二:兜底文案写「未知错误」。 这句话没有解释、没有下一步,用户只能退出。兜底文案要回答「发生了什么、数据有没有丢、现在能做什么」。
反例三:核心功能无兜底时隐藏入口。 隐藏让用户以为功能不存在,也掩盖了监控指标。明确提示至少能让客服与监控看到同一件事。
反例四:只在客户端做开关。 客户端拿不到配置时,开关状态取决于本地缓存。缓存过期后必须有默认值,而默认值应该偏保守:高风险功能默认关闭,基础功能默认开启。
开关审计清单
发布前按七项审计,能把大部分开关事故挡在门外:
- 每条开关是否有 owner、创建原因与过期时间?
- 同一 key 是否存在同层相反规则?冲突是否已合并或分离目标?
- kill switch 是否独立于实验开关?
- 每个降级动作是否配有兜底文案与下一步动作?
- 核心功能无兜底时,是否明确阻塞而不是静默隐藏?
- 客户端拿不到配置时的默认值是否已定义并测试?
- 开关变更是否有审计日志,能回答「谁在什么时候改了什么」?
七项里最容易漏的是过期时间。临时开关没有过期时间,一年后仍在生效,没人敢删,也没人知道它当初为了解决什么问题。
小结
降级与开关解决的是「故障发生时还能做什么」。
- 开关分全局、端、实验、用户四层,越具体优先级越高;同层再用 priority 与 id 排序。
- 冲突只发生在同层、目标相交、值相反的三条规则之间;不同层由优先级裁决。
- 降级动作由故障类型、功能重要度与兜底能力共同决定。
- 核心功能无兜底时明确阻塞,可选功能无兜底时隐藏入口。
- 兜底文案必须包含标题、解释与下一步动作;未注册功能要有通用文案。
- 开关审计的重点是 owner、冲突、过期时间与默认值。
开关只能切断传播,不能证明恢复。故障是否真的过去、缓存是否刷新、指标是否回到基线,需要演练来回答。5.3 把演练写成可判定的报告。
阅读导航:上一节:5.1 故障域与爆炸半径 · 下一节:5.3 故障演练与恢复验证 。
常见错误与边界
- 全局开关压过用户级例外,一次「全量关闭」把本可服务的用户一起停掉。
- 同一 key 在后台保存两条相反规则,行为取决于保存顺序,测试无法复现。
- 用实验开关代替 kill switch,只能影响实验组,救不了全量。
- 核心功能无兜底时直接隐藏入口,用户以为功能不存在,监控也失去信号。
- 兜底文案只写「未知错误」,没有解释与下一步动作。
- 客户端拿不到配置时没有默认值,开关状态取决于本地缓存是否过期。
- 临时开关没有过期时间与 owner,一年后没人敢删。
- 只测开关打开与关闭,不测同一上下文多层规则同时命中的赢家。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。