把拾页的同步
从 iCloud 迁到自建服务

一万二千个付费用户,三万多台设备,一个不能再出错的十月。

本文目录 · 7 节
  1. 三个绕不过去的限制
  2. 先把协议写清楚
  3. 服务长什么样
  4. 迁移:双写四个月
  5. 冲突:不要替用户做决定
  6. 结果与代价
  7. 如果你也想迁

拾页从第一天起就用 CloudKit 同步。理由很简单:我一个人,不想维护服务器。用户把网页存进拾页,高亮、笔记和阅读位置跟着走,这些数据不该由我操心。这个决定撑了三年。到今年春天,它开始撑不住了。

不是 CloudKit 坏了。它替我挡了很多事,也没收过钱。问题是拾页长大了:一万二千个付费用户,三万多台设备,国内用户占七成。数据一多,原来可以忍的东西就变成了每天要处理的客服邮件。

01三个绕不过去的限制

第一是延迟。CloudKit 的服务器在境外,国内用户推一条笔记,快的时候两秒,慢的时候十几秒。客服邮件里出现最多的一句话是「我 Mac 上改了,iPhone 还是旧的」。第二是冲突。CloudKit 只告诉我「这条记录变了」,不告诉我哪个字段变了。标题和正文被一起覆盖,用户丢掉的是整段高亮。第三是调试。同步出问题时,我只能让用户导出日志,自己在本地复现。没有服务端,就没有任何可以查的地方。

还有一条不那么技术的:我没法在服务端做任何判断。不能限流,不能灰度,不能在一个用户的数据写坏时单独修。所有规则只能塞进客户端,然后等 App Store 审核。对于一个每天都在改同步逻辑的产品,这太慢了。

最难的其实是解释。用户不关心服务器在哪里,他只知道 iPhone 上的高亮没有出现在 Mac 上。我回邮件时只能说「再等一会」。这句话说了三年,自己都不太信了。

CloudKit 的单条记录上限是 1 MB,附件单独计量。拾页的长笔记和图片附件经常触到这条线,早期的「保存失败」大多来自这里。1

02先把协议写清楚

迁移之前,我花了两周只做一件事:把「同步」拆成可以验证的规则。不是先买服务器,也不是先写接口。

每条修改是一个 changeset,带文档 id、设备 id、一个 Lamport 计数和字段级的操作。服务端只追加,不覆盖;每个 changeset 拿一个单调递增的 seq。客户端拉取 since=seq,合并以后把本地的 seq 推进。删除写成墓碑,保留九十天。

用 Lamport 计数而不是时间戳,是因为设备的时间会错。用户的电脑可能慢五分钟,也可能因为时区设置差八小时。顺序应该由逻辑决定,不由钟决定。2

这套东西不新鲜,Git 和 CRDT 的论文里都有。真正花时间的是边界:同一个字段两台设备同时改、离线三个月后回来、用户在两个窗口里改同一段。每一条都要有确定的答案,不能靠「应该不会发生」。

墓碑比想象中重要。没有它,一台离线两个月的设备回来时,会把已经删掉的笔记重新推上来。保留九十天是我拍的:比最长离线时间多一倍,又不至于让表无限膨胀。

sync.ts · 服务端
type Change = {
  docId: string;
  deviceId: string;
  lamport: number;
  ops: Array<{
    field: "title" | "body" | "tags";
    value: string;
  }>;
};

// 服务端只追加,不覆盖历史
app.post("/sync/push", async (req) => {
  const { since, changes } = req.body;
  // 单事务,WAL 模式
  const seq = await db.append(changes);
  const remote =
    await db.changesSince(since);
  return { seq, changes: remote };
});

03服务长什么样

一台 2C4G 的机器,Node 22,SQLite(WAL 模式),附件放 R2。前面挂 Cloudflare,负责证书、限流和缓存。没有 Kubernetes,没有消息队列,也没有 Redis。按现在的量,一个月十二美元左右,大头是 R2 的流量。

旧路径 iCloud / CloudKit 只读桥接,1.0 客户端还在用 2027 年 1 月删除
图 1 · 迁移后的同步链路。设备之间不直接通信,所有写入先拿到一个 seq;旧的 iCloud 路径保留只读,直到 1.0 用户全部升级。

SQLite 放在同步服务里不是妥协。写入走单事务,读走 WAL,十二万次写入一天也压不垮一台小机器。真正需要小心的是备份:每天凌晨把数据库快照传到另一家对象存储,保留三十天,并且每个月真的恢复一次。没有恢复过的备份不算备份。3

没有用 Postgres,是因为现在只有一张 changes 表在长,一天不到一百兆。等它真的需要分片,再换也不迟。为一个还不存在的问题上分布式系统,是我过去三年最常犯的错。

04迁移:双写四个月

直接切换是不负责任的。我的做法是双写:新客户端同时写 CloudKit 和自建服务,读还是以 CloudKit 为准。四周之后,把读切到自建服务,CloudKit 只做回滚。又过了八周,确认没有用户回退,才关掉写。

灰度按设备号哈希,先放 5%,再 20%、50%。每个阶段至少观察一个完整的周末——周末是拾页的使用高峰。回滚开关一直在设置里,直到今天还没有删。

旧客户端也没有被丢下。1.0 的用户继续走 CloudKit,服务端保留一条只读的桥接,把他们的修改转成 changeset。桥接代码很丑,但它让三万多台设备里最后那 8% 不必被迫升级。

迁移期间最难的其实不是技术,是节奏。白天不敢动,怕用户在上班时间丢数据;晚上改完,又不敢立刻发版。那四个月我养成了一个习惯:每次发布前,先在自己的三台设备上断网、改同一段、再联网,看谁赢。

双写期间最怕两边不一致。每天凌晨跑一次对账,随机抽一千个文档比较字段,对不上就记下来。四个月抓到十七个 bug,大多数是我自己写的边界。

05冲突:不要替用户做决定

「我不在乎同步是怎么实现的,我只在乎换电脑的时候东西还在。」

——一位用户在三月发来的邮件。这句话后来贴在我显示器边上。

最早我用「最后写入者赢」,测试时看起来没问题,真实数据里一地鸡毛。用户 A 在 iPad 上改标题,用户 B 在 Mac 上补正文,最后只剩一个人的版本。

现在的规则是按字段合并:标题、正文、标签分开比较 Lamport;正文冲突时不猜,保留两份,在编辑器顶部放一条「这段有两个版本」,让用户选。多一次点击,少一次数据丢失。

这个决定让同步层多写了大约六百行,也让我第一次觉得,慢一点是值得的。用户不需要知道 Lamport 是什么,他只需要知道东西没有丢。

06结果与代价

迁移后,国内平均同步延迟从 3.4 秒降到 380 毫秒(我自己埋点统计的 p50)。客服邮件里「没有同步」的主题,从每月 40 封降到 6 封。成本从 CloudKit 的免费额度变成每月十二美元,换来的是可以查的日志、可以改的协议,和一个能自己负责的晚上。

也有代价。以前出了问题是苹果的问题,现在是我的问题。十月的一个周六,证书过期,三千多台设备同步停了四十分钟。我坐在电脑前,第一次希望有个同事。

同步状态现在有三档:已同步、同步中、需要处理。最后一档以前不存在,因为 CloudKit 不给我这个信息。现在它每天会出现十几次,每一次都能点开看到原因。

07如果你也想迁

我的顺序是:先写协议,再写代码;先想清楚冲突怎么合并,再想服务器买哪家;先做双写和回滚,再谈切换。最后,把「同步成功」做成用户能看见的状态——拾页现在会在标题栏下面显示一行很小的字:已同步到 3 台设备,12:04。

它不解决任何技术问题,但能少掉一半「是不是没同步」的邮件。

注释

  1. CloudKit 的 CKRecord 单条记录上限 1 MB,附件单独计量。拾页的长笔记和图片附件经常触到这条线,早期的「保存失败」大多来自这里。↩
  2. Lamport 计数只保证因果顺序,不保证全局实时。对同步来说够用;真正需要强一致的只有「同一字段不能同时赢两次」。↩
  3. WAL 模式允许读写并发,但检查点仍会短暂阻塞写入。夜里做快照比白天安全,也不要和备份脚本抢同一个磁盘。↩

写于 2026 年 10 月,杭州。文中的延迟与邮件数字来自拾页的埋点和客服标签,已做四舍五入。错字和不同意见都欢迎写信:hi@zhouyu.example。

MONTHLY LETTER · 每月 1 日

每月一封,
写做完的事。

拾页的开发进度、收入数字,和这个月读到的书。不写“干货”,也不替谁总结行业。

4,012 位订阅者 · 随时可以退订

※每月 1 日发一封。不转发,不投放,随时退订。