《互联网产品的客户端架构·规模化与架构演进》5.2 降级、开关与兜底

开关越多,事故时越容易互相打架:用户级、实验级、端级与全局规则同时命中时,必须有一个确定的赢家。本节把开关层级、冲突检测、降级动作与兜底内容写成可测试的 TypeScript 模块,并给出优先级判据、常见反例与发布前的审计清单,让每一次关闭都有据可查。

一次「全量关闭」把故障放大

某团队的支付通道在晚高峰出现区域性失败。值班同学没有细看影响范围,直接在后台把收银台全局开关关掉。故障很快「消失」了:所有端都不再发起支付请求。

但用户侧的问题变大了。已经下单待支付的用户找不到入口,实验组里本来走备用通道的用户也被一起关掉,客服收到的投诉从「支付失败」变成「无法支付」。更糟的是,这个全局开关同时被三个实验复用,关闭它等于一次性停掉三个正在运行的实验。

复盘时发现两个结构性问题:

  • 开关只有「开/关」两种状态,没有层级。全局关闭压过了端级与用户级的例外。
  • 开关没有冲突检测。同一个 key 上同时存在全局、端级、实验级规则,谁生效取决于后台的保存顺序。

降级不是把功能关掉,而是在故障发生时选择一条仍然可用的路径。要做到这一点,开关必须先有确定的优先级。

开关的四个层级

层级作用范围匹配条件优先级
global所有用户与端无条件1
endpoint单一端形态端匹配2
experiment实验组用户属于任一目标组3
user单个用户用户 id 匹配4

优先级从低到高是全局、端、实验、用户。越具体的规则越晚被覆盖,也越应该由更接近用户的人负责。这个顺序不是唯一解,但必须是唯一且写死的解;如果每次事故都靠人判断谁大,开关本身就会成为故障源。

同一层级内还需要第二排序键。本节用 priority 数值,大者胜出;数值相同再按规则 id 字典序。三层排序保证同一个上下文永远得到同一个赢家,测试才能稳定。

冲突与优先级

用内置规则跑四个上下文,可以直接看到层级覆盖的结果:

上下文命中的规则生效值
Android,无用户与实验g-cashier-on开启
iOS,无用户与实验e-ios-off 覆盖全局关闭
iOS,实验组 cashier-v2x-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 与过期时间。

反例二:兜底文案写「未知错误」。 这句话没有解释、没有下一步,用户只能退出。兜底文案要回答「发生了什么、数据有没有丢、现在能做什么」。

反例三:核心功能无兜底时隐藏入口。 隐藏让用户以为功能不存在,也掩盖了监控指标。明确提示至少能让客服与监控看到同一件事。

反例四:只在客户端做开关。 客户端拿不到配置时,开关状态取决于本地缓存。缓存过期后必须有默认值,而默认值应该偏保守:高风险功能默认关闭,基础功能默认开启。

开关审计清单

发布前按七项审计,能把大部分开关事故挡在门外:

  1. 每条开关是否有 owner、创建原因与过期时间?
  2. 同一 key 是否存在同层相反规则?冲突是否已合并或分离目标?
  3. kill switch 是否独立于实验开关?
  4. 每个降级动作是否配有兜底文案与下一步动作?
  5. 核心功能无兜底时,是否明确阻塞而不是静默隐藏?
  6. 客户端拿不到配置时的默认值是否已定义并测试?
  7. 开关变更是否有审计日志,能回答「谁在什么时候改了什么」?

七项里最容易漏的是过期时间。临时开关没有过期时间,一年后仍在生效,没人敢删,也没人知道它当初为了解决什么问题。

小结

降级与开关解决的是「故障发生时还能做什么」。

  • 开关分全局、端、实验、用户四层,越具体优先级越高;同层再用 priority 与 id 排序。
  • 冲突只发生在同层、目标相交、值相反的三条规则之间;不同层由优先级裁决。
  • 降级动作由故障类型、功能重要度与兜底能力共同决定。
  • 核心功能无兜底时明确阻塞,可选功能无兜底时隐藏入口。
  • 兜底文案必须包含标题、解释与下一步动作;未注册功能要有通用文案。
  • 开关审计的重点是 owner、冲突、过期时间与默认值。

开关只能切断传播,不能证明恢复。故障是否真的过去、缓存是否刷新、指标是否回到基线,需要演练来回答。5.3 把演练写成可判定的报告。

阅读导航:上一节:5.1 故障域与爆炸半径 · 下一节:5.3 故障演练与恢复验证 。

常见错误与边界

  • 全局开关压过用户级例外,一次「全量关闭」把本可服务的用户一起停掉。
  • 同一 key 在后台保存两条相反规则,行为取决于保存顺序,测试无法复现。
  • 用实验开关代替 kill switch,只能影响实验组,救不了全量。
  • 核心功能无兜底时直接隐藏入口,用户以为功能不存在,监控也失去信号。
  • 兜底文案只写「未知错误」,没有解释与下一步动作。
  • 客户端拿不到配置时没有默认值,开关状态取决于本地缓存是否过期。
  • 临时开关没有过期时间与 owner,一年后没人敢删。
  • 只测开关打开与关闭,不测同一上下文多层规则同时命中的赢家。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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