事故:回滚包把新字段写没了
《潮汐方舟》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是模型接口,原子性结论要在目标平台验证。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。