《移动游戏产品的架构·客户端与移动运行时》4.2 状态保存与恢复:快照与操作日志

90 秒单局与 8–12 分钟对局不该共用一套恢复策略。本节把快照与操作日志放进同一张成本表,用回放预算而不是产品名选择路线,并实现可合并的恢复令牌、幂等重放、乱序重排与快照加尾日志,最后比较消消与方舟各自的选择、恢复上限、检查点间隔与服务端校验。

事故:七分钟后台之后,棋盘少了两步

《潮汐方舟》的一次版本回归里,测试同学把游戏切到后台七分钟,回来继续战斗,发现最后一个回合的伤害没有结算。日志显示,客户端在进入后台时保存过一次快照,但快照之后还有两次操作只存在于内存里;进程被系统回收后,这两步随内存一起消失。

另一条路线看似更稳:每次操作都写日志,回来逐条重放。但同一个版本在低端机上又出现了另一个问题:一场 12 分钟的战斗积累了三百多条操作,恢复时逐条重放,首屏黑屏时间明显变长。两条路线都不是错的,错在用同一条路线服务两种时长的对局。

快照与操作日志的区别不是「哪种更先进」,而是成本随什么增长:快照的写入与读取成本随状态大小增长,操作日志的重放成本随会话时长增长。选择判据必须把这两个增长项放进同一张表。

两条路线:快照与操作日志

维度快照操作日志
保存内容完整状态的一份副本每次状态变更的记录
写入成本随状态大小增长随操作次数增长,单次很小
恢复成本一次读取加反序列化从头或从检查点逐条重放
崩溃覆盖只覆盖最后一次成功快照覆盖每条已落盘操作
主要失败模式快照之后的操作丢失日志过长、重放超时
合并难度两份快照需要状态级合并令牌可按操作 id 去重合并

快照适合状态大、操作稀疏、后台时间短的场景;操作日志适合状态小、操作频繁、需要精确恢复的场景。真实产品通常不是二选一,而是「周期性快照 + 快照之后的尾日志」。本节把纯日志与「快照加尾日志」作为两条可选路线,而不是把快照当成唯一答案。

跨设备的云存档可以参考本地存档 与云存档 ,离线优先的缓存策略可以参考离线优先缓存 。这些章节讨论存储位置与同步,本节只讨论恢复路线的成本判据。

判据:先算回放预算,再选路线

设全量日志的重放成本为 baseRestoreMs + operations × perOperationMs,快照恢复成本为 snapshotRestoreMs。如果全量日志不超过快照成本,就选操作日志:它更精确,且天然覆盖崩溃;如果超过,就选快照加尾日志,把重放量压到「最后一个检查点之后」。

输入消消方舟
单局 / 单场时长(案例设定)90 秒8–12 分钟
操作次数(实验参数 24 / 30 次每分钟)36240–360
全量日志回放(实验参数)112ms520–760ms
快照恢复(实验参数)120ms120ms
选择操作日志快照加尾日志
恢复上限112ms120ms

表中的操作密度、单次回放成本和快照成本是 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 分钟只是案例输入;操作密度变化后,同一产品也应该切换路线。
  • 边界:本节不处理服务端权威裁决。方舟的战斗令牌必须由服务端校验,客户端合并只解决传输去重,不解决「哪次伤害有效」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查