拾页从 1.0 开始用 CloudKit 做同步。它让我在几乎没有后端运维的情况下,把一款跨 Mac 和 iPhone 的稍后读应用卖出了 12,000 份。对一个只有一个人的团队来说,这是当时最正确的选择。
但到 2.0,需求变了。我想要分享链接、同步历史、可恢复的误删,以及能自己看懂的同步日志。CloudKit 是个可靠的黑盒,而黑盒里的问题,最后都会变成客服邮件。于是我决定把同步迁到自己的服务器上。
这篇文章记录整个过程:数据怎么迁、协议怎么改、冲突怎么处理,以及哪些地方比预想中更难。它不是教程,更像一份施工记录。
为什么离开 iCloud
CloudKit 解决的是“把数据从 A 设备送到 B 设备”。它做得很好。但拾页 2.0 需要的不止是搬运:服务端要能判断重复高亮、给出稳定的分享页面、记录每一次修改,并在用户误删后保留三十天的恢复窗口。
更麻烦的是诊断。用户说“我的高亮不见了”,我拿到的只有一个 CKError。没有请求 ID,没有变更游标,也没有办法在本地重放那个账号的状态。1 当付费用户越来越多,这种不可见性开始变成真实成本。
还有一个很现实的原因:我想让拾页有一个自己的域名。同步服务是第一步。它不一定酷,但它必须属于我,能被我理解、备份和修复。
先把边界定清楚
迁移之前,我给自己写了三条约束:
- 本地 SQLite 永远是唯一的事实来源。没有网络时,拾页必须完整可用。
- 服务器只做两件事:转交变更,保存可恢复的备份。它不渲染页面,也不替用户做决定。
- 不引入 CRDT。拾页的数据关系比通用协同简单,产品规则可以解决大部分冲突。2
这三条约束后来救了我很多次。每次想加一个“服务器顺手做掉”的功能,我都会先问:如果服务器挂了,客户端还能不能正常工作?答案通常是能,于是功能被放回客户端。
操作日志,而不是数据库镜像
旧方案里,客户端把整条记录推上去,服务器保留最新版本。新方案改成只追加的操作日志:每一次修改都是一条不可变的 change。服务器按顺序保存,客户端按游标拉取,重复应用同一条 change 不会产生副作用。
type Change = {
id: string; // 客户端生成的 UUID
deviceId: string;
entity: "page" | "highlight";
entityId: string;
lamport: number; // 设备内的逻辑时钟
op: "upsert" | "delete";
payload: Record<string, unknown>;
createdAt: number;
};
服务端用一张普通的 SQLite 表接住它。没有消息队列,也没有 Redis。以拾页现在的写入量,单机 SQLite 足够撑很久;真到撑不住的那天,再换也不迟。
create table changes (
seq integer primary key autoincrement,
id text not null unique,
device_id text not null,
entity text not null,
entity_id text not null,
lamport integer not null,
op text not null,
payload text not null,
created_at integer not null
);
create index changes_entity
on changes(entity, entity_id, lamport);
每条 change 都会在同一个事务里写入 changes 和对应的物化表。游标就是 seq。客户端不需要理解数据库结构,只要按顺序消费。
推拉分开,增量优先
同步协议只有两个端点:拉取和上传。客户端带着上次的游标请求增量,服务器返回最多五百条 change;客户端上传时一次提交一批,服务器按 change.id 去重。
app.get("/v1/changes", async (c) => {
const since = Number(c.req.query("since") ?? 0);
const rows = await db.all(
`select * from changes
where seq > ?
order by seq
limit 500`,
since
);
return c.json({
changes: rows,
cursor: rows.at(-1)?.seq ?? since
});
});
WebSocket 只做一件事:告诉另一台设备“有新东西了”。它不承载数据,也不假设连接一直存在。断线后仍然回到轮询,只是轮询间隔从三十秒变成一个很长的数字。这样即使推送服务完全坏掉,同步也只是变慢,不会停止。
冲突是产品规则
迁到自建服务之后,我第一次能看见冲突。结果显示,大部分冲突不是两个人同时编辑同一段文字,而是同一台设备在不同网络状态下重复提交。幂等解决了其中八成。
剩下的两成,我用三条很具体的规则处理:
- 高亮只追加,不覆盖。两段高亮即使范围重叠,也各自保留;用户删除时写入墓碑,三十天后再清掉。
- 页面标题和阅读状态使用 Lamport 时钟做后写优先;时钟相同,则比较设备 ID,保证所有客户端得到同一个答案。
- 如果一条 change 引用了本地不存在的页面,先放进暂存区,等页面到达后再应用,而不是直接丢弃。
同步的难点从来不是网络,而是两个设备都认为自己是对的。
这些规则都不优雅,但它们可以被解释给用户听。比如“两条高亮都保留了,你可以自己决定删哪一条”,比一个自动合并后的奇怪段落要诚实得多。
双写两周,只读一晚
数据迁移没有用任何神奇的工具。我写了一个导出器,把 CloudKit 的记录逐条转成 NDJSON,再用同一个导入器跑三遍:第一遍只校验,第二遍写到影子库,第三遍才写正式库。每一遍都对比实体数量、内容哈希和随机抽样的五十条记录。
八月最后两周,新旧两条路径并行写入,每天凌晨跑一次对账。九月的第一个周五,我把同步切到只读模式,用一个晚上完成了最后一次导入。周六早上六点,第一批真实设备连上新服务,游标从零开始,历史数据通过导入器补齐。
没有停机,是因为客户端本来就不依赖服务器。即使那一晚服务完全不可用,用户仍然可以打开拾页、读已经缓存的文章、写高亮;只是暂时同步不出去。把“不可用”降级成“稍后同步”,是本地优先架构最值钱的地方。
迁移之后
三个月后,拾页的同步 p95 从 1.8 秒降到 420 毫秒,关于同步的客服邮件从每月十八封降到三封。服务跑在一台很小的机器上,算上备份,每月成本约十八美元。3
但自建并没有让同步变简单。它只是把问题从黑盒里拿出来,放到我面前:要监控、要备份、要处理证书到期,也要接受自己会在凌晨两点被报警叫醒。真正的收益不是“更便宜”,而是“终于知道发生了什么”。
现在回头看,最危险的不是迁移本身,而是迁移前那种“应该没问题”的感觉。把旧数据导出三遍、把回滚脚本写两遍、把每个 change 都当成可能重复的请求来处理,这些无聊的动作才是这次没有丢数据的原因。
下一步,我会把同步服务做成可观测的:每个设备、每个游标、每次冲突都有一条能看懂的记录。不是为了炫技,只是希望下一次用户说“我的高亮不见了”时,我能直接告诉他:它在什么时候、从哪台设备、变成了什么。
把同步做得无聊,是这次迁移最重要的成果。