事故:用户划掉应用,什么都没有发生
《潮汐消消》上线初期有一个稳定复现的投诉:玩家在单局中途从最近任务划掉应用,重新打开后回到主界面,棋盘和奖励都没了。开发在 onDestroy 里写了保存,也在 applicationWillTerminate 里写了保存,但两条路径都没有执行。
Android 上,从最近任务划掉、系统低内存回收、厂商省电策略清理,都可能直接终止进程,不保证 onDestroy。iOS 上,应用进入挂起状态后,系统可以在没有通知的情况下回收;willTerminate 只覆盖特定终止路径。更根本的是,进程死亡时没有可执行上下文:没有事件循环、没有网络、没有足够时间完成一次异步写盘。
所以「异常退出的兜底」不能建立在「退出时做点什么」上。它必须建立在进程还活着的时候已经把关键状态写到磁盘,以及下次启动时能判断上次是否正常结束。
进程死亡没有回调
移动端有三类终止路径,客户端能做的动作完全不同。
| 终止路径 | 是否有回调 | 客户端能做什么 |
|---|---|---|
| 正常退出(主动结束、系统温和终止) | 有,但不保证 | 标记干净退出、清理临时文件 |
| 后台被系统回收 | 通常没有 | 只能依赖之前落盘的检查点 |
| 用户划掉或强行停止 | 通常没有 | 只能依赖脏标记与检查点 |
| 崩溃(未捕获异常、原生崩溃) | 崩溃处理器可能执行,但不保证完成异步写 | 依赖同步写或上一次检查点 |
这张表决定了一个基本设计:保存动作必须前移。不要问「退出时怎么保存」,要问「哪些时刻的状态值得落盘,落盘失败最多损失多少」。前者依赖一个不可靠的回调,后者是可以计算和测试的预算。
崩溃采集与卡顿观测可以参考崩溃与卡顿 ,但崩溃上报解决的是「事后知道发生了什么」,不解决「玩家回来时状态还在不在」。两者要分开设计。
落盘时机:从「退出时保存」改为「检查点」
检查点不是每帧都写。每帧写会拖慢渲染,也会加速闪存磨损。合理的做法是选几个业务上可接受回退的边界:单局结束、回合结束、进入后台、关键奖励发放之后。
| 时机 | 消消的动作 | 方舟的动作 |
|---|---|---|
| 会话开始 | 写脏标记,记录 sessionId | 写脏标记与战斗令牌 |
| 单局 / 回合结束 | 写完整棋盘检查点 | 写战斗检查点与服务端序号 |
| 进入后台 | 强制写一次,随后停止主动工作 | 强制写一次,标记需要服务端校验 |
| 回到前台 | 读取检查点,校验后继续 | 读取令牌,先向服务端确认 |
| 正常退出 | 删除脏标记与临时文件 | 删除脏标记,保留服务端结算结果 |
检查点之间要有节流。mga-lab 的实验参数是消消 15 秒、方舟 30 秒;这不是案例事实。节流的意义不是省磁盘,而是避免「每次小操作都同步写」把帧预算吃光。真正需要精确恢复的操作,应该在业务层合并成一次检查点,而不是逐条同步落盘。脏标记负责回答「上次是否正常结束」:会话开始时写入,正常退出时删除;下次启动如果还能读到它,就说明上次没有走到干净退出。脏标记必须和检查点使用同一个 sessionId,否则会把上一局的记录误认成本局的进度。
原子写:临时文件、sync、rename
直接覆盖主文件有一个经典问题:写到一半掉电,主文件变成半截 JSON,旧状态也没了。原子写的顺序是「写临时文件、刷盘、重命名覆盖主文件」。
| 步骤 | 动作 | 失败后的结果 |
|---|---|---|
| 1 | 把完整记录写入 session.json.tmp,要求同步刷盘 | 主文件仍是上一个检查点 |
| 2 | 校验和与版本写入记录本身 | 读取时可识别半截或篡改 |
| 3 | rename(tmp, session.json) | 原子替换,读者只会看到旧或新 |
| 4 | 启动时丢弃遗留的 .tmp | 未完成 rename 的数据不被当成已提交 |
关键点是:只有 rename 成功,记录才算提交。临时文件即使完整,也不能在启动时直接拿来恢复;否则「写入完成但尚未确认」的数据会混进正式状态。第一次检查点尤其如此:如果主文件还不存在,rename 前崩溃,本次检查点就应当丢失,而不是从临时文件猜一个状态。
校验和解决另一个问题:闪存、文件系统或人为修改都可能让记录损坏。读取时先验证版本与校验和,失败就返回「丢失」,而不是把半截数据反序列化后继续跑。恢复提示宁可保守,也不要把错误状态带进游戏。
恢复提示与双案例差异
启动时读取脏标记与主记录,只有三种结果:干净启动、可恢复、已丢失。可恢复时还要决定「自动继续」还是「回到安全位置」,这取决于产品对状态权威的定义。
| 结果 | 判据 | 消消的提示 | 方舟的提示 |
|---|---|---|---|
| clean | 没有脏标记 | 正常进入主界面 | 正常进入大厅 |
| recover | 脏标记、记录有效、sessionId 一致 | 自动回到最近检查点 | 提示回大厅,服务端校验令牌 |
| lost | 记录缺失、校验失败或 sessionId 不一致 | 提示本局无法恢复,回到主界面 | 提示战斗无法恢复,回到大厅 |
消消是 90 秒单局、弱联网,客户端对棋盘有足够的权威,自动恢复的代价最多是一局;方舟是 8–12 分钟强联网战斗,伤害、掉落和赛季积分由服务端裁决,客户端不能仅凭本地记录宣布「战斗继续」。这条差异必须写进策略,而不是留给调用方临场判断。
服务端权威与客户端预测的边界可以参考客户端预测与服务端权威 ,反作弊校验可以参考反作弊校验 。本地存档的基本结构可以参考本地存档 ,云存档可以参考云存档 。
可运行模型:recovery.ts
下面的 recovery.ts 把脏标记、检查点节流、校验和、临时文件与原子重命名封装成一个记录器。文件系统通过 FileHost 注入,测试用内存实现模拟掉电;模型不依赖真实磁盘。
// file: src/runtime/recovery.ts
// tier: L1
// verify: node --test test/runtime/recovery.test.ts
import type { Clock } from "../core/clock.ts";
export interface FileHost {
write(path: string, data: string, options: { readonly sync: boolean }): void;
read(path: string): string | null;
rename(from: string, to: string): void;
remove(path: string): void;
exists(path: string): boolean;
}
export interface RecoveryPaths {
readonly primary: string;
readonly temp: string;
readonly dirty: string;
}
export function recoveryPaths(dir: string, name = "session"): RecoveryPaths {
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
return {
primary: `${base}/${name}.json`,
temp: `${base}/${name}.json.tmp`,
dirty: `${base}/${name}.dirty`,
};
}
export interface RecordBody {
readonly version: 1;
readonly sessionId: string;
readonly seq: number;
readonly writtenAtMs: number;
readonly payload: string;
}
export interface RecordEnvelope extends RecordBody {
readonly checksum: string;
}
function canonicalBody(body: RecordBody): string {
return JSON.stringify([
body.version,
body.sessionId,
body.seq,
body.writtenAtMs,
body.payload,
]);
}
export function checksumOf(text: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
export function sealRecord(body: RecordBody): RecordEnvelope {
return { ...body, checksum: checksumOf(canonicalBody(body)) };
}
export function serializeRecord(envelope: RecordEnvelope): string {
return `${JSON.stringify(envelope)}\n`;
}
export function parseRecord(text: string): RecordEnvelope | null {
try {
const parsed = JSON.parse(text) as Partial<RecordEnvelope>;
if (
parsed.version !== 1 ||
typeof parsed.sessionId !== "string" ||
typeof parsed.seq !== "number" ||
typeof parsed.writtenAtMs !== "number" ||
typeof parsed.payload !== "string" ||
typeof parsed.checksum !== "string"
) {
return null;
}
const body: RecordBody = {
version: 1,
sessionId: parsed.sessionId,
seq: parsed.seq,
writtenAtMs: parsed.writtenAtMs,
payload: parsed.payload,
};
return checksumOf(canonicalBody(body)) === parsed.checksum
? { ...body, checksum: parsed.checksum }
: null;
} catch {
return null;
}
}
export type RecoveryProduct = "match3" | "ark";
export type RecoveryHint =
| "resume-last-match"
| "return-to-lobby"
| "server-reconcile";
export interface RecoveryPolicy {
readonly product: RecoveryProduct;
readonly checkpointEveryMs: number;
readonly autoResume: boolean;
readonly hint: RecoveryHint;
}
export const MATCH3_RECOVERY: RecoveryPolicy = {
product: "match3",
checkpointEveryMs: 15_000,
autoResume: true,
hint: "resume-last-match",
};
export const ARK_RECOVERY: RecoveryPolicy = {
product: "ark",
checkpointEveryMs: 30_000,
autoResume: false,
hint: "server-reconcile",
};
export function promptFor(policy: RecoveryPolicy): string {
if (policy.product === "match3") {
return "检测到未正常退出的对局,可回到最近检查点继续。";
}
return "检测到未正常退出的战斗,请回到大厅并由服务端校验战斗令牌。";
}
export interface CleanDecision {
readonly kind: "clean";
}
export interface RecoverDecision {
readonly kind: "recover";
readonly record: RecordEnvelope;
readonly policy: RecoveryPolicy;
readonly prompt: string;
}
export interface LostDecision {
readonly kind: "lost";
readonly reason: string;
}
export type RecoveryDecision = CleanDecision | RecoverDecision | LostDecision;
export class CrashRecoveryRecorder {
#files: FileHost;
#paths: RecoveryPaths;
#clock: Clock;
#policy: RecoveryPolicy;
#sessionId: string | null = null;
#lastCheckpointAtMs: number | null = null;
constructor(
files: FileHost,
paths: RecoveryPaths,
clock: Clock,
policy: RecoveryPolicy,
) {
this.#files = files;
this.#paths = paths;
this.#clock = clock;
this.#policy = policy;
}
get sessionId(): string | null {
return this.#sessionId;
}
get lastCheckpointAtMs(): number | null {
return this.#lastCheckpointAtMs;
}
beginSession(sessionId: string): void {
if (sessionId.length === 0) {
throw new Error("session id must not be empty");
}
this.#files.write(this.#paths.dirty, sessionId, { sync: true });
this.#sessionId = sessionId;
this.#lastCheckpointAtMs = null;
}
checkpoint(seq: number, payload: string, force = false): RecordEnvelope | null {
if (this.#sessionId === null) {
throw new Error("beginSession must be called before checkpoint");
}
if (!Number.isInteger(seq) || seq < 0) {
throw new RangeError(`checkpoint seq must be a non-negative integer: ${seq}`);
}
const now = this.#clock.nowMs();
if (
!force &&
this.#lastCheckpointAtMs !== null &&
now - this.#lastCheckpointAtMs < this.#policy.checkpointEveryMs
) {
return null;
}
const envelope = sealRecord({
version: 1,
sessionId: this.#sessionId,
seq,
writtenAtMs: now,
payload,
});
this.#files.write(this.#paths.temp, serializeRecord(envelope), { sync: true });
this.#files.rename(this.#paths.temp, this.#paths.primary);
this.#lastCheckpointAtMs = now;
return envelope;
}
markCleanExit(): void {
this.#files.remove(this.#paths.dirty);
this.#sessionId = null;
this.#lastCheckpointAtMs = null;
}
inspect(): RecoveryDecision {
this.discardTemp();
const dirty = this.#files.read(this.#paths.dirty);
if (dirty === null) {
return { kind: "clean" };
}
const raw = this.#files.read(this.#paths.primary);
if (raw === null) {
return { kind: "lost", reason: "dirty marker without checkpoint" };
}
const record = parseRecord(raw);
if (record === null) {
return { kind: "lost", reason: "checkpoint failed checksum validation" };
}
if (record.sessionId !== dirty) {
return { kind: "lost", reason: "checkpoint belongs to another session" };
}
return {
kind: "recover",
record,
policy: this.#policy,
prompt: promptFor(this.#policy),
};
}
discardTemp(): boolean {
if (!this.#files.exists(this.#paths.temp)) {
return false;
}
this.#files.remove(this.#paths.temp);
return true;
}
discardRecord(): void {
this.#files.remove(this.#paths.temp);
this.#files.remove(this.#paths.primary);
this.#files.remove(this.#paths.dirty);
this.#sessionId = null;
this.#lastCheckpointAtMs = null;
}
}
配套测试 test/runtime/recovery.test.ts 覆盖十条路径:干净启动、检查点跨进程恢复、干净退出抑制提示、临时文件写入后 rename 前掉电、记录损坏、脏标记无检查点、跨会话记录、检查点节流与强制写、校验和篡改,以及两个案例的策略差异。运行命令是 node --test test/runtime/recovery.test.ts。
这里是模型,不是设备实测;真实设备的 fsync 语义、目录项持久化、闪存掉电行为和文件系统挂载参数都会影响原子性;FileHost 只保证「调用顺序正确」,不保证「硬件一定按顺序持久化」。上线前要在目标机型上做掉电与强杀验证。
双案例:一局回放与一场战斗的兜底
消消的兜底目标是「最多丢一局」:检查点密集、记录小、自动恢复,玩家回来点一下就能继续,服务端只做最终校验。
方舟的兜底目标是「不把错误战斗带进结算」:检查点记录战斗令牌,恢复时必须回大厅让服务端确认,宁可放弃本地表现状态,也不让客户端单方面续战。
两者共用同一套原子写与脏标记,差异只在检查点频率、恢复提示和权威归属。把这三项做成策略,记录器本身不需要知道产品名。
与相邻章节的关系
4.1 定义了生命周期状态,4.2 定义了恢复路线,本节补齐最坏情况:状态机收不到 destroy 时,靠脏标记与检查点判断上次是否正常结束。第 11 章会把存档结构、迁移与反作弊展开;本节只处理「记录是否完整、是否该提示恢复」。
小结
- 进程死亡通常没有回调,异常退出的兜底必须依赖提前落盘的检查点,而不是退出时的异步保存。
- 脏标记回答「上次是否正常结束」;它必须与检查点使用同一个 sessionId,否则会误认上一局。
- 原子写的顺序是写临时文件、同步刷盘、rename 覆盖主文件;只有 rename 成功才算提交。
- 校验和让损坏记录变成「丢失」,而不是被反序列化后继续运行。
- 消消可以自动续局,方舟必须回大厅由服务端校验;差异应写成策略,不写成产品名分支。
阅读导航:上一节:4.2 状态保存与恢复:快照与操作日志 · 下一节:5.1 配置 schema 与版本化 。
常见错误与边界
- 在
onDestroy或willTerminate里做唯一一次保存。这两条路径在强杀和后台回收时都不保证执行;保存必须前移到检查点。 - 直接覆盖主文件,或把临时文件当成已提交数据。写到一半掉电会丢旧状态;rename 未完成的数据也不应被恢复。
- 只校验 JSON 能解析,或脏标记与检查点不比对 sessionId。半截数据可能解析成功,上一局记录也可能被误认成本局;必须校验版本、校验和与会话号。
- 让方舟客户端自行续战。强联网战斗的权威在服务端;本地记录只能作为表现缓存,恢复前必须校验令牌。
- 边界:本节不实现真实文件系统与崩溃上报。
FileHost是模型接口,掉电、fsync 与闪存行为需要真机验证。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。