上周我把备份真的恢复了一遍,验的是「一份备份能不能还原出来」。这周磁盘又红了,我想着删几份老增量腾点地方,删完顺手复验了一下——在册的 8 个恢复点,只剩 1 个还能用。

「增量」这个词有两种意思,我以前一直混着用

我把 14 天的数据造出来(80 个文件、每天改 6 个加 2 个删 2 个),然后写了两套实现。

A 是差异链:一份全量 + 每天一个「今天改过什么」的包,恢复就是全量往上逐层叠。

function restoreA(A, day, missing) {
  const out = { ...A.full };
  for (let d = 1; d <= day; d++) {
    if (missing && missing.has(d)) continue;      // 这一天被清理掉了
    for (const p in A.diffs[d - 1]) out[p] = A.diffs[d - 1][p];   // 只有 set,没有 delete
  }
  return out;
}

B 是清单式:每次快照都记一份「当时有哪些文件、每个文件的内容指纹是什么」,内容本身按指纹去重丢进一个共享对象库,恢复就是照着清单把对象取回来。

体积上两件事几乎一样:14 天各做一次全量是 6267.4 KiB,A 是 973.6 KiB,B 是 975.0 KiB(对象 940.0 + 清单 35.0),都省了 84%。B 的对象库其实比 A 小 33.6 KiB——我把三成修改设成了「回滚到旧版本」,这部分被去重吃干净了——但省下的这点正好被 35 KiB 的清单开销抵消。所以「这不就是一回事吗」这个印象,在体积上是对的。

差别在恢复。我用 A 恢复第 13 天,比源数据多出 26 个文件;14 个恢复点累计多出 182 个。

原因蠢得我不想承认:增量包只记新增和修改,不记删除。第 3 天删掉的文件,第 13 天恢复出来又活了。

修起来便宜到不像话,在增量包里加一句「这几个文件没了」:

for (const p in prev) if (!(p in cur)) del.push(p);        // 记墓碑
for (const p of A3.tomb[d - 1]) delete out[p];             // 恢复时执行删除

加完 14 个恢复点全部干净,墓碑一共多占 0.36 KiB。这个价格没什么好犹豫的。

中间一份坏了,差异链要赔进去 8 个恢复点

接着我做了件更坏的事:把第 6 天那个增量包里的一个文件改坏几个字节(模拟静默位翻转),再恢复。

差异链这边,第 6 天到第 13 天 8 个恢复点全部内容错,而恢复过程一路返回成功——没有任何校验,错了也不吭声。

清单式这边,同样坏一个对象(第 6 天引入、第 8 天被覆盖掉),受影响的只有引用它的 2 个快照,而且不是静默的:取对象时重算一次指纹就当场报错。

这个校验不免费,但便宜得没道理不做。我把 14 个恢复点全巡检一遍,要重算 1120 个对象、6.12 MiB 内容的指纹,实测 28.0 ms,折合 218 MiB/s。每天跑一次,代价 28 毫秒。

这里有个必须说清楚的对照。我挑的是「后来被改掉」的东西;如果坏的是一个从头到尾没变过的文件,两边都是 14/14 全废,谁也不比谁强。清单式赢的不是「更抗损坏」,是把损坏关在更小的范围里。

还有一条:差异链想做到「能发现错误」,得额外存一份每天的指纹清单,而清单式那份清单本来就有。我给 A 补上之后重测,8 个坏点全部被检出,但一个也修不回来。检出和能修是两件事,别把报警变响了当成数据变安全了。

真正的坑:清理不是「删老的」

保留策略是月备第 0 天 + 周备第 7 天 + 最近 7 天,在册 8 个恢复点。我按最直觉的写法清:不在名单里的备份单元直接删。A 里被删的是第 1~6 天的增量,240.4 KiB,看着挺爽。

结果那 8 个在册恢复点,内容层面对的只剩 1 个。

因为「保留第 7 天」在差异链底下真正的含义是「保留第 1 到第 7 天整条链」。我以为删掉的是 6 份没人要的老增量,实际删掉的是第 7 天那个周备,第 8 到 13 天一起陪葬。

改成依赖感知清理之后(一份增量只有在没有任何被保留的恢复点需要它时才可删),这 6 份一份都不能删,释放 0 字节,但 8 个恢复点内容全对。我又换了个更狠的策略试(只留第 0 天 + 最近 3 天):A 可用 1/4,B 可用 4/4。

清单式这边,删掉 6 份清单立刻只释放 15.0 KiB,对象库一个字节没动。跑完一次引用计数 GC 才回收 8 个对象、44.8 KiB,而对象库总量是 940.0 KiB。

这事官方文档早就写了,我以前没往心里去。restic 的 forget 页面写着 "the data that was referenced by files in this snapshot is still stored in the repository",要真正回收得跑 prune,"two commands need to be called in sequence";borg 的 prune 页面直接一句 "Repository disk space is not freed until you run borg compact"(核对日期 2026-10-26,两条链接当时均 200 无跳转)。所以「我删了快照但磁盘没掉」不是 bug,是这个设计的必然——删快照和删数据是两件事,中间隔着一个 GC。

异地副本:86.9% 的完成度,0% 的可用性

三种同步结果摆在一起:

  • a 完整同步前 10 天 → 10/10 个恢复点可恢复
  • b 14 份清单全同步、对象只到了 86.9% → 0/14
  • c 只传最近 7 天新增的 47 个对象 → 0/14

按对象数看,b 完成了 86.9%,报表很好看;按「能恢复几个点」看是 0。

c 那个是我以前的直觉做法——每天只把新增的传走。它在清单式下同样不成立:第 13 天那份快照引用的是 80 个对象,其中大半是第 0 天就存在的老东西,传得再勤也没用。部分数据的备份不是打了折的备份,是没有备份。

所以我把异地验收改成按快照算,不看传了多少字节:

function verifyOffsite(snaps, objs) {
  return snaps.map((s, d) => {
    let missing = 0, bad = 0;
    for (const p in s.paths) {
      const h = s.paths[p], c = objs[h];
      if (c === undefined) missing++;
      else if (H(c) !== h) bad++;
    }
    return { day: d, ok: missing === 0 && bad === 0, missing, bad };
  });
}

我的判断

  • 别再笼统叫它「增量备份」了,先把语义定死:是差异链还是清单式快照。我现在只用后者,因为它把三件事拆开了——删快照、删数据、校验数据。差异链把这三件捆在一起,动一个连累一堆。
  • 记删除。0.36 KiB 换 182 个幽灵文件归零,这是这两天最划算的一笔。
  • 保留策略写在差异链上,等于在写「我保留哪几条链」,不是「我保留哪几天」。落笔前先问一句:这条链断在哪一天我还认。
  • 异地副本的验收单位必须是恢复点,不是字节。86.9% 和 0% 可以同时成立,这事我以前没想过。
  • 检出错误不等于能恢复。8 个坏点全部被报警抓到了,也全部修不回来。真正救我的是爆炸半径只有 2 的那个方案,不是更响的报警。

实验脚本是 bkcore.js 和 bkrun.js,零依赖,数据全是我造的,跑一遍十几秒。我手上没有生产备份库能拿来做这种破坏性测试,所以上面每个数字都来自这个模型,不是从真机器上摘的——但它把「差在哪」讲得比任何文档都清楚。

上回那篇在这里,验的是单份备份能不能还原,和这篇是上下集。

添加新评论

文章目录