拾页从 1.0 开始用 CloudKit 做同步。它让我在几乎没有后端运维的情况下,把一款跨 Mac 和 iPhone 的稍后读应用卖出了 12,000 份。对一个只有一个人的团队来说,这是当时最正确的选择。

但到 2.0,需求变了。我想要分享链接、同步历史、可恢复的误删,以及能自己看懂的同步日志。CloudKit 是个可靠的黑盒,而黑盒里的问题,最后都会变成客服邮件。于是我决定把同步迁到自己的服务器上。

这篇文章记录整个过程:数据怎么迁、协议怎么改、冲突怎么处理,以及哪些地方比预想中更难。它不是教程,更像一份施工记录。

为什么离开 iCloud

CloudKit 解决的是“把数据从 A 设备送到 B 设备”。它做得很好。但拾页 2.0 需要的不止是搬运:服务端要能判断重复高亮、给出稳定的分享页面、记录每一次修改,并在用户误删后保留三十天的恢复窗口。

更麻烦的是诊断。用户说“我的高亮不见了”,我拿到的只有一个 CKError。没有请求 ID,没有变更游标,也没有办法在本地重放那个账号的状态。1 当付费用户越来越多,这种不可见性开始变成真实成本。

还有一个很现实的原因:我想让拾页有一个自己的域名。同步服务是第一步。它不一定酷,但它必须属于我,能被我理解、备份和修复。

先把边界定清楚

迁移之前,我给自己写了三条约束:

  • 本地 SQLite 永远是唯一的事实来源。没有网络时,拾页必须完整可用。
  • 服务器只做两件事:转交变更,保存可恢复的备份。它不渲染页面,也不替用户做决定。
  • 不引入 CRDT。拾页的数据关系比通用协同简单,产品规则可以解决大部分冲突。2

这三条约束后来救了我很多次。每次想加一个“服务器顺手做掉”的功能,我都会先问:如果服务器挂了,客户端还能不能正常工作?答案通常是能,于是功能被放回客户端。

操作日志,而不是数据库镜像

旧方案里,客户端把整条记录推上去,服务器保留最新版本。新方案改成只追加的操作日志:每一次修改都是一条不可变的 change。服务器按顺序保存,客户端按游标拉取,重复应用同一条 change 不会产生副作用。

TypeScript
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 足够撑很久;真到撑不住的那天,再换也不迟。

SQL
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 去重。

TypeScript · 拉取端点
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,再用同一个导入器跑三遍:第一遍只校验,第二遍写到影子库,第三遍才写正式库。每一遍都对比实体数量、内容哈希和随机抽样的五十条记录。

拾页同步架构迁移示意图 左侧是旧的 iCloud 托管路径,右侧是新的自建同步服务,客户端仍然以本地 SQLite 为准。 Mac / iPhone 本地 SQLite iCloud CloudKit 旧路径 Mac / iPhone 本地 SQLite 变更 sync.zhouyu.dev 变更日志 · 鉴权 SQLite 主库 每日备份 新路径:增量推拉,本地优先 拾页同步架构迁移示意图(竖版) 设备以本地 SQLite 为准,通过增量接口连接自建同步服务,服务端保存变更日志和主库,并每日备份。 Mac 与 iPhone 本地 SQLite · 唯一事实来源 增量 push / pull sync.zhouyu.dev 变更日志 · 鉴权 · 游标 SQLite 主库 按 seq 排序 · 每日快照 异地备份 可回滚 30 天 客户端离线时,读写仍然完整可用
图 1:迁移后的同步路径。WebSocket 只负责提醒,真正的数据仍然通过增量接口传输。

八月最后两周,新旧两条路径并行写入,每天凌晨跑一次对账。九月的第一个周五,我把同步切到只读模式,用一个晚上完成了最后一次导入。周六早上六点,第一批真实设备连上新服务,游标从零开始,历史数据通过导入器补齐。

没有停机,是因为客户端本来就不依赖服务器。即使那一晚服务完全不可用,用户仍然可以打开拾页、读已经缓存的文章、写高亮;只是暂时同步不出去。把“不可用”降级成“稍后同步”,是本地优先架构最值钱的地方。

迁移之后

三个月后,拾页的同步 p95 从 1.8 秒降到 420 毫秒,关于同步的客服邮件从每月十八封降到三封。服务跑在一台很小的机器上,算上备份,每月成本约十八美元。3

但自建并没有让同步变简单。它只是把问题从黑盒里拿出来,放到我面前:要监控、要备份、要处理证书到期,也要接受自己会在凌晨两点被报警叫醒。真正的收益不是“更便宜”,而是“终于知道发生了什么”。

现在回头看,最危险的不是迁移本身,而是迁移前那种“应该没问题”的感觉。把旧数据导出三遍、把回滚脚本写两遍、把每个 change 都当成可能重复的请求来处理,这些无聊的动作才是这次没有丢数据的原因。

下一步,我会把同步服务做成可观测的:每个设备、每个游标、每次冲突都有一条能看懂的记录。不是为了炫技,只是希望下一次用户说“我的高亮不见了”时,我能直接告诉他:它在什么时候、从哪台设备、变成了什么。

把同步做得无聊,是这次迁移最重要的成果。

脚注

  1. CloudKit 的 record zone 变更令牌只保证顺序,不提供服务端查询和审计。遇到环境重置或账号迁移时,旧令牌也可能失效。↩

  2. 这里没有采用 CRDT。拾页的主要对象是页面与高亮的从属关系,业务规则比通用合并更简单,也更容易向用户解释。↩

  3. 机器约 12 美元/月,备份存储约 4 美元/月,域名和证书约 2 美元/月。不含我自己的维护时间。↩