《移动游戏产品的架构·网络、资源与热更新》11.2 灰度发布:分组、比例与观测

方舟有 25 万 DAU,Android 占 65%、低端机占 15%,一次战斗数值热更不可能一次推全量。本节把灰度拆成渠道、机型档位、账号哈希三个分组维度,给出稳定分桶、阶梯准入、观测门禁和停止晋级的判断规则,并按阶段观察下载与版本收敛指标。

战斗数值改完了,先给谁

《潮汐方舟》的 DAU 是 25 万,MAU 180 万,Android 占 65%、iOS 占 35%,4GB 以下低端机占 15%。一次战斗数值热更平均 45MB,每周一次;热更内容包含战斗数值、赛季配置、活动、UI 资源和语音,代码热更只走 Android。

数值改动最难的地方不是打包,而是「错了之后影响多大」。如果一次推全量,几十分钟内 25 万玩家会同时进入新数值,论坛、客服和排行榜会一起爆炸;如果只给内部账号,又验证不出真实机型和真实网络下的问题。灰度发布要在这两个极端之间找一条可观测、可停止、可回退的路径。

方舟的分组口径是三条:按渠道、按机型档位、按账号哈希尾号。三条不是互斥的,而是叠加使用:渠道决定包从哪来,机型决定设备扛不扛得住,账号哈希决定同一批人是否稳定地留在同一组。

三个分组维度,各自回答不同问题

分组维度不是越多越好。每增加一个维度,样本就被切薄一次,观测指标更难达到统计意义。方舟只用三个,因为每个都对应一个真实的失败模式。

维度取值回答的问题漏掉会怎样
渠道Android 应用商店、TapTap、iOS包体、审核和平台能力是否一致iOS 的配置热更问题被 Android 数据掩盖
机型档位低、中、高(按内存与 GPU)低端机是否扛得住新资源与逻辑15% 的低端机在全量后才暴露崩溃
账号哈希尾号0–9999 的稳定桶同一批账号是否始终落在同一组玩家每次登录换组,指标无法归因

渠道维度是硬边界。方舟的代码热更只走 Android,iOS 只接受资源与配置;如果灰度只按比例、不看渠道,iOS 用户可能被分到「需要代码补丁」的组,结果不是更新失败,而是根本收不到更新。

机型档位是风险维度。低端机只占 15%,但它们贡献了大部分内存和帧率问题;灰度必须先覆盖低端机里的一小撮,而不是等高端机验证完再放。卷一对低端机内存水位有专门讨论,帧预算的观测口径见 9.2 帧预算与卡顿归因 。

账号哈希是统计维度。它保证同一个账号在多次登录、多个阶段中落在同一个桶里,指标才能按「人」归因,而不是按「请求」归因。

哈希分桶:稳定比均匀更重要

分桶的输入必须是「账号 + 实验盐」,输出是 0 到 9999 的整数。方舟用 sha256(salt:accountId) 取前四个字节,对 10,000 取模。这样同一个账号在同一个实验里永远得到同一个桶,换实验则重新洗牌。

稳定比均匀更重要。客户端每次启动都重新随机、或者用请求时间做种子,会让同一批玩家在阶段之间来回跳:昨天在 5% 组,今天掉回对照组,指标曲线会变成噪声。更隐蔽的问题是「按 DAU 比例随机」:同一个人今天被抽中、明天不被抽中,客诉会认为更新时有时无。

盐值要按实验隔离。战斗数值用 battle-values-2026-11,赛季配置用 season-2026-11,两个实验的桶互不相关,避免「总是同一批人先当小白鼠」。但同一个实验的所有阶段必须共用同一个盐,否则阶段之间无法比较。

分桶只解决「人在哪」,不解决「什么时候进」。阶段推进时,已经进入低比例组的账号应该保持进入状态,不能因为比例从 5% 调到 20% 就被重新洗牌;新增的 15% 才是这次放量的增量。实现上就是把「已入选」做成单调状态,而不是每次用当前比例重算。

灰度阶梯:门槛、比例与单调性

一个灰度策略由若干阶段组成,每个阶段声明开始时间、比例、允许的渠道和机型档位。客户端每次拉策略时,按时间顺序找到所有已经生效的阶段,再看自己是否满足准入条件。下面是一组示例阶梯,比例只用于说明推进方式,不是案例事实。

阶段比例渠道机型档位目的
internal100%全渠道高内部与测试账号先验证
android-canary5%Android 应用商店、TapTap全档位覆盖 Android 真实机型
android-half50%Android 应用商店、TapTap全档位扩大样本,观察长尾
full100%全渠道全档位全量,iOS 只走资源与配置

比例可以下降,但「已入选」不能撤销。internal 阶段 100% 只对高档位开放,进入 android-canary 后比例变成 5%,内部高档位账号仍然是入选状态;新增的是 Android 中低档位里桶值落在前 5% 的账号。把「当前比例」和「历史入选」分开,是灰度状态机的核心。

准入条件要按阶段独立判断。一个账号可能在 internal 阶段因为机型档位不符被过滤,在 android-canary 阶段因为渠道不符被过滤,直到 full 阶段才被接纳。每次判断都从「这个阶段允不允许」出发,而不是从「上一个阶段的结果」出发;唯一需要继承的是「已经入选」这个事实。

排除名单优先级最高。内部测试账号、客服账号、主播账号如果需要保持稳定,应放进排除名单,不参与任何比例抽取。排除名单按账号 id 精确匹配,不做哈希,避免「看起来像内部号」的误伤。

观测:四个门禁指标与三个体验基线

灰度不是「放量然后看论坛」。每个阶段都要有明确的观测指标和停止条件。方舟把指标分成两类:交付类指标决定补丁能不能到、能不能装上;体验类指标决定装上之后有没有变差。

指标口径方舟基线恶化时的动作
下载成功率开始下载到校验通过的比例98.6%暂停放量,检查 CDN 与包体
热更成功率拉取、校验、应用三步全成功按阶段统计暂停并回滚补丁
差量命中率可用差量补丁完成更新的设备占比按阶段统计检查清单与回退数量
版本收敛发布后 7 天升级到新版本的用户占比按渠道统计调整推送与强更策略

体验类基线沿用方舟的稳态数据:崩溃率 0.12%、ANR 0.03%、Jank 率 1.4%。灰度阶段不要求这些指标变好,只要求不显著变差;一旦越线,先停止放量,再判断是补丁问题还是样本波动。

指标要按「阶段 × 渠道 × 机型档位」切片。只看总体会掩盖局部问题:Android 低端机的下载失败可能被 iOS 的成功率稀释,5% 组的崩溃可能被 95% 对照组平均掉。切片的代价是样本变薄,所以每个阶段都要设最小样本量,样本不足时只观察、不结论。

观测窗口也要固定。补丁应用后立即看崩溃率,会把「下载中的旧版本」和「已应用的新版本」混在一起;至少等一个完整会话周期,方舟的会话中位是 34 分钟,窗口不能短于这个量级。

停止、保持与晋级

灰度推进不是自动的。每个阶段结束后,由发布负责人根据门禁指标做三选一:晋级、保持、回滚。

判断条件动作
晋级交付指标达标,体验指标未越线,样本量足够开启下一阶段,保留当前阶段数据
保持指标波动但未越线,或样本量不足延长观察窗口,不扩大比例
回滚交付指标显著下降,或体验指标越线且可归因停止放量,按 11.3 的流程回退

「保持」是最容易被忽略的选项。团队往往在「继续」和「回滚」之间二选一,结果要么带着噪声放量,要么因为一次抖动误杀一个好版本。保持阶段的成本只是时间,误判的成本是玩家体验和一次完整的回滚流程。

晋级前要确认「增量样本」而不是「累计样本」。5% 到 50% 新增了 45% 的账号,这些账号才是这一阶段的验证对象;如果只看累计 50% 的总体指标,新增部分的问题会被前 5% 的稳定数据掩盖。

实现

下面的文件把稳定分桶、阶段准入、单调入选和汇总观测落成一次计算。输入是账号、渠道、机型档位、当前时间和策略,输出是是否入选、命中阶段、桶值和原因;汇总函数按阶段与原因计数。

// file: src/hotfix/rollout.ts
// tier: L1
// verify: node --test test/hotfix/rollout.test.ts

import { createHash } from "node:crypto";

export type Channel = "android-store" | "taptap" | "ios";
export type DeviceTier = "low" | "mid" | "high";

export interface RolloutContext {
  readonly channel: Channel;
  readonly deviceTier: DeviceTier;
  readonly now: string;
}

export interface RolloutStage {
  readonly name: string;
  readonly percent: number;
  readonly channels: readonly Channel[];
  readonly tiers: readonly DeviceTier[];
  readonly startAt: string;
}

export interface RolloutPolicy {
  readonly experiment: string;
  readonly salt: string;
  readonly stages: readonly RolloutStage[];
  readonly exclusions?: readonly string[];
}

export type DecisionReason =
  | "enrolled"
  | "below-threshold"
  | "excluded"
  | "filtered"
  | "not-started";

export interface RolloutDecision {
  readonly accountId: string;
  readonly experiment: string;
  readonly enrolled: boolean;
  readonly stage: string | null;
  readonly bucket: number;
  readonly reason: DecisionReason;
}

export interface RolloutSummary {
  readonly experiment: string;
  readonly total: number;
  readonly enrolled: number;
  readonly byStage: Readonly<Record<string, number>>;
  readonly reasons: Readonly<Record<string, number>>;
}

export const BUCKETS = 10_000;

export function bucketOf(accountId: string, salt: string): number {
  if (accountId.trim() === "") {
    throw new Error("accountId is empty");
  }
  if (salt.trim() === "") {
    throw new Error("salt is empty");
  }
  const digest = createHash("sha256").update(`${salt}:${accountId}`, "utf8").digest();
  return digest.readUInt32BE(0) % BUCKETS;
}

function thresholdOf(stage: RolloutStage): number {
  return Math.round((stage.percent / 100) * BUCKETS);
}

function validatePolicy(policy: RolloutPolicy): void {
  if (policy.experiment.trim() === "") {
    throw new Error("experiment is empty");
  }
  if (policy.salt.trim() === "") {
    throw new Error("salt is empty");
  }
  if (policy.stages.length === 0) {
    throw new Error("policy has no stages");
  }

  const names = new Set<string>();
  let previousStart = Number.NEGATIVE_INFINITY;

  for (const stage of policy.stages) {
    if (stage.name.trim() === "") {
      throw new Error("stage name is empty");
    }
    if (names.has(stage.name)) {
      throw new Error(`duplicate stage: ${stage.name}`);
    }
    names.add(stage.name);
    if (!Number.isInteger(stage.percent) || stage.percent < 0 || stage.percent > 100) {
      throw new Error(`invalid percent for ${stage.name}: ${stage.percent}`);
    }
    if (stage.channels.length === 0) {
      throw new Error(`stage ${stage.name} has no channels`);
    }
    if (stage.tiers.length === 0) {
      throw new Error(`stage ${stage.name} has no tiers`);
    }
    const start = Date.parse(stage.startAt);
    if (Number.isNaN(start)) {
      throw new Error(`invalid startAt for ${stage.name}: ${stage.startAt}`);
    }
    if (start <= previousStart) {
      throw new Error(`stages must be strictly ascending at ${stage.name}`);
    }
    previousStart = start;
  }
}

export function evaluateRollout(
  accountId: string,
  context: RolloutContext,
  policy: RolloutPolicy,
): RolloutDecision {
  validatePolicy(policy);
  const now = Date.parse(context.now);
  if (Number.isNaN(now)) {
    throw new Error(`invalid now: ${context.now}`);
  }
  const bucket = bucketOf(accountId, policy.salt);
  const base = { accountId, experiment: policy.experiment, bucket };

  const excluded = new Set(policy.exclusions ?? []);
  if (excluded.has(accountId)) {
    return { ...base, enrolled: false, stage: null, reason: "excluded" };
  }

  let lastActive: RolloutStage | null = null;
  let lastAdmitting: RolloutStage | null = null;
  let enrolledStage: RolloutStage | null = null;

  for (const stage of policy.stages) {
    if (Date.parse(stage.startAt) > now) break;
    lastActive = stage;
    if (
      !stage.channels.includes(context.channel) ||
      !stage.tiers.includes(context.deviceTier)
    ) {
      continue;
    }
    lastAdmitting = stage;
    if (enrolledStage === null && bucket < thresholdOf(stage)) enrolledStage = stage;
  }

  if (enrolledStage !== null) {
    return { ...base, enrolled: true, stage: enrolledStage.name, reason: "enrolled" };
  }
  if (lastAdmitting !== null) {
    return {
      ...base,
      enrolled: false,
      stage: lastAdmitting.name,
      reason: "below-threshold",
    };
  }
  return {
    ...base,
    enrolled: false,
    stage: lastActive === null ? null : lastActive.name,
    reason: lastActive === null ? "not-started" : "filtered",
  };
}

export function summarizeRollout(
  decisions: readonly RolloutDecision[],
): RolloutSummary {
  const byStage: Record<string, number> = {};
  const reasons: Record<string, number> = {};
  let enrolled = 0;
  let experiment = "";
  for (const decision of decisions) {
    if (experiment === "") experiment = decision.experiment;
    if (decision.experiment !== experiment) {
      throw new Error("cannot summarize decisions from different experiments");
    }
    if (decision.enrolled) enrolled += 1;
    const stageKey = decision.stage ?? "none";
    byStage[stageKey] = (byStage[stageKey] ?? 0) + 1;
    reasons[decision.reason] = (reasons[decision.reason] ?? 0) + 1;
  }
  return { experiment, total: decisions.length, enrolled, byStage, reasons };
}

export function formatRolloutSummary(summary: RolloutSummary): string {
  const share =
    summary.total === 0 ? "0.0" : ((summary.enrolled / summary.total) * 100).toFixed(1);
  const stages = Object.keys(summary.byStage)
    .sort()
    .map((key) => `${key}=${summary.byStage[key]}`)
    .join(" ");
  const reasons = Object.keys(summary.reasons)
    .sort()
    .map((key) => `${key}=${summary.reasons[key]}`)
    .join(" ");
  return [
    `experiment ${summary.experiment}`,
    `enrolled ${summary.enrolled}/${summary.total} (${share}%)`,
    `stages ${stages}`,
    `reasons ${reasons}`,
  ].join("\n");
}

bucketOf 用 sha256(salt:accountId) 的前四个字节对 10,000 取模,保证同一账号在同一实验里稳定落在同一个桶。空账号或空盐直接抛错,避免调用方把空值当成合法输入。

validatePolicy 检查实验名、盐、阶段数量、阶段名唯一性、比例范围、渠道与档位非空、开始时间可解析且严格递增。它不要求比例单调递增,因为 internal 阶段可能是 100%、对外阶段反而从 5% 开始;单调性由「已入选不撤销」保证,而不是由比例保证。

evaluateRollout 先处理排除名单,再按时间顺序遍历已生效阶段:渠道和档位都满足才进入比例判断,桶值小于阈值的账号记为入选。已经入选的账号不会被后续阶段撤销;没有入选但至少通过一个阶段准入的账号返回 below-threshold;所有阶段都不满足准入的返回 filtered。

summarizeRollout 按阶段和原因计数,并拒绝混合不同实验的决策;formatRolloutSummary 输出入选比例、阶段分布和原因分布,适合贴进发布看板。

运行结果与边界

用一组示例账号运行,输出如下。账号、桶值和比例都是示例输入,不是方舟的真实分组结果:

experiment battle-values-2026-11
enrolled 13/60 (21.7%)
stages android-5pct=48 internal=12
reasons below-threshold=35 enrolled=13 filtered=12

这组数据能直接读出三件事:internal 阶段只覆盖了高档位账号,android-5pct 阶段覆盖了其余 Android 账号,iOS 的非高档位账号被渠道过滤。入选率 21.7% 高于 5%,是因为 internal 的 100% 档位账号已经全部入选;这正是「累计入选」和「当前比例」的差别。

边界一:分桶只保证稳定,不保证样本代表性。如果某个渠道的账号 id 分布有偏,哈希桶在该渠道内仍可能不均匀;发布前应按渠道抽样检查桶分布,而不是假设哈希天然均匀。

边界二:策略是客户端拉取的,存在时间差。客户端可能缓存旧策略,阶段开始后仍按上一阶段判断;策略要带版本号和生效时间,客户端定期刷新,服务端在关键阶段可以主动推送。

边界三:灰度不负责补丁内容是否正确。它只决定「谁先拿到」,补丁本身的差量、校验和应用失败由 11.1 与 10.3 负责。灰度能限制影响面,但不能把一个坏补丁变成好补丁。

边界四:观测指标依赖客户端上报。上报丢失、延迟和重复会让比例失真;关键门禁应同时看服务端下载日志和客户端事件,不能只信一端。

与相邻章节的关系

11.1 定义了补丁的生成与校验,本节决定补丁按什么顺序、发给哪些账号;11.3 会讨论回滚流程和版本收敛,回答「灰度失败后怎么退、退多快」。10.3 的失败兜底是单机视角,本节是发布视角。

本节不讨论 CDN 调度和下载器并发,那属于 12.x;也不讨论资源分组,那属于 8.x。灰度策略只回答「谁在什么时候拿到哪个版本」,不改变版本内容。

小结

  • 分组用渠道、机型档位、账号哈希三个维度,分别回答包从哪来、设备扛不扛得住、同一批人是否稳定。
  • 分桶用 sha256(salt:accountId) 对 10,000 取模,同一实验共用盐,不同实验隔离盐。
  • 灰度阶梯按阶段声明比例和准入条件,已入选的账号不因后续比例变化而撤销。
  • 观测门禁分交付与体验两类:下载成功率、热更成功率、差量命中率、版本收敛,加上崩溃、ANR、Jank 基线。
  • 阶段结束只有三种动作:晋级、保持、回滚;「保持」是避免噪声误判的必要选项。

阅读导航:上一节:11.1 差量算法与补丁格式 · 下一节:11.3 回滚与版本收敛 。

常见错误与边界

  1. 按请求随机分组。同一账号每次登录换组,指标变成噪声,玩家也会觉得更新时有时无。
  2. 每个阶段重新洗牌。比例从 5% 调到 20% 时把已入选账号重新抽一遍,等于撤销了上一阶段的验证结论。
  3. 不同实验共用一个盐。同一批账号总是先当小白鼠,实验之间也无法独立比较。
  4. 只按比例、不看渠道。iOS 被分到需要代码补丁的组,更新直接失败。
  5. 忽略低端机档位。低端机只占 15%,但内存和帧率问题集中在这里,全量后才暴露就晚了。
  6. 只看总体指标。Android 低端机的失败被 iOS 的成功率稀释,5% 组的崩溃被对照组平均掉。
  7. 观测窗口太短。补丁应用和旧版本会话混在一起,崩溃率无法归因。
  8. 把「保持」当成失败。带着噪声放量或因为一次抖动误杀好版本,成本都比多等一个窗口高。
  9. 用累计样本判断增量。5% 到 50% 的新增账号才是本阶段验证对象,总体指标会掩盖新增部分的问题。
  10. 假设哈希分布天然均匀。渠道账号 id 可能有偏,发布前必须按渠道抽样检查桶分布。
  11. 边界:灰度不修复坏补丁。它只限制影响面,补丁正确性由 11.1 的校验和 10.3 的兜底负责。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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