拾页的同步一直靠 iCloud Drive。对第一版来说,这是最省事的选择:不用服务器,不用账号,Mac 和 iPhone 之间也能凑合着用。到 1.9 版,问题开始变多。

01为什么离开 iCloud

最直接的问题是没有可观测性。用户说“昨天的高亮今天没了”,我只能让他导出日志,再猜是文件协调、版本冲突,还是某次写入根本没有发生。iCloud 的延迟也不稳定:同一段笔记在 Mac 上写完,iPhone 有时几秒就到,有时要等半分钟。

还有一次,一台 Mac 的 iCloud 目录在系统更新后没有挂载,应用却认为同步已经完成。三天的阅读记录只留在本地,用户直到换电脑才发现。数据没有丢,但“同步正常”这四个字变得不可信。

更大的问题在架构上。拾页的数据是一个 SQLite 文件,通过文件协调器整包上传。只要两台设备同时改,最后就是一份文件覆盖另一份。我们可以在文件层面做合并,但粒度太粗。2.0 要支持更细的标注编辑,这个模型撑不住。

所以目标不是“把 iCloud 换掉”,而是把同步从一个黑盒文件服务,变成我能解释、能回放、能在出问题时定位的系统。

02先写清楚不做什么

迁移前我列了四条约束:

  1. 离线优先。没有网络时,阅读和高亮照常写进本地 SQLite。
  2. 只同步变化。1.2 万用户里,最大的库有 4.7 万条高亮,整包上传不可接受。
  3. 一个人维护。服务端只有一个二进制、一个数据库、一条备份任务。
  4. 可以回滚。迁移期间旧数据保持只读,至少保留 30 天。

我也放弃了两件事:不做端到端加密,因为服务端需要做全文索引;不做实时协同,因为这一版只需要最终一致。这两个决定以后都可以改,但迁移时不能同时做。

同步不是把数据传到另一台机器,而是让两台机器对同一份数据达成一致。

第 41 周开发日志

这句话后来被我贴在显示器边上。它提醒我,同步的难点不是网络请求,而是两台设备对“现在是什么”有不同答案。

03把每次修改当成事件

本地仍然是 SQLite,表结构没有大改。新增的是一张 changes 表,每次写入都追加一条事件,而不是直接覆盖同步状态。

SQL / changes
-- 每次写入追加一条,不覆盖旧状态
CREATE TABLE changes (
  change_id   TEXT PRIMARY KEY,      -- UUID,客户端生成
  device_id   TEXT NOT NULL,
  entity      TEXT NOT NULL,         -- article / highlight / note
  entity_id   TEXT NOT NULL,
  op          TEXT NOT NULL,         -- upsert / delete
  payload     BLOB NOT NULL,         -- JSON,zlib 压缩
  local_seq   INTEGER NOT NULL,
  created_at  INTEGER NOT NULL
);
CREATE INDEX changes_pending ON changes(device_id, local_seq);

change_id 在客户端生成,服务端用它做幂等;local_seq 只在单台设备内单调递增,用来断点续传。真正的全局顺序由服务端返回的 server_seq 决定,客户端只保存一个游标。

冲突策略很笨:字段级最后写入者获胜,时间取混合逻辑时钟,时钟相同则比较 device_id。高亮是不可变对象,修改等于删除旧行再加一行,所以不会出现两个版本抢同一个 ID。删除留下 30 天的墓碑,避免离线设备把已删内容重新推上来。1

这套模型不优雅,但有一个好处:任何一条数据都能回答“谁在什么时候改的”。出问题时,我不需要猜。

04一个游标,一次幂等

服务端是 Go 写的,一个进程,Postgres 存事件和索引。同步只有两个接口:推和拉。

GO / push handler
func (s *Server) Push(w http.ResponseWriter, r *http.Request) {
    var req PushRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    seq, err := s.store.Append(r.Context(), req.DeviceID, req.Changes)
    if err != nil {
        http.Error(w, "append failed", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(PushResponse{ServerSeq: seq})
}

Append 在一个事务里做三件事:按 change_id 去重、写入 server_seq、更新全文索引。请求可以重试,重复的 change_id 会被忽略,返回当前游标。拉取用 GET /v1/changes?since=…&limit=500,客户端拿到空数组就说明追平了。

全文索引是这次迁移的隐性收益。以前搜索只能在本机做,现在服务端可以在不打开文章的情况下返回命中片段。代价是正文会经过服务器,所以隐私说明要写清楚,不能含糊。2

服务端没有做微服务,也没有上 Kubernetes。一个 4 核 8G 的机器,一个 Postgres,一个每小时上传到对象存储的备份。对现在的量来说,足够了;不够的时候再加。

部署也没有想象中复杂:一个 systemd 服务、一个 Caddy 反向代理、一条每天验证恢复的脚本。真正花时间的是监控面板——延迟、失败率、每人待处理事件数。没有这些数字,自建服务只是把黑盒从 Apple 换到了自己家。

05双写、回填、对账

真正麻烦的不是新代码,而是 1.2 万个已有数据库。我的迁移分三步,任何一步失败都能停下来。

第一步,双写。1.9.4 同时写 iCloud 文件和本地 changes,但只从 iCloud 读。这个版本跑了两周,确认事件表没有漏写。

第二步,回填。用户第一次打开 2.0 时,客户端把本地库压缩成一批事件推到服务端。为了不把服务器打满,客户端按 200 条一批、每批间隔 300 毫秒发送;服务端对每个用户限速。

第三步,对账。回填结束后,客户端计算本地每张表的行数和内容哈希,服务端返回同样的摘要。不一致就重跑该用户的回填,不猜。

迁移工具是一个单独的命令行程序,复用客户端的数据库层。它可以在不启动 GUI 的情况下跑完整个流程,这对排查问题很重要。

回填期间最怕的不是慢,而是重复。用户在两台设备上同时打开 2.0,两个客户端都以为自己是第一次回填。服务端用 device_id 和 change_id 挡住了重复事件,但仍有少量旧数据因为本地库损坏而缺失。对账把这部分找了出来,也让我第一次知道,1.2 万个库里大约有 40 个需要人工处理。

回滚方案也写死在工具里:服务端保留 30 天的完整事件,客户端保留迁移前的 iCloud 快照;如果对账失败,用户可以回到 1.9.4,数据不会分叉。

06三周之后

迁移窗口是三周。第一周只对内部设备开放,第二周放 5% 用户,第三周全量。上线后最常出现的不是数据丢失,而是时钟漂移导致的“最后修改时间”看起来不对。我们把显示层改成服务端时间,问题就消失了。

现在拾页的同步延迟中位数是 180 毫秒,99 分位在 2 秒以内。这个数字不算漂亮,但对一个阅读应用够了。3

更重要的是,我终于能在服务器上看到每一次同步。用户说高亮丢了,我可以直接查事件:是没有写、没有推、还是推了但被墓碑覆盖。大多数问题在十分钟内能有答案。

上线后的客服量没有增加,反而少了。以前用户描述“同步坏了”,我只能让他重装;现在我可以先看事件时间线,再决定要不要打扰他。这个变化很小,却是这次迁移里我最满意的部分。

这次迁移没有让拾页变得更“智能”,它只是让一件本来就该可靠的事,变得可以检查。2.0 还有很多没做完,但至少同步这一层,不再是我解释不清的部分。

写于杭州,2026 年 10 月。迁移工具和同步服务的部分代码会在整理后开源。

注释

  1. Apple 的 NSFileCoordinator 文档把冲突解决留给应用。对 SQLite 这种单文件数据库,冲突往往表现为整文件版本覆盖,而不是行级合并。↩
  2. 全文索引只索引文章正文和用户自己的高亮,不索引图片 OCR。隐私说明在设置页的“同步”一栏。↩
  3. 统计窗口为 2026 年 9 月 22 日至 10 月 20 日,只统计成功完成的同步请求。↩

周屿

独立开发者,住杭州。做拾页和慢信,维护 slugify-zh。这里写产品、代码和一个人工作的日常。

回首页