小程序存档写到一半怎么办?双槽保存与损坏恢复实战

6 阅读6分钟

一个本地保存的小程序,用户花时间种的花、调好的实验参数,最后可能都装在一份 JSON 里。

如果这份文件在覆盖写入时出了问题,下次启动读到的可能只有半段内容。此时如果捕获异常、返回默认状态,再让自动保存继续运行,原来的数据就更难找回了。

给微境沙盘做原生微信端时,我把保存拆成了两个轮换槽位。每次写其中一个,读取时同时检查两份,选择有效的新版本。下面结合 native/client/repository.ts 的实际实现,讲一下保存失败后怎么继续处理。

1. 新版本写另一个槽

以花园存档为例,同一个逻辑档案对应两个文件。

world-garden.0.json
world-garden.1.json

每份记录带有格式版本 version、档案编号 id、保存序号 revision、业务数据 data 和校验值 checksum。version 用于识别格式,revision 在每次成功保存后递增,用来判断哪份更新。

写入槽位按 revision % 2 选择。

保存过程槽 0槽 1启动时选择
首次保存空revision 1,有效revision 1
再保存一次revision 2,有效revision 1,有效revision 2
第三次写入失败revision 2,有效新内容不完整revision 2

第三次写入只影响槽 1,槽 0 仍有上一次有效记录。恢复后可能少掉最后一次未成功保存的修改,但用户还有一个明确的接续点。

当前实现每次从两个槽里读取有效记录,再计算新序号,省去了额外维护“当前槽位指针”的步骤。

2. 写完以后,回读一次再确认

项目把文件访问封装成 FilePort,提供 read、write、list 和 remove。微信端适配到 wx.getFileSystemManager(),文件放在 wx.env.USER_DATA_PATH 下的独立目录。

同步写入失败会抛异常,调用方需要处理。这里还增加了一次写后回读,确认此时读到的内容与准备写入的文本一致。以下是生成并验证好记录之后的写入部分。 微信文件写入文档

const name = `${id}.${revision % 2}.json`;

this.room(name, text);
this.io.write(name, text);

if (this.io.read(name) !== text) {
  throw Error('存档写入不完整,上一份已保留');
}

return record;

room() 在写入前检查文件和目录预算。页面只有在 save() 返回后才更新自己持有的 revision。失败时保留内存中的当前内容,并显示错误,用户可以重试。

回读确认的是当前可读内容。操作系统缓存、设备断电等更底层的持久化行为,需要另外验证。这里把可能发生的单次槽位写坏,转化为下次启动时可检查、可回退的状态。

3. 读取两个槽,先验内容,再比版本

读取时有三种结果需要区分。

  • 两个文件都不存在,返回空结果,由首次使用流程创建档案。
  • 至少一份有效,选择较大的 revision。
  • 找到了文件,但两份都无法通过校验,抛出错误并保留原文件。

下面是项目的 load() 方法。parseRecord() 负责格式、校验值和业务数据校验,validId() 负责档案编号规则。

load(id: string): LoadedRecord | null {
  if (!validId(id)) throw Error('存档编号无效');
  const valid: SavedRecord[] = [];
  let found = 0;
  let bad = false;

  for (const slot of [0, 1]) {
    const raw = this.io.read(`${id}.${slot}.json`);
    if (raw === null) continue;
    found++;

    try {
      const record = parseRecord(raw);
      if (record.id !== id) throw Error('存档归属无效');
      valid.push(record);
    } catch {
      bad = true;
    }
  }

  if (!found) return null;
  if (!valid.length) throw Error('无法读取存档,原文件已保留');

  valid.sort((a, b) => b.revision - a.revision);
  if (
    valid.length === 2 &&
    valid[0].revision === valid[1].revision &&
    JSON.stringify(valid[0]) !== JSON.stringify(valid[1])
  ) {
    throw Error('存档版本冲突,原文件已保留');
  }

  return { record: valid[0], recovered: bad };
}

recovered 表示这次发现了坏槽,同时找到了可以继续使用的有效记录。两个槽具有相同序号、内容却不同,也会作为冲突交给上层处理。

校验分两层。外层检查字段、格式版本、编号和正整数序号,校验值针对 JSON.stringify(data) 计算。内层再由 parseDocument() 检查具体世界类型、模型版本与参数范围。

项目采用一个32位字符串校验值辅助检测意外损坏。它有碰撞可能,覆盖范围是业务数据,身份认证和防篡改需要单独设计。

4. 恢复之后,坏文件还要留一下

假设槽 1 写坏了,当前从槽 0 的 revision 2 恢复。下一次保存 revision 3,还会写槽 1。

正式覆盖前,项目先把坏槽原文另存为恢复副本。副本也要经过容量检查和回读确认,成功后再继续写。这样以后排查问题或尝试人工恢复时,还能拿到当时留下的内容。

两槽都坏时,普通自动保存会停止。用户可以主动导入经过校验的外部备份,程序先逐份保留原始内容和恢复清单,再尝试恢复。

项目还允许调用方传入预期版本。旧页面持有 revision 2,而存档已经变成 revision 3 时,会拒绝这次旧写入,让页面先重新载入。这个检查适用于当前串行文件访问流程,多进程或多写入者场景还需要相应的互斥协议。

5. 用“只写一半”验证恢复路径

文件接口抽出来以后,可以在测试里注入一个写入不完整的实现。内存文件系统用 Map 保存文本,把下一次写入故意截成一半,就能重复检查同一条失败路径。

下面从项目测试中整理了一段。Memory 实现 FilePort,partial 模式只保存传入文本的前半段。

const io = new Memory();
const repo = new NativeRepository(io);
const data = createDocument('garden', 0);
const first = repo.save('world-garden', data, 0);

data.name = '新的花园名字';
io.mode = 'partial';

assert.throws(
  () => repo.save('world-garden', data, first.revision),
  /写入不完整/,
);

io.mode = '';
const recovered = repo.load('world-garden')!;
assert.equal(recovered.record.revision, first.revision);
assert.equal(recovered.recovered, true);

repo.save('world-garden', data, first.revision);
assert.ok(io.list().some((name) => name.startsWith('recovery-')));

另外几项测试覆盖了两槽损坏后拒绝自动覆盖、外部备份无效、保存抛异常和空间不足。测试会比较失败前后的文件内容,并检查恢复副本是否保留了损坏原文。这些用例验证的是文件协议,真实设备的杀进程、断电和长期使用仍需单独测试。

存档预算也要算上两槽和恢复副本。微境沙盘当前把单份记录限制为4 MiB、整个目录限制为32 MiB,这两个数字是项目自行设置的预算。达到上限会提示用户导出和整理作品,保留已有文件。

文中的代码来自微境沙盘原生微信端。想看看沙盘本身,可以打开已上线网页版,体验种植观察、光学和其他交互实验。 微境沙盘