拔掉网线之后:本地优先软件与 CRDT

上周在高铁上,我改一份方案。

隧道里没有信号。那个笔记应用没有转圈,没有弹「网络不可用」,我能继续打字,甚至还能看见光标。我当时还挺感动,觉得它做得挺体面。

出了隧道我合上电脑。第二天打开,那份文档旁边多了一个文件:方案 (冲突副本 2026-09-11)

我盯着那个文件名看了一会儿。它不是报错,也没有丢东西——两个版本都在,我自己拼回去就行。但这个结果让我愣了一下:我在隧道里那二十分钟以为自己在「编辑」,其实只是在「暂存」。

一根网线的特写,水晶头和线缆

# 云优先这个默认,其实挺奇怪的

我们这代人已经很习惯一件事:文件存在别人的机器上,自己硬盘上的那份只是缓存。

顺着这个默认往下推,会推出一些不太对劲的结论:

  • 你想看三年前写的东西,得先联网。那东西是你的,但你打不开它;
  • 公司关停服务,你的数据就没了。这不是假设,Google Reader、Delicious、无数个 sync 服务都发生过;
  • 本地明明有这份文件,「打开」这个动作却要等一次网络往返;
  • 最微妙的是心理层面的:你不再觉得它是「你的文件」,你觉得它是「你的账号里的文件」。

2019 年 Ink & Switch 那篇《Local-First Software》把反过来做的那套东西给了个名字。它的核心主张只有一句:数据的主副本在你本地,网络只负责同步,不负责授权。

一排服务器机柜,指示灯亮着

论文里列了七条理想,我最在意的是第三条:网络是可选的(The network is optional)。注意它不是说「要支持离线模式」,而是说——在线只是同步的时机,不是工作的前提。

这句话听着简单,实现起来是另一回事。因为一旦主副本在本地,你就躲不开那个问题了。

# 同步为什么难

如果只有你一个人,同步根本不叫问题:把文件传上去,完事。

一旦有两个人同时改,你就必须回答一个问题:谁的改动算数?

最朴素的答案是「最后写入者获胜」(Last-Write-Wins,LWW)。给每次改动盖一个时间戳,谁的晚,谁留下。

一条岔路,路牌指向不同方向

它有两个大坑。

第一个坑是时钟。设备时钟会漂移,会跨时区,会有人手动调过,NTP 也不总能救你。「谁更晚」这个判断,在分布式系统里比你想象的不可靠得多。

第二个坑更严重:LWW 的本质是丢弃,不是合并。我改了标题,你改了正文,这两件事本来可以共存,但 LWW 只留一个——它不知道「标题」和「正文」是两个字段,它只知道「这份文档在 15:32:07 被写了一次」。

一只沙漏,沙子正在往下漏

真正让人崩溃的场景也在这:我只改了一个字,结果我的整份文档被覆盖了。从用户视角看,这不叫冲突,这叫丢数据。

另一条朴素的路是加锁:谁在编辑谁锁住,别人只能看。Office 共享文档和早期 Google Docs 都这么干过。它好懂,但它把「协作」变成了「排队」,而且锁的释放本身就不可靠——客户端崩了怎么办?

# 两条路:OT 和 CRDT

工业界真正跑通的是另外两条路。

第一条叫 OT(Operational Transformation,操作变换)。它把改动描述成「操作」——不是「文档变成了什么」,而是「在第 5 个字符后面插入 abc」。两个人并发改,服务端收到两个操作,把它们互相「变换」一下:让后到的那个适配先到的那个已经造成的效果,再应用。

Google Docs 用的就是这套。它很成熟,文档结构也紧凑。代价是它需要一个中心服务器来定全序——每个操作都得由服务端发一个序号。一旦服务端是唯一的裁判,「本地是主副本」就没法彻底成立:离线期间你只能攒着操作本地重放,重连时再让服务端帮你变换,而这里面的边缘情况多到能写一本书。

第二条路叫 CRDT(Conflict-free Replicated Data Type)。它换了个思路:不去「变换」冲突,而是把数据结构本身设计成——不管以什么顺序合并,结果都一样。

一盘棋局,黑白格子交错

具体就是三个性质:

  • 交换律merge(a, b)merge(b, a) 结果相同;
  • 结合律merge(merge(a, b), c)merge(a, merge(b, c)) 结果相同;
  • 幂等merge(a, a) 还是 a

只要这三条成立,你就可以随便合并——任意顺序、任意次数、重复合并——最终一定收敛到同一个状态。不需要中心服务器,不需要全局定序,不需要锁。冲突不是被「解决」的,是根本不发生

这三行听起来像废话,但把它们落到具体数据结构上,是过去十几年分布式系统里最漂亮的一段工作。

举几个例子。

只增计数器(G-Counter):每个副本有一份数组,自己那个槽位 +1,合并时逐位取最大值。计数只会涨不会丢,两个人同时点赞一定都算上。

集合(OR-Set):加元素时给它一个唯一 tag;删除不是真删,而是把这个 tag 扔进一个「墓碑」集合。合并就是并集。这样「我加了、你删了」这类恼人的情况就有了确定答案。

文本,这是最难的一个。难点在于「在第 5 个字符后面插入」这句话,在别人也在插入的时候会漂移——你说的「第 5 个」,和他说的「第 5 个」,可能不是同一个位置。

做法是给每个字符发一个全局唯一、且能定序的 ID(fractional indexing,或者 Lamport 时间戳加站点 ID)。删除同样不是真删,是立个墓碑。这样每个字符的位置由它的 ID 决定,不受别人插入的影响。Yjs 的 YATA、Automerge 的 RGA,都是这一类结构的不同实现。

落到代码里,大概是这样:

import * as Y from 'yjs'

const doc = new Y.Doc()
const text = doc.getText('content')

text.insert(0, '你好')

// 把状态序列化成一段二进制,可以存 IndexedDB,也可以发给别人
const update = Y.encodeStateAsUpdate(doc)

// 另一台机器,可能是三天后,可能是离线状态下
const doc2 = new Y.Doc()
Y.applyUpdate(doc2, update)

doc2.getText('content').toString() // '你好'

这段代码看着平平无奇,重点是 applyUpdate 可以乱序调用、重复调用,结果都一样。你不用关心这段 update 是第几个到的、有没有到过。这就是交换律和幂等给你的东西——也是「网络是可选的」能成立的原因。

# 但你得为它付钱

天下没有免费的收敛。CRDT 的代价非常实在:

墓碑不消失。 删掉的东西还在那儿,只是看不见。一篇被反复修改一万次的文档,内部结构里躺着一万个字符的骨架。Yjs 做了不少优化(合并连续的删除、用整数压缩 ID),但量级问题没有消失。

ID 比内容大。 为了能全局定序,每个字符都要带上站点 ID 和时钟。一篇十万字的文档,内部表示可能是正文的几倍。这个数字在浏览器里是要进内存的。

调试像考古。 你看到的 abc,内部是一串带着 ID 和墓碑的链表。出了 bug,你没有一个「人类可读」的视图可以打开看。这是我见过的每个用 CRDT 的团队都抱怨过的事。

「最终」到底有多最终。 CRDT 保证的是最终一致,不是当下一致。两个副本在断网期间各自演化,重连后合并的语义是「都保留」——对文本来说是对的,但对某些东西是错的。比如两个人都把同一个开关关掉了两次,你想要的答案可能是「关一次」,而不是「两次都记下来」。

# 谁在真的用它

不是纸上谈兵,已经有不少产品跑在上面:

  • Figma:多人同时在一个画布上操作,它的场景树本质上就是一套 CRDT 化的结构;
  • Linear:任务状态同步,断网可用,重连后自动合并;
  • 苹果的备忘录和提醒事项:CloudKit 底层用的就是 CRDT,Apple 在工程博客里公开讲过;
  • Obsidian Sync 和一批本地 markdown 编辑器:文件本身就是本地主副本,同步只是同步;
  • 前端最主流的两个库是 YjsAutomerge,前者性能更好,后者历史更久、语义更讲究。

顺带说一句 git。git 其实是个「人工 CRDT」:它的 DAG 保证任意顺序合并同一组提交,结果一致;真正冲突的地方它不猜,原样停下来交给你。

差别在于粒度:git 的合并单位是文件行,而且它愿意停下来问你。CRDT 不愿意停下来——这是它的优点,也是它最贵的代价。

# 结尾

回到那趟高铁。

我现在的做法很土:文档是本地的 markdown 文件,同步靠 git。它一点都不优雅,冲突时照样要我手动解。

但它有一件 CRDT 给不了我的东西:那个冲突我看得见。

一台老式打字机,键帽排列整齐

本地优先这件事,我想说的其实不是「离线也能用」。那只是顺带的好处。

真正被换掉的是那个默认假设:数据在我的硬盘上,网络只是让它也出现在别的地方——而不是反过来。

CRDT 是把这个假设在工程上变可行的那半边。它不完美,墓碑、膨胀、调试难,每一个都是真问题,随便一个都能劝退人。

但它是我见过的第一个不需要「找个中心来裁判」、就能让好几个人同时写同一份东西的办法。这个想法本身,我觉得比任何一个具体的库都值钱。


题图与部分配图来自开放授权的摄影作品,感谢这些作者:Crossroads: Invest or Spend — ccPixs.com;Hourglass — dinohoax;Chess Middlegame Puzzle - 3 — ChessNetwork;typewriter — Ak~i(均为 CC BY 2.0,Flickr)。