本文目录 / Contents
拾页 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,把变更打包成下面这个结构发出去。
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 较新的为准;
- 删除保留墓碑,避免旧设备一上线又把内容复活。
它不完美,但每一种行为都能用一句话解释给用户听。这比「理论上更先进」重要。
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 的公测定在明年一月。同步不会是发布会上最亮的那一页,但它是底下那块地板。地板铺好之后,我终于可以把注意力放回更小的事情:一个更安静的阅读模式,一组更好的快捷键,和一段读起来不费力的文字。