5% 灰度里的第二小时
「回声」的新模型通过了 11.2 的离线门禁,按计划先放 1% 流量。第一个观测窗口里,treatment 只有 100 个请求、1 个错误,延迟和成本都正常;样本量低于门槛,系统返回 hold,没有继续加量,也没有宣布成功。
第二天凌晨,灰度扩到 5%。半小时后的窗口里,treatment 2500 个请求错了 53 个,错误率 2.12%;control 47500 个请求错了 475 个,错误率 1.00%。错误率绝对值和相对 control 的差值同时越过硬指标,自动回滚在 5% 这一档触发,没有再往 25% 走。
这次没有等到用户投诉,是因为回滚条件在发布前就写死了。本节讨论灰度比例、观测指标、回滚条件,以及最容易被忽略的一半:回滚之后,已经写下的数据怎么办。文中数字来自 ai-lab 的示例灰度窗口运行结果,不是生产实测。
灰度比例与粘性分桶
灰度不是随机抽请求,而是按租户分桶。同一个租户在同一个 rollout 内始终落在同一臂:
bucket = floor(hash(rolloutId, tenantId) / 2^32 × 10000)
arm = bucket < percent × 100 ? treatment : control
分桶只依赖 rolloutId 与 tenantId,不依赖当前比例。1% 时进入 treatment 的租户,在 5%、25% 时仍然是 treatment;扩量只会让更多租户进入,不会把已经体验过新模型的租户换回旧模型。反过来,缩量时也不会把 control 租户误送进 treatment。
示例用 10000 个租户模拟 5% 分配,得到 517 个 treatment。比例不是精确的 500,因为哈希分桶有随机波动;517 仍在合理范围内。灰度系统不应该追求每个窗口都精确等于目标比例,而应该保证同一租户的臂稳定。
比例阶梯通常取 1%、5%、25%、50%、100%。每一档都要有独立的最小样本量与观测窗口,不能因为“上一档没问题”就跳过。1% 只能验证可用性与基本延迟,质量信号要到 5% 或更高才出现。
观测哪些指标
灰度窗口至少需要五组指标:
| 指标 | 用途 | 建议口径 |
|---|---|---|
| 错误率 | 可用性 | 网关错误类别,不含降级 |
| P95 延迟 | 交互体验 | 端到端,不含排队 |
| 平均成本 | 成本 | 整数微元/请求 |
| 质量通过率 | 效果 | 抽样评测,固定判定器 |
| 样本量 | 证据 | 两臂各自的请求数与评测数 |
错误率要同时看绝对值与相对 control 的差值。只看绝对值,会在 control 本身就不稳定的时段误判;只看差值,会在两臂都变差时漏判。两个条件都满足才算硬指标通过。
质量指标需要单独抽样评测,不能直接用用户反馈代替。用户反馈有滞后与选择偏差,灰度窗口只有几十分钟,等不到足够反馈。示例的 5% 窗口每臂抽 250 条与 950 条,1% 窗口只有 100 条;低于 200 条时质量检查不启用,窗口只能 hold。
硬指标与软指标
回滚条件按指标性质分两类:
- 硬指标:错误率绝对值、错误率相对差值。一次越界立即回滚,因为错误直接影响可用性。
- 软指标:P95 延迟、平均成本、质量通过率。连续两个窗口越界才回滚,避免一次抖动打回全量。
示例的 5% 窗口同时触发两条硬指标:treatment 错误率 2.12% 超过 2.00% 的绝对上限,相对 control 的差值 1.12 个百分点超过 0.50 个百分点的上限。evaluateWindow 返回 hardBreach=true,evaluateRollout 不再等待第二个窗口,直接回滚。
软指标连续计数有一个细节:样本不足的窗口不能重置计数,也不能算作通过。它只返回 hold;否则一次低流量窗口会把之前的越界记录清零。
回滚之后,数据不会自动回到过去
回滚路由只需要把新流量切回 control,但已经发生的副作用不会消失:
- 在途请求可能还在 treatment 上执行,切流后仍会写缓存、写事件、调用下游。
- treatment 写入的缓存条目可能被 control 读到,造成模型行为混杂。
- 同一个幂等键可能已经由 treatment 返回过结果,重算会重复扣费或重复写下游。
- 账本与事件流已经记录了 treatment 的消耗,不能改写历史。
因此回滚计划要按“切流、排空、隔离、对账”四步走:
- 新流量 100% 切回 control,停止 treatment 分配。
- 给在途请求一个排空期限;期限后仍未结束的请求标记为未知,进入对账。
- 缓存键提升 epoch,而不是暴力删除。旧条目自然过期,新请求不会读到 treatment 的结果。
- 保留幂等记录,按
rolloutId + arm对账账本、事件与下游写入;只补差异,不改历史。
缓存为什么用 epoch 而不是删除?批量删除会制造缓存穿透,回滚瞬间把压力打到模型;epoch 提升让旧键失效但保留数据,读不到时按正常路径重建,压力可控。
幂等记录为什么保留?因为 treatment 已经返回的结果是事实。回滚后同一个键再来,应该返回原结果或明确告知状态,而不是重新调用模型。重算可能产生第二次副作用,也可能因为模型已切回而返回不一致的内容。
实现
下面的模块做三件事:按租户粘性分桶、按窗口评估硬软指标、生成回滚计划。它不直接操作路由或缓存,只输出可被发布系统执行的判定与步骤。
// file: src/perf/rollout.ts
// tier: L1
// verify: node --test test/perf/rollout.test.ts
import { createHash } from "node:crypto";
import { AiError } from "../core/errors.ts";
export interface ArmMetrics {
requests: number; errors: number; p95LatencyMs: number;
avgCostMicros: number; qualityPasses: number; qualitySamples: number;
}
export interface RolloutWindow {
/** 当前灰度比例,1 到 100 的整数。 */
stagePercent: number; startedAtMs: number;
control: ArmMetrics; treatment: ArmMetrics;
}
export interface RollbackPolicy {
/** treatment 错误率允许比 control 高出的绝对值。 */
maxErrorRateDelta: number; maxErrorRate: number;
maxLatencyRatio: number; maxCostRatio: number; maxQualityDrop: number;
minRequestsPerArm: number; minQualitySamples: number;
/** 软指标连续多少个窗口越界才回滚;硬指标立即回滚。 */
consecutiveBreaches: number;
}
export const DEFAULT_ROLLBACK: RollbackPolicy = {
maxErrorRateDelta: 0.005, maxErrorRate: 0.02,
maxLatencyRatio: 1.15, maxCostRatio: 1.1, maxQualityDrop: 0.02,
minRequestsPerArm: 1000, minQualitySamples: 200, consecutiveBreaches: 2,
};
export type Arm = "control" | "treatment";
/**
* 同一个租户在同一个 rollout 内始终落在同一臂:分桶只依赖 rolloutId 与 tenantId,
* 不依赖当前比例,所以 1% 升到 5% 时已经进入 treatment 的租户不会被换回去。
*/
export function assignArm(tenantId: string, rolloutId: string, percent: number): Arm {
if (typeof tenantId !== "string" || tenantId.trim() === "") fail("tenantId is required");
if (typeof rolloutId !== "string" || rolloutId.trim() === "") fail("rolloutId is required");
if (!Number.isFinite(percent) || percent < 0 || percent > 100) fail("percent must be between 0 and 100");
if (percent === 0) return "control";
if (percent === 100) return "treatment";
const digest = createHash("sha256").update(`${rolloutId}\u0000${tenantId}`, "utf8").digest();
const bucket = Math.floor((digest.readUInt32BE(0) / 4294967296) * 10000);
return bucket < Math.round(percent * 100) ? "treatment" : "control";
}
function fail(message: string): never {
throw new AiError("invalid-request", `rollout: ${message}`);
}
function integer(value: number, field: string, min: number): void {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
}
function validateMetrics(metrics: ArmMetrics, label: string): void {
integer(metrics.requests, `${label}.requests`, 0);
integer(metrics.errors, `${label}.errors`, 0);
if (metrics.errors > metrics.requests) fail(`${label}.errors cannot exceed requests`);
if (!Number.isFinite(metrics.p95LatencyMs) || metrics.p95LatencyMs < 0) fail(`${label}.p95LatencyMs must be >= 0`);
integer(metrics.avgCostMicros, `${label}.avgCostMicros`, 0);
integer(metrics.qualitySamples, `${label}.qualitySamples`, 0);
integer(metrics.qualityPasses, `${label}.qualityPasses`, 0);
if (metrics.qualityPasses > metrics.qualitySamples) fail(`${label}.qualityPasses cannot exceed qualitySamples`);
}
function rate(numerator: number, denominator: number): number {
return denominator === 0 ? 0 : numerator / denominator;
}
function ratio(candidate: number, baseline: number): number {
if (baseline === 0) return candidate === 0 ? 1 : Number.POSITIVE_INFINITY;
return candidate / baseline;
}
export type CheckSeverity = "hard" | "soft";
export interface RolloutCheck {
id: string;
passed: boolean;
severity: CheckSeverity;
detail: string;
}
export interface WindowEvaluation {
stagePercent: number;
checks: RolloutCheck[];
hardBreach: boolean;
softBreach: boolean;
sufficient: boolean;
action: "pass" | "hold" | "breach";
}
/**
* 单个观测窗口的判定。错误率是硬指标,延迟、成本与质量是软指标:
* 硬指标立即回滚,软指标要连续多个窗口越界才回滚,避免一次抖动打回全量。
*/
export function evaluateWindow(window: RolloutWindow, policy: RollbackPolicy = DEFAULT_ROLLBACK): WindowEvaluation {
integer(window.stagePercent, "stagePercent", 1);
if (window.stagePercent > 100) fail("stagePercent must be <= 100");
integer(window.startedAtMs, "startedAtMs", 0);
validateMetrics(window.control, "control");
validateMetrics(window.treatment, "treatment");
const controlError = rate(window.control.errors, window.control.requests);
const treatmentError = rate(window.treatment.errors, window.treatment.requests);
const controlQuality = rate(window.control.qualityPasses, window.control.qualitySamples);
const treatmentQuality = rate(window.treatment.qualityPasses, window.treatment.qualitySamples);
const checks: RolloutCheck[] = [];
checks.push({ id: "error-absolute", passed: treatmentError <= policy.maxErrorRate, severity: "hard", detail: `treatment error ${(treatmentError * 100).toFixed(2)}%, limit ${(policy.maxErrorRate * 100).toFixed(2)}%` });
checks.push({ id: "error-delta", passed: treatmentError - controlError <= policy.maxErrorRateDelta, severity: "hard", detail: `delta ${((treatmentError - controlError) * 100).toFixed(2)}pt, limit ${(policy.maxErrorRateDelta * 100).toFixed(2)}pt` });
checks.push({ id: "latency", passed: ratio(window.treatment.p95LatencyMs, window.control.p95LatencyMs) <= policy.maxLatencyRatio, severity: "soft", detail: `p95 ratio ${ratio(window.treatment.p95LatencyMs, window.control.p95LatencyMs).toFixed(3)} <= ${policy.maxLatencyRatio}` });
checks.push({ id: "cost", passed: ratio(window.treatment.avgCostMicros, window.control.avgCostMicros) <= policy.maxCostRatio, severity: "soft", detail: `cost ratio ${ratio(window.treatment.avgCostMicros, window.control.avgCostMicros).toFixed(3)} <= ${policy.maxCostRatio}` });
const qualitySufficient =
window.control.qualitySamples >= policy.minQualitySamples &&
window.treatment.qualitySamples >= policy.minQualitySamples;
if (qualitySufficient) {
checks.push({ id: "quality", passed: treatmentQuality >= controlQuality - policy.maxQualityDrop, severity: "soft", detail: `quality delta ${((treatmentQuality - controlQuality) * 100).toFixed(2)}pt >= -${(policy.maxQualityDrop * 100).toFixed(2)}pt` });
}
const requestsSufficient =
window.control.requests >= policy.minRequestsPerArm &&
window.treatment.requests >= policy.minRequestsPerArm;
const sufficient = requestsSufficient && qualitySufficient;
const hardBreach = checks.some((check) => check.severity === "hard" && !check.passed);
const softBreach = checks.some((check) => check.severity === "soft" && !check.passed);
return {
stagePercent: window.stagePercent, checks, hardBreach, softBreach, sufficient,
action: hardBreach || softBreach ? "breach" : sufficient ? "pass" : "hold",
};
}
export interface RolloutDecision {
action: "continue" | "hold" | "rollback";
stoppedAtStage: number | null;
breachStreak: number;
windows: WindowEvaluation[];
reasons: string[];
}
/**
* 按时间顺序回放观测窗口。硬指标一次越界就回滚;软指标连续越界达到阈值才回滚。
* 样本不足的窗口只返回 hold,不用“看起来没问题”代替证据。
*/
export function evaluateRollout(
windows: readonly RolloutWindow[],
policy: RollbackPolicy = DEFAULT_ROLLBACK,
): RolloutDecision {
if (windows.length === 0) fail("at least one rollout window is required");
integer(policy.minRequestsPerArm, "minRequestsPerArm", 1);
integer(policy.minQualitySamples, "minQualitySamples", 1);
integer(policy.consecutiveBreaches, "consecutiveBreaches", 1);
const evaluations: WindowEvaluation[] = [];
const reasons: string[] = [];
let streak = 0;
let action: RolloutDecision["action"] = "continue";
let stoppedAtStage: number | null = null;
let previousStart = -1;
for (const window of windows) {
if (window.startedAtMs < previousStart) fail("windows must be ordered by startedAtMs");
previousStart = window.startedAtMs;
const evaluation = evaluateWindow(window, policy);
evaluations.push(evaluation);
for (const check of evaluation.checks.filter((item) => !item.passed)) {
reasons.push(`stage ${window.stagePercent}%: ${check.id} (${check.detail})`);
}
if (evaluation.hardBreach) {
action = "rollback";
stoppedAtStage = window.stagePercent;
break;
}
if (evaluation.softBreach) {
streak += 1;
if (streak >= policy.consecutiveBreaches) {
action = "rollback";
stoppedAtStage = window.stagePercent;
break;
}
action = "hold";
continue;
}
if (evaluation.sufficient) {
streak = 0;
action = "continue";
} else {
action = "hold";
}
}
return { action, stoppedAtStage, breachStreak: streak, windows: evaluations, reasons };
}
export interface RollbackContext {
rolloutId: string; treatmentPercent: number; rollbackAtMs: number;
inFlightRequests: number; drainPerSecond: number; graceMs: number;
cacheKeyPrefixes: readonly string[]; downstreamTargets: readonly string[];
idempotencyWindowMs: number;
}
export interface RollbackPlan {
routeTreatmentPercent: 0; drainDeadlineMs: number; cacheAction: "bump-epoch";
cacheEpoch: string; keepIdempotencyRecords: true; idempotencyWindowMs: number;
reconcileTargets: string[]; steps: string[];
}
/**
* 回滚计划先切流、再排空、最后对账。缓存用 epoch 提升而不是暴力删除;
* 幂等记录保留,避免同一个键被重算成第二次副作用。
*/
export function planRollback(context: RollbackContext): RollbackPlan {
if (typeof context.rolloutId !== "string" || context.rolloutId.trim() === "") fail("rolloutId is required");
if (!Number.isFinite(context.treatmentPercent) || context.treatmentPercent < 0 || context.treatmentPercent > 100) {
fail("treatmentPercent must be between 0 and 100");
}
integer(context.rollbackAtMs, "rollbackAtMs", 0);
integer(context.inFlightRequests, "inFlightRequests", 0);
if (!Number.isFinite(context.drainPerSecond) || context.drainPerSecond <= 0) fail("drainPerSecond must be > 0");
integer(context.graceMs, "graceMs", 0);
integer(context.idempotencyWindowMs, "idempotencyWindowMs", 0);
const drainMs = context.inFlightRequests === 0 ? 0 : Math.ceil((context.inFlightRequests / context.drainPerSecond) * 1000);
const drainDeadlineMs = drainMs + context.graceMs;
const cacheEpoch = `${context.rolloutId}:${context.rollbackAtMs}`;
const prefixes = [...new Set(context.cacheKeyPrefixes)].sort();
const targets = [...new Set(context.downstreamTargets)].sort();
const steps = [
"route 100% of new traffic back to control and stop treatment assignment",
`drain ${context.inFlightRequests} in-flight requests within ${drainDeadlineMs}ms`,
prefixes.length > 0 ? `bump cache epoch to ${cacheEpoch} for [${prefixes.join(", ")}]` : `bump cache epoch to ${cacheEpoch}`,
`keep idempotency records for at least ${context.idempotencyWindowMs}ms; never recompute a served key`,
targets.length > 0 ? `reconcile downstream targets [${targets.join(", ")}] by rolloutId and arm` : "reconcile downstream targets by rolloutId and arm",
];
return {
routeTreatmentPercent: 0, drainDeadlineMs, cacheAction: "bump-epoch", cacheEpoch,
keepIdempotencyRecords: true, idempotencyWindowMs: context.idempotencyWindowMs,
reconcileTargets: targets, steps,
};
}
assignArm 只依赖 rolloutId 与 tenantId,所以扩量不会改变已进入 treatment 的租户。evaluateWindow 把错误率标为 hard,把延迟、成本、质量标为 soft;evaluateRollout 对 hard 立即回滚,对 soft 累计连续窗口。
planRollback 不删除任何数据。它输出切流比例、排空期限、缓存 epoch、幂等保留时长与对账目标,让发布系统按步骤执行。把“回滚”定义成一组可审计的动作,而不是一句“切回去”。
示例样本上的运行结果
在实验工程根目录执行:
node examples/perf/rollout.ts
本机一次运行的真实输出如下:
decision=rollback stopped=5 streak=0
window stage=1 action=hold sufficient=false hard=false soft=false
window stage=5 action=breach sufficient=true hard=true soft=false
reason stage 5%: error-absolute (treatment error 2.12%, limit 2.00%)
reason stage 5%: error-delta (delta 1.12pt, limit 0.50pt)
assignment 5% treated=517/10000
rollback route=0 drain=7500ms epoch=exp-42:1762300900000
step route 100% of new traffic back to control and stop treatment assignment
step drain 1800 in-flight requests within 7500ms
step bump cache epoch to exp-42:1762300900000 for [chat:reply, chat:reply:v2]
step keep idempotency records for at least 86400000ms; never recompute a served key
step reconcile downstream targets [event-log, ledger] by rolloutId and arm
这组结果说明三个判断。第一,1% 窗口不是“通过”,而是 hold:100 个请求与 100 条质量样本都不足,系统没有足够证据加量。第二,5% 窗口的硬指标一次越界就触发回滚,stopped=5,没有继续升到 25%。第三,回滚计划给出 7500ms 的排空期限:1800 个在途请求按 300 个/秒排空需要 6000ms,再加 1500ms 宽限。5% 的目标是 500 个租户,实际分到 517 个,偏差来自哈希分桶;如果某次分到 700 个,就要检查哈希输入是否包含会变化的字段。
小结
灰度按租户粘性分桶,扩量不改变已进入 treatment 的租户。错误率是硬指标,一次越界立即回滚;延迟、成本与质量是软指标,连续两个窗口越界才回滚;样本不足只能 hold。回滚不是删数据:先切流、再排空、用 epoch 隔离缓存、保留幂等记录、按 arm 对账下游写入。
示例样本在 1% 窗口因样本不足 hold,在 5% 窗口因错误率 2.12% 与差值 1.12 个百分点触发硬回滚,排空 1800 个在途请求用了 7500ms。下一节进入规模化稳定性:当供应商限流、区域故障与流量突增同时发生时,这套灰度与回滚机制怎样不被自身压垮。
阅读导航:上一节:11.2 评测集与回归门禁 · 下一节:12.1 供应商限流与分流 。
常见错误与边界
- 按请求随机分桶。同一用户会在两臂之间跳,体验与指标都失真。
- 分桶包含会变化的字段。扩量时已进入 treatment 的用户被换回 control。
- 1% 窗口就宣布质量通过。样本不足,质量检查根本没有启用。
- 只看错误率绝对值。control 本身变差时,差值检查才能发现。
- 硬指标也等连续两个窗口。错误已经发生,等待只会扩大影响。
- 软指标一次越界就回滚。正常抖动会把发布反复打断。
- 样本不足的窗口算作通过。低流量窗口会把连续越界计数清零。
- 回滚时删除缓存。缓存穿透会把压力瞬间打回模型。
- 回滚后重算幂等键。可能重复扣费或重复写下游。
- 改写历史账本与事件。回滚应补差异,不应篡改已发生的记录。
- 把示例样本结果写成生产实测。数字必须标注来源与样本量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。