这条在讲 ctrodb 作者给客户端数据库加离线同步的经历。出处是 Odejobi Abiola Samuel,7 月 5 日在 dev.to 发的,同步代码在 github.com/ctrotech-tutor/ctrodb 的 src/sync/ 下,npm 装法是 npm install ctrodb。
我一开始以为是"介绍自己造的轮子",读到中间那个数字才决定推——同步功能的代码量超过了数据库其余部分的总和。数据库本身才几千行,提供 schema 校验、查询、响应式信号。同步把它超了。
这个数字比任何"同步很难"的说法都具体。做客户端数据库的人容易把同步当收尾功能:核心写完,加个 push/pull 就完了。结果作者花了三个月,重写了三版冲突解决器,废弃了一套已经发布的传输系统。
三版冲突解决器。废弃的是发布过的传输系统,不是内测的。
我读到这里先笑了一下。不是笑作者,是笑"同步就是两端存个 JSON 再合并"这种念头。夹子里还存着几篇同样画风的。
然后是真正值得点的部分。作者没写"最终方案",他写的是"我做完又删掉了什么"。
有个细节我读到后半段才确认值得摘:他花两周构建了一个同步顺序依赖系统,确保被引用的记录先同步。理由是父记录没到,子记录先到会挂。两周做出来,后来发现数据库在应用层早处理了这个问题。系统删掉,同步跑得好好的。
真做工程的人会理解:最大的成本往往不是没做出来的东西,是做出来发现不需要的东西。
变更日志那块也不含糊。插件挂在 onAfterCreate、onAfterUpdate、onAfterDelete 上,每次写入追加一条 SyncChangeRecord:
{
id,
collection,
recordId,
type,
data,
prevData,
timestamp,
status,
retries,
errorMessage,
createdAt,
updatedAt
}
关键在写入边界:这条变更日志和主数据写在同一个事务里,同成败。评论里有个叫 mote 的写到点子上——大多数人忽略这一点,孤儿变更会让数据库在表面"已同步"的状态下静默不一致。所谓孤儿变更,就是数据写了但变更没记上,或者变更记了但数据没写。状态栏看着 synced,两边早就分叉了,没人提醒。
这是个本地一致性问题,跟网络半点关系没有。要出问题,连第二台设备都不用。
然后才到文章里最花篇幅的部分:冲突。
默认 lww,最后写入者胜出。文章里举例:Alice 在手机上改了笔记标题,Bob 在笔记本上改了同一条笔记的正文,两个人离线改,然后重连。Bob 先同步完,Alice 后同步就覆盖掉 Bob 的正文。静默覆盖,没有通知,数据不能恢复。
lww 在离线协同里几乎都是错的。唯一的好处是最简单。作者自己也说了,lww 能过演示场景,一撞上两台设备同时编辑同一记录就露馅。
他先后重写了三版冲突解决器,最后只留 custom 钩子。原因是:库不知道记录的业务语义。标题和正文能不能合并,只有写应用的人知道。引擎只负责找到冲突字段、暴露双方版本,决定交给调用方。
这句话值回阅读时间。很多同步库死磕"自动冲突解决",最后卡在"这个字段归你、那个字段归我"这种判断上——这不是库能替你做的决策。
后面的坑是评论区挖出来的。有个叫 Vinicius 的指出推送的幂等性漏洞:服务端已经提交了批次,但响应丢了,客户端重推,服务端重复应用。作者回复说,客户端生成的变更 ID 已经在每条 SyncChangeRecord 里有记录了,但服务端去重还没接入,是下一步工作。
这个比孤儿变更更隐蔽。孤儿变更是本地事务边界问题,一层事务就能盖住;响应丢失导致的重复应用,是跨网络的"提交了但不知道提交了"的问题,跟本地事务用的不是同一套机制。作者本地那层用事务解决了,网络层这个还没接。
还有个评论在聊墓碑回收。删除和编辑并发,墓碑是正确做法,但"什么时候能安全回收墓碑"才是真难点。有人提 Postgres 复制槽的思路,用单调游标让墓碑确认自然完成,再按最大离线时长让超期客户端全量重同步。
作者接话说,目前时间戳用的还是 new Date().toISOString(),正在研究混合逻辑时钟,还没决定。
new Date().toISOString() 当同步时间戳,这种诚实我服。分布式系统里,除非所有设备永远联网对时,否则设备间时间差直接决定谁的修改赢、谁的被覆盖。
到这儿我基本确定这篇值得推。不是因为能照抄,是因为它把浅水区和深水区的界划得够清楚。浅水区是自己能做的:事务、插件、分页、重试、故障传输、调试函数。深水区是还没搞定的:网络幂等、时钟偏差、墓碑回收。这条分界线本身就是内容。
而且这种区分在技术博客里少。多数作者写到"我的方案"就停了。这篇还在最后写"哪些是我做完又删掉的、哪些是我到现在还没解决的",甚至评论里被点到的问题也接住了。
不点链接也能带走的:给客户端数据库做同步,先把"这是数据库本体之外的事"这个预期扔了。同步就是另一个数据库,它有自己的变更日志、游标、幂等边界。不管它,它会在第二次设备重连时冲掉第一条数据。
