事故:一次活动配置绕过了分档
《潮汐消消》的家园装饰活动曾经把「高清粒子」预设直接写进活动配置,只按 RAM 是否大于 4GB 做判断。上线后,一批刚好 4GB、GPU 档位较低但系统版本较新的设备被分进高档:稳态内存从案例设定的 420MB 逼近 520MB 水位,Jank 率随之抬头。回滚活动配置后问题消失,但根因没有消失——分档逻辑散落在业务 if 里,任何新入口都可以绕开它。
第二次事故更隐蔽:系统升级后 OS 大版本变化,客户端缓存的档位没有失效,同一台设备继续使用旧预设。两次事故指向同一个结论:分档必须是统一输入,画质必须是可校验配置,变更必须是可灰度、可回滚的流程。
分档不是机型名单
机型名单的维护成本随长尾增长,而且新机永远比名单快。可维护的做法是四维评分加硬门槛。
| 维度 | 分档规则(案例设定) | 分值 | 说明 |
|---|---|---|---|
| RAM | <4096MB / 4096–6143MB / >=6144MB | 0 / 1 / 2 | 低于 3072MB 直接判低档 |
| GPU 档位 | 0 / 1–2 / 3 | 0 / 1 / 2 | 由平台适配层给出,不由业务猜 |
| 屏幕刷新率 | <90Hz / 90–119Hz / >=120Hz | 0 / 1 / 2 | 高刷设备通常有更强的持续性能 |
| OS 版本 | Android <9、iOS <14 | 硬门槛 | 不参与加分,直接判低档并记录原因 |
总分 0–6:<=2 为低档,3–4 为中档,>=5 为高档。硬门槛优先于总分:系统不受支持或 RAM 低于 3072MB 时,即使 GPU 与刷新率都是高分,也判低档。反过来,不允许业务用「这台机器跑分很高」覆盖硬门槛。
未知值必须保守处理。GPU 档位读不到时按 0 分计并记录 unknown,不能默认成高档;刷新率读不到时按 60Hz 计。分档结果要带原因列表,否则线上只能看到「他被分到低档」,无法判断是硬件、系统还是读取失败。
策略表:预设、映射与版本
分档只回答「这台设备属于哪一档」,画质由策略表决定。策略表包含三部分:预设、档位到预设的映射、版本号。
| 预设 | 帧率 | 渲染比例 | 粒子等级 | 阴影 | 纹理等级 |
|---|---|---|---|---|---|
low-30 | 30fps | 0.75 | 0 | 关 | 0 |
mid-30 | 30fps | 1.0 | 1 | 关 | 1 |
high-60 | 60fps | 1.0 | 2 | 开 | 2 |
表中的数值是 mga-lab 的比较参数,不是消消的线上配置。案例设定只提供默认 30fps、高档 60fps 与低端机 35% 这三项事实;具体渲染比例要由项目自己的性能预算决定。
策略表必须可校验:每个档位都要有映射,映射必须指向存在的预设,帧率只能是 30 或 60,渲染比例必须在 [0.5, 1],灰度桶的百分比必须在 [0, 100]。校验失败时拒绝整份配置,而不是跳过坏字段继续加载——半份配置比没有配置更难排查。
可配置、可灰度、可回滚
灰度用确定性分桶:把设备 id 与灰度盐做哈希,取模 100,落在百分比内即命中。确定性保证同一台设备每次启动都进入同一组,不会因为重开游戏在两组预设之间跳变。灰度还必须限定目标档位,避免把低档预设误推给高档设备。
| 变更类型 | 判据 | 动作 |
|---|---|---|
| 配置非法 | 校验返回非空错误 | 拒绝应用,保留当前版本 |
| 灰度命中 | 桶号小于百分比且档位匹配 | 使用灰度预设并记录桶号 |
| 指标越线 | 会话数达标且 Jank 或崩溃超阈值 | 回滚到上一版本 |
| 手动回滚 | 运营或值班决定 | 从版本历史恢复上一版 |
护栏阈值要高于基线又低于不可接受值。案例设定中消消 Jank 率 2.1%、崩溃率 0.18%;可以把灰度护栏设为 Jank 3.0%、崩溃 0.2%,并要求至少 5000 个会话才判定,样本不足时继续观察而不是宣布成功。这里的阈值是策略示例,不是线上阈值。
回滚有两层:配置非法的「拒绝即回滚」,以及指标越线的「运行期回滚」。两层都要留下版本号与原因,否则回滚之后无法回答「当时到底跑了哪一版」。
35% 低端机对策略的影响
案例设定中消消 DAU 80 万,低端机(4GB RAM 以下)占 35%,也就是约 28 万台设备每天至少一次会话。这不是长尾,而是策略的主要受众。
| 分档 | 占比口径 | 命中预设 | 说明 |
|---|---|---|---|
| 低档 | 案例设定 35% | low-30 | 不可被活动配置绕过 |
| 中档 | 实验参数 50% | mid-30 | 默认画质 |
| 高档 | 实验参数 15% | high-60 | 高刷与强 GPU |
中高档的 50% / 15% 是 mga-lab 的实验参数,案例圣经只给出低端机 35%。这张表的用途不是预测收入,而是提醒:任何提高低档成本的改动都会作用在约 28 万台设备上,灰度和回滚必须默认覆盖这批人,而不是等高档用户先验证。
低端机还有一层现实约束:它们更容易触发系统省电与后台回收。档位判定应在启动早期完成并缓存,但 OS 大版本变化、RAM 读取失败、GPU 信息缺失时必须重新判定;缓存的是结论与原因,不是「永远不会变」的标签。
可运行模型:device-tiers.ts
下面的模型把四维评分、硬门槛、策略校验、确定性灰度、版本回滚、车队影响与护栏判定放在同一个文件里。设备参数通过 DeviceProfile 注入,不依赖真实机型。
// file: src/input/device-tiers.ts
// tier: L1
// verify: node --test test/input/device-tiers.test.ts
export type Tier = "low" | "mid" | "high";
export type Platform = "android" | "ios";
export type GpuTier = 0 | 1 | 2 | 3;
export interface DeviceProfile {
readonly id: string; readonly platform: Platform; readonly ramMb: number;
readonly gpuTier: GpuTier; readonly refreshHz: number; readonly osMajor: number;
}
export interface TierThresholds {
readonly ramMb: readonly [number, number]; readonly gpuTier: readonly [number, number];
readonly refreshHz: readonly [number, number]; readonly minRamMb: number;
readonly minOsMajor: Readonly<Record<Platform, number>>;
readonly lowMaxScore: number; readonly highMinScore: number;
}
export const DEFAULT_TIER_THRESHOLDS: TierThresholds = {
ramMb: [4096, 6144], gpuTier: [1, 3], refreshHz: [90, 120],
minRamMb: 3072, minOsMajor: { android: 9, ios: 14 }, lowMaxScore: 2, highMinScore: 5,
};
export interface TierDecision {
readonly tier: Tier; readonly score: number;
readonly factors: Readonly<Record<"ram" | "gpu" | "refresh", number>>;
readonly cappedBy: "ram-floor" | "unsupported-os" | null; readonly reasons: readonly string[];
}
function band(value: number, floor: number, ceiling: number): number {
if (value < floor) {
return 0;
}
return value < ceiling ? 1 : 2;
}
export function decideTier(
profile: DeviceProfile,
thresholds: TierThresholds = DEFAULT_TIER_THRESHOLDS,
): TierDecision {
const ram = band(profile.ramMb, thresholds.ramMb[0], thresholds.ramMb[1]);
const gpu = band(profile.gpuTier, thresholds.gpuTier[0], thresholds.gpuTier[1]);
const refresh = band(profile.refreshHz, thresholds.refreshHz[0], thresholds.refreshHz[1]);
const score = ram + gpu + refresh;
const reasons = [
`ram=${profile.ramMb}->${ram}`, `gpu=${profile.gpuTier}->${gpu}`,
`hz=${profile.refreshHz}->${refresh}`, `os=${profile.platform}-${profile.osMajor}`,
];
const factors = { ram, gpu, refresh };
if (profile.osMajor < thresholds.minOsMajor[profile.platform]) {
return { tier: "low", score, factors, cappedBy: "unsupported-os", reasons };
}
if (profile.ramMb < thresholds.minRamMb) {
return { tier: "low", score, factors, cappedBy: "ram-floor", reasons };
}
const tier: Tier = score <= thresholds.lowMaxScore ? "low"
: score >= thresholds.highMinScore ? "high" : "mid";
return { tier, score, factors, cappedBy: null, reasons };
}
export interface QualityPreset {
readonly id: string; readonly targetFps: 30 | 60; readonly renderScale: number;
readonly particleLevel: 0 | 1 | 2; readonly shadow: boolean; readonly textureLevel: 0 | 1 | 2;
}
export interface CanaryConfig {
readonly percent: number; readonly presetId: string;
readonly tiers: readonly Tier[]; readonly salt: string;
}
export interface QualityPolicy {
readonly version: string;
readonly tierPresets: Readonly<Record<Tier, string>>;
readonly presets: Readonly<Record<string, QualityPreset>>;
readonly canary: CanaryConfig | null;
}
export const DEFAULT_POLICY: QualityPolicy = {
version: "2026.10.31-1",
tierPresets: { low: "low-30", mid: "mid-30", high: "high-60" },
presets: {
"low-30": { id: "low-30", targetFps: 30, renderScale: 0.75, particleLevel: 0, shadow: false, textureLevel: 0 },
"mid-30": { id: "mid-30", targetFps: 30, renderScale: 1, particleLevel: 1, shadow: false, textureLevel: 1 },
"high-60": { id: "high-60", targetFps: 60, renderScale: 1, particleLevel: 2, shadow: true, textureLevel: 2 },
},
canary: null,
};
export interface QualityDecision {
readonly tier: Tier; readonly score: number; readonly presetId: string;
readonly preset: QualityPreset; readonly canary: boolean; readonly bucket: number;
readonly policyVersion: string; readonly reasons: readonly string[];
}
export function bucketOf(deviceId: string, salt: string): number {
let hash = 0x811c9dc5;
const text = `${deviceId}:${salt}`;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % 100;
}
export function resolveQuality(
profile: DeviceProfile,
policy: QualityPolicy = DEFAULT_POLICY,
thresholds: TierThresholds = DEFAULT_TIER_THRESHOLDS,
): QualityDecision {
const tierDecision = decideTier(profile, thresholds);
const baseId = policy.tierPresets[tierDecision.tier];
const base = policy.presets[baseId];
if (!base) {
throw new Error(`policy ${policy.version} has no preset ${baseId}`);
}
const canary = policy.canary;
const bucket = canary ? bucketOf(profile.id, canary.salt) : 100;
const canaryHit = canary !== null && canary.tiers.includes(tierDecision.tier)
&& bucket < canary.percent && policy.presets[canary.presetId] !== undefined;
const preset = canaryHit ? policy.presets[canary.presetId] : base;
const reasons = canaryHit
? [...tierDecision.reasons, `canary=${bucket}<${canary.percent}`]
: tierDecision.reasons;
return {
tier: tierDecision.tier, score: tierDecision.score, presetId: preset.id, preset,
canary: canaryHit, bucket, policyVersion: policy.version, reasons,
};
}
export function validatePolicy(policy: QualityPolicy): readonly string[] {
const errors: string[] = [];
if (policy.version.trim().length === 0) {
errors.push("version is empty");
}
for (const tier of ["low", "mid", "high"] as const) {
const id = policy.tierPresets[tier];
if (!id) {
errors.push(`tierPresets.${tier} is missing`);
} else if (!policy.presets[id]) {
errors.push(`tierPresets.${tier} references unknown preset ${id}`);
}
}
for (const [id, preset] of Object.entries(policy.presets)) {
if (preset.id !== id) {
errors.push(`preset key ${id} does not match id ${preset.id}`);
}
if (preset.targetFps !== 30 && preset.targetFps !== 60) {
errors.push(`preset ${id} targetFps must be 30 or 60`);
}
if (!(preset.renderScale >= 0.5 && preset.renderScale <= 1)) {
errors.push(`preset ${id} renderScale must be within [0.5, 1]`);
}
}
if (policy.canary) {
if (!(policy.canary.percent >= 0 && policy.canary.percent <= 100)) {
errors.push("canary.percent must be within [0, 100]");
}
if (!policy.presets[policy.canary.presetId]) {
errors.push(`canary references unknown preset ${policy.canary.presetId}`);
}
if (policy.canary.tiers.length === 0) {
errors.push("canary.tiers must not be empty");
}
}
return errors;
}
export interface PolicyApplyResult {
readonly ok: boolean; readonly active: QualityPolicy;
readonly errors: readonly string[]; readonly rolledBack: boolean;
}
export class PolicyRegistry {
#active: QualityPolicy;
readonly #history: QualityPolicy[] = [];
constructor(initial: QualityPolicy = DEFAULT_POLICY) {
const errors = validatePolicy(initial);
if (errors.length > 0) {
throw new Error(`invalid initial policy: ${errors.join("; ")}`);
}
this.#active = initial;
}
get active(): QualityPolicy {
return this.#active;
}
apply(next: QualityPolicy): PolicyApplyResult {
const errors = validatePolicy(next);
if (errors.length > 0) {
return { ok: false, active: this.#active, errors, rolledBack: true };
}
this.#history.push(this.#active);
this.#active = next;
return { ok: true, active: this.#active, errors: [], rolledBack: false };
}
rollback(): PolicyApplyResult | null {
const previous = this.#history.pop();
if (!previous) {
return null;
}
this.#active = previous;
return { ok: true, active: previous, errors: [], rolledBack: true };
}
}
export interface FleetImpact {
readonly total: number; readonly byTier: Readonly<Record<Tier, number>>;
readonly byPreset: Readonly<Record<string, number>>;
readonly lowTierShare: number; readonly canaryShare: number;
}
export function fleetImpact(decisions: readonly QualityDecision[]): FleetImpact {
const byTier: Record<Tier, number> = { low: 0, mid: 0, high: 0 };
const byPreset: Record<string, number> = {};
let canary = 0;
for (const decision of decisions) {
byTier[decision.tier] += 1;
byPreset[decision.presetId] = (byPreset[decision.presetId] ?? 0) + 1;
if (decision.canary) {
canary += 1;
}
}
const total = decisions.length;
return {
total, byTier, byPreset,
lowTierShare: total === 0 ? 0 : byTier.low / total,
canaryShare: total === 0 ? 0 : canary / total,
};
}
export interface CanaryMetrics { readonly sessions: number; readonly jankRate: number; readonly crashRate: number; }
export interface Guardrail { readonly minSessions: number; readonly maxJankRate: number; readonly maxCrashRate: number; }
export const DEFAULT_GUARDRAIL: Guardrail = {
minSessions: 5000, maxJankRate: 0.03, maxCrashRate: 0.002,
};
export interface RollbackVerdict { readonly rollback: boolean; readonly reasons: readonly string[]; }
export function shouldRollback(
metrics: CanaryMetrics,
guardrail: Guardrail = DEFAULT_GUARDRAIL,
): RollbackVerdict {
if (metrics.sessions < guardrail.minSessions) {
return { rollback: false, reasons: ["insufficient-sessions"] };
}
const reasons: string[] = [];
if (metrics.jankRate > guardrail.maxJankRate) {
reasons.push(`jank-rate=${metrics.jankRate}>${guardrail.maxJankRate}`);
}
if (metrics.crashRate > guardrail.maxCrashRate) {
reasons.push(`crash-rate=${metrics.crashRate}>${guardrail.maxCrashRate}`);
}
return { rollback: reasons.length > 0, reasons };
}
配套测试 test/input/device-tiers.test.ts 覆盖 13 条路径:三档评分、RAM 边界、OS 硬门槛、RAM 地板、阈值替换、分桶确定性、10% 灰度分布、非目标档位不命中、配置校验、非法更新拒绝、版本回滚、35% 车队占比与护栏判定。运行命令是 node --test test/input/device-tiers.test.ts。
模型不测量真实 GPU 性能,也不推断厂商调度策略;GPU 档位是平台适配层输入,测试只验证「拿到什么就怎么判」。这里是模型,不是设备实测。真机上的持续性能、发热降频与内存水位仍要进入兼容性矩阵。
双案例:消消守下限,方舟守上限
消消的低端机占 35%,策略的第一目标是守住内存水位与 30fps 下限。低档预设必须由统一入口下发,活动、家园、商店都不能自带画质分支;灰度默认先覆盖低档,确认没有越线后再扩大。
方舟低端机只占 15%,但默认 60fps、首包 350MB,低档策略的代价更高。它的灰度需要更长的观察窗口和更严格的崩溃护栏,且低档预设不能简单照搬消消的渲染比例。
与相邻章节的关系
8.1 把输入归一化为意图,本节把设备差异归一化为档位与预设;8.3 处理屏幕形态与布局,不改变画质档位。策略表的结构与版本化可以参考配置驱动配置表 ,远程配置的下发与回滚可以参考远程热更 ,低端机降级的整体取舍可以参考低端机降级 。
小结
- 分档是可解释的评分加硬门槛,不是机型名单;未知值必须保守,不能默认高档。
- 画质是配置,不是散落在业务里的
if;配置必须整份校验,失败时拒绝加载。 - 灰度用设备 id 加盐的确定性分桶,保证同一设备不会在两组预设之间跳变。
- 回滚分两层:非法配置拒绝即回滚,指标越线运行期回滚;两层都要记录版本与原因。
- 35% 低端机是主要受众,任何提高低档成本的策略都必须默认覆盖这批设备。
阅读导航:上一节:8.1 触控、陀螺仪与传感器抽象 · 下一节:8.3 平板、折叠屏与异形屏 。
常见错误与边界
- 用机型名单代替评分。新机永远比名单快,长尾维护成本会吞掉收益;名单只能作为兜底覆盖,不能作为主规则。
- 让业务入口自带画质判断。活动、商店、家园各写一套
if,迟早绕过统一分档;预设只能由策略入口下发。 - 把未知 GPU 或未知刷新率当成高档。读取失败是常态,默认高档会把最不稳定的设备推进最重的配置。
- 灰度用随机数而不是确定性哈希。同一台设备重开游戏就可能换组,指标无法归因,玩家也会感到画质闪烁。
- 护栏样本不足就宣布灰度成功。5000 会话是示例下限;样本不足时只能继续观察,不能把「没出事」当成「不会出事」。
- 边界:本节不测量真实 GPU 性能与发热降频。
DeviceProfile是模型输入,档位判定不能替代兼容性矩阵与真机压测。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。