《互联网产品的客户端架构·规模化与架构演进》3.3 动态配置与热更新的停止条件

热更新的难点不是把配置推下去,而是知道什么时候必须停下来。本节从一次凌晨配置推送引发的崩溃出发,给出默认、环境、实验三层配置的覆盖规则,确定性的灰度分桶,以及基于崩溃率、错误率与延迟的批次停止条件,并用可运行的 TypeScript 模块把停止判定写成代码。

凌晨两点的一次配置推送

某电商客户端在发版夜推送了一条配置:把首页推荐流的 pageSize 从 20 改成 50。配置在 10 分钟内全量生效,随后崩溃率从 0.1% 升到 3.2%。值班同学的第一反应是「回滚配置」,但旧配置已经被覆盖,服务端只保留了最新值。

事后复盘发现三个问题:

  • 配置没有分层,实验值和默认值混在一张表里。
  • 灰度按「请求时间取模」分桶,同一台设备每次请求落到不同桶,无法定位影响面。
  • 没有停止条件,推送后没有任何自动判定,全靠人盯曲线。

热更新的核心不是「推得快」,而是推得出去,也停得下来。

配置分层:覆盖顺序要写死

配置至少分三层,优先级从低到高:

层来源典型内容变更频率
default客户端内置兜底值、安全上限随版本
environment环境配置区域、渠道、宿主版本差异天级
experiment实验平台灰度开关、比例、参数分钟级

合并规则必须是「高层覆盖低层,未覆盖的键保留低层值」。这条规则听起来简单,但实现时常见两个错误:

第一个错误是按数组顺序合并。调用方把 experiment 放在前面,结果默认值覆盖了实验值。层的优先级应该由层名决定,而不是由传入顺序决定。

第二个错误是深合并。配置值应该是标量,嵌套对象会让「删除一个键」变得无法表达。需要结构化的配置,应该在 schema 层展开成扁平键。

分层还带来一个附带好处:回滚可以只回滚一层。实验层出问题,把实验层恢复到上一个健康快照即可,不必动默认值。

灰度分桶必须确定且可复现

按请求时间取模的问题是:同一台设备在两次请求中可能落到不同桶,实验组和对照组混在一起,指标无法归因。

可用的做法是对「设备 id + 实验键」做哈希,取模 100:

  • 同一设备、同一实验,永远落在同一个桶。
  • 不同实验之间相互独立,不会因为设备 id 相同而总是同组。
  • 服务端和客户端可以用同一套算法复算,排查时能对得上。

分桶函数不需要密码学强度,但必须稳定。FNV-1a 这类简单哈希足够,关键是输入拼接口径要固定:先实验键、后设备 id,中间用固定分隔符。顺序变了,分桶就全变了。

比例含义判定
0%关闭恒为假
1%金丝雀桶号 < 1
20%小流量桶号 < 20
100%全量恒为真

比例要在客户端和服务端使用同一份定义。两边各写一套「四舍五入」,会在边界上出现 1% 的设备永远进不了实验。

健康信号与停止条件

灰度推进不能只看「有没有报错」。至少要同时看四类信号:

信号说明典型阈值超限动作
sampleSize本批次样本量≥ 200不足则保持
crashRate崩溃会话占比≤ 0.5%回滚
errorRate错误请求占比≤ 2%回滚
p95LatencyMs95 分位延迟≤ 800ms回滚

阈值不是越严越好。样本量门槛必须先满足,否则一次偶发崩溃就会让 1% 的金丝雀批次回滚,灰度永远推不出去。

样本不足时的动作是「保持」,不是「继续」。保持意味着停在本批次等数据,继续意味着在证据不足时放量,两者风险完全不同。

还要区分「信号缺失」与「信号正常」。某个批次没有上报,可能是采集故障,也可能是插件没有加载。两种情况都不应该被当成健康。

批次推进与停止

一个可执行的批次计划是 1% → 5% → 20% → 100%,每一批都要过同一套健康判定:

  • 判定为 continue:进入下一批。
  • 判定为 hold:停在当前批,等待更多样本或人工确认。
  • 判定为 rollback:停止推进,回到上一个稳定版本。

停止条件要写成「首个不健康批次即停」,而不是「所有批次跑完再统计」。后者的问题在于,等统计出来时,100% 的用户已经受影响。

把热更新写成代码

下面这个模块把配置分层、确定性分桶、健康判定、版本化更新与批次推进放在一起。它不负责网络传输,只负责「收到配置之后怎么判」。

// file: src/plugin/hot-update.ts
// tier: L2
// verify: node --test test/plugin/hot-update.test.ts

export type ConfigLayer = "default" | "environment" | "experiment";

export type ConfigValue = string | number | boolean;

export type ConfigPatch = Record<string, ConfigValue>;

export type LayerConfig = { layer: ConfigLayer; values: ConfigPatch };

const LAYER_RANK: Record<ConfigLayer, number> = {
  default: 0,
  environment: 1,
  experiment: 2,
};

export function resolveConfig(layers: LayerConfig[]): ConfigPatch {
  const ordered = [...layers].sort(
    (a, b) => LAYER_RANK[a.layer] - LAYER_RANK[b.layer],
  );
  const merged: ConfigPatch = {};
  for (const layer of ordered) {
    for (const [key, value] of Object.entries(layer.values)) {
      merged[key] = value;
    }
  }
  return merged;
}

export function rolloutBucket(unitId: string, key: string): number {
  let hash = 2166136261;
  const input = `${key}:${unitId}`;
  for (let index = 0; index < input.length; index += 1) {
    hash ^= input.charCodeAt(index);
    hash = Math.imul(hash, 16777619);
  }
  return (hash >>> 0) % 100;
}

export function inRollout(unitId: string, key: string, percent: number): boolean {
  const clamped = Math.max(0, Math.min(100, percent));
  if (clamped === 0) return false;
  if (clamped === 100) return true;
  return rolloutBucket(unitId, key) < clamped;
}

export type HealthSignal = {
  sampleSize: number;
  crashRate: number;
  errorRate: number;
  p95LatencyMs: number;
};

export type StopPolicy = {
  minSampleSize: number;
  maxCrashRate: number;
  maxErrorRate: number;
  maxP95LatencyMs: number;
};

export type Action = "continue" | "hold" | "rollback";

export type BatchDecision = { action: Action; reasons: string[] };

export function evaluateHealth(
  signal: HealthSignal,
  policy: StopPolicy,
): BatchDecision {
  if (
    !Number.isFinite(signal.sampleSize) ||
    signal.sampleSize < policy.minSampleSize
  ) {
    return { action: "hold", reasons: ["sample-too-small"] };
  }
  const reasons: string[] = [];
  if (signal.crashRate > policy.maxCrashRate) reasons.push("crash-rate");
  if (signal.errorRate > policy.maxErrorRate) reasons.push("error-rate");
  if (signal.p95LatencyMs > policy.maxP95LatencyMs) reasons.push("latency");
  if (reasons.length > 0) return { action: "rollback", reasons };
  return { action: "continue", reasons: ["healthy"] };
}

export type UpdateEnvelope = { version: number; patch: ConfigPatch };

export type ApplyResult =
  | { applied: true; version: number; config: ConfigPatch }
  | {
      applied: false;
      reason: "stale-version" | "same-version" | "empty-patch" | "invalid-value";
    };

export function applyUpdate(
  current: { version: number; config: ConfigPatch },
  envelope: UpdateEnvelope,
): ApplyResult {
  if (!Number.isInteger(envelope.version) || envelope.version < 0) {
    return { applied: false, reason: "invalid-value" };
  }
  if (envelope.version < current.version) {
    return { applied: false, reason: "stale-version" };
  }
  if (envelope.version === current.version) {
    return { applied: false, reason: "same-version" };
  }
  const keys = Object.keys(envelope.patch);
  if (keys.length === 0) return { applied: false, reason: "empty-patch" };
  for (const key of keys) {
    if (key.trim() === "") return { applied: false, reason: "invalid-value" };
    const value = envelope.patch[key];
    if (typeof value === "number" && !Number.isFinite(value)) {
      return { applied: false, reason: "invalid-value" };
    }
    if (typeof value === "string" && value.trim() === "") {
      return { applied: false, reason: "invalid-value" };
    }
  }
  return {
    applied: true,
    version: envelope.version,
    config: { ...current.config, ...envelope.patch },
  };
}

export type RolloutBatch = { name: string; percent: number; policy: StopPolicy };

export type RolloutStep = {
  batch: string;
  percent: number;
  action: Action;
  reasons: string[];
};

export type RolloutPlan = {
  steps: RolloutStep[];
  stopped: boolean;
  stopAt: string | undefined;
};

export function planRollout(
  batches: RolloutBatch[],
  observations: Record<string, HealthSignal>,
): RolloutPlan {
  const steps: RolloutStep[] = [];
  for (const batch of batches) {
    const signal = observations[batch.name];
    const decision = signal
      ? evaluateHealth(signal, batch.policy)
      : { action: "hold" as Action, reasons: ["no-signal"] };
    steps.push({
      batch: batch.name,
      percent: batch.percent,
      action: decision.action,
      reasons: decision.reasons,
    });
    if (decision.action !== "continue") {
      return { steps, stopped: true, stopAt: batch.name };
    }
  }
  return { steps, stopped: false, stopAt: undefined };
}

export const DEFAULT_POLICY: StopPolicy = {
  minSampleSize: 200,
  maxCrashRate: 0.005,
  maxErrorRate: 0.02,
  maxP95LatencyMs: 800,
};

export const ROLLOUT_BATCHES: RolloutBatch[] = [
  { name: "canary", percent: 1, policy: DEFAULT_POLICY },
  { name: "early", percent: 5, policy: DEFAULT_POLICY },
  { name: "broad", percent: 20, policy: DEFAULT_POLICY },
  { name: "full", percent: 100, policy: DEFAULT_POLICY },
];

代码里有三个设计值得说明:

  1. resolveConfig 先按层名排序再合并,调用方传数组的顺序不影响结果。
  2. evaluateHealth 先看样本量,再看指标,避免小样本误判。
  3. applyUpdate 用单调版本号拒绝旧包与重复包,杜绝「回滚到旧版本又被旧包覆盖」。

判据与反例

判断一套热更新是否可控,看四个判据:

  • 可复现:同一设备在同一实验中分桶稳定,服务端能复算。
  • 可分层:覆盖顺序由层名决定,未覆盖的键有兜底值。
  • 可停止:每个批次都有明确的停止与回滚条件。
  • 可回退:版本号单调,旧包不会覆盖新包。

反例一:用时间戳分桶。同一设备每次请求可能进入不同组,实验数据无法归因。

反例二:只看平均延迟。平均延迟被大量快请求稀释,少数慢请求已经把 p95 拉到不可接受的水平。

反例三:回滚时把配置版本号也回退。版本号回退后,下一次推送会被判定为「同版本」而拒绝,热更新通道被自己堵死。

回滚不是撤销按钮

很多团队把回滚理解成「把配置改回去」。真正的回滚至少要回答三个问题:

问题错误做法正确做法
回滚到哪回滚到上一个推送值回滚到上一个通过健康判定的版本
谁执行值班同学手动改自动触发,人工确认
如何验证看曲线恢复对比回滚批次的崩溃率与基线

第一个问题最容易被忽略。「上一个推送值」可能本身就没有经过验证,回滚到它只是从一个故障跳到另一个故障。保留「最近一个健康版本」的快照,比保留全部历史更有用。

回滚还要有冷却期。自动回滚触发后,短时间内不应该允许同一配置再次推送,否则会出现「推上去、滚下来、再推上去」的抖动。

小结

热更新的难点在停止条件,不在推送通道。

  • 配置分 default、environment、experiment 三层,覆盖顺序由层名决定。
  • 灰度分桶按「实验键 + 设备 id」哈希,保证同设备稳定、不同实验独立。
  • 健康判定先看样本量,再看崩溃率、错误率与 p95 延迟。
  • 批次推进遇到首个不健康批次即停,回滚目标是最近一个健康版本。

能停下来的更新,才配叫热更新。

阅读导航:上一节:3.2 沙箱、权限与失败隔离 · 下一节:4.1 性能预算与分端指标 。

常见错误与边界

  1. 按时间戳或请求序号分桶。 同一设备会漂移,实验组与对照组混在一起,指标不可归因。
  2. 按数组顺序合并配置层。 调用方顺序一变结果就变,层优先级必须由层名决定。
  3. 样本不足时继续放量。 证据不足时应该保持,继续放量等于拿用户做实验。
  4. 只看平均延迟。 平均值会掩盖长尾,至少要看 p95 或 p99。
  5. 样本门槛设得过高。 门槛高于小批次能拿到的样本量,灰度会永远停在第一批。
  6. 回滚时回退版本号。 版本号必须单调递增,否则后续推送会被判为重复包。
  7. 自动回滚后立刻允许重推。 没有冷却期会出现推上去、滚下来的抖动。
  8. 把回滚目标设为上一个推送值。 上一个值未必健康,应该回到最近一个通过健康判定的版本。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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