本文目录 / Contents
  1. 为什么要离开 iCloud
  2. 先画清楚边界
  3. 一台 2C4G 的机器
  4. 客户端只做三件事
  5. 能解释比先进重要
  6. 双写、灰度与旧钥匙
  7. 代价与收获

拾页 1.x 的同步一直跑在 iCloud 上。对只有一个人的团队来说,这几乎是最省事的选择:不用注册账号,不用维护服务器,用户打开应用,数据就在那里。直到去年夏天,一位用户写信给我:她在 Mac 上标了 47 处高亮,回到 iPhone,只剩下 12 处。

那不是一个靠「再同步一次」能解决的下午。也是从那天起,我开始认真考虑,把同步迁到自己手里。

01 / WHY为什么要离开 iCloud

iCloud 并没有坏。对大多数用户,它仍然工作得很好。问题在于,当它不工作时,我看不见发生了什么。

CloudKit 给我的是一层被精心封装过的接口:记录、变更令牌、冲突回调。它不给我完整的服务端日志,也不允许我按自己的方式重放一次同步。用户说「丢了」,我只能让对方退出账号、重装、再把设备寄来——最后这一项是玩笑,但前面两项不是。更多时候,我只是在客户端加日志、猜、再试。

第二个原因是边界。拾页迟早要有 Windows 版和可分享的网页,而 iCloud 的私有数据库只在苹果生态里。第三个原因更私人:我想把「数据放在哪里、保留多久、出问题怎么修」写进自己的隐私页,而不是指向一份我无法修改的协议。

所以这不是一次技术炫技。它只解决一个问题:让同步从黑盒变成我能负责的系统。

同步不是把文件搬来搬去,而是让两台设备对同一段时间达成一致。

— 迁移文档第一页

02 / BOUNDARY先画清楚边界

动手前,我先不写代码,只画边界。拾页的数据其实只有三类:

  • 文档结构和高亮:小、变化频繁,必须强一致;
  • 附件:大、变化少,可以晚一点到;
  • 搜索索引:完全可重建,不需要同步。

对应地,元数据放进 PostgreSQL,附件放进兼容 S3 的对象存储,索引留在本地 SQLite。设备仍然以本地数据库为准,联网只是把本地变更推上去、把别人的变更拉下来。断网时,拾页和以前一样能用。

我也做了一个不太讨喜的决定:不做端到端加密。2.0 需要跨设备全文搜索和公开分享页,端到端加密会让这两件事变得昂贵。传输走 TLS,服务端磁盘加密,权限按用户隔离;这些会明确写进隐私说明。取舍不漂亮,但至少诚实。

03 / SERVER一台 2C4G 的机器

服务端没有微服务。一台 2 核 4GB 的云主机1,Caddy 处理 TLS,Go 写一个单体 API,PostgreSQL 存变更,对象存储放附件。每天凌晨做一次全量备份,再把备份同步到另一家对象存储。听起来朴素,但它撑住了目前约 2,800 位日活用户。

核心表只有一张:每次客户端提交的不是「最新文档」,而是一串带版本号的变更。这样服务端不需要理解编辑器结构,只负责顺序、幂等和保留历史。表里记录 doc_id、device_id、revision、base_revision、payload 和幂等键;主键是「文档 + 设备 + 版本」,幂等键唯一。它不存「正确答案」,只存发生过什么。

04 / PROTOCOL客户端只做三件事

客户端这边延续拾页原来的本地优先模型。所有编辑先写本地 SQLite,同时追加到一张 outbox 表。同步器每两秒检查一次 outbox,把变更打包成下面这个结构发出去。

SyncClient.swift · PushEnvelope
struct PushEnvelope: Codable {
    let docID: UUID
    let baseRevision: Int64
    let changes: [Change]
    // 同一次重试复用同一个 key
    let idempotencyKey: UUID
}

服务端只做两件事:如果 baseRevision 与当前一致,接受并返回新的 revision;如果不一致,返回 409 和当前版本。客户端收到 409 不直接覆盖,而是拉下完整文档,用 base、local、remote 做一次三方合并。如果同一个段落被两边同时改写,保留服务端版本,把本地版本放进「版本历史」,30 天内可恢复2。

附件走预签名 URL 直传对象存储。网络失败时不重试整个文档,只重试那一个请求;指数退避到 30 秒,幂等键保证重复请求不会写出两份。

05 / CONFLICT能解释比先进重要

我一开始想上 CRDT。读了几天论文,写了一个原型,然后放弃了:拾页的核心对象是文章、段落和高亮,不是多人实时协作文档。为一个低频冲突引入一套难以调试的数据结构,不划算。

最后采用的是「块级最后写入 + 墓碑」:

  • 新增高亮是幂等加法,两台设备各标一句,两边都保留;
  • 同一段落被两边修改,以服务端 revision 较新的为准;
  • 删除保留墓碑,避免旧设备一上线又把内容复活。

它不完美,但每一种行为都能用一句话解释给用户听。这比「理论上更先进」重要。

迁移后的拾页同步架构示意图 Mac 与 iPhone 先把编辑写入本地 SQLite,再通过 Sync API 推送到 PostgreSQL 中的版本记录;附件由设备直传对象存储。 01 / DEVICES 02 / CONTROL PLANE 03 / DATA PLANE Mac local SQLite · outbox iPhone offline ready Sync API Go · single binary push / pull idempotent writes PostgreSQL revisions · cursors daily backup Object storage attachments presigned URLs changes commit upload TLS 1.3 · 每台设备一枚独立 token · 失败重试上限 30 秒 FIG. 01
图 1:迁移后的同步链路。设备先写本地,再推到自己的 API;附件单独走对象存储,不再经过 iCloud。SCALE / SCHEMATIC

06 / MIGRATION双写、灰度与旧钥匙

真正的迁移从今年 7 月开始,用了三个周末。

第一周,所有新变更双写:iCloud 和自建服务各写一份。读取仍然来自 iCloud,自建服务只收不读。这样即使新服务坏了,用户也感知不到。

第二周,写迁移工具。CloudKit 没有给我一个批量导出的按钮,我让每台设备把本地数据库完整推送一次。12,000 位付费用户里,首周有 67% 的设备完成上传,剩下的是长期不联网的 Mac。对这部分数据,我保留了一年的 iCloud 导入入口。

第三周,灰度读取。按用户 ID 的哈希分 5%、20%、50%、100% 四批切流。每一批观察 48 小时,看三个指标:同步失败率、409 比例、附件的 P95 延迟。100% 之后,旧的 iCloud 写入继续保留了一个月,像一把没有立刻扔掉的旧钥匙3。

最难的不是代码,是决定什么时候停。迁移期间,我每天最想做的事是「再修一个边缘情况」。但再修下去,发布永远不会发生。

07 / THE BILL代价与收获

成本从每月 0 元变成了大约 32 美元:主机、备份、对象存储,加上一点点监控。还有看不见的部分:凌晨两点的报警,升级 PostgreSQL 前要读 release notes,以及「服务器是不是又挂了」的幻觉。

收获也很具体。上线六周后,同步失败率从 1.8% 降到 0.3%,关于「高亮不见了」的邮件从每周三四封变成零。更重要的是,现在用户说同步有问题,我可以打开日志,看到是哪台设备、哪个 revision、在哪一步停住。那种「终于知道该去哪里找」的感觉,比任何漂亮架构图都值。

如果重来一次,我会更早做三件事:第一天就加结构化日志;先写迁移工具,再写新功能;把重试策略写成文档,而不是留在脑子里。

基础设施的意义不是更酷。是当它坏掉的时候,你知道该去哪里。

拾页 2.0 的公测定在明年一月。同步不会是发布会上最亮的那一页,但它是底下那块地板。地板铺好之后,我终于可以把注意力放回更小的事情:一个更安静的阅读模式,一组更好的快捷键,和一段读起来不费力的文字。

注释 / Footnotes

  1. 2C4G 指 2 核 CPU、4GB 内存。实际配置会随版本升级,但到现在还没有换过机器。↩
  2. 冲突版本保留 30 天,超过之后随快照一起清理。↩
  3. iCloud 写入在 2026 年 9 月底彻底关闭;旧数据导入入口至少保留到 2027 年 10 月。↩
Thanks for reading

周屿

独立开发者,在杭州做拾页和慢信。相信小工具也能有长久的生命。目前大部分时间在准备拾页 2.0。 关于我 ↗