拾页从 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 视为重试。
表结构尽量让错误显形
新库只有五张表: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 月。感谢在迁移期间帮我压测、复现和校对的朋友。