事故:重启就好了,所以一直没修
《潮汐消消》的家园系统有一个持续了两个版本的投诉:连续进出家园十几次后,手机会变热、切场景变慢,杀掉进程重开就恢复。因为不崩溃、不报错、重启能解决,这个问题在需求评审里始终排不上优先级。
直到一次活动版本,低端机在连续进出家园后开始出现闪退。团队把内存曲线拉出来,看到的是一条锯齿向上的线:每次进入家园抬升,退出后只回落一部分,下一次再抬升。没有单点尖峰,也没有 OOM 日志,只有「退出时少还了一点」的持续累积。
这类问题最难的地方不是修复,而是定位。泄漏点可能是一个没解绑的事件监听、一个没有上限的缓存、一条被闭包捕获的纹理引用。靠代码走查只能碰运气,靠用户反馈只能知道「哪个场景」;需要的是在场景边界自动回答:这一次退出,比上一次进入前多了哪些 key、每个 key 多了多少字节、引用计数是多少。
泄漏的三种形态
把资源泄漏按快照特征分类,比按代码位置分类更有用,因为同一类特征往往对应同一种修复手法。
| 形态 | 运行表现 | 快照特征 | 常见修复位置 |
|---|---|---|---|
| 无主引用 | 退出后资源仍在,引用计数不归零 | 同 key 的 refs 大于 0,且持有者已销毁 | 事件解绑、回调清理、定时器取消 |
| 缓存无上限 | 进出越多次越高,重启归零 | 条目数量持续增加,单条 refs 为 0 | 给缓存设容量与淘汰策略 |
| 键增长 | 同一功能每次生成新 key | added 列表出现带序号或时间戳的 key | 资源 key 规范化、复用池 |
三种形态对应三种不同的断言。第一种要断言「场景退出后 refs 归零」;第二种要断言「缓存总量不超过容量」;第三种要断言「同一场景第二次进入不产生新 key」。只写一个「内存不涨」的总量断言,无法区分它们,也无法指出修复方向。
案例设定里消消的总资源是 480MB,场景多、单局短,第二种形态最常见:为了减少加载卡顿,团队给图集加了一层缓存,却没设上限,家园的装饰图集越积越多。
方舟单场 8–12 分钟、战斗资源更重,第一种形态更常见:战斗结束后的特效回调仍持有立绘引用,退出战斗后引用计数不归零。
LRU 缓存必须有容量与保护位
缓存不是泄漏的免罪符。一个没有容量上限的缓存,本质上是把泄漏写进了设计。mga-lab 的 LruResourceCache 用三个约束把缓存变成可审计的结构。
- 容量:构造时给出字节上限,所有
put都按「替换后的总量」预估,超出就淘汰。 - 保护位:
pinned表示常驻资源,refs表示正在使用;两者都不参与淘汰。 - 显式失败:当所有条目都受保护、无法腾出空间时,
put返回capacity-exhausted,并保持缓存不变,而不是悄悄超限。
淘汰顺序按最近使用时间,同 tick 时按 key 排序,保证测试可复现。get 会更新使用时间,peek 不会;这个区分让「只读诊断」不会污染 LRU 顺序。
保护位也带来一个必须接受的边界:缓存可能因为大量 pinned 条目而无法工作。此时正确做法是让上层看到失败并调整预加载策略,而不是让缓存突破容量。把失败暴露出来,比让它变成一条缓慢上升的内存曲线要好。
引用计数快照与场景前后差值
快照只记录四件事:key、字节数、引用计数、是否 pinned。它不记录纹理格式、显存地址或引擎对象,因为泄漏定位关心的是「谁还在」,不是「长什么样」。
两个快照相减,得到五类结果:新增、移除、变大、变小、总字节差。解释规则要固定下来,否则同一份报告会被不同的人读成不同结论。
| 差值 | 含义 | 是否泄漏 |
|---|---|---|
| added | 基线没有、现在有的 key | 通常是;除非是本次场景应加载的资源 |
| removed | 基线有、现在没有的 key | 不是;可能是正常淘汰 |
| grew | 同 key 字节变大 | 通常是;检查是否重复加载或尺寸变化 |
| shrank | 同 key 字节变小 | 不是;可能是降级或替换 |
| byteDelta 为正 | 总量增加 | 需要结合 added 与 grew 判断 |
关键是把「场景应加载的资源」排除在泄漏之外。做法是在场景退出、资源释放并清理未引用条目之后取基线;下一次同一场景退出后取快照,与基线比较。此时任何 added 或 grew 都是异常,removed 与 shrank 不算。
可回归的泄漏测试
泄漏测试要能进 CI,就必须满足三个条件:不依赖真实设备、不依赖时间、失败时能指出 key。mga-lab 的 SceneLeakGuard 只做三件事:标记基线、取当前快照、比较并返回报告。
一个典型的往返用例是:加载常驻资源并 pin、标记基线、进入场景加载若干资源、retain/release、调用 dropUnreferenced 清理未引用条目、再次取快照、断言报告通过。如果场景代码忘了 release,条目会以 refs 大于 0 留在快照里,报告给出 added 列表和字节差;如果场景重复加载同一个 key,报告给出 grew。
容差参数只用于吸收合法的缓存波动,默认是 0。把它设成「几百 KB 让测试稳定」之前,先确认波动来自哪里;大多数波动其实是 key 不稳定,应该修 key,而不是放宽阈值。
可运行模型:LRU、快照差值与泄漏守卫
下面的 cache.ts 把容量、保护位、快照与差值收敛到一个文件。diffSnapshots 是纯函数,输入两份快照,输出五类差值;SceneLeakGuard 保存基线并在检查时返回带字节差与明细的报告。
// file: src/resource/cache.ts
// tier: L1
// verify: node --test test/resource/cache.test.ts
import { fail, ok, type Result } from "../core/result.ts";
export type CacheError = "invalid-size" | "missing-key" | "capacity-exhausted";
export interface CacheOptions {
readonly pinned?: boolean;
readonly refs?: number;
}
export interface CacheEntryView {
readonly key: string;
readonly sizeBytes: number;
readonly refs: number;
readonly pinned: boolean;
readonly lastUsedTick: number;
}
export interface SnapshotEntry {
readonly key: string;
readonly sizeBytes: number;
readonly refs: number;
readonly pinned: boolean;
}
export interface CacheSnapshot {
readonly totalBytes: number;
readonly entries: readonly SnapshotEntry[];
}
export interface EvictionReport {
readonly evictedKeys: readonly string[];
readonly freedBytes: number;
readonly totalBytes: number;
}
interface CacheEntry {
sizeBytes: number;
refs: number;
pinned: boolean;
lastUsedTick: number;
}
export class LruResourceCache {
#capacityBytes: number;
#entries = new Map<string, CacheEntry>();
#tick = 0;
constructor(capacityBytes: number) {
if (!Number.isInteger(capacityBytes) || capacityBytes <= 0) {
throw new RangeError(`capacityBytes must be a positive integer, got ${capacityBytes}`);
}
this.#capacityBytes = capacityBytes;
}
get capacityBytes(): number {
return this.#capacityBytes;
}
get totalBytes(): number {
let sum = 0;
for (const entry of this.#entries.values()) sum += entry.sizeBytes;
return sum;
}
put(key: string, sizeBytes: number, options: CacheOptions = {}): Result<CacheEntryView, CacheError> {
if (!Number.isInteger(sizeBytes) || sizeBytes < 0) return fail("invalid-size", `${key}: ${sizeBytes}`);
if (sizeBytes > this.#capacityBytes) {
return fail("capacity-exhausted", `${key}: ${sizeBytes} > ${this.#capacityBytes}`);
}
const existing = this.#entries.get(key);
const projected = this.totalBytes - (existing?.sizeBytes ?? 0) + sizeBytes;
const victims = this.#pickVictims(projected, key);
if (victims === null) return fail("capacity-exhausted", `${key}: protected entries fill the cache`);
for (const victim of victims) this.#entries.delete(victim);
this.#tick += 1;
this.#entries.set(key, {
sizeBytes,
refs: options.refs ?? existing?.refs ?? 0,
pinned: options.pinned ?? existing?.pinned ?? false,
lastUsedTick: this.#tick,
});
return ok(this.#view(key));
}
get(key: string): CacheEntryView | null {
const entry = this.#entries.get(key);
if (entry === undefined) return null;
this.#tick += 1;
entry.lastUsedTick = this.#tick;
return this.#view(key);
}
peek(key: string): CacheEntryView | null {
return this.#entries.has(key) ? this.#view(key) : null;
}
retain(key: string): Result<CacheEntryView, CacheError> {
const entry = this.#entries.get(key);
if (entry === undefined) return fail("missing-key", key);
entry.refs += 1;
return ok(this.#view(key));
}
release(key: string): Result<CacheEntryView, CacheError> {
const entry = this.#entries.get(key);
if (entry === undefined) return fail("missing-key", key);
entry.refs = Math.max(0, entry.refs - 1);
return ok(this.#view(key));
}
setPinned(key: string, pinned: boolean): Result<CacheEntryView, CacheError> {
const entry = this.#entries.get(key);
if (entry === undefined) return fail("missing-key", key);
entry.pinned = pinned;
return ok(this.#view(key));
}
evict(key: string): Result<number, CacheError> {
const entry = this.#entries.get(key);
if (entry === undefined) return fail("missing-key", key);
if (entry.pinned || entry.refs > 0) return fail("capacity-exhausted", `${key}: protected`);
this.#entries.delete(key);
return ok(entry.sizeBytes);
}
trim(): EvictionReport {
const candidates = [...this.#entries.entries()]
.filter(([, entry]) => !entry.pinned && entry.refs === 0)
.sort((a, b) => a[1].lastUsedTick - b[1].lastUsedTick || a[0].localeCompare(b[0]));
const victims: string[] = [];
let remaining = this.totalBytes;
for (const [key, entry] of candidates) {
if (remaining <= this.#capacityBytes) break;
victims.push(key);
remaining -= entry.sizeBytes;
}
return this.#remove(victims);
}
dropUnreferenced(): EvictionReport {
const keys = [...this.#entries.entries()]
.filter(([, entry]) => !entry.pinned && entry.refs === 0)
.map(([key]) => key)
.sort();
return this.#remove(keys);
}
snapshot(): CacheSnapshot {
const entries = [...this.#entries.entries()]
.map(([key, entry]) => ({ key, sizeBytes: entry.sizeBytes, refs: entry.refs, pinned: entry.pinned }))
.sort((a, b) => a.key.localeCompare(b.key));
return { totalBytes: this.totalBytes, entries };
}
#remove(keys: readonly string[]): EvictionReport {
let freedBytes = 0;
for (const key of keys) {
freedBytes += this.#entries.get(key)?.sizeBytes ?? 0;
this.#entries.delete(key);
}
return { evictedKeys: [...keys], freedBytes, totalBytes: this.totalBytes };
}
#pickVictims(projected: number, protectedKey: string | null): string[] | null {
if (projected <= this.#capacityBytes) return [];
const candidates = [...this.#entries.entries()]
.filter(([key, entry]) => key !== protectedKey && !entry.pinned && entry.refs === 0)
.sort((a, b) => a[1].lastUsedTick - b[1].lastUsedTick || a[0].localeCompare(b[0]));
let remaining = projected;
const victims: string[] = [];
for (const [key, entry] of candidates) {
victims.push(key);
remaining -= entry.sizeBytes;
if (remaining <= this.#capacityBytes) return victims;
}
return null;
}
#view(key: string): CacheEntryView {
const entry = this.#entries.get(key);
if (entry === undefined) throw new Error(`missing cache entry: ${key}`);
return {
key,
sizeBytes: entry.sizeBytes,
refs: entry.refs,
pinned: entry.pinned,
lastUsedTick: entry.lastUsedTick,
};
}
}
export interface SizeChange {
readonly key: string;
readonly beforeBytes: number;
readonly afterBytes: number;
}
export interface SnapshotDiff {
readonly added: readonly string[];
readonly removed: readonly string[];
readonly grew: readonly SizeChange[];
readonly shrank: readonly SizeChange[];
readonly byteDelta: number;
}
export function diffSnapshots(before: CacheSnapshot, after: CacheSnapshot): SnapshotDiff {
const beforeMap = new Map(before.entries.map((entry) => [entry.key, entry.sizeBytes]));
const afterMap = new Map(after.entries.map((entry) => [entry.key, entry.sizeBytes]));
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key)).sort();
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key)).sort();
const grew: SizeChange[] = [];
const shrank: SizeChange[] = [];
for (const [key, beforeBytes] of beforeMap) {
const afterBytes = afterMap.get(key);
if (afterBytes === undefined || afterBytes === beforeBytes) continue;
const change = { key, beforeBytes, afterBytes };
if (afterBytes > beforeBytes) grew.push(change);
else shrank.push(change);
}
grew.sort((a, b) => a.key.localeCompare(b.key));
shrank.sort((a, b) => a.key.localeCompare(b.key));
return {
added,
removed,
grew,
shrank,
byteDelta: after.totalBytes - before.totalBytes,
};
}
export interface LeakReport {
readonly scene: string;
readonly ok: boolean;
readonly byteDelta: number;
readonly diff: SnapshotDiff;
}
export class SceneLeakGuard {
#baselines = new Map<string, CacheSnapshot>();
mark(scene: string, snapshot: CacheSnapshot): void {
this.#baselines.set(scene, snapshot);
}
hasBaseline(scene: string): boolean {
return this.#baselines.has(scene);
}
check(scene: string, snapshot: CacheSnapshot, toleranceBytes = 0): LeakReport {
const baseline = this.#baselines.get(scene);
if (baseline === undefined) throw new Error(`no baseline for scene: ${scene}`);
const diff = diffSnapshots(baseline, snapshot);
const leaked = diff.added.length > 0 || diff.grew.length > 0;
const overTolerance = diff.byteDelta > toleranceBytes;
return { scene, ok: !leaked && !overTolerance, byteDelta: diff.byteDelta, diff };
}
}
配套测试 test/resource/cache.test.ts 覆盖十条路径:LRU 淘汰顺序、pin 保护、引用保护、直接淘汰受保护条目、非法尺寸、快照差值、场景往返通过、忘记释放被发现、pin 资源不算泄漏,以及缺失基线直接抛错。运行命令是 node --test test/resource/cache.test.ts。
这里是模型,不是设备上的测量。真实引擎的纹理、音频与脚本对象有各自的释放时机,快照只能覆盖进入缓存的那部分;模型负责把「场景往返后结构应还原」写成断言,引擎侧的释放验证仍要回到设备矩阵。
与相邻章节的关系
6.1 的引用计数回答「谁在持有」,本节把持有关系固化成快照并比较;没有 6.1 的 owner 与代际,快照里的 refs 无法解释。6.2 的降级会改变快照,回归测试要区分「降级导致的 shrank」与「泄漏导致的 grew」。垃圾回收与引擎内存模型的细节可以参考内存与 GC ,远程热更引入的版本差异可以参考远程热更 。
小结
- 泄漏通常不崩溃、重启就好,必须靠场景边界的结构化快照定位,而不是靠用户反馈。
- 三种形态要分别断言:无主引用的 refs 不归零、缓存条目持续增加、同一场景产生新 key。
- LRU 必须有容量与保护位;无法腾出空间时返回
capacity-exhausted,不要悄悄超限。 - 快照只记录 key、字节、refs、pinned;差值中的 added 与 grew 才是泄漏信号,removed 与 shrank 不是。
- 泄漏测试要能在 CI 重复执行:固定基线、固定场景、失败时给出 key 与字节差。
阅读导航:上一节:6.2 内存水位与低端机降级 · 下一节:7.1 UI 栈、弹窗队列与返回键语义 。
常见错误与边界
- 用总量断言代替结构断言。总量不涨也可能是一个 key 变大、另一个被淘汰;必须比较 added、grew 与 refs。
- 给缓存不设上限。没有容量的缓存就是把泄漏写进设计;容量、pin、refs 三个约束缺一不可。
- 把 removed 与 shrank 当泄漏。正常淘汰和降级都会让快照变小;只有 added 与 grew 需要解释。
- 基线取在场景加载之后。基线必须取在释放与清理之后,否则本次场景的资源会被当成基线的一部分,泄漏永远测不出来。
- 用容差掩盖不稳定的 key。带序号或时间戳的 key 会让每次快照都出现 added;应先修 key 规范,再谈阈值。
- 边界:快照不覆盖引擎内部对象与显存。未被缓存接管的纹理、音频句柄与脚本闭包不在模型内,需要引擎侧工具与设备验证补位。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。