《移动游戏产品的架构·客户端与移动运行时》10.1 存档格式与迁移

《潮汐方舟》回滚包把新字段写没后,赛季进度无法恢复。本节把存档拆成信封、schema、迁移链与向前兼容窗口四层:格式不对拒绝读,字段不对拒绝写,迁移断链停在断点,校验和不通过回退备份,并给出可运行的 SaveStore 模型与 13 条测试。

事故:回滚包把新字段写没了

《潮汐方舟》2.6 赛季版本给存档加了 seasonPass 与 seasonPoints。灰度发现结算异常,值班按流程回滚到 2.5 包。旧客户端启动后读到 2.6 存档,按旧 schema 反序列化、忽略不认识的字段,下一次自动保存又把整份档写回旧格式。玩家在 2.6 里打出的赛季进度被静默抹掉,备份槽也在第二次保存时被覆盖。事故报告里最刺眼的一句是:「我们以为旧包只是不显示新字段,没想到它会删掉。」

结论:存档不是把对象 JSON.stringify 一下。 它需要版本、schema、迁移链、兼容窗口和可回退的写入路径。

信封、schema 与校验和

一份可演进的存档由四层组成:

  • 信封:magic、formatVersion、appVersion、savedAtMs、revision、playTimeMs。
  • schema:每个格式版本要求哪些字段、字段类型与数值区间。
  • 迁移链:v1→v2→v3 的逐版本函数,不允许跳版。
  • 校验和:对固定顺序序列化的信封求 FNV-1a,挡住截断与半写。

校验和只回答「文件是否完整」,不回答「是否有人故意改过」。后者需要签名,放到 10.3。这个边界要写进注释,否则团队会把 checksum 当安全机制。

schema 的校验策略是整份拒绝:必填字段缺失、整数越界、列表元素类型不对,都返回错误,调用方保留上一份可用存档。未知字段不报错,因为新版本写入的字段可能被旧版本读到;但旧版本也不应该写回。

迁移链:逐版本、可验证、可回退

迁移不是「读旧字段、拼新对象」的一次性脚本,而是一条有向链:

  • 每一步只处理 n→n+1,声明 from 与 to。
  • 链必须连续;缺少任一步返回 migration-gap,不尝试猜。
  • 每步执行后由目标版本 schema 校验;任何一步抛错返回 migration-failed,原文件保持不动。
  • 迁移在内存中完成,只有写入成功才落盘;失败时用户仍能回到旧档。

常见迁移类型是字段改名、新增字段给默认值、拆分复合字段、删除字段前先归档。删除最危险:旧客户端仍可能写回,所以删除字段必须配合向前兼容窗口,而不是只改 schema。

向前兼容窗口

存档格式与当前客户端的关系行为
< minReadableFormat太旧,已停止支持拒绝读取
minReadableFormat .. currentFormat-1可迁移迁移后读写
currentFormat匹配正常读写
currentFormat+1 .. maxForwardFormat更新,字段未知只读,禁止回写
> maxForwardFormat未知,无法保证语义拒绝读取

窗口有三个边界:minReadableFormat(再旧就拒绝)、currentFormat、maxForwardFormat(再新就拒绝)。读到比当前新的格式时,客户端进入只读模式:可以展示已知字段、可以触发升级提示,但禁止回写。模型里的 formatFloor 记录磁盘上见过的最高格式,一旦抬高,write 直接返回 future-version,直到客户端升级。这正是回滚包事故的补丁:旧包不再有资格覆盖新档。

可运行模型:store.ts

模型把信封、schema、校验和、迁移链、只读窗口、临时文件与备份槽放在一个文件里。FileHost 是平台文件接口,测试用内存实现替换;生产环境应把 write(..., { sync: true }) 映射到 fsync。

// file: src/save/store.ts
// tier: L1
// verify: node --test test/save/store.test.ts

import { fail, ok, type Result } from "../core/result.ts";

export type SaveFormatError = "corrupt" | "checksum-mismatch" | "schema-invalid"
  | "unsupported-version" | "future-version" | "migration-gap" | "migration-failed"
  | "invalid-meta" | "write-failed";
export type SaveValue = string | number | boolean | readonly string[];
export type SavePayload = Readonly<Record<string, SaveValue>>;

export interface SaveSchema {
  readonly formatVersion: number;
  readonly required: readonly string[]; readonly ints: readonly string[];
  readonly strings: readonly string[]; readonly lists: readonly string[];
  readonly ranges?: Readonly<Record<string, readonly [number, number]>>;
}

export interface SaveBody {
  readonly magic: "MGA1"; readonly formatVersion: number; readonly appVersion: string;
  readonly savedAtMs: number; readonly revision: number; readonly playTimeMs: number;
  readonly payload: SavePayload;
}

export interface SaveEnvelope extends SaveBody { readonly signature: string | null; readonly checksum: string }

export function signingBody(body: SaveBody): string {
  const { magic, formatVersion, appVersion, savedAtMs, revision, playTimeMs, payload } = body;
  return JSON.stringify([magic, formatVersion, appVersion, savedAtMs, revision, playTimeMs, payload]);
}

export function fnv1a(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 checksumOf(body: SaveBody, signature: string | null): string {
  return fnv1a(`${signingBody(body)}|${signature ?? ""}`);
}

export function sealEnvelope(body: SaveBody, signature: string | null = null): SaveEnvelope {
  return { ...body, signature, checksum: checksumOf(body, signature) };
}

export function serializeEnvelope(envelope: SaveEnvelope): string {
  return `${JSON.stringify(envelope)}\n`;
}

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function badNumber(value: unknown, integer = false): boolean {
  return typeof value !== "number" || !Number.isFinite(value) || value < 0
    || (integer && !Number.isInteger(value));
}

export function parseEnvelope(text: string): Result<SaveEnvelope, SaveFormatError> {
  let raw: unknown;
  try {
    raw = JSON.parse(text);
  } catch {
    return fail("corrupt", "save file is not valid JSON");
  }
  if (!isObject(raw)) {
    return fail("corrupt", "save root must be an object");
  }
  const { magic, formatVersion, appVersion, savedAtMs, revision, playTimeMs } = raw;
  const { payload, signature, checksum } = raw;
  const invalid = magic !== "MGA1"
    || badNumber(formatVersion, true) || (formatVersion as number) < 1
    || typeof appVersion !== "string" || appVersion.length === 0
    || badNumber(savedAtMs) || badNumber(revision, true) || badNumber(playTimeMs)
    || !isObject(payload) || !(signature === null || typeof signature === "string")
    || typeof checksum !== "string";
  if (invalid) {
    return fail("corrupt", "envelope shape is invalid");
  }
  const body: SaveBody = {
    magic: "MGA1", formatVersion: formatVersion as number, appVersion: appVersion as string,
    savedAtMs: savedAtMs as number, revision: revision as number,
    playTimeMs: playTimeMs as number, payload: payload as SavePayload,
  };
  return checksumOf(body, signature as string | null) === checksum
    ? ok({ ...body, signature: signature as string | null, checksum: checksum as string })
    : fail("checksum-mismatch", "checksum does not match the serialized body");
}

export function validatePayload(schema: SaveSchema, payload: SavePayload): readonly string[] {
  const errors: string[] = [];
  for (const name of schema.required) {
    if (!(name in payload)) errors.push(`${name} is required`);
  }
  for (const name of schema.ints) {
    const value = payload[name];
    const range = schema.ranges?.[name] ?? [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER];
    if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value)
      || value < range[0] || value > range[1])) errors.push(`${name} out of range`);
  }
  for (const name of schema.strings) {
    if (payload[name] !== undefined && typeof payload[name] !== "string") errors.push(`${name} not a string`);
  }
  for (const name of schema.lists) {
    const value = payload[name];
    if (value !== undefined && (!Array.isArray(value) || !value.every((item) => typeof item === "string"))) {
      errors.push(`${name} not a string list`);
    }
  }
  return errors;
}

export interface Migration {
  readonly from: number; readonly to: number;
  readonly apply: (payload: SavePayload) => SavePayload;
}

export function migratePayload(
  payload: SavePayload,
  fromVersion: number,
  toVersion: number,
  migrations: readonly Migration[],
  schemas: ReadonlyMap<number, SaveSchema>,
): Result<{ readonly payload: SavePayload; readonly applied: readonly number[] }, SaveFormatError> {
  if (fromVersion > toVersion) {
    return fail("unsupported-version", `cannot downgrade ${fromVersion} to ${toVersion}`);
  }
  let current = payload;
  let cursor = fromVersion;
  const applied: number[] = [];
  while (cursor < toVersion) {
    const step = migrations.find((candidate) => candidate.from === cursor);
    if (step === undefined || step.to !== cursor + 1) {
      return fail("migration-gap", `no contiguous migration from format ${cursor}`);
    }
    try {
      current = step.apply(current);
    } catch (error) {
      const reason = error instanceof Error ? error.message : String(error);
      return fail("migration-failed", `migration ${cursor}->${step.to}: ${reason}`);
    }
    applied.push(step.to);
    cursor = step.to;
  }
  const schema = schemas.get(toVersion);
  if (schema === undefined) {
    return fail("unsupported-version", `no schema registered for format ${toVersion}`);
  }
  const errors = validatePayload(schema, current);
  return errors.length === 0
    ? ok({ payload: current, applied })
    : fail("schema-invalid", errors.join("; "));
}

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;
}

export type SavePaths = Readonly<{ primary: string; temp: string; backup: string }>;

export type ReadWindow = Readonly<{
  minReadableFormat: number; currentFormat: number; maxForwardFormat: number;
}>;

export type WriteMeta = Readonly<{
  appVersion: string; savedAtMs: number; revision: number;
  playTimeMs: number; signature?: string | null;
}>;

export type ReadOutcome = Readonly<{
  envelope: SaveEnvelope; payload: SavePayload; source: "primary" | "backup";
  readOnly: boolean; applied: readonly number[];
}>;

export class SaveStore {
  readonly #files: FileHost;
  readonly #paths: SavePaths;
  readonly #window: ReadWindow;
  readonly #schemas = new Map<number, SaveSchema>();
  readonly #migrations: readonly Migration[];
  formatFloor = 0;

  constructor(files: FileHost, paths: SavePaths, window: ReadWindow,
    schemas: readonly SaveSchema[], migrations: readonly Migration[] = []) {
    this.#files = files;
    this.#paths = paths;
    this.#window = window;
    this.#migrations = [...migrations];
    for (const schema of schemas) {
      this.#schemas.set(schema.formatVersion, schema);
    }
    if (!this.#schemas.has(window.currentFormat)) {
      throw new Error(`current schema ${window.currentFormat} is not registered`);
    }
  }

  read(): Result<ReadOutcome, SaveFormatError> {
    if (this.#files.read(this.#paths.temp) !== null) {
      this.#files.remove(this.#paths.temp);
    }
    let firstError: { readonly error: SaveFormatError; readonly reason: string } | null = null;
    for (const source of ["primary", "backup"] as const) {
      const text = this.#files.read(source === "primary" ? this.#paths.primary : this.#paths.backup);
      if (text === null) {
        continue;
      }
      const parsed = parseEnvelope(text);
      if (!parsed.ok) {
        firstError ??= { error: parsed.error, reason: parsed.reason };
        continue;
      }
      if (parsed.value.formatVersion > this.#window.currentFormat) {
        this.formatFloor = Math.max(this.formatFloor, parsed.value.formatVersion);
      }
      const loaded = this.#load(parsed.value, source);
      if (loaded.ok) {
        return loaded;
      }
      firstError ??= { error: loaded.error, reason: loaded.reason };
    }
    return firstError === null
      ? fail("corrupt", "no save file found")
      : fail(firstError.error, firstError.reason);
  }

  #load(envelope: SaveEnvelope, source: "primary" | "backup"):
    Result<ReadOutcome, SaveFormatError> {
    const version = envelope.formatVersion;
    if (version < this.#window.minReadableFormat) {
      return fail("unsupported-version", `format ${version} below ${this.#window.minReadableFormat}`);
    }
    if (version > this.#window.maxForwardFormat) {
      return fail("future-version", `format ${version} beyond ${this.#window.maxForwardFormat}`);
    }
    if (version > this.#window.currentFormat) {
      return ok({ envelope, payload: envelope.payload, source, readOnly: true, applied: [] });
    }
    const current = this.#schemas.get(this.#window.currentFormat) as SaveSchema;
    if (version === this.#window.currentFormat) {
      const errors = validatePayload(current, envelope.payload);
      return errors.length === 0
        ? ok({ envelope, payload: envelope.payload, source, readOnly: false, applied: [] })
        : fail("schema-invalid", errors.join("; "));
    }
    const migrated = migratePayload(envelope.payload, version,
      this.#window.currentFormat, this.#migrations, this.#schemas);
    if (!migrated.ok) {
      return migrated;
    }
    return ok({
      envelope, payload: migrated.value.payload, source,
      readOnly: false, applied: migrated.value.applied,
    });
  }

  write(payload: SavePayload, meta: WriteMeta): Result<SaveEnvelope, SaveFormatError> {
    if (this.formatFloor > this.#window.currentFormat) {
      return fail("future-version", `format ${this.formatFloor} already on disk; upgrade first`);
    }
    const validMeta = meta.appVersion.length > 0
      && Number.isInteger(meta.revision) && meta.revision >= 0
      && Number.isFinite(meta.savedAtMs) && meta.savedAtMs >= 0
      && Number.isFinite(meta.playTimeMs) && meta.playTimeMs >= 0;
    if (!validMeta) {
      return fail("invalid-meta", "appVersion, revision, savedAtMs or playTimeMs is invalid");
    }
    const schema = this.#schemas.get(this.#window.currentFormat) as SaveSchema;
    const errors = validatePayload(schema, payload);
    if (errors.length > 0) {
      return fail("schema-invalid", errors.join("; "));
    }
    const envelope = sealEnvelope({
      magic: "MGA1", formatVersion: schema.formatVersion, appVersion: meta.appVersion,
      savedAtMs: meta.savedAtMs, revision: meta.revision,
      playTimeMs: meta.playTimeMs, payload,
    }, meta.signature ?? null);
    try {
      const existing = this.#files.read(this.#paths.primary);
      if (existing !== null && parseEnvelope(existing).ok) {
        this.#files.write(this.#paths.backup, existing, { sync: true });
      }
      this.#files.write(this.#paths.temp, serializeEnvelope(envelope), { sync: true });
      this.#files.rename(this.#paths.temp, this.#paths.primary);
    } catch (error) {
      return fail("write-failed", error instanceof Error ? error.message : String(error));
    }
    return ok(envelope);
  }
}

测试 test/save/store.test.ts 覆盖 13 条路径:往返、篡改回退备份、schema 拒绝、rename 失败保留旧档、v1→v2 迁移、迁移断链、迁移抛错、向前只读与写锁、超窗拒绝、低于下限拒绝、未知字段保留、损坏主档替换、非法元数据。命令:node --test test/save/store.test.ts。

这里是模型,不是设备实测:FileHost 的原子性依赖平台实现,真机上还要验证掉电、磁盘满与沙盒权限。

双案例:消消的档小,方舟的档重

《潮汐消消》单局 90 秒,存档主要是关卡进度、金币与家园布局,写入频繁但体积小;它适合更短的兼容窗口与更激进的自动迁移,因为回滚成本低。《潮汐方舟》单场 8–12 分钟,存档包含卡组、赛季与战斗历史,且大量字段由服务端结算;它的窗口要更长、迁移要更保守,任何客户端侧删除字段都必须先确认服务端不再读取。

与相邻章节的关系

4.3 的崩溃恢复解决「同一局写到哪里」,本节解决「跨版本怎么读、怎么写」;5.1 的配置 schema 管策划表,本节管玩家档,两者都坚持整份校验。跨书可参考存档结构与迁移 与本地存档 。

小结

  • 存档是带版本的信封,不是业务对象的直接序列化。
  • schema 管字段,迁移链管版本,校验和管完整性,三者缺一不可。
  • 迁移必须逐版本连续;失败时保留原档,不产生半迁移文件。
  • 向前兼容窗口允许读新档,但必须禁止旧客户端回写。
  • 写入用临时文件加 rename,并在覆盖前保留一份可解析的备份。

阅读导航:上一节:9.3 功耗与发热的验收方法 · 下一节:10.2 云存档冲突与合并 。

常见错误与边界

  • 只加字段不改版本号。旧客户端会把新字段当未知字段丢掉,下一次保存就永久丢失;字段变更必须伴随格式版本。
  • 迁移函数直接改原对象。失败时内存里的旧档已被污染,回退也无从谈起;迁移应返回新对象。
  • 把校验和当防篡改。玩家改完 JSON 重算 FNV 就能通过;防篡改要靠签名与服务端权威,见 10.3。
  • 允许旧客户端写新格式。只读窗口不是体验优化,而是数据安全边界;formatFloor 必须阻止回写。
  • 迁移链跳版。v1→v3 的捷径会在中间版本新增必填字段时崩溃;只允许 n→n+1。
  • 备份槽与主槽同一次写入覆盖。先写临时文件、再备份旧主档、最后 rename,顺序错了备份也会丢。
  • 边界:本节不测量真实磁盘掉电与文件系统行为。FileHost 是模型接口,原子性结论要在目标平台验证。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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