《移动游戏产品的架构·网络、资源与热更新》13.2 交付流水线与发布门禁

发布不是「上传完就结束」,而是一条有依赖、有检查、有回滚分支的流水线。本节把构建、签名、上传、灰度、全量与回滚写成可运行的状态图,给出依赖校验、拓扑排序与就绪判定,并用双案例说明为什么回滚必须和发布同时设计、门禁参数为何不能跨产品直接照抄。

「包传上去了」

一次发布事故的复盘记录里,最刺眼的一句话是:「包传上去了,以为就好了。」

这句话背后是一条断掉的流水线:构建产物没有签名校验,上传后没有 CDN 校验和比对,灰度没有观测窗口,回滚没有保留旧版本。每个环节单独看都没错,连起来却没有任何一处能回答「现在这个版本能不能给全量用户」。

交付流水线的核心不是自动化工具,而是阶段之间的依赖与检查。本节把它写成一张可运行的状态图。

六个阶段与它们的依赖

卷二涉及的发布阶段可以收成六个:

阶段做什么依赖通过检查
build打出资源包与安装包无产物哈希
sign签名与完整性校验build签名验证
upload上传 CDN 与商店sign校验和一致
gray按比例放量upload灰度指标在预算内
full全量发布gray版本收敛
rollback回到上一可用版本upload旧版本仍可取

注意 rollback 只依赖 upload,不依赖 gray。这是一个刻意的设计:回滚能力必须在灰度之前就存在。如果等到灰度出问题才去准备旧版本,回滚就已经晚了。

把流水线写成代码

下面的模块做三件事:校验阶段定义是否有重复或未知依赖、给出拓扑顺序、根据已完成阶段计算下一步可执行什么。

// file: src/delivery/pipeline.ts
// tier: L1
// verify: node --test test/delivery/pipeline.test.ts
export type StageId = "build" | "sign" | "upload" | "gray" | "full" | "rollback";

export interface Stage {
  readonly id: StageId;
  readonly requires: readonly StageId[];
  readonly check: string;
}

export const PIPELINE: readonly Stage[] = [
  { id: "build", requires: [], check: "artifacts hashed" },
  { id: "sign", requires: ["build"], check: "signature verified" },
  { id: "upload", requires: ["sign"], check: "cdn checksum matched" },
  { id: "gray", requires: ["upload"], check: "gray metrics within budget" },
  { id: "full", requires: ["gray"], check: "convergence reached" },
  { id: "rollback", requires: ["upload"], check: "previous version available" },
];

export interface PipelineReport {
  readonly ready: readonly StageId[];
  readonly blocked: readonly StageId[];
  readonly unknown: readonly string[];
}

export function validatePipeline(stages: readonly Stage[]): readonly string[] {
  const problems: string[] = [];
  const ids = new Set<string>();
  for (const stage of stages) {
    if (ids.has(stage.id)) {
      problems.push(`duplicate stage: ${stage.id}`);
    }
    ids.add(stage.id);
  }
  for (const stage of stages) {
    for (const dep of stage.requires) {
      if (!ids.has(dep)) {
        problems.push(`unknown dependency: ${stage.id} -> ${dep}`);
      }
    }
  }
  return problems;
}

export function topoOrder(stages: readonly Stage[]): readonly StageId[] {
  const byId = new Map(stages.map((stage) => [stage.id, stage]));
  const visiting = new Set<StageId>();
  const done = new Set<StageId>();
  const order: StageId[] = [];

  function visit(id: StageId): void {
    if (done.has(id)) {
      return;
    }
    if (visiting.has(id)) {
      throw new Error(`cycle detected at ${id}`);
    }
    const stage = byId.get(id);
    if (!stage) {
      throw new Error(`unknown stage: ${id}`);
    }
    visiting.add(id);
    for (const dep of stage.requires) {
      visit(dep);
    }
    visiting.delete(id);
    done.add(id);
    order.push(id);
  }

  for (const stage of stages) {
    visit(stage.id);
  }
  return order;
}

export function evaluatePipeline(
  stages: readonly Stage[],
  completed: readonly string[],
): PipelineReport {
  const done = new Set(completed);
  const known = new Set(stages.map((stage) => stage.id));
  const unknown = completed.filter((id) => !known.has(id));
  const ready: StageId[] = [];
  const blocked: StageId[] = [];

  for (const stage of stages) {
    if (done.has(stage.id)) {
      continue;
    }
    const satisfied = stage.requires.every((dep) => done.has(dep));
    if (satisfied) {
      ready.push(stage.id);
    } else {
      blocked.push(stage.id);
    }
  }
  return { ready, blocked, unknown };
}

export function formatPipeline(stages: readonly Stage[]): string {
  const header = "stage\trequires\tcheck";
  const rows = stages.map(
    (stage) => `${stage.id}\t${stage.requires.join(",") || "-"}\t${stage.check}`,
  );
  return [header, ...rows].join("\n");
}

validatePipeline 检查两类结构错误:重复阶段与未知依赖。topoOrder 用深度优先遍历给出一个合法顺序,并在遇到环时抛错。evaluatePipeline 不执行任何阶段,只回答「现在能做哪一步、还差什么」。

这种「只算状态、不执行动作」的写法很重要:流水线逻辑可以脱离 Jenkins、GitHub Actions 或自研平台单测,工具换了,判断规则不用重写。

还有一个细节:topoOrder 对同一层的阶段不保证稳定顺序。默认定义里 gray 与 rollback 都只依赖 upload,谁先谁后取决于遍历顺序。生产实现应显式排序,或者在展示时把二者标成并行分支,不要让运营误以为必须先灰度再准备回滚。

一次正常发布

用默认阶段跑一遍,从零开始的就绪状态是:

已完成readyblocked
无buildsign, upload, gray, full, rollback
buildsignupload, gray, full, rollback
build, signuploadgray, full, rollback
build, sign, uploadgray, rollbackfull
build, sign, upload, grayfull—
全部——

第三行是最值得注意的:上传完成后,gray 与 rollback 同时就绪。这意味着团队在放量之前,必须已经确认旧版本可用、回滚路径可走。很多团队把回滚当成「出事再说」,而流水线在这里强制它成为并行选项。

灰度阶段的三个检查

gray 阶段不是一个「点一下」的动作,它至少有三次检查:

  1. 进入前:灰度包与全量包是否为同一产物?很多事故来自「灰度包和全量包不是一次构建」。
  2. 进行中:崩溃率、下载成功率、关键转化是否在预算内?指标要事先定,不能边看边改。
  3. 结束前:版本收敛是否达到阈值?灰度放量不等于用户升级,老版本可能长期滞留。

三次检查都通过,full 才就绪。任何一个失败,走 rollback 分支,而不是「再观察一下」。

这条规则听上去很硬,但它换来的是可预期:每次灰度失败都走同一条路径,复盘时不需要争论「当时为什么不回滚」。

双案例的流水线差异

消消与方舟共用一套交付平台,但门禁参数完全不同:

项目消消方舟
热更频率双周一次每周一次
单次热更体积平均 12MB平均 45MB
回滚时限配置 30 分钟战斗数值 15 分钟
灰度起点5%2%
全量条件下载成功率 ≥97%下载成功率 ≥98% 且战报一致率 ≥99.5%
旧版本保留最近 3 个配置版本最近 2 个数值版本 + 1 个整包

方舟的灰度起点更低、回滚时限更短,因为它的单场战斗更长、数值影响更大;消消的配置回滚可以放宽到 30 分钟,因为它的活动配置即使短暂异常,也不会造成不可逆的战斗结果。

同一套流水线,参数必须按产品风险重新定。 直接复制另一款产品的门禁参数,是交付事故的常见来源。

流水线之外的三件事

代码只能表达阶段与依赖,有三件事必须靠流程保证:

  • 产物唯一:灰度包、全量包、回滚包必须来自同一次构建,用哈希关联。
  • 变更可追溯:每个阶段记录谁触发、改了什么、依据哪次评审。
  • 演练常态化:回滚不能只写在文档里,每季度至少演练一次,记录实际耗时。

这三件事没有一件能靠单测覆盖,但它们决定了流水线在真实事故中是否可用。门禁的价值不在于拦住多少版本,而在于出事时能不能按预期回退。

与相邻章节的关系

本节依赖第 10 章的热更政策边界、第 11 章的灰度与回滚。那两章分别给出内容红线与回滚策略,本节把它们组织成一条有依赖关系的流水线。

上一节 13.1 给出指标矩阵,本节把矩阵接进 gray 的进入与结束条件;下一节 13.3 把矩阵、流水线与附录 C 的清单合并成卷二评审门禁。

小结

  • 发布流水线有六个阶段:build、sign、upload、gray、full、rollback。
  • 回滚只依赖 upload,必须在灰度之前就具备,而不是出事后再准备。
  • 流水线逻辑应与工具解耦:只算状态、不执行动作,才能单测。
  • 灰度阶段有三次检查:产物一致、指标在预算内、版本收敛达标。
  • 双案例的门禁参数不同,按产品风险重新定,不能直接复制。
  • 产物唯一、变更可追溯、回滚演练,是代码之外必须保证的三件事。
  • 门禁的价值在事故时体现,不在于平时拦了多少版本。

阅读导航:上一节:13.1 网络与资源指标矩阵 · 下一节:13.3 卷二评审与进入卷三 。

常见错误与边界

  1. 把上传当发布。 上传只是第三阶段,灰度与全量各有检查;上传成功不代表用户能用。
  2. 灰度包与全量包分开构建。 两次构建的产物可能不一致,事故时无法判断问题出在代码还是构建。
  3. 回滚没有旧版本。 只保留最新包,出事时只能「发新版本修复」,时限不可控。
  4. 灰度只看崩溃率。 下载成功率、关键转化、版本收敛同样重要,单一指标会漏掉静默失败。
  5. 门禁参数照抄。 消消与方舟的风险不同,阈值必须按产品重定。
  6. 流水线逻辑写死在 CI 配置里。 换工具就要重写,且无法单测;应把状态判定抽成独立模块。
  7. 没有产物哈希。 无法证明灰度包与全量包是同一产物,复盘时只能靠记忆。
  8. 回滚从不演练。 文档里的 15 分钟,第一次实战可能是 90 分钟。
  9. 把 full 当终点。 全量之后还有版本收敛与旧版本清理,否则包体与 CDN 成本会持续累积。
  10. 失败后「再观察一下」。 灰度检查失败应直接走回滚分支,观察窗口不能替代判定。
  11. 忽略商店审核时间。 整包发布受审核影响,流水线排期必须把审核时长算进去。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查