工程文件版本迁移与崩溃恢复:schemaVersion、Migration 链与原子持久化

工程文件版本迁移与崩溃恢复:schemaVersion、Migration 链与原子持久化

用户花三小时剪完的片子,工程文件就是他的资产。一次版本升级打不开旧文件、一次断电写坏 JSON、一次崩溃后状态丢失------任何一种都会让用户对产品彻底失去信任。本文拆解工程文件(project document)的全生命周期可靠性:版本号驱动的迁移链、读写原子性、自动保存的锁与冲突、崩溃后的恢复协议。

一、版本号:迁移的唯一锚点

document 的第一个字段就是版本:

ts 复制代码
const projectDocumentSchema = z.object({
  schemaVersion: z.literal(1),
  // ...
})

z.literal(1) 的含义:当前代码只接受 v1,其他版本一律拒绝直接解析------打开 v0/v2 文件不能"试着 parse 看看",字段错位产生的脏数据比明确报错危险得多。加载流程先读版本、再走迁移、最后校验:

ts 复制代码
async function loadProject(path: string): Promise<Result<ProjectDocument>> {
  const raw = await readTextFile(path)
  const json = JSON.parse(raw)                     // JSON 语法层
  const version = readVersion(json)                // 先取版本(容忍未知字段)

  if (version < CURRENT_VERSION) {
    const migrated = migrateUp(json, version, CURRENT_VERSION)
    // 迁移后再过当前版本完整 schema
    const parsed = projectDocumentSchema.safeParse(migrated)
    if (!parsed.success) return fail('MIGRATION_INVALID', parsed.error.issues)
    return ok(parsed.data)
  }
  if (version > CURRENT_VERSION) {
    return fail('PROJECT_VERSION_TOO_NEW', { found: version, supported: CURRENT_VERSION })
  }
  // ...同版本直接校验
}

高版本文件被低版本应用打开(用户没升级 / 装了两个版本)必须拒绝并明确提示 ,而不是 strip 未知字段后保存------一旦保存,新字段被写丢,用户的新功能数据永久损坏。这是与 IPC schema 默认 strip 策略相反的场景:加载可宽容未知字段用于展示,保存绝不可静默丢弃。

二、Migration 链:每次结构变更只写一步

不要写"从任意版本迁到最新"的巨型函数------要写相邻版本间的单向小步迁移,串成链:

ts 复制代码
interface Migration {
  from: number
  to: number
  description: string
  up: (doc: any) => any      // 纯函数:输入旧版对象,输出新版对象
}

const migrations: Migration[] = [
  {
    from: 1, to: 2,
    description: 'clip 增加 transform 字段(默认恒等变换)',
    up: (doc) => ({
      ...doc,
      clips: doc.clips.map((c: any) => ({
        ...c,
        transform: c.transform ?? IDENTITY_TRANSFORM,  // 旧数据补默认值
      })),
    }),
  },
  {
    from: 2, to: 3,
    description: 'tracks[].type 枚举增加 voiceover;字幕迁移到独立 subtitles 数组',
    up: (doc) => {
      // 结构重排:旧版字幕嵌在 text track clips 里,抽出为顶层 subtitles
      const subtitles = extractLegacySubtitles(doc.clips)
      return { ...doc, subtitles, clips: doc.clips.filter(notLegacyTextClip) }
    },
  },
]

function migrateUp(doc: any, from: number, to: number): any {
  let current = doc
  for (const m of migrations) {
    if (m.from >= from && m.to <= to) {
      current = m.up(current)
      current.schemaVersion = m.to
    }
  }
  return current
}

迁移编写的六条铁律:

  1. 纯函数、无 IO:不读文件、不弹对话框、不调 AI------迁移要可单测、可批量跑。
  2. 只依赖相邻版本:v1→v2 的迁移只认 v1 形状;v3 的迁移只处理 v2 的输出。
  3. 每步可独立测试:准备 v1 的 fixture,跑完整链得到 vN,断言字段;每个迁移单独快照。
  4. 默认值用 .default() 语义补齐:新增字段必须给老数据安全默认(与 Zod default 策略一致)。
  5. 不可逆信息显式处理:删除/合并字段前把数据搬到新位置(如字幕从 clip 抽出),不能直接丢。
  6. 迁移与校验分离:迁移函数可以暂时产出"中间形态",链路终点必须过一次当前版本完整 schema 兜底。

迁移链是追加式的:代码演进到 v8 时,v1→v2 的迁移永远保留------你永远不知道用户上一个打开该工程的是哪年的版本。

三、写盘原子性:杜绝半个文件

崩溃最常发生的时刻就是写盘瞬间。直接 writeFile(path, data) 不是原子的:写到一半断电,文件截断成半个 JSON,工程彻底损坏。标准解法是临时文件 + fsync + rename:

ts 复制代码
async function atomicWrite(path: string, data: string): Promise<void> {
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
  try {
    const fd = await fs.open(tmp, 'w')
    try {
      await fd.writeFile(data, 'utf8')
      await fd.sync()          // 强制落盘,别只停在 OS 页缓存
    } finally {
      await fd.close()
    }
    await rename(tmp, path)    // rename 在同卷上是原子的(POSIX 保证;Windows 上 MoveFileEx + REPLACE_EXISTING)
  } catch (e) {
    await rm(tmp).catch(() => {})
    throw e
  }
}

三个层次:write 保证内容进文件、fsync 保证落物理盘(否则断电时页缓存丢失,rename 已经完成)、rename 保证目录项切换原子------任何时刻崩溃,目标路径上要么是旧文件、要么是新文件,不存在半个。跨卷 rename 不是原子的(会退化成复制),tmp 文件必须与目标在同一目录。

四、备份代际:自动保存的时间胶囊

即使写入原子,逻辑错误(迁移 bug、用户误操作后又保存)也会把错误状态"原子地"写进去。备份策略采用代际保留(grandfather-father-son 的简化版):

text 复制代码
my-project.miaoma.json              ← 当前
my-project.miaoma.json.bak          ← 上一次保存
backups/
  my-project.20260919-143022.json   ← 最近 N 份定时快照(如 10 份)
ts 复制代码
class SaveCoordinator {
  async save(doc: ProjectDocument) {
    if (await exists(targetPath)) {
      await copyFile(targetPath, backupPath)       // 当前→.bak(先备份再覆盖)
    }
    await atomicWrite(targetPath, serialize(doc))
    await rotateSnapshots(doc.projectId, keep: 10) // 定时快照轮转
  }
}

打开文件时若主文件损坏,自动按 .bak → snapshots 最新 顺序尝试恢复并提示"主文件损坏,已从 X 时刻的备份恢复(可能丢失最近 N 分钟编辑)"------用户知情、数据尽力,比打不开强十倍。备份清理按数量 LRU,不按时间(防止一个老工程堆几百份备份)。

五、自动保存:防抖、锁与多窗口冲突

自动保存(autosave)三件套:

防抖写入

变更后等 1.5s 静默期再写,连续编辑只落盘一次;同时每 60s 强制一次(防止持续编辑永不落盘):

ts 复制代码
class DebouncedSaver {
  private timer: NodeJS.Timeout | null = null
  private lastSave = Date.now()
  scheduleSave(doc: ProjectDocument) {
    if (this.timer) clearTimeout(this.timer)
    this.timer = setTimeout(() => this.flush(doc), 1500)
    if (Date.now() - this.lastSave > 60_000) this.flush(doc)
  }
}

写期间的变更竞争

异步写盘进行中用户又改了文档:必须快照待写内容(写的是调度时刻的深拷贝),写完后检查"期间是否有新变更",有则再排一次。绝不能让写盘闭包持有可变 document 引用------写到一半数据被改,文件内容可能撕裂(部分字段新、部分字段旧)。

单写者锁

同一工程被两个窗口/两个应用实例打开,最后保存者覆盖另一人的全部工作。用带进程标识与心跳的锁文件:

json 复制代码
// my-project.miaoma.lock
{ "pid": 18422, "appVersion": "4.2.0", "startedAt": "2026-09-19T06:00:00Z", "host": "workstation-01" }
ts 复制代码
async function acquireLock(projectPath: string): Promise<LockResult> {
  const lockPath = projectPath + '.lock'
  const existing = await readJson(lockPath).catch(() => null)
  if (existing && (await isProcessAlive(existing.pid))) {
    return { acquired: false, owner: existing }  // 提示:已在另一窗口打开
  }
  // 锁文件过期(进程已死)→ 抢占
  await atomicWrite(lockPath, { pid: process.pid, startedAt: new Date().toISOString() })
  return { acquired: true }
}

锁的关键不是创建,是失效检测:pid 复用、崩溃残留都可能让锁误判。进一步可用"锁文件 mtime 心跳"(持锁方每 10s touch 一次),超过 30s 未刷新视为死锁可抢占。正常退出时删锁;崩溃后下次打开检测到死锁,提示恢复而非静默进入(崩溃现场可能需要用户决定用哪个备份)。

六、崩溃恢复协议

应用异常退出后重启,恢复流程:

text 复制代码
1. 启动时扫描 userData/recovery/ 下的恢复清单(每个打开过的工程一条)
2. 对每个工程:
   a. 锁文件存在但持锁 pid 已死 → 上次崩溃
   b. 对比:主文件 / .bak / 最近自动快照 / 未保存草稿(见下)
   c. 弹恢复面板:列出各候选的时间与来源,让用户选择
3. 用户选择 → 走标准迁移 + schema 校验 → 打开;校验失败的候选标红不允许直接用

未保存草稿(unsaved changes 保险)

自动保存只覆盖"已保存过的工程"。新建未保存的工程也需要保险:变更防抖后写入 userData/recovery/draft-<id>.json,标记 untitled: true。重启时提示"有未命名的恢复草稿"。草稿与正式文件同一 schema、同一迁移链------恢复通道不享受数据格式特权,否则它会成为脏数据后门。

七、完整性校验与写后验证

关键工程(或调试开关)下做写后读验证:写完立即重新读取并 parse + 校验,与写入对象关键字段(hash 摘要)比对。成本不高(本地 IO),能立刻抓到磁盘满、权限被改、杀软拦截写入等环境问题------失败时停止后续自动保存并报警,避免连续覆盖好备份。

更高级的做法是在文件尾部附一个结构摘要:

json 复制代码
{ "document": { ... }, "__integrity": { "schemaVersion": 1, "sha256": "..." } }

读取时先验摘要,损坏在 parse 之前就被发现(半个 JSON 连版本号都读不出来的场景)。

八、小结

机制 核心做法
版本 literal 版本号;低版本迁移、高版本拒绝;永不静默 strip 后保存
迁移 相邻版本纯函数链;追加不删;默认值补齐;终点完整校验
写盘 tmp + fsync + rename 原子写;同卷;失败清理
备份 .bak 上代 + 快照代际轮转;损坏时按代际恢复
自动保存 1.5s 防抖 + 60s 保底;写快照防撕裂
锁 pid + mtime 心跳;死锁可抢占;冲突显式提示
崩溃恢复 恢复清单 + 候选面板;未命名草稿同 schema
完整性 写后读验证;尾部 sha256 摘要

工程文件可靠性的设计哲学是假设每一层都会失败:假设升级会跨多个版本(迁移链)、假设断电发生在写盘中途(原子写)、假设错误数据会被保存(备份代际)、假设应用会崩溃(恢复协议)、假设磁盘会说谎(写后验证)。用户不会看到这些机制中的任何一个------他们只会形成一个朴素的信念:"这个软件不会弄丢我的东西。"这是专业工具最硬的口碑。

相关推荐
IT_陈寒1 小时前
React状态管理这个坑,我是怎么翻车的
前端·人工智能·后端
风骏时光牛马1 小时前
大模型底层架构与AI源码深度解析
前端
parade岁月1 小时前
vtable-guild 被收录进 vuejs/awesome-vue 了 🎉
前端·vue.js
去伪存真1 小时前
开发自己的第一个MCP--用 AI 智能重构 Excel 处理工作流
前端·人工智能
GreenTea1 小时前
深度拆解 ScienceBuddy:如何用“双层递归自进化”构建高可靠科研 Agent Harness
前端·后端·算法
计算机魔术师1 小时前
AGI 来了?黄仁勋刚说恭喜,Marcus 就翻脸要关停 OpenAI
前端
IT_陈寒1 小时前
React组件意外更新的罪魁祸首,我排查了这一整天
前端·人工智能·后端
子兮曰1 小时前
Laya 深度解析:421M 开源决策模型硬刚 Jev,33ms 背后藏了啥
前端·后端·python