事故:告警响了,但没有人知道先关什么
《潮汐消消》的低端机占比是 35%,活动版本上线后,客服侧出现一类「玩到第三关闪退」的反馈,机型集中在 2GB 与 3GB 内存的 Android 设备。崩溃平台看不到 Java 异常,只有系统日志里的进程回收记录。
客户端其实有水位告警:阈值 520MB,超过就上报一条埋点。问题有两个。第一,520MB 是写死的产品级常量,在 2GB 设备上按 35% 预算只有 716MB,520MB 已经占到 72.6%,触发告警时早已越过 70% 的目标线,留给降级的时间窗口很窄。第二,告警只上报,不动作;值班同学看到曲线后手动调用了一次「清理所有纹理」,画面瞬间变糊、背景音乐断裂,内存却只降了几十 MB,因为真正占大头的是被引用的图集和预加载音频。
这次复盘之后,团队把「内存管理」拆成三个可分别验证的问题:预算怎么算、什么时候算超标、超标后按什么顺序降级。三者缺一,告警就只是一个延迟的事故通知。
预算用整数 MB,按设备内存分档
预算的第一个原则是单位统一。成本、内存、流量一律用整数单位,本节统一用 MB,所有比例计算向下取整。浮点预算会在多机型、多语言、多轮降级后产生无法对账的尾差,最后没人说得清「到底超了 0.4MB 还是 4MB」。
第二个原则是分档。案例设定中,4GB RAM 以下算低端机,预算取设备内存的 35%;4GB 到 6GB 取 45%;6GB 及以上取 50%。这三个比例是策略参数,不是案例指标,真实项目要按平台版本与机型分布校准,但「低端 35%」是本节要固定的口径。
| 设备内存 | 档位 | 预算比例 | 预算 MB | 说明 |
|---|---|---|---|---|
| 2048MB | low | 35% | 716 | 716.8 向下取整 |
| 3072MB | low | 35% | 1075 | 1075.2 向下取整 |
| 4095MB | low | 35% | 1433 | 仍属「4GB 以下」 |
| 4096MB | mid | 45% | 1843 | 恰好 4GB,进入中档 |
| 6143MB | mid | 45% | 2764 | 2764.35 向下取整 |
| 6144MB | high | 50% | 3072 | 恰好 6GB,进入高档 |
边界必须写清楚:4096 不属于低端。很多团队把「4GB 以下」口头理解成「4GB 及以下」,结果同一台 4GB 设备在两个版本里拿到不同预算,水位曲线出现无法解释的跳变。mga-lab 用严格小于判断,测试里专门锁住 4095 与 4096 两个点。
水位是预算的比例,不是产品的常量
有了预算,水位就可以用比例表达。mga-lab 取三档:70% 为正常上限,85% 为告警线,95% 为临界线。超过 85% 必须降级,超过 95% 必须放弃非关键资源,否则系统很可能先一步回收进程。
这个模型解释了一个常见矛盾:产品级告警值 520MB 在低端机上偏晚,在高档机上又偏早。消消的稳态内存是 420MB,告警是 520MB;方舟分别是 1,150MB 与 1,450MB。把绝对值直接套到所有设备,会得到两张互相矛盾的曲线。
| 设备内存 | 档位 | 预算 MB | 消消告警 520MB 占比 | 方舟告警 1,450MB 占比 | 结论 |
|---|---|---|---|---|---|
| 2048MB | low | 716 | 72.6% | 202.5% | 方舟不可运行,消消告警即越线 |
| 3072MB | low | 1075 | 48.4% | 134.9% | 方舟不可运行,消消需提前降级 |
| 4096MB | mid | 1843 | 28.2% | 78.7% | 消消宽松,方舟进入 elevated |
| 6144MB | high | 3072 | 16.9% | 47.2% | 两者均在正常区 |
表格里的占比由案例设定值与预算公式算出,不是设备上的测量。它给出的决策很直接:方舟在 4GB 以下设备上,基线水位已经超过设备预算,不能靠「运行时再降级」兜底,必须在机型准入或画质预设上先做选择。案例设定里方舟低端机占比 15%,这 15% 就是准入策略要覆盖的人群。
消消则相反:2GB 设备上预算 716MB,520MB 告警占 72.6%,刚好越过 70% 的正常上限。它需要把告警线改成「预算的 85%」,也就是 608MB,并在进入关卡前就完成一次预算检查,而不是等运行到一半再降。
降级顺序:先做无感回收,最后动纹理
降级不是「把能关的都关掉」,而是一条有代价排序的阶梯。mga-lab 的顺序是:场景外资源卸载、图集裁剪、音频码率、纹理分辨率。排序依据是玩家可感知度,不是实现难度。
| 顺序 | 步骤 | 预计回收比例 | 玩家可感知度 | 可逆 |
|---|---|---|---|---|
| 1 | scene-unload:卸载非当前场景与未引用资源 | 8% | 无 | 是 |
| 2 | atlas-trim:合并重复图集、裁掉空白页 | 6% | 低 | 是 |
| 3 | audio-bitrate:降低音乐与音效码率 | 3% | 低 | 是 |
| 4 | texture-scale:降低纹理采样分辨率 | 12% | 高 | 否 |
前两步不改变玩家看到的画面,应该先做;音频降级只在长时间游玩时被察觉,排在第三步;纹理降级会让画面变糊且通常不可逆,必须最后做,并且只在临界线以上触发。比例是策略参数,实践中要按项目的资源构成测量后替换,但顺序不应随意调换。
阶梯还必须允许「降不下去」这个结果。若四步全部执行仍未回到目标水位,说明预算本身不成立,继续加压只会牺牲体验而不解决问题,正确动作是放弃本局或退出到低负载场景,并上报一次预算失败。
可运行模型:预算、压力与降级计划
下面的 memory-budget.ts 实现三件事:按设备内存分档并算出整数预算、按整数比较给出压力等级、按固定顺序生成降级计划。planDegradation 会记录每一步的降前与降后 MB,阶梯用尽仍未达标时返回 reachedTarget: false,而不是假装成功。
// file: src/resource/memory-budget.ts
// tier: L1
// verify: node --test test/resource/memory-budget.test.ts
export type DeviceTier = "low" | "mid" | "high";
export type ProductId = "match3" | "ark";
export type PressureLevel = "normal" | "elevated" | "high" | "critical";
export interface TierRule {
readonly tier: DeviceTier;
readonly belowMb: number;
readonly budgetPercent: number;
}
export const TIER_RULES: readonly TierRule[] = [
{ tier: "low", belowMb: 4096, budgetPercent: 35 },
{ tier: "mid", belowMb: 6144, budgetPercent: 45 },
{ tier: "high", belowMb: Number.MAX_SAFE_INTEGER, budgetPercent: 50 },
];
export const PRESSURE_THRESHOLDS = {
normal: 70,
elevated: 85,
high: 95,
} as const;
export interface ProductCase {
readonly product: ProductId;
readonly steadyLowEndMb: number;
readonly alarmMb: number;
readonly lowEndSharePercent: number;
}
export const PRODUCT_CASES: readonly ProductCase[] = [
{ product: "match3", steadyLowEndMb: 420, alarmMb: 520, lowEndSharePercent: 35 },
{ product: "ark", steadyLowEndMb: 1150, alarmMb: 1450, lowEndSharePercent: 15 },
];
export type DegradeStepId = "scene-unload" | "atlas-trim" | "audio-bitrate" | "texture-scale";
export interface DegradeStep {
readonly id: DegradeStepId;
readonly savingsPercent: number;
readonly visibleToPlayer: "none" | "low" | "medium" | "high";
readonly reversible: boolean;
}
export const DEGRADE_ORDER: readonly DegradeStep[] = [
{ id: "scene-unload", savingsPercent: 8, visibleToPlayer: "none", reversible: true },
{ id: "atlas-trim", savingsPercent: 6, visibleToPlayer: "low", reversible: true },
{ id: "audio-bitrate", savingsPercent: 3, visibleToPlayer: "low", reversible: true },
{ id: "texture-scale", savingsPercent: 12, visibleToPlayer: "high", reversible: false },
];
export interface AppliedStep {
readonly id: DegradeStepId;
readonly beforeMb: number;
readonly afterMb: number;
readonly savedMb: number;
}
export interface DegradePlan {
readonly beforeMb: number;
readonly targetMb: number;
readonly afterMb: number;
readonly applied: readonly AppliedStep[];
readonly reachedTarget: boolean;
}
export interface CaseBudget {
readonly product: ProductId;
readonly tier: DeviceTier;
readonly budgetMb: number;
readonly alarmMb: number;
readonly headroomMb: number;
readonly fits: boolean;
}
function requireInteger(value: number, label: string): void {
if (!Number.isInteger(value) || value < 0) {
throw new RangeError(`${label} must be a non-negative integer, got ${value}`);
}
}
export function tierFor(ramMb: number): DeviceTier {
requireInteger(ramMb, "ramMb");
for (const rule of TIER_RULES) {
if (ramMb < rule.belowMb) {
return rule.tier;
}
}
return "high";
}
export function budgetMbFor(ramMb: number): number {
const tier = tierFor(ramMb);
const rule = TIER_RULES.find((item) => item.tier === tier);
if (rule === undefined) {
throw new Error(`no tier rule for ${tier}`);
}
return Math.floor((ramMb * rule.budgetPercent) / 100);
}
export function pressureLevel(usedMb: number, budgetMb: number): PressureLevel {
requireInteger(usedMb, "usedMb");
requireInteger(budgetMb, "budgetMb");
if (budgetMb === 0) {
throw new RangeError("budgetMb must be positive");
}
const scaled = usedMb * 100;
if (scaled <= budgetMb * PRESSURE_THRESHOLDS.normal) {
return "normal";
}
if (scaled <= budgetMb * PRESSURE_THRESHOLDS.elevated) {
return "elevated";
}
if (scaled <= budgetMb * PRESSURE_THRESHOLDS.high) {
return "high";
}
return "critical";
}
export function needsAlarm(level: PressureLevel): boolean {
return level === "high" || level === "critical";
}
export function planDegradation(
usedMb: number,
budgetMb: number,
targetPercent = PRESSURE_THRESHOLDS.normal,
steps: readonly DegradeStep[] = DEGRADE_ORDER,
): DegradePlan {
requireInteger(usedMb, "usedMb");
requireInteger(budgetMb, "budgetMb");
requireInteger(targetPercent, "targetPercent");
if (targetPercent > 100) {
throw new RangeError("targetPercent must be <= 100");
}
const targetMb = Math.floor((budgetMb * targetPercent) / 100);
let current = usedMb;
const applied: AppliedStep[] = [];
for (const step of steps) {
if (current <= targetMb) {
break;
}
const saved = Math.max(1, Math.floor((current * step.savingsPercent) / 100));
const next = current - saved;
applied.push({ id: step.id, beforeMb: current, afterMb: next, savedMb: saved });
current = next;
}
return {
beforeMb: usedMb,
targetMb,
afterMb: current,
applied,
reachedTarget: current <= targetMb,
};
}
export function caseBudget(product: ProductId, ramMb: number): CaseBudget {
const preset = PRODUCT_CASES.find((item) => item.product === product);
if (preset === undefined) {
throw new Error(`unknown product: ${product}`);
}
const budgetMb = budgetMbFor(ramMb);
const headroomMb = budgetMb - preset.alarmMb;
return {
product,
tier: tierFor(ramMb),
budgetMb,
alarmMb: preset.alarmMb,
headroomMb,
fits: headroomMb >= 0,
};
}
export function watermarkRows(ramMb: number): readonly CaseBudget[] {
return PRODUCT_CASES.map((item) => caseBudget(item.product, ramMb));
}
配套测试 test/resource/memory-budget.test.ts 覆盖分档边界、向下取整、压力阈值、降级顺序与提前停止、阶梯用尽、双案例水位,以及非整数输入被拒绝。运行命令是 node --test test/resource/memory-budget.test.ts。
这里是模型,不是设备上的测量。真实设备上系统缓存、显存、厂商定制 ROM 都会改变可用内存;模型负责把「预算与降级顺序」写成可回归的策略,设备矩阵负责验证策略在具体机型上的效果。
与相邻章节的关系
6.1 的资源图提供 stats().bytes 与 owner 视角,本节把它换算成设备预算下的压力等级;没有准确的字节账,水位只是猜测。6.3 会把降级后的快照与基线比较,确认降级没有引入新的泄漏。低端机降级的更多机型策略可以参考低端机降级
,崩溃与卡顿的观测口径可以参考崩溃与卡顿
。
小结
- 预算按设备内存分档,低端(小于 4GB)取 35%,计算一律用整数 MB 向下取整。
- 4096MB 属于中档,不是低端;边界要写进测试,不能靠口头约定。
- 水位用预算比例表达,70% 正常、85% 告警、95% 临界;产品级绝对值会随设备失真。
- 方舟在 4GB 以下设备上基线水位已超预算,必须靠机型准入或画质预设解决,不能只靠运行时降级。
- 降级按无感优先:场景卸载、图集裁剪、音频码率、纹理分辨率;阶梯用尽要如实报告失败。
阅读导航:上一节:6.1 资源句柄、引用计数与生命周期 · 下一节:6.3 资源泄漏定位与回归 。
常见错误与边界
- 把水位写成产品级常量。同一阈值在 2GB 设备上占预算 72.6%,在 6GB 设备上只占 16.9%;水位必须由设备预算算出。
- 用浮点 MB 做预算。多轮降级后会出现无法对账的小数尾差;预算与水位都用整数,比例计算向下取整。
- 把 4096MB 当低端。「4GB 以下」是严格小于,边界机型要单独锁定,否则同一设备跨版本预算跳变。
- 降级从纹理开始。纹理降级不可逆且最容易被玩家察觉,应先做场景卸载与图集裁剪,把纹理放在最后。
- 降级计划没有「失败」状态。四步执行完仍超预算时继续加压只会牺牲体验;应放弃本局或退到低负载场景并上报。
- 边界:本模型不模拟显存与系统缓存。GPU 纹理常驻、系统页缓存与厂商 ROM 的可用内存差异只能在机型矩阵上验证,模型只保证策略口径一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。