三个人同时改一份文档
先不看答案,看问题。假设你和两个同事在没有网络的飞机上,各自用笔记本编辑同一份文档的同一个副本。 落地后一同步,会发生什么?如果你用过传统的同步方式,下面三种死法至少中过一种:
锁:先到先得,其他人干等
文件被一个人锁住,别人只能看不能改。协调成本高,离线时更是不可能——断网了谁来发锁?
后写覆盖:谁最后保存,谁说了算
你写了一小时,同事先一步保存,你再保存——他的改动无声无息没了。数据丢了,还没人告诉你。
服务器仲裁:离线就瘫痪
所有改动都得先经过中央服务器排定顺序,它挂了你停摆,网差了你卡住,它记错排序大家一起错。
人真正想要的是第四种:谁也不用等、改动谁都别丢、断网照样写——合起来的时候自动拼成一份。 CRDT 就是为这个目标发明的。
CRDT 的魔法:三条性质
CRDT 全称 Conflict-free Replicated Data Type(无冲突复制数据类型)。 名字很唬人,本质是:一种把"合并"这个动作设计得极其乖巧的数据结构。 每一台设备都保存一份完整副本,各自独立地改;副本之间随时交换更新;交换完,自动合并。
它凭什么能自动合并?靠合并运算 ⊔(读作 merge)满足的三条数学性质:
合并顺序无所谓。你先把 A 的更新发给我、还是先把 B 的发给我,最终结果一样。网络乱序、迟到、重排都不怕。
先合并哪几份也无所谓。三台、五台还是二十台设备,怎么分组同步,结果都一致。拓扑结构随便你摆。
同一份更新重复发几次都不影响结果。网络重发、断线重连、客户端重试,全都安全——不需要去重逻辑。
实现它的两件必备零件
- 全局唯一 ID:每个操作都要能被唯一认出来。通常用 (设备ID, 递增序号) 配对,比如 (A, 7) 表示"A 这台设备的第 7 次操作"。这样合并时才能给并发操作排出确定的先后,而不靠物理时钟。
- 版本向量 / 状态向量:每台设备记录"我见过哪些更新"(比如 {A:7, B:3})。同步时双方交换这个向量,就能算出"你缺哪几条",只发差额——这就是 CRDT 增量同步省流量的原因。
亲手试:乱序合并
说一百遍不如点三下。下面这句话,三台设备各自离线改了一处。 随便你按什么顺序把它们的改动并进来——注意最终那一句。
这就是 CRDT 的核心观感:设备 B 和 C 明明改的是同一个位置(都在 "hello" 后面加字), 搁传统方式这叫"冲突",得人来裁决;搁 CRDT,系统按唯一 ID 排出确定的先后(B 的在前、C 的在后), 两句话都留着,谁的字都没丢。顺序你随意,结果就一个。
五种常见 CRDT
CRDT 不是单一的算法,是一大家子。按"数据长什么样"分,常用的有这几类:
每台设备各存各的计数,合并时逐台取最大值再求和。只增不减,天生不会冲突。
把"加"和"减"分别记在两个只增计数器里,合并后相减。加购 +1、取消 −1,各端随便点。
整块覆盖式的数据,谁的戳大听谁的。坑在于物理时钟会漂——设备时间不准就可能"改丢",所以成熟实现用逻辑时钟(Lamport 时钟)而不是墙上时间。
G-Set 只能加不能删;OR-Set(observed-remove)用"加过的痕迹"来支持安全删除——你删掉的元素,别人再加能正确保留,不会幽灵复发。
最有价值也最难的一类。每个字符带唯一 ID 和插入锚点,形成一棵可合并的树;删除留墓碑。你看到的"多人同时打字自动合并",背后就是它。
谁在真实世界里用它
而 CRDT 领域几个绕不开的开源库:
- Yjs —— 生态最大的文本/结构化 CRDT 框架,编辑器绑定一大堆(ProseMirror、Monaco、CodeMirror…)。
- Automerge —— 最早把"像 JSON 一样建模的文档 + 自动合并"做成完整产品的项目。
- Loro —— 新一代 Rust 实现,主打性能与完整同步能力,也是「CRDTs Are Not Enough」那场分享的主角团队。
- Diamond Types —— 同样在 Rust 生态里死磕文本 CRDT 的性能极限。
CRDT vs OT:两条路线
协同编辑的两大流派。简单说:OT 把聪明放在服务器,CRDT 把聪明放在数据结构。
| 维度 | OT(操作变换) | CRDT |
|---|---|---|
| 服务器角色 | 核心:必须参与,实时做变换运算 | 可选:可以只是个哑广播/存储 |
| 拓扑 | 通常中心化 | 任意,含 P2P、网状 |
| 离线编辑 | 难,回连时要补算大量变换 | 原生支持,本来就是为离线设计的 |
| 冲突处理 | 服务器按顺序变换操作 | 数据结构自动合并 |
| 代价 | 算法实现坑极多,中心依赖强 | 元数据/墓碑带来存储与计算开销 |
| 代表 | Google Docs | JupyterLab、Excalidraw |
它搞不定的事(以及"CRDT 还不够")
CRDT 很强,但边界很硬。想清楚它不做什么,比知道它做什么更重要。
一、强一致性场景:从原理上就无解
需要"任何时刻所有人看到同一个数"的场景——支付扣款、抢票、库存扣减、会议室抢占式预约—— CRDT 处理不了。它保证的是最终一致(可能晚几秒才一致),而这些场景要求强一致,必须有人裁决谁先谁后。 这类问题请交给数据库事务,别硬套 CRDT。
二、"一致"不等于"合理"
CRDT 保证所有端最终显示同一份内容,但不保证这份内容符合人的直觉。两个人在同一段里写了完全不同的句子, 系统可能把两段都留着拼接起来——技术上完美一致,读起来像车祸现场。所以产品上还需要冲突提示、历史回放这类 UI 来兜底。
三、CRDT 只是零件,不是整机
这正是那场分享「CRDTs Are Not Enough」要说的:CRDT 库(Loro、Yjs、Automerge…) 本质是比较纯的计算,它管"数据怎么合并",但不管网络、不碰磁盘、不懂权限、不做事务。 要做成产品级本地优先同步引擎,还缺一大堆:
- 网络层:怎么增量同步、P2P 怎么连、断线怎么补
- 持久化:本地存哪、怎么懒加载、元数据与正文分层(先看目录再拉内容)
- 权限与鉴权:谁能改哪部分、去中心化下怎么验证身份
- 端到端加密:服务端只当中转、连字节都看不懂
- 事务:把"更新目录 + 写入消息"这类跨流操作捆成一次提交,避免"收到新消息通知、点进去却看不到消息"
所以完整的图景是:CRDT 负责"合并"这一半,同步引擎负责"搬运、存盘、授权、兜底"另一半。 两半合起来,才是本地优先软件真正想给用户的承诺——秒开、离线可用、多端同步、后端可换、数据归你。
想再往下挖
- Conflict-free Replicated Data Types Shapiro 等,2011 —— CRDT 的奠基论文,想根治理论看这篇
- Local-first software Ink & Switch,2019 —— 本地优先理念那篇必读,讲的正是"云端该退居备份"
- Yjs 官方文档 想看能跑的代码、编辑器接入从这开始
- Loro 新一代 Rust CRDT 库,本文灵感来源团队的项目
- CRDTs Are Not Enough · 分享逐字稿 V2EX 本地优先会议首场,从 CRDT 到同步引擎的工程一手经验