拾页的第一版同步只用了三天。把 Core Data 的存储放进 CloudKit 容器,打开开关,两台 Mac 之间就能看见同一篇稍后读。那时我以为同步已经做完了。后来两年里,真正花时间的部分才慢慢浮出来:一台机器离线,另一台改了标题;高亮被移动,位置在两边不一样;用户在系统设置里换了 Apple ID,本地数据突然找不到归属。CloudKit 能同步,但它不让我决定两台机器打架时该听谁的。
这不是一篇 CloudKit 的控诉。它替我撑过了最初两年,也让拾页在没有服务器的情况下活了下来。迁移只是因为产品走到了一个需要更细控制的地方,而 iCloud 的抽象层恰好挡在中间。
为什么离开 iCloud
iCloud 的吸引力很直接:不用服务器,不用账号系统,用户已经登录。代价也直接:同步是黑盒。出现冲突时,客户端只能收到一个错误,再自己重新拉取;要复现一个线上问题,我得同时拥有两台设备、同一个账号,还要等 iCloud 把状态推过来。1
还有一个不常被提起的原因:测试。CloudKit 的开发环境与生产环境分离,容器、账号和真实数据之间的边界很深。每次改 schema,我都要重新部署、等待,再用两台设备验证。一个下午常常只够改一个字段。
更麻烦的是,拾页 2.0 需要版本历史、导出、可以回滚的批量编辑,以及未来给阅读器用的只读接口。这些都要求服务端理解拾页的数据,而不只是存一个文件。我列了三条迁移理由:冲突合并要可解释;数据要能导出,也要能搬走;我需要在本地把整条同步链路跑起来。
先把同步写成一个动词
新的设计把同步拆成三件事:记下变化、交换变化、合并变化。每台 Mac 仍然先写本地 SQLite,写完立刻返回,不等网络。后台把这次修改追加到变更日志,带着设备 id、递增的序号和逻辑时钟。网络恢复时,客户端把本地还没上传的日志推给网关,网关按文档去重,返回对方设备还没看到的部分。
这个顺序很重要。同步不是一个保存按钮,而是本地写入之后的一段后台工作。用户关掉网络、合上盖子、换一个 Wi-Fi,都不会让编辑器卡住。
数据模型:变更日志,不是状态快照
最早的实现每次上传整篇文档。它简单,但会丢东西:两台机器同时改不同字段,后上传的那份会把另一份覆盖。迁移时我把同步单位从文档改成字段,只记录真正变化的列。
CREATE TABLE changes (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
device_id TEXT NOT NULL,
seq INTEGER NOT NULL,
fields TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX changes_by_doc
ON changes (doc_id, seq);
行里的 seq 由服务端分配,客户端用 device_id 和 local_seq 做幂等键;同一个 patch 重发多少次,结果都一样。变更日志按账号分区,正文和附件放在对象存储,数据库只保留索引和指针。主数据库使用 SQLite 的 WAL 模式,同一账号的写操作串行执行,读取不必排队。2迁移脚本按批次读旧数据,先写入新库,再逐条比对,确认没有字段在转换中丢失。
冲突:按字段合并,而不是整篇覆盖
两个字段都变了,才需要真的做决定。做法不复杂:保留一个 base 版本,比较本地和远端;只有一边离开 base 时,采用那一边;两边都离开 base 时,用逻辑时钟决胜,并把输掉的值写进历史,让用户能看见。3
function merge(base, local, remote) {
const next = { ...base };
const fields = [
'title', 'url', 'color', 'position'
];
for (const field of fields) {
const l = local[field];
const r = remote[field];
if (l === r) {
next[field] = l;
continue;
}
if (l === base[field]) {
next[field] = r;
continue;
}
if (r === base[field]) {
next[field] = l;
continue;
}
const localT = local.clock[field];
const remoteT = remote.clock[field];
next[field] =
localT > remoteT ? l : r;
}
return next;
}
这套规则不完美,但它可解释。用户看到的是“标题按这台 Mac 的修改,颜色按另一台”,而不是整篇回到某个昨天。对稍后读应用来说,把改动拆开,比隐藏所有冲突更重要。
高亮的位置和颜色是两列,标题和网址也是两列。大多数冲突其实只碰其中一个。字段级合并让另一边的修改继续存在,也让我能在设置里展示一条可读的历史,而不是一个无法解释的“已同步”。
“同步不是把数据搬来搬去,是让两台设备对同一件事达成一致。”
失败如何被看见
旧版同步的错误只有“同步失败”。用户不知道是哪一篇、为什么、要不要重试。新服务把错误分成三类:网络、鉴权、数据。网络错误自动重试,指数退避;鉴权错误提示重新登录;数据错误保留本地副本,并把冲突写进历史。
我在设置页加了一个很小的同步状态:上次成功的时间、待上传的变更数、最近一次错误。它不弹窗,也不催促。一个后台进程应该像水电气一样,出问题时才让人注意到。
迁移:双写、回滚和一次停写
迁移没有在一个周末完成。第一周,新服务只读,所有写入仍然走 iCloud。第二周开始双写,客户端把同一份变更分别送到两边,再比较结果。差异写进一张临时表,我每天早上去看。
真正上线前,我做了一次停写演练:在开发版里关掉 iCloud 写路径,保留读路径,让新服务跑完整的一周。回滚开关一直在,直到确认旧数据全部回填。最难的不是代码,是承认旧路径还要留一段时间。
用户不会关心同步供应商。他们只会在换电脑时问一句:我的高亮还在吗。所以迁移的目标不是“自建”,而是“换过去以后,用户不需要知道”。
结果:更慢的第一次,更快的每一天
新的同步在首次登录时更慢,因为要把历史数据完整拉下来;之后的增量同步更快,也更安静。失败不再只显示一个红色感叹号,而是告诉用户哪一篇没有同步、可以重试,或者先导出。
我也终于能在本地复现问题。把两台模拟器的数据库复制出来,改一行时钟,跑一遍合并,问题就在眼前。这样的调试体验,是 iCloud 给不了的。
数据在自己手里,不等于可以随便看。服务端只存同步需要的字段,日志里不记录标题和正文。导出功能会放在 2.0 设置页的第一层,用户可以把全部数据带走。
代价与下一步
自建同步意味着我也要维护服务器、备份、证书和监控。它不是一个免费的升级。为了不让这些工作吞掉做产品的时间,我把它压到很小的范围:一台机器、一个数据库、一份每天离线备份,以及一个会在失败时叫醒我的告警。
2.0 还会继续改同步。下一步是把阅读进度也纳入变更日志,让“读到哪”不再是一个单独的状态;再往后,才是多设备同时编辑同一段笔记。迁移结束的那天,我在开发日志里写:同步终于成了拾页自己的问题。这句话既是负担,也是我想要的自由。