技术 阅读 12 分钟

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

一次不浪漫但值得的迁移:从 CloudKit 容器,到杭州的一台 Postgres、一个对象存储和我自己写的同步服务。

拾页从 1.0 开始就把数据放在 iCloud 里。用户不用注册,换一台 Mac 登录同一个 Apple ID,收进来的文章和划过的线就会自己出现。对一个只有一个人的产品来说,这是当时最省事、也最体面的选择。

四年之后,它变成了边界。不是 CloudKit1 坏了,而是我开始需要一些它不打算给我的东西:可查询的运营数据、可导出的用户数据、跨平台的余地,以及在同步出问题时能自己看到日志。

先承认平台的好

我把这次迁移当成一次架构调整,而不是对 iCloud 的否定。前三年它替我省掉了账号体系、鉴权、推送和一部分运维。拾页早期的同步成功率一直在 99.7% 以上,这个数字不是白来的。

真正让我下决心的是三件小事:想做「一年内的高亮统计」,CloudKit 的查询能力不够,只能在客户端拉全量再算;有用户要求导出全部笔记,我得临时写一套脚本;一台旧 Mac 的时钟快了 11 分钟,产生了一批乱序记录,而我拿不到服务端日志。单看每一件都能绕过去,放在一起,就说明数据层已经不在我手里。

“我不怕你们收费,我怕有一天我想把文章搬走,发现它们不在我这儿。”

一位用户在迁移前的邮件

这封邮件我看了很久。它说的不是价格,是所有权。

在此之前,我一直把 CloudKit 当成房间里的承重墙:看不见,但默认它会在。决定迁移之后,我先花了两周确认这堵墙到底承什么重,哪些是我真的需要,哪些只是习惯了它的存在。

迁移的目标不是「搬完」,是「随时能回去」

我没有一上来就停 CloudKit。方案写了三条约束:任何时刻都能切回 iCloud,最多丢 24 小时;用户不需要重新登录,旧数据自动出现;切换按用户分批,出问题只影响一小部分人。听起来保守,但一个人维护的服务,保守是最便宜的特性。

还有一条约束没有写进文档:不能占用我全部的工作时间。拾页还在卖,慢信还要回信,客服每周仍要处理四五十封邮件。因此每一步都必须能在半天内停手,并且第二天还能接上。

最终采用的是双写、回填和影子读。客户端先继续写 CloudKit,同时把变更写进新的 /changes 接口;服务端回填历史数据;读请求按开关比例走新库,但两边都返回时,比较结果的内容哈希。这样做多花大约六周,也让我在迁移期间同时维护两套同步逻辑。事后看,这六周买到了三次可以悄悄回滚的机会。

type Change = {
  id: string;               // 客户端生成的 UUID
  recordId: string;         // 对应的高亮或文章
  deviceId: string;
  revision: number;         // 同一个 recordId 单调递增
  payload: HighlightPatch;
  idempotencyKey: string;   // 重试时保持不变
};

// 服务端只接受更高的 revision;相同 revision 视为重试。
迁移期间的双写与回填架构 Mac 与 iPhone 客户端同时写入同步 API;API 写入 Postgres 和对象存储。回填 worker 从只读的旧 CloudKit 读取历史数据,再通过同步 API 幂等写入新库。 Mac 客户端 本地 outbox iPhone 客户端 本地 outbox 同步 API 杭州 · 4C8G Postgres 文章 · 高亮 · changes 对象存储 快照 · 附件 回填 worker 可重入 · 按用户游标 CloudKit(旧) 只读历史数据 双写 分页读取 幂等写入 迁移期一次性回填 在线读写
示意图:迁移期间客户端保留 CloudKit 双写,旧数据由回填 worker 逐用户搬入新库,读路径再分批切过去。左右滑动可看全图。

表结构尽量让错误显形

新库只有五张表:users、documents、highlights、changes 和 devices。最先写的是 changes 的约束。同步系统的大部分 bug 最后都会表现成重复、乱序或丢失,所以我想让这三件事在数据库层就写不进去。

create table changes (
  id              uuid primary key,
  user_id         uuid not null references users(id),
  record_id       uuid not null,
  device_id       text not null,
  revision        bigint not null,
  payload         jsonb not null,
  idempotency_key text not null,
  received_at     timestamptz not null default now(),
  unique (user_id, idempotency_key),
  unique (record_id, revision)
);

unique (user_id, idempotency_key) 负责让重试变成无害的重复请求;unique (record_id, revision) 把客户端乱序直接变成 409,而不是一条看起来正常、实际上更旧的记录。删除不物理删除,写 tombstone;高亮按字段合并,阅读进度取较新的值。产品里没有「合并冲突」这个词,冲突应该在后台安静地解决。

为了让回滚更简单,我没有同时删除 CloudKit 里的旧记录。新库的每条数据都保存了旧的 recordName 和 container 标识,回退时只需要反向同步最近 24 小时的增量。

回填的 36 小时

历史数据比预估的多。1.2 万付费用户留下约 320 万条高亮、41 万篇文章和 90 万条笔记;CloudKit 的导出接口按用户游标翻页,平均每个用户要 2.4 秒。全部跑完用了 36 小时3。

回填 worker 不追求快,只追求可重入。每个用户开始前写一条 migration_jobs 记录,成功后写入数据量和内容哈希;失败就停在该用户,下一轮从游标继续。回填过程最怕的是把「没读到」当成「没有数据」,所以每个游标页都记录了请求时间和返回数量,数量为 0 时不会直接结束,而是至少重复一次。

for try await page in cloudKit.pages(of: .highlight, user: user.id) {
    let rows = page.records.map(HighlightRow.init)
    try await db.upsert(rows, onConflict: .ignore)
    try await job.checkpoint(page.cursor, count: rows.count)
}

对每个用户的高亮正文和更新时间做一次哈希,再和源库比对,能发现「看起来成功、其实少了一页」的错误。36 小时里有 17 个用户对不上,原因分别是重复记录、空正文,以及一条 2019 年的时间戳被写成了 0001 年。回填脚本没有开源,原因是它和旧容器的字段映射绑得太紧2。

切换那天

10 月 2 日,第一批 1% 用户切到新读路径。开关在服务端,按 user_id 哈希,不依赖客户端版本。第一晚只看四个指标:读请求错误率、写请求 p95、冲突率、回退次数。

两小时后,回退次数升到 0.8%。原因很普通:一位用户在两台 Mac 上同时打开同一篇文章,旧客户端把 revision 从 8 写回 7。我们加了服务端单调 revision 校验,并把这类请求降级成一次客户端刷新。

当天下午扩到 10%,晚上 50%,第三天全量。CloudKit 双写保留 90 天。到第十天,回退按钮没有再被按过。

全量后一周,我们把客服模板里「同步冲突」的解释删掉了。以前用户看到这个提示会以为数据坏了,现在它多数时候只意味着另一台设备抢先保存了一次。

迁移里最危险的不是技术,而是「应该没问题」的错觉。先让系统允许你后悔,再谈优雅。

现在

新的同步服务跑在杭州一台 4C8G 的机器上,Postgres 存结构化数据,对象存储放大纲和原始网页快照。每月成本约 1,600 元,比 CloudKit 贵,但换来了慢查询、导出任务和一条能看的日志。

今天拾页的同步成功率是 99.93%,比迁移前高一点。更重要的是,我终于可以在不改客户端的情况下修服务端。对一个第四年的独立开发者来说,这比「自建」两个字重要得多。

如果你也在维护一个靠某个平台长起来的小产品,我的建议不是尽早搬家,而是尽早准备一把能搬家的钥匙。平台没有错,依赖也没有错;把出口留在自己手里就好。

这件事做完之后,拾页没有变得更「高级」。它只是回到了一个普通软件该有的样子:数据有出处,故障有线索,明天想改的时候,不用先问平台同不同意。

写于杭州,2026 年 9 月。感谢在迁移期间帮我压测、复现和校对的朋友。

脚注

  1. CloudKit 是 Apple 的云数据同步框架。本文不讨论它的公开定价,因为拾页的用量一直落在免费额度内。 回到正文
  2. 迁移期间的回填脚本没有开源,原因是它包含旧容器的字段映射;数据导出工具计划在拾页 2.0 公测时一起发布。 回到正文
  3. 文中用户数与数据量为便于阅读做了取整。收入相关数字不在本文讨论范围内。 回到正文