战斗数值改完了,先给谁
《潮汐方舟》的 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% 才是这次放量的增量。实现上就是把「已入选」做成单调状态,而不是每次用当前比例重算。
灰度阶梯:门槛、比例与单调性
一个灰度策略由若干阶段组成,每个阶段声明开始时间、比例、允许的渠道和机型档位。客户端每次拉策略时,按时间顺序找到所有已经生效的阶段,再看自己是否满足准入条件。下面是一组示例阶梯,比例只用于说明推进方式,不是案例事实。
| 阶段 | 比例 | 渠道 | 机型档位 | 目的 |
|---|---|---|---|---|
| internal | 100% | 全渠道 | 高 | 内部与测试账号先验证 |
| android-canary | 5% | Android 应用商店、TapTap | 全档位 | 覆盖 Android 真实机型 |
| android-half | 50% | Android 应用商店、TapTap | 全档位 | 扩大样本,观察长尾 |
| full | 100% | 全渠道 | 全档位 | 全量,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 回滚与版本收敛 。
常见错误与边界
- 按请求随机分组。同一账号每次登录换组,指标变成噪声,玩家也会觉得更新时有时无。
- 每个阶段重新洗牌。比例从 5% 调到 20% 时把已入选账号重新抽一遍,等于撤销了上一阶段的验证结论。
- 不同实验共用一个盐。同一批账号总是先当小白鼠,实验之间也无法独立比较。
- 只按比例、不看渠道。iOS 被分到需要代码补丁的组,更新直接失败。
- 忽略低端机档位。低端机只占 15%,但内存和帧率问题集中在这里,全量后才暴露就晚了。
- 只看总体指标。Android 低端机的失败被 iOS 的成功率稀释,5% 组的崩溃被对照组平均掉。
- 观测窗口太短。补丁应用和旧版本会话混在一起,崩溃率无法归因。
- 把「保持」当成失败。带着噪声放量或因为一次抖动误杀好版本,成本都比多等一个窗口高。
- 用累计样本判断增量。5% 到 50% 的新增账号才是本阶段验证对象,总体指标会掩盖新增部分的问题。
- 假设哈希分布天然均匀。渠道账号 id 可能有偏,发布前必须按渠道抽样检查桶分布。
- 边界:灰度不修复坏补丁。它只限制影响面,补丁正确性由 11.1 的校验和 10.3 的兜底负责。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。