事故:七分钟后台之后,棋盘少了两步
《潮汐方舟》的一次版本回归里,测试同学把游戏切到后台七分钟,回来继续战斗,发现最后一个回合的伤害没有结算。日志显示,客户端在进入后台时保存过一次快照,但快照之后还有两次操作只存在于内存里;进程被系统回收后,这两步随内存一起消失。
另一条路线看似更稳:每次操作都写日志,回来逐条重放。但同一个版本在低端机上又出现了另一个问题:一场 12 分钟的战斗积累了三百多条操作,恢复时逐条重放,首屏黑屏时间明显变长。两条路线都不是错的,错在用同一条路线服务两种时长的对局。
快照与操作日志的区别不是「哪种更先进」,而是成本随什么增长:快照的写入与读取成本随状态大小增长,操作日志的重放成本随会话时长增长。选择判据必须把这两个增长项放进同一张表。
两条路线:快照与操作日志
| 维度 | 快照 | 操作日志 |
|---|---|---|
| 保存内容 | 完整状态的一份副本 | 每次状态变更的记录 |
| 写入成本 | 随状态大小增长 | 随操作次数增长,单次很小 |
| 恢复成本 | 一次读取加反序列化 | 从头或从检查点逐条重放 |
| 崩溃覆盖 | 只覆盖最后一次成功快照 | 覆盖每条已落盘操作 |
| 主要失败模式 | 快照之后的操作丢失 | 日志过长、重放超时 |
| 合并难度 | 两份快照需要状态级合并 | 令牌可按操作 id 去重合并 |
快照适合状态大、操作稀疏、后台时间短的场景;操作日志适合状态小、操作频繁、需要精确恢复的场景。真实产品通常不是二选一,而是「周期性快照 + 快照之后的尾日志」。本节把纯日志与「快照加尾日志」作为两条可选路线,而不是把快照当成唯一答案。
跨设备的云存档可以参考本地存档 与云存档 ,离线优先的缓存策略可以参考离线优先缓存 。这些章节讨论存储位置与同步,本节只讨论恢复路线的成本判据。
判据:先算回放预算,再选路线
设全量日志的重放成本为 baseRestoreMs + operations × perOperationMs,快照恢复成本为 snapshotRestoreMs。如果全量日志不超过快照成本,就选操作日志:它更精确,且天然覆盖崩溃;如果超过,就选快照加尾日志,把重放量压到「最后一个检查点之后」。
| 输入 | 消消 | 方舟 |
|---|---|---|
| 单局 / 单场时长(案例设定) | 90 秒 | 8–12 分钟 |
| 操作次数(实验参数 24 / 30 次每分钟) | 36 | 240–360 |
| 全量日志回放(实验参数) | 112ms | 520–760ms |
| 快照恢复(实验参数) | 120ms | 120ms |
| 选择 | 操作日志 | 快照加尾日志 |
| 恢复上限 | 112ms | 120ms |
表中的操作密度、单次回放成本和快照成本是 mga-lab 的比较参数,不是案例事实;案例事实表只提供单局时长。真实项目应把埋点得到的操作频率、状态大小和低端机读盘速度代进去,判据不变。
判据的关键不是「90 秒一定用日志、8 分钟一定用快照」,而是「全量重放是否超过快照成本」。一个 90 秒但操作极密的产品,同样可能落到快照加尾日志;一个 10 分钟但操作稀疏的产品,也可能全量日志更便宜。产品名不是判据,预算才是。
可合并的恢复令牌
后台恢复经常遇到两个写者:本地日志和云端日志。玩家可能在两台设备上各玩一段,或者本地先写、云端后同步。恢复令牌必须可合并,而且要能识别「同一操作被重复提交」与「同一位置被两个不同操作占用」。
| 情况 | 判据 | 处理 |
|---|---|---|
| 同一 opId、同一负载 | 重复提交或本地与云端重叠 | 保留一份,计入 duplicates |
| 同一 opId、不同负载 | 幂等键被复用或状态被改写 | payload-mismatch,拒绝合并 |
| 同一 seq、不同 opId | 两个写者各自递增 | seq-collision,拒绝合并 |
| sessionId 不同 | 跨局或跨账号 | session-mismatch,拒绝合并 |
| seq 乱序但无冲突 | 网络重传或批量上传 | 按 seq 排序后重放 |
合并只做三件事:按 opId 去重、按 seq 检测冲突、按 seq 排序。它不判断「哪个操作更正确」,也不做业务合并;一旦出现负载不一致或序号冲突,就把冲突交给上层,而不是猜一个赢家。这条边界很重要:恢复令牌是传输与去重结构,不是业务仲裁器。
幂等重放与快照尾日志
重放的核心是 applied 集合:每个操作在执行前检查 opId 是否已经出现过,出现过就跳过。这样同一份日志重放两次,状态不会翻倍;本地与云端令牌合并后,重叠部分也只执行一次。
快照加尾日志则多一个 watermarkSeq:快照记录「已经包含到哪个序号」,恢复时只重放 seq > watermarkSeq 的尾日志。快照里的 applied 集合仍然保留,用来防止尾日志与快照重叠时重复执行。两个机制叠加,才能同时处理「快照之后的崩溃」与「尾日志被重复上传」。
顺序也不能假设。网络重传、批量上传、多写者合并都可能让日志乱序到达;重放前按 seq 排序,冲突在合并阶段已经拒绝,排序只是让合法日志按确定顺序执行。测试里专门放了一条乱序日志,断言最终分数与顺序执行一致。
可运行模型:resume.ts
下面的 resume.ts 把路线选择、令牌合并、幂等重放与快照尾日志放在同一个文件里。它不读写文件,也不访问网络;存储与同步由外层注入。
// file: src/runtime/resume.ts
// tier: L2
// verify: node --test test/runtime/resume.test.ts
import { fail, ok, type Result } from "../core/result.ts";
export interface SessionProfile { readonly name: string; readonly secondsMin: number; readonly secondsMax: number; readonly actionsPerMinute: number; }
export const MATCH3_SESSION: SessionProfile = { name: "潮汐消消", secondsMin: 90, secondsMax: 90, actionsPerMinute: 24 };
export const ARK_SESSION: SessionProfile = { name: "潮汐方舟", secondsMin: 480, secondsMax: 720, actionsPerMinute: 30 };
export interface RestorePolicy { readonly baseRestoreMs: number; readonly perOperationMs: number; readonly snapshotRestoreMs: number; readonly checkpointEveryMs: number; }
export const DEFAULT_RESTORE_POLICY: RestorePolicy = {
baseRestoreMs: 40,
perOperationMs: 2,
snapshotRestoreMs: 120,
checkpointEveryMs: 20_000,
};
export type RestoreStrategy = "operation-log" | "snapshot-plus-log";
export interface RestorePlan { readonly strategy: RestoreStrategy; readonly replayMs: number; readonly checkpointMs: number; readonly reason: string; }
export interface OperationCount { readonly min: number; readonly max: number; }
export function operationCount(profile: SessionProfile): OperationCount {
const perSecond = profile.actionsPerMinute / 60;
return {
min: Math.ceil(profile.secondsMin * perSecond),
max: Math.ceil(profile.secondsMax * perSecond),
};
}
export function chooseRestorePlan(
profile: SessionProfile,
policy: RestorePolicy = DEFAULT_RESTORE_POLICY,
): RestorePlan {
const counts = operationCount(profile);
const fullLogMs = policy.baseRestoreMs + counts.max * policy.perOperationMs;
if (fullLogMs <= policy.snapshotRestoreMs) {
return {
strategy: "operation-log",
replayMs: fullLogMs,
checkpointMs: 0,
reason: `全量回放 ${counts.max} 次操作用 ${fullLogMs}ms,不超过快照恢复 ${policy.snapshotRestoreMs}ms`,
};
}
const tailOps = Math.ceil((policy.checkpointEveryMs / 1_000) * (profile.actionsPerMinute / 60));
const tailMs = policy.baseRestoreMs + tailOps * policy.perOperationMs;
return {
strategy: "snapshot-plus-log",
replayMs: Math.max(policy.snapshotRestoreMs, tailMs),
checkpointMs: policy.checkpointEveryMs,
reason: `全量回放 ${counts.max} 次操作用 ${fullLogMs}ms,超过快照恢复 ${policy.snapshotRestoreMs}ms,改为快照加尾日志`,
};
}
export type OperationKind = "score" | "move" | "combo";
export interface OperationRecord { readonly seq: number; readonly opId: string; readonly kind: OperationKind; readonly amount: number; }
export interface ScoreState { readonly score: number; readonly moves: number; readonly applied: ReadonlySet<string>; }
export const EMPTY_SCORE: ScoreState = { score: 0, moves: 0, applied: new Set<string>() };
export interface ApplyOutcome {
readonly state: ScoreState;
readonly applied: boolean;
}
export function applyOperation(state: ScoreState, op: OperationRecord): ApplyOutcome {
if (state.applied.has(op.opId)) {
return { state, applied: false };
}
if (!Number.isInteger(op.amount) || op.amount < 0) {
throw new RangeError(`operation amount must be a non-negative integer: ${op.amount}`);
}
const applied = new Set(state.applied);
applied.add(op.opId);
switch (op.kind) {
case "score":
return { state: { score: state.score + op.amount, moves: state.moves, applied }, applied: true };
case "move":
return { state: { score: state.score, moves: state.moves + 1, applied }, applied: true };
case "combo":
return { state: { score: state.score + op.amount, moves: state.moves + 1, applied }, applied: true };
default:
throw new Error(`unknown operation kind: ${String(op.kind)}`);
}
}
export interface ReplayResult { readonly state: ScoreState; readonly applied: number; readonly skipped: number; }
export function replayOperations(state: ScoreState, ops: readonly OperationRecord[]): ReplayResult {
const ordered = [...ops].sort((a, b) => a.seq - b.seq || a.opId.localeCompare(b.opId));
let current = state;
let applied = 0;
let skipped = 0;
for (const op of ordered) {
const outcome = applyOperation(current, op);
current = outcome.state;
if (outcome.applied) {
applied += 1;
} else {
skipped += 1;
}
}
return { state: current, applied, skipped };
}
export interface ResumeToken { readonly sessionId: string; readonly baseSeq: number; readonly ops: readonly OperationRecord[]; }
export type MergeConflictReason = "session-mismatch" | "payload-mismatch" | "seq-collision";
export interface MergeConflict {
readonly opId: string;
readonly reason: MergeConflictReason;
}
export interface MergeOutcome {
readonly token: ResumeToken;
readonly duplicates: number;
}
export function mergeResumeTokens(
a: ResumeToken,
b: ResumeToken,
): Result<MergeOutcome, readonly MergeConflict[]> {
if (a.sessionId !== b.sessionId) {
return fail([{ opId: "", reason: "session-mismatch" }], `${a.sessionId} != ${b.sessionId}`);
}
const byId = new Map<string, OperationRecord>();
const bySeq = new Map<number, string>();
const conflicts: MergeConflict[] = [];
let duplicates = 0;
for (const op of [...a.ops, ...b.ops]) {
const existing = byId.get(op.opId);
if (existing) {
if (existing.seq !== op.seq || existing.kind !== op.kind || existing.amount !== op.amount) {
conflicts.push({ opId: op.opId, reason: "payload-mismatch" });
} else {
duplicates += 1;
}
continue;
}
const seqOwner = bySeq.get(op.seq);
if (seqOwner !== undefined && seqOwner !== op.opId) {
conflicts.push({ opId: op.opId, reason: "seq-collision" });
continue;
}
byId.set(op.opId, op);
bySeq.set(op.seq, op.opId);
}
if (conflicts.length > 0) {
return fail(conflicts, `${conflicts.length} conflict(s)`);
}
const ops = [...byId.values()].sort((x, y) => x.seq - y.seq || x.opId.localeCompare(y.opId));
return ok({
token: { sessionId: a.sessionId, baseSeq: Math.min(a.baseSeq, b.baseSeq), ops },
duplicates,
});
}
export interface Snapshot { readonly version: 1; readonly score: number; readonly moves: number; readonly applied: readonly string[]; readonly watermarkSeq: number; }
export function takeSnapshot(state: ScoreState, watermarkSeq: number): Snapshot {
return {
version: 1,
score: state.score,
moves: state.moves,
applied: [...state.applied].sort(),
watermarkSeq,
};
}
export function restoreSnapshot(snapshot: Snapshot, tail: readonly OperationRecord[]): ReplayResult {
if (snapshot.version !== 1) {
throw new RangeError(`unsupported snapshot version: ${String(snapshot.version)}`);
}
const base: ScoreState = {
score: snapshot.score,
moves: snapshot.moves,
applied: new Set(snapshot.applied),
};
return replayOperations(base, tail.filter((op) => op.seq > snapshot.watermarkSeq));
}
export class ResumeJournal {
#sessionId: string;
#baseSeq: number;
#nextSeq: number;
#ops: OperationRecord[] = [];
#state: ScoreState = EMPTY_SCORE;
constructor(sessionId: string, baseSeq = 0) {
this.#sessionId = sessionId;
this.#baseSeq = baseSeq;
this.#nextSeq = baseSeq;
}
get state(): ScoreState {
return this.#state;
}
get size(): number {
return this.#ops.length;
}
append(kind: OperationKind, amount: number, opId?: string): OperationRecord {
const seq = this.#nextSeq;
const record: OperationRecord = { seq, opId: opId ?? `${this.#sessionId}:${seq}`, kind, amount };
const outcome = applyOperation(this.#state, record);
if (!outcome.applied) {
throw new Error(`duplicate operation id: ${record.opId}`);
}
this.#state = outcome.state;
this.#ops.push(record);
this.#nextSeq = seq + 1;
return record;
}
token(): ResumeToken {
return { sessionId: this.#sessionId, baseSeq: this.#baseSeq, ops: [...this.#ops] };
}
restore(other: ResumeToken): Result<ReplayResult, readonly MergeConflict[]> {
const merged = mergeResumeTokens(this.token(), other);
if (!merged.ok) {
return fail(merged.error, merged.reason);
}
const replay = replayOperations(EMPTY_SCORE, merged.value.token.ops);
this.#ops = [...merged.value.token.ops];
this.#state = replay.state;
this.#baseSeq = merged.value.token.baseSeq;
const last = this.#ops[this.#ops.length - 1];
this.#nextSeq = last ? last.seq + 1 : this.#baseSeq;
return ok(replay);
}
}
配套测试 test/runtime/resume.test.ts 覆盖九条路径:两条案例的路线选择、密集操作产品的路线翻转、令牌合并去重、三类冲突、乱序与重复重放、快照加尾日志、日志恢复后的序号续接,以及重复 opId 与非法数值的拒绝。运行命令是 node --test test/runtime/resume.test.ts。
这里是模型,不是设备实测;真实的快照序列化、磁盘吞吐和网络重传延迟都要在目标机型上测;模型只保证「给定参数,选择与重放结果确定」。
双案例:90 秒全量日志,8–12 分钟快照加尾
消消的 90 秒单局只有三十多次操作,全量回放低于快照成本,选操作日志;崩溃后最多重放一局,逻辑简单,恢复时间可控。
方舟的 8–12 分钟战斗会积累两三百次操作,全量回放超过快照成本,选快照加尾日志;尾日志只保留最后一个检查点之后的部分,恢复上限由检查点间隔决定。
两者的共同点是令牌结构与幂等重放完全相同;不同点只在检查点间隔与是否需要服务端校验。把差异收敛到策略参数,恢复逻辑就不必为两个产品各写一套。
与相邻章节的关系
4.1 的状态机负责判断「现在是冷启动还是热启动」,本节把这个判断变成恢复路线的输入。4.3 会处理最坏情况:进程在两次检查点之间被杀死,连日志都没写完,此时只能依赖脏标记与原子写。存档结构迁移可以参考存档结构迁移 ,冲突队列可以参考冲突同步队列 。
小结
- 快照成本随状态大小增长,操作日志成本随会话时长增长;选择判据是回放预算,不是产品名。
- 90 秒单局在本节参数下选操作日志,8–12 分钟战斗选快照加尾日志;参数变化时结论可以翻转。
- 可合并令牌按 opId 去重、按 seq 检测冲突、按 seq 排序;负载不一致与序号冲突必须显式拒绝。
- 幂等重放依赖
applied集合,快照尾日志依赖watermarkSeq;两者叠加才能覆盖重复上传与快照后崩溃。 - 两个案例共用令牌与重放逻辑,只在检查点间隔和服务端校验权限上不同。
阅读导航:上一节:4.1 冷启动、热启动与后台挂起 · 下一节:4.3 杀进程与异常退出的兜底 。
常见错误与边界
- 把「操作日志」当成「每次操作都同步落盘」。同步写会拖慢帧;应先写内存队列,再按检查点批量落盘,崩溃时接受最后一个检查点之后的丢失。
- 只按 opId 去重,不检查负载。同一个 opId 被复用于不同操作时,静默去重会丢掉一次真实变更;必须报 payload-mismatch。
- 合并时直接覆盖 seq。两个写者各自递增会产生 seq-collision;覆盖会让其中一个操作永远无法重放。
- 快照与尾日志都保留 applied,却不设 watermark。恢复时会把快照已包含的操作再执行一次,分数翻倍。
- 用产品名硬编码路线。90 秒与 8 分钟只是案例输入;操作密度变化后,同一产品也应该切换路线。
- 边界:本节不处理服务端权威裁决。方舟的战斗令牌必须由服务端校验,客户端合并只解决传输去重,不解决「哪次伤害有效」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。