《互联网产品的客户端架构·规模化与架构演进》6.3 发布流水线与灰度回滚

发布流水线的价值不在自动化,而在每一步都能给出可解释的放行或阻断。本节把阶段门禁、灰度比例、健康阈值与回滚触发写成纯函数:前置阶段未通过则后续阻塞,样本不足时保持不动,指标越界立即回滚,并把每次决策追加成可追溯的版本记录。灰度不是按比例放量,而是按证据放量。

一次「全量发布后才发现」的回滚

某客户端在周五晚发布新版本,流水线全部绿灯:类型检查通过、单元测试通过、构建成功、制品已上传。灰度配置写的是 1% -> 5% -> 20% -> 50% -> 100%,但值班同学为了赶在周末前收尾,直接点了「全量」。

两小时后崩溃率从 0.12% 升到 1.8%,客服开始收到闪退反馈。回滚按钮点了,但回滚目标版本没有被记录,团队花了四十分钟才确认上一版是哪个制品。事后复盘发现,流水线并不缺步骤,缺的是每一步的可解释判定:为什么可以推进、为什么必须停、回滚到哪个版本。

流水线不是步骤列表,是门禁图

把阶段画成一条直线,会默认「前面的步骤过了,后面就能跑」。真实依赖更像一张图:构建依赖静态检查,部署依赖构建与安全扫描,而文档检查可以独立并行。每个阶段要回答两个问题:

问题输入不满足时
前置阶段是否通过依赖阶段的最终状态阻塞,不执行本阶段检查
本阶段必需检查是否通过每项检查的 pass/fail/skip失败或阻塞,不进入下一阶段

「跳过」不能当成通过。一个被跳过的安全扫描,与一个没跑的安全扫描,在证据上是同一件事。门禁只接受明确的 pass;skip、缺失结果、未知前置阶段都应该阻塞,并给出具体原因。

灰度不是比例,是证据

灰度比例只是表象,真正决定是否推进的是观测窗口内的健康指标。一个可执行的判定需要三样输入:

输入含义缺失时的后果
观测样本数这个窗口内有多少次启动或请求少量样本触发误判
指标阈值错误率、崩溃率、P95 的上限没有统一口径,靠感觉推进
当前档位已经放量到多少跳档、重复推进或回滚到错误版本

推进规则可以压缩成四句话:指标越界立即回滚;样本不足保持不动;健康且未满量推进到下一档;健康且已满量标记完成。四句话都不依赖具体指标名,换成启动成功率或 ANR 率也能复用。

阈值比较要用「严格大于」:等于阈值视为健康,否则阈值本身会变成一个无法达到的数字。样本数则要用「小于」判定不足:刚好达到最小样本数时,应该允许下结论。

回滚必须记录目标版本

回滚不是「再发一次上一版」这么简单。制品可能被覆盖,渠道可能有时滞,配置也可能已经变化。回滚决策至少要记录:版本号、制品键、回滚时的灰度比例、触发原因与时间。没有制品键,回滚只能靠人找包;没有原因,复盘只能靠猜。

版本记录还要能按版本过滤并按时间排序。发布过程中事件是追加的,读取时不能假设写入顺序就是时间顺序;并发流水线、补录与时钟漂移都会打乱顺序。

把门禁、灰度与记录写成代码

下面的模块把流水线门禁、灰度计划校验、健康判定、推进决策与版本记录分开实现。每个函数只做一件事,因此可以单独测试,也可以被不同的发布系统复用。

// file: src/build/release-pipeline.ts
// tier: L1
// verify: node --test test/build/release-pipeline.test.ts

export type CheckStatus = "pass" | "fail" | "skip";

export type StageSpec = {
  id: string;
  requires: string[];
  requiredChecks: string[];
};

export type CheckOutcome = {
  stage: string;
  check: string;
  status: CheckStatus;
};

export type GateStatus = "passed" | "failed" | "blocked";

export type Gate = {
  stage: string;
  status: GateStatus;
  blockers: string[];
};

export type PipelineReport = {
  allowed: boolean;
  gates: Gate[];
  firstBlocker: string | null;
};

export type HealthMetric = "errorRate" | "crashRate" | "p95Ms";

export type HealthThresholds = Record<HealthMetric, number>;

export type HealthObservation = {
  windowMs: number;
  samples: number;
  metrics: Record<HealthMetric, number>;
};

export type HealthVerdict = {
  healthy: boolean;
  reason: "healthy" | "breach" | "insufficient-samples";
  breaches: string[];
};

export type RolloutAction = "promote" | "hold" | "rollback" | "complete";

export type RolloutDecision = {
  action: RolloutAction;
  targetPercent: number;
  reason: string;
};

export type ReleaseEvent = {
  version: string;
  artifactKey: string;
  percent: number;
  action: RolloutAction;
  at: number;
  note?: string;
};

export const HEALTH_METRICS: HealthMetric[] = [
  "errorRate",
  "crashRate",
  "p95Ms",
];

export function evaluatePipeline(
  stages: StageSpec[],
  outcomes: CheckOutcome[],
): PipelineReport {
  const observed = new Map<string, CheckStatus>();
  for (const outcome of outcomes) {
    observed.set(`${outcome.stage}\u0000${outcome.check}`, outcome.status);
  }

  const gateByStage = new Map<string, Gate>();
  const gates: Gate[] = [];
  for (const stage of stages) {
    const blockers: string[] = [];
    for (const requirement of stage.requires) {
      const gate = gateByStage.get(requirement);
      if (!gate) {
        blockers.push(`requires:${requirement}:unknown`);
      } else if (gate.status !== "passed") {
        blockers.push(`requires:${requirement}`);
      }
    }
    for (const check of stage.requiredChecks) {
      const status = observed.get(`${stage.id}\u0000${check}`);
      if (status === undefined) blockers.push(`missing:${check}`);
      else if (status === "fail") blockers.push(`fail:${check}`);
      else if (status === "skip") blockers.push(`skip:${check}`);
    }
    let status: GateStatus = "passed";
    if (blockers.length > 0) {
      status = blockers.some((item) => item.startsWith("fail:"))
        ? "failed"
        : "blocked";
    }
    const gate: Gate = { stage: stage.id, status, blockers };
    gateByStage.set(stage.id, gate);
    gates.push(gate);
  }

  const allowed = gates.every((gate) => gate.status === "passed");
  const firstBlocker =
    gates.find((gate) => gate.blockers.length > 0)?.blockers[0] ?? null;
  return { allowed, gates, firstBlocker };
}

export function validatePlan(steps: number[]): string[] {
  const errors: string[] = [];
  if (steps.length === 0) errors.push("empty");
  for (let i = 0; i < steps.length; i += 1) {
    const step = steps[i];
    if (!Number.isInteger(step) || step < 1 || step > 100) {
      errors.push(`range:${i}`);
    }
    if (i > 0 && step <= steps[i - 1]) errors.push(`order:${i}`);
  }
  if (steps.length > 0 && steps[steps.length - 1] !== 100) {
    errors.push("final");
  }
  return errors;
}

export function nextStep(steps: number[], currentPercent: number): number | null {
  for (const step of steps) {
    if (step > currentPercent) return step;
  }
  return null;
}

export function stepIndex(steps: number[], percent: number): number {
  return steps.indexOf(percent);
}

export function evaluateHealth(
  observation: HealthObservation,
  thresholds: HealthThresholds,
  minSamples: number = 100,
): HealthVerdict {
  if (observation.samples < minSamples) {
    return {
      healthy: false,
      reason: "insufficient-samples",
      breaches: [],
    };
  }
  const breaches: string[] = [];
  for (const metric of HEALTH_METRICS) {
    const value = observation.metrics[metric];
    if (value > thresholds[metric]) {
      breaches.push(`${metric}=${value}>${thresholds[metric]}`);
    }
  }
  if (breaches.length > 0) {
    return { healthy: false, reason: "breach", breaches };
  }
  return { healthy: true, reason: "healthy", breaches: [] };
}

export function decideRollout(input: {
  plan: number[];
  currentPercent: number;
  observation: HealthObservation;
  thresholds: HealthThresholds;
  minSamples?: number;
}): RolloutDecision {
  const {
    plan,
    currentPercent,
    observation,
    thresholds,
    minSamples = 100,
  } = input;
  const index = stepIndex(plan, currentPercent);
  if (index < 0) {
    return {
      action: "hold",
      targetPercent: currentPercent,
      reason: "unknown-step",
    };
  }
  const planErrors = validatePlan(plan);
  if (planErrors.length > 0) {
    return {
      action: "hold",
      targetPercent: currentPercent,
      reason: `invalid-plan:${planErrors.join(",")}`,
    };
  }
  const verdict = evaluateHealth(observation, thresholds, minSamples);
  if (verdict.reason === "breach") {
    return {
      action: "rollback",
      targetPercent: 0,
      reason: verdict.breaches.join(";"),
    };
  }
  if (!verdict.healthy) {
    return {
      action: "hold",
      targetPercent: currentPercent,
      reason: verdict.reason,
    };
  }
  if (currentPercent >= 100) {
    return {
      action: "complete",
      targetPercent: 100,
      reason: "healthy-at-full",
    };
  }
  const target = plan[index + 1];
  return {
    action: "promote",
    targetPercent: target,
    reason: `healthy:${currentPercent}->${target}`,
  };
}

export function recordRelease(
  history: ReleaseEvent[],
  event: ReleaseEvent,
): ReleaseEvent[] {
  return [...history, { ...event }];
}

export function releaseTimeline(
  history: ReleaseEvent[],
  version: string,
): ReleaseEvent[] {
  return history
    .filter((event) => event.version === version)
    .map((event) => ({ ...event }))
    .sort((a, b) => a.at - b.at);
}

export function latestRelease(
  history: ReleaseEvent[],
  version: string,
): ReleaseEvent | undefined {
  const timeline = releaseTimeline(history, version);
  return timeline[timeline.length - 1];
}

evaluatePipeline 先算前置阶段,再算本阶段检查。前置阶段失败时,本阶段即使检查全过也只能是 blocked,而不是 passed;这个区分很重要,前者说明「没轮到」,后者说明「已经验证」。

decideRollout 把健康判定与档位推进放在一起,但把「未知档位」与「非法计划」单独处理。两者都不应该触发回滚:回滚是针对已发布版本的健康问题,配置错误应该在推进前被拦住。

recordRelease 返回新数组而不是原地追加,是为了让调用方可以保留旧快照。发布系统经常需要「决策前」与「决策后」两份记录做对比,原地修改会让审计日志失去意义。

测试与真实输出

在实验工程根目录执行:

node --test test/build/release-pipeline.test.ts

本机一次运行的真实输出末尾如下,耗时随机器变化:

✔ 非法计划时拒绝推进 (0.034042ms)
✔ 版本时间线按时间排序且只含目标版本 (0.084083ms)
✔ 追加记录不修改原数组 (0.053167ms)
✔ 最新记录取时间线末尾 (0.043458ms)
ℹ tests 26
ℹ suites 0
ℹ pass 26
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 127.875958

用例覆盖了门禁的四种状态、计划校验、阈值边界、四类推进决策与版本时间线。它们不测试真实流量、渠道时滞与人工审批,那些需要在发布系统里做集成验证。

判据与反例

判断一条发布流水线是否可信,看下面五条:

判据通过的样子反例
门禁可解释阻断时给出阶段与检查名只显示「发布失败」
跳过不等于通过skip 阻塞必需检查跳过的扫描被记成绿色
推进有证据样本数与阈值同时满足1% 放量十分钟就全量
回滚有目标记录版本与制品键回滚时现找上一版安装包
记录可追溯事件带时间、原因与比例只在聊天群里说「回滚了」

第一个反例是「用平均错误率代替分档观测」。全量错误率 0.3% 看起来正常,但 1% 灰度组可能是 5%;平均值把问题稀释了。健康判定必须针对当前灰度组,而不是全站。

第二个反例是「样本不足时继续推进」。低流量版本在十分钟内可能只有几十次启动,一次崩溃就会让崩溃率看起来爆表;反过来,样本太少也可能掩盖真实问题。正确动作是保持当前档,延长观测窗口,而不是二选一。

第三个反例是「回滚后不清理灰度状态」。回滚到 0% 后,灰度标记仍然指向新版本,下一次发布可能把旧标记当成新证据。版本记录要能表达「已回滚」,而不只是「比例为 0」。

小结

发布流水线的核心不是自动化,而是可解释的决策。

  • 阶段门禁要区分失败、阻塞与跳过,只接受明确的通过。
  • 灰度推进由样本数与健康阈值共同决定,指标越界立即回滚。
  • 回滚必须记录目标版本与制品键,版本事件要能按时间追溯。

先把「为什么放行」写清楚,再追求发布速度。否则速度只是把未知风险更快地送到用户面前。

阅读导航:上一节:6.2 产物治理与缓存 · 下一节:7.1 日志、指标与追踪的最小契约 。

常见错误与边界

  • 把跳过当成通过。被跳过的安全扫描没有产生任何证据,不应放行。
  • 前置阶段失败仍执行后续检查。后续结果没有意义,还会掩盖真正的阻断点。
  • 只看全站平均指标。灰度组的问题会被大盘稀释,越放量越晚发现。
  • 样本不足就下结论。少量请求下的崩溃率与延迟都不稳定。
  • 用严格大于等于比较阈值。阈值本身变成不可达,团队会不断调高数字。
  • 灰度计划允许跳档。1% 直接到 100% 等于没有灰度,只是延迟了全量时间。
  • 回滚不记录制品键。制品被覆盖或渠道时滞后,无法确认回滚到哪一版。
  • 版本记录原地修改。审计日志失去前后快照,复盘无法还原决策现场。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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