· 工程 / 架构 / 备份

为什么我们把备份核心整个换掉了

P-Pass 要做的事很简单:手机拍下照片,它自动回到家里那台电脑。

但”自动备份”里藏着一串不能含糊的问题:传到一半按了暂停,这张照片算什么?网络断了以后,为什么有的该自己继续、有的必须等人点”继续”?取消当前一轮,到底取消眼前看到的几张,还是整批还没扫描到的照片?电脑上的文件后来被人从 Finder 删了,手机该不该悄悄再传一遍?

旧核心回答不了这些问题。所以这次我们没有再给它补一条分支,直接把它整个换掉了。

旧系统把”批次”当成了照片

旧路径大致是这样:

扫描相册
→ 算 hash
→ 生成 manifest
→ 传输
→ 提交
→ 推进 watermark

它以一轮批处理为单位。一次 Worker 被唤醒,尽量把这一批事情做完。

这在最初很自然:照片不多,目标只是”把新文件送过去”。问题出在真实使用不是一条直线。过程中会发生暂停、断网、低电量、电脑离线、改相册范围、手机重启、远端文件被手动删掉。发现、待传、已确认、失败和暂停分别散在 Worker、状态文件和不同的调度通道里。

于是系统经常只能说”这一轮在跑”或”这一轮结束了”,却说不清用户真正问的那句话:这一张照片现在到底怎样了?

把一张照片的命运塞进一整个批次,就像快递公司只记录”一车货出发了”,不记录每个包裹有没有签收。车在路上抛锚时,没人能可靠地知道哪一件已经到了、哪一件该继续、哪一件该停下。

先划一条线:什么才叫备份完成

新设计最先定下来的是一条完成语义,类名和数据库都排在它后面:

一张照片只有在 Desktop 已完整接收、验证并可靠保存,且明确给出完成凭据后,才是 CONFIRMED。

“开始发送了""电脑看到请求了""本地算完 hash 了”,这些都不算完成。

这条线看起来严格,但它让很多边界自己变清楚:

  • 完成凭据已经到手,之后用户改相册范围、暂停或取消本轮,都不能把它改回未完成;
  • 只有 partial、没有完成凭据的项,改范围后就不能偷偷完成;
  • Desktop 外部缺失是一个需要记录的事实,不等于用户要求自动补传;
  • 手机和 Desktop 两边的记录不一致时,能对照的是同一张照片的完成凭据,两边各自猜一轮批次有没有结束是猜不出结果的。

这样一来,“已备份”在手机和 Desktop 两边都能拿同一份完成凭据核对。

新核心:一本账、一条队列

新模型的中心是一份保存在手机上的逻辑账本。它记录每一张照片的身份、顺序、状态、失败、完成凭据,以及它属于哪个配对和范围版本。“这轮备份大概做到了哪里”这种账,它记不了,也不再需要。

整条路径被拆成了四个很明确的角色:

触发器        = 可能有新照片
发现器        = 把新照片可靠写入账本
严格消费者    = 只处理当前队头的一张
Desktop       = 原生 fetch/resume,保存后回完成凭据

触发器不再承担”开始一轮备份”的职责。无论是相册变化、打开 App、周期任务还是手动动作,表达的都只是:可能有新照片,请检查。 多个触发会合并,不需要每来一次通知就再开一条竞争的传输管道。

发现器按 MediaStore 的增量游标找照片,一页最多 500 项。候选项写入账本和发现游标前移必须在同一次本地提交中完成:崩溃发生在提交前,两件事都没发生;提交成功,两件事一起生效。

否则会出现最危险的情况:游标先前进,照片还没入队。下次启动看起来”已经扫描过”,实际上那几张就永远被跳过了。

账本里有了待传项,严格消费者才开始工作。它只处理 UploadCursor 指向的当前队头;这一张没有进入终态,后面的不许抢跑。传输仍交给 iroh-blobs 原生 fetch/resume:断点续传是底层协议的能力,业务层不再自己维护另一套 offset、chunk map 或”再传一次”的协议。

这么拆以后,原来散在不同通道里的”这张照片怎样了”,只剩一份可查的答案。

暂停、条件等待和取消当前轮

有了单张账本,几个原本容易混在一起的动作终于能说人话。

用户点”暂停” 是长期命令。App 被杀掉、网络恢复、后台任务又被系统唤醒,都不能替用户解除暂停;只有用户点”继续”,才能从原队头接着传。

条件等待 则不同。Wi-Fi、电量或 Desktop 暂时不满足时,当前传输停止但有效 partial 保留。条件恢复后可以自动续传,不计入失败预算,也不需要用户重新点一次继续。

“取消当前轮” 不做逐文件删除。它只在暂停后出现,表达的是”这一轮所有尚未完成的照片都不要再传”。它会继续分页把这一轮还没发现到的候选项标为取消,保证整轮都被取消,不会只取消了屏幕前 500 张。已获得完成凭据的照片不受影响;以后新入队的照片属于下一轮;若用户反悔,只能显式点”重新传输”救回这轮取消项。

这些状态看上去多,对应的是几种不同的用户意图,不能混成一个”停止”。用户说”我现在不想传”和系统说”现在不具备传输条件”,恢复规则本来就不一样。

为什么不能继续补旧 Worker

旧批处理路径的基本单位就是”一轮”,水位和确认也是围绕这一轮设计的,补几个 if 改不了这一点。新规则的基本单位则是一张照片,完成事实来自 Desktop 回执,暂停、范围、取消和配对都要能在重启后继续解释。

两套模型的中心不同,继续把新语义塞回旧路径,只会留下两份相互竞争的真相。

所以生产切换的第一步是划边界,删旧代码放在后面:旧 BackupWorker、Runner、确认和重传队列被标为 legacy;新核心只允许落在 backup/flow。随后才把发现请求、原子入账、严格队头、原生传输和完成凭据接成一条生产路径。最后,旧 Worker 被降为系统唤醒的适配器,不再自己扫描、hash、生成 manifest 或提交批次。

这样”新系统”和”旧系统”之间就有了可以检查的物理边界,不靠约定维持。

先写会失败的情况,再写新路径

换核心时最容易犯的错,是拿旧测试逼新代码长回旧形状。

这次先把仍然有效的产品规则写成失败 case matrix:发现页提交前崩溃,游标和队列必须一起不变;暂停后重启,队头仍不能自己开始;Desktop 的迟到完成回执不能因为本地已经暂停或取消就被误丢;取消轮结束之后的新照片又必须能正常进入下一轮。

每条测试都要回答一个问题:把保护去掉以后,系统会不会真的坏?如果不会,测试守的就不是规则。

真机回归里也按这个办法处理了两个问题。范围扩大时,旧游标之前的新相册照片没有进新账本;暂停和取消的竞态里,Desktop 已经持久化完成凭据,手机却可能暂时还停在待传态。它们都没有靠清数据或重配对绕过去,分别补成了新的 Flow 语义和回执收敛规则。

换完核心以后 bug 还会有,区别是每个新问题都能落到同一份账上查清、复现,修复时不用靠猜。

这次换掉的是什么

“传照片”这个能力一直都在,我们换掉的是”什么时候才算备份完成”的判定:

以前:一轮任务跑完,大概就算备份完成
现在:每张照片有持久状态;Desktop 给出完成凭据,才算完成

前者适合一次性脚本,后者才适合一个会被暂停、被打断、会换电脑、会遇到真实用户操作的长期备份产品。

这条新主路径已经完成生产切换,并继续在真机回归中收敛边界。离结案还早,这篇只记录我们为什么不再给批处理打补丁,假装它还能回答所有问题。

相关材料: