CRDT:让多人协作"无冲突"同步的数学魔法
当多台计算机在网络不可靠的情况下同时修改同一份数据,CRDT 从数据结构的设计层面把冲突"消除在萌芽状态",让实时协作、离线编辑、去中心化同步变得工程可实现。
一、技术产生的背景
想象一下:你和同事同时打开 Google Docs 编辑同一份文档,你在改第三段,他在改第八段,两个人都没有感觉到"锁"或"排队",改动实时就同步了——这是怎么做到的?
更深一层的问题是:当多台计算机在网络不可靠的情况下同时修改同一份数据,如何保证最终所有副本一致?
这个问题来自分布式系统的"CAP 不可能三角"——一致性(Consistency)、可用性(Availability)、分区容忍性(Partition tolerance),三者最多只能满足两个。传统的数据库方案(如 MySQL 主从复制)选择了强一致性,但代价是网络分区时一部分节点不可用。而对 Google Docs、Figma、Notion 这类协作应用来说,用户不能接受"断网就不能编辑",必须走"乐观复制(optimistic replication)"路线:允许各个副本独立修改,先改再说,之后再想办法让数据趋于一致。
早期的解决方案叫 OT(Operational Transformation),1989 年提出。Google Docs、Microsoft Office 365 等老牌协同产品都基于 OT。但 OT 的设计很"脆弱":它需要一个中心服务器来编排所有操作,算法复杂度极高,每支持一种新操作就要写一套转换逻辑,而且对离线场景的支持非常差。
所以问题来了:有没有一种方法,能在数学层面保证数据自动收敛一致,而不需要复杂的协议和中心服务器?
这就引出了 CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)。
二、想要解决的核心问题
CRDT 要解决的核心痛点很简单:在多节点同时修改同份数据时,消除"冲突解决"这个步骤。
传统的冲突处理思路是:检测冲突 → 解决冲突。这是典型的"事后处理"模式,就像两个人同时改了一个文件的同一行,Git 告诉你"冲突了,你手动合并吧"。但在实时协作场景里,手动合并不可接受。
CRDT 的思路截然不同:从数据结构的设计层面,就把冲突"消除在萌芽状态"。它通过数学上的「可交换性」和「单调性」来保证:无论各副本以什么顺序收到并发操作,最终的状态都完全一致。不需要锁、不需要领导者、不需要回滚、不需要复杂的冲突检测协议。
没有 CRDT 会怎样?
- Google Docs 需要中心服务器处理所有操作排序
- Figma 的多人模式根本无法实现
- 离线编辑后上线,数据很难自动合并
- 分布式数据库的副本同步会变得极其复杂且容易出错
三、具体的实现方案
3.1 两种基本流派
CRDT 分为两大类别:
① State-based CRDT(CvRDT)
副本之间传递"状态"(整个数据结构的快照或增量),每个副本收到后调用一个合并函数(merge)来本地融合。核心要求是这个 merge 函数必须满足:
- 交换律:A merge B = B merge A
- 结合律:(A merge B) merge C = A merge (B merge C)
- 幂等性:A merge A = A
满足这三条,就能保证不管以什么顺序合并,最终结果都一样。
② Op-based CRDT(CmRDT)
副本之间传递"操作"(比如"在第3段后插入字符'X'"),每个操作在设计上就保证可交换——不管先执行 A 操作还是 B 操作,结果一样。这要求操作本身满足交换律,并且每个操作只需要执行一次(通过可靠的广播协议来保证)。
简单类比:打乒乓球时记录比分。A 说"我得了1分",B 说"我得了1分",不管谁先说的,总分都是2——这就是"操作可交换"。如果改成 A 说"比分+1",到了 B 那里执行时可能加在谁头上?这就乱了。CRDT 的设计就是让操作天然不乱。
3.2 常见 CRDT 数据结构
计数器(Counter)——最基础的例子
- G-Counter(只能加):每个副本维护自己的计数器。总计数 = 所有副本计数器之和。合并时每个位置取最大值。
- PN-Counter(可加可减):用两个 G-Counter,一个记录增加,一个记录减少。
集合(Set)——有玄机
- G-Set(只增集合):只能添加元素,不能删除。
- OR-Set(Observed-Remove Set):支持删除但解决了"删除后再添加"的问题。每个元素有一个唯一标签(tag)。删除时不是删"这个元素",而是删"我看到过的那些标签"。这样两个人同时 A 添加元素又 B 删除它,结果取决于谁"观察到"了谁的操作,不会出现数据丢失。
寄存器(Register)
- LWW-Register(Last-Writer-Wins):保留时间戳最近的那个写入。简单但有效,适合用户偏好等场景。
3.3 核心难点:文本序列(Sequence CRDT)
最复杂也最有价值的是文本编辑器场景——用户可以在任意位置插入、删除字符。如何在多人并发编辑中保持文本序列一致?
目前最主流的是 RGA(Replicated Growable Array) 算法,Yjs 框架就是基于此:
- 每个字符一个唯一 ID:ID 由 (客户端标识, 逻辑时钟) 组成,例如 (client_3, 42),全局唯一、单调递增。
- 插入时指定位置:插入新字符时,不仅要传字符内容和 ID,还要传"我要插在哪个 ID 后面"。这样每个副本都能在本地链表中找到正确的前置节点。
- 并发插入的处理:如果两个人在同一个位置之后分别插入字符 A 和 B,各副本收到操作的顺序可能不同。但没关系——RGA 规定:ID 较大的字符排在前面(或按某种确定性的优先级规则)。所以不管 A 先到还是 B 先到,最终排序结果一样。
- 删除是"墓碑机制":删除一个字符时,不是真的移除,而是给它打一个"已删除"标记(tombstone)。这样另一个副本收到插入操作时,依然能引用到这个已删除的字符(因为删除不影响"引用存在"这件事)。这点很反直觉但很关键。
类比:想象一列火车,每个车厢上都挂着一个不可更改的编号。A 从某节车厢后面接上自己的一节蓝色车厢,B 从同一位置接上一节红色车厢。不管两个人谁先动手,最后这列火车的车厢顺序由编号大小决定——永远是同一个结果。
3.4 实际落地的框架
| 框架 | 核心算法 | 代表使用者 |
|---|---|---|
| Yjs | YATA(RGA 变体) | Notion、Roam Research、AFFiNE |
| Automerge | RGA + 自动合并 | 开源社区,底层协议灵活 |
| Loro | 新型 RGA | 新一代前端协同框架 |
Yjs 是目前生态最成熟的,它提供:
- 核心层:纯 CRDT 数据结构,只定义数据变更模型
- 绑定层:适配不同编辑器(ProseMirror、Quill、CodeMirror、Monaco 等)
- 同步层:支持 WebSocket、WebRTC、IndexedDB 等多种同步渠道
3.5 CRDT vs OT 一图看清
| 维度 | OT | CRDT |
|---|---|---|
| 提出时间 | 1989 | 2006(WOOT)/ 2011(正式) |
| 核心思路 | 转换操作参数保证一致 | 设计可交换数据保证一致 |
| 是否需要中心服务器 | 通常需要 | 不依赖 |
| 算法复杂度 | 高(每种操作要写转换函数) | 中等(需要设计数据结构) |
| 客户端存储 | 操作日志小 | 需要存储额外元数据(ID、墓碑) |
| 离线支持 | 差 | 天然支持 |
| 代表产品 | Google Docs、Office 365 | Figma、Notion、Yjs 项目 |
总结
CRDT 最厉害的地方在于它的勇气——它不试图在"发生冲突后如何解决"这个问题上优化,而是直接用数学方法让冲突从根本上就不可能发生。
这就是CRDT的精髓:通过设计数据结构和操作规则(比如“只增不减”、“给所有变化打上唯一ID”),让来自任何节点、任何顺序的操作,最终合并的结果都是一样的。
这种思路,让实时协作、离线编辑、去中心化同步变成了工程可实现的事情,也直接催生了 Figma、Notion、AFFiNE 等一系列新一代协作产品。今天,你每一次在 Notion 里和同事同时编辑一个页面,背后都是 CRDT 的"无冲突魔法"在悄悄工作。
从工程实践角度看,CRDT 的价值不仅体现在协作产品中。在分布式数据库、边缘计算、P2P 网络等场景,CRDT 的"自动收敛"特性正在被越来越多地采用。如果你正在设计需要多端同步的系统,在引入复杂的冲突解决协议之前,不妨先看看 CRDT 能否满足需求——说不定你需要的不是解决冲突,而是让冲突根本不会发生。
参考资料