拔掉网线之后:本地优先软件与 CRDT
- 作者:Bougie
- 创建于:2026-09-19
上周在高铁上,我改一份方案。
隧道里没有信号。那个笔记应用没有转圈,没有弹「网络不可用」,我能继续打字,甚至还能看见光标。我当时还挺感动,觉得它做得挺体面。
出了隧道我合上电脑。第二天打开,那份文档旁边多了一个文件:方案 (冲突副本 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 编辑器:文件本身就是本地主副本,同步只是同步;
- 前端最主流的两个库是 Yjs 和 Automerge,前者性能更好,后者历史更久、语义更讲究。
顺带说一句 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)。