凌晨两点的一次配置推送
某电商客户端在发版夜推送了一条配置:把首页推荐流的 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% | 回滚 |
p95LatencyMs | 95 分位延迟 | ≤ 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 },
];
代码里有三个设计值得说明:
resolveConfig先按层名排序再合并,调用方传数组的顺序不影响结果。evaluateHealth先看样本量,再看指标,避免小样本误判。applyUpdate用单调版本号拒绝旧包与重复包,杜绝「回滚到旧版本又被旧包覆盖」。
判据与反例
判断一套热更新是否可控,看四个判据:
- 可复现:同一设备在同一实验中分桶稳定,服务端能复算。
- 可分层:覆盖顺序由层名决定,未覆盖的键有兜底值。
- 可停止:每个批次都有明确的停止与回滚条件。
- 可回退:版本号单调,旧包不会覆盖新包。
反例一:用时间戳分桶。同一设备每次请求可能进入不同组,实验数据无法归因。
反例二:只看平均延迟。平均延迟被大量快请求稀释,少数慢请求已经把 p95 拉到不可接受的水平。
反例三:回滚时把配置版本号也回退。版本号回退后,下一次推送会被判定为「同版本」而拒绝,热更新通道被自己堵死。
回滚不是撤销按钮
很多团队把回滚理解成「把配置改回去」。真正的回滚至少要回答三个问题:
| 问题 | 错误做法 | 正确做法 |
|---|---|---|
| 回滚到哪 | 回滚到上一个推送值 | 回滚到上一个通过健康判定的版本 |
| 谁执行 | 值班同学手动改 | 自动触发,人工确认 |
| 如何验证 | 看曲线恢复 | 对比回滚批次的崩溃率与基线 |
第一个问题最容易被忽略。「上一个推送值」可能本身就没有经过验证,回滚到它只是从一个故障跳到另一个故障。保留「最近一个健康版本」的快照,比保留全部历史更有用。
回滚还要有冷却期。自动回滚触发后,短时间内不应该允许同一配置再次推送,否则会出现「推上去、滚下来、再推上去」的抖动。
小结
热更新的难点在停止条件,不在推送通道。
- 配置分
default、environment、experiment三层,覆盖顺序由层名决定。 - 灰度分桶按「实验键 + 设备 id」哈希,保证同设备稳定、不同实验独立。
- 健康判定先看样本量,再看崩溃率、错误率与 p95 延迟。
- 批次推进遇到首个不健康批次即停,回滚目标是最近一个健康版本。
能停下来的更新,才配叫热更新。
阅读导航:上一节:3.2 沙箱、权限与失败隔离 · 下一节:4.1 性能预算与分端指标 。
常见错误与边界
- 按时间戳或请求序号分桶。 同一设备会漂移,实验组与对照组混在一起,指标不可归因。
- 按数组顺序合并配置层。 调用方顺序一变结果就变,层优先级必须由层名决定。
- 样本不足时继续放量。 证据不足时应该保持,继续放量等于拿用户做实验。
- 只看平均延迟。 平均值会掩盖长尾,至少要看 p95 或 p99。
- 样本门槛设得过高。 门槛高于小批次能拿到的样本量,灰度会永远停在第一批。
- 回滚时回退版本号。 版本号必须单调递增,否则后续推送会被判为重复包。
- 自动回滚后立刻允许重推。 没有冷却期会出现推上去、滚下来的抖动。
- 把回滚目标设为上一个推送值。 上一个值未必健康,应该回到最近一个通过健康判定的版本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。