2010年9月,Google Docs团队发布了一篇博客文章,宣布新版Google Docs采用了全新的协作架构。他们提到,旧版本需要约1秒钟来显示其他用户的编辑,而新版本将延迟降低到了毫秒级。这背后的技术——Operational Transformation(操作转换)——在之后的十几年里成为了实时协作的行业标准。

但OT有一个致命缺陷:它需要一个中心服务器来协调所有操作。如果你想做一个完全离线的协作应用呢?或者你想让用户在没有服务器的情况下也能同步数据呢?

这就是CRDT(Conflict-free Replicated Data Types,无冲突复制数据类型)要解决的问题。它不需要服务器协调,不需要锁定机制,甚至不需要网络连接——每个副本独立运行,最终却能达到一致状态。

数学上的保证

2011年,INRIA的研究员Marc Shapiro及其团队在分布式系统研讨会上发表了那篇开创性的论文《Conflict-free Replicated Data Types》。论文提出了一个惊人的结论:如果数据结构满足某些数学性质,那么无论用户如何并发操作,系统都能自动收敛到一致状态,且不需要任何同步机制。

这个结论的核心在于两个概念:单调半格(Monotonic Semilattice)交换性(Commutativity)

状态型CRDT(State-based CRDT,或称CvRDT)要求所有可能的状态构成一个半格结构。当两个副本合并时,计算它们的最小上界(Least Upper Bound)。由于半格的性质,这个合并操作满足交换律、结合律和幂等性——无论按什么顺序合并、合并多少次,最终结果都一样。

操作型CRDT(Operation-based CRDT,或称CmRDT)则走另一条路。它要求所有并发操作都满足交换律:如果操作A和B是并发的,那么先执行A再执行B,和先执行B再执行A,最终状态必须相同。

这两种方法在数学上是等价的。Shapiro的论文证明了任何CvRDT都可以被一个CmRDT模拟,反之亦然。

一个计数器的例子

想象一个分布式计数器。用户A在自己的副本上执行了3次增量操作,用户B在自己的副本上执行了5次增量操作。当他们同步时,最终结果应该是多少?

最简单的方案是G-Counter(增长计数器)。每个副本维护一个向量,记录每个节点执行的增量次数。当A执行increment时,它增加自己对应的向量分量。合并时,取两个向量的逐元素最大值。最终计数就是所有向量分量之和。

节点A的状态: [3, 0, 0]  (A执行了3次increment)
节点B的状态: [0, 5, 0]  (B执行了5次increment)
合并后:      [3, 5, 0]  (取逐元素最大值)
最终计数:    8

这个设计保证了一定正确,因为向量在每个维度上单调增长,而最大值操作满足半格的所有性质。

但G-Counter只能增量,不能减量。要支持减量,需要PN-Counter(正负计数器)。它由两个G-Counter组成:一个记录增量,一个记录减量。最终计数就是两者的差。

集合的挑战

计数器只是开始。真正的挑战在于集合(Set)——数据结构中最基础也最重要的类型之一。

普通的集合无法成为CRDT。假设用户A添加元素x,同时用户B删除元素x。当他们同步时,应该保留x还是删除x?这取决于操作顺序,但CRDT不允许依赖顺序。

解决方案是2P-Set(两阶段集合)。它维护两个集合:添加集A和删除集R(称为墓碑集)。查询时返回A - R。如果元素被删除,它会被加入R,之后即使再次添加也无效——因为元素同时在A和R中。

这导致了一个奇怪的行为:元素一旦被删除就不能再添加。这对于购物车这样的场景是不可接受的。

更好的方案是OR-Set(观测移除集合)。每个元素在添加时都获得一个唯一标识符。删除时只删除当前已知的那些标识符。这样,并发添加和删除可以和平共处:如果添加操作在删除之后到达,新添加的标识符不在删除集中,元素就会保留。

文本编辑:交织的噩梦

当CRDT用于文本编辑时,问题变得更加复杂。文本本质上是一个字符列表,而列表的插入和删除操作天然存在冲突。

早期的研究者提出了WOOT、Treedoc、Logoot、LSEQ等算法。它们都基于一个共同思路:给每个字符分配一个唯一标识符,标识符之间有全序关系。字符在文档中的位置由标识符的排序决定。

当用户在两个字符之间插入新字符时,算法生成一个新的标识符,这个标识符在排序上恰好位于两个相邻字符的标识符之间。听起来很优雅,但存在一个致命问题。

2019年,Martin Kleppmann等人在论文《Interleaving anomalies in collaborative text editors》中指出了这个问题:当两个用户同时在同一位置插入不同内容时,合并结果可能产生字符级别的交织。

假设文档最初只有一个换行符。用户A离线编辑,添加了"eggs"(每个字符依次插入)。用户B也离线编辑,添加了"bread"。当两人同步时,可能的结果是"ebgrgesad"——两个词的字符交织在一起,完全无法阅读。

这是因为Logoot和LSEQ等算法在生成标识符时,两个用户选择了相同的树路径,而tiebreaker(决胜机制)导致字符逐个交替排列。这个问题在之前的三十多年里几乎没有被正式讨论过。

2023年,Matthew Weidner和Martin Kleppmann在论文《The Art of the Fugue》中提出了Fugue算法。他们定义了一个新概念——最大非交织性(Maximal Non-Interleaving),并证明了FugueMax算法满足这个性质。在某些极端情况下,交织是不可避免的(数学上可以证明),但FugueMax能在所有可避免的情况下避免交织。

Fugue使用一棵双向树来表示文本序列。每个插入的字符成为树中的一个节点,可能是其左边字符的右孩子,也可能是其右边字符的左孩子。当并发插入发生在同一位置时,它们落在不同的子树中,深度优先遍历自然地保持了各自的连续性。

墓碑与内存代价

CRDT的一个经典批评是内存开销。删除的元素不能真正删除,必须保留为"墓碑"(tombstone),因为其他副本可能还持有针对该元素的并发操作。

对于一个活跃编辑的文本文档,墓碑的数量可能远远超过当前可见的字符数。早期的研究报告称每个字符需要数百字节的元数据。

但现代CRDT库已经大大优化了这个问题。Yjs的作者Kevin Jahns在2020年展示了,通过将连续插入的字符合并为单个"waypoint"对象,并使用Protocol Buffers编码,内存开销可以降低到每个字符约23字节——这已经足够实用。

另一个优化方向是垃圾回收。Yjs实现了墓碑回收机制:当所有副本都确认收到了某个删除操作后,对应的墓碑可以安全移除。但这需要一个额外的协调步骤。

OT还是CRDT?

既然OT在Google Docs等应用中工作良好,为什么还需要CRDT?

关键区别在于架构假设。OT假设存在一个中心服务器来序列化所有操作。当操作A和B并发到达服务器时,服务器决定一个顺序(比如A先于B),然后对B进行转换以考虑A的影响,最后将转换后的B广播给所有客户端。

这个设计有几个问题。首先,它需要服务器持续在线。如果服务器宕机,协作就会停止。其次,服务器的处理能力成为瓶颈——每个操作都需要服务器处理和转发。最后,对于复杂的操作类型,转换函数的正确性很难证明。历史上,多个OT算法被发现存在收敛性bug。

CRDT则完全不同。每个副本可以独立运行,不需要等待其他副本的确认。操作可以延迟到达、乱序到达,甚至永不到达——只要最终到达,系统就能收敛。这使得CRDT天然适合P2P架构和离线优先应用。

Figma的CTO Evan Wallace在讨论他们的协作架构时提到,Figma使用了一种混合方法:对于实时编辑,使用类似OT的技术来保证低延迟;对于离线场景,则依赖CRDT式的最终一致性。

Notion则选择了更简单的路径。据Notion工程师透露,他们的文本协作使用Last-Write-Wins策略,由服务器决定最终状态。这牺牲了某些一致性保证,但换来了实现的简单性。

性能之争:Yjs vs Automerge

当开发者选择CRDT库时,通常会面临Yjs和Automerge之间的抉择。这两个库代表了不同的设计哲学。

Yjs由Kevin Jahns开发,专注于文本编辑场景。它实现了YATA算法的一个变种,对内存和性能进行了极致优化。在基准测试中,Yjs处理一个包含260,000次编辑的真实文本编辑轨迹时,每秒可以处理约39,000次操作,内存使用约3.3MB。

Automerge最初由Martin Kleppmann用JavaScript实现,后来用Rust重写为Automerge 2.0。它提供了更丰富的数据类型(类似JSON的数据模型),但性能开销更大。在相同测试条件下,Automerge 2.0每秒处理约52,000次操作,但内存使用更高。

一个经常被引用的数据是:对于一份100页的文档,早期版本的Automerge需要约10秒和200MB内存来加载,而Yjs几乎可以瞬间完成。Automerge 2.0大幅改善了这个问题,但在纯文本编辑场景下,Yjs仍然有明显的性能优势。

选择哪个库取决于应用场景。如果核心需求是实时文本协作,Yjs是更好的选择。如果需要复杂的数据结构(嵌套对象、映射、列表组合)或者需要在不同语言之间共享状态,Automerge提供了更完整的解决方案。

本地优先软件的兴起

CRDT最重要的应用场景可能是"本地优先"(Local-First)软件。这个概念由Ink & Switch研究所在2019年的一篇论文中正式提出。

传统云应用假设网络始终可用。用户的数据存储在服务器上,客户端只是一个"瘦终端"。当网络断开时,应用变得无用。

本地优先软件则将数据主权还给用户。所有数据优先存储在本地设备上,云服务器只作为可选的同步媒介。用户可以在完全离线的状态下工作,当网络恢复时自动同步。

CRDT是实现本地优先软件的关键技术。它使得多个设备可以在没有协调服务器的情况下独立修改数据,最终自动合并。

典型的本地优先应用包括:

  • 笔记应用:如Obsidian(使用Yjs进行实时协作)
  • 任务管理:如Linear(使用自定义的同步协议)
  • 设计工具:如Figma(使用混合OT/CRDT方法)
  • 音乐制作:如某些DAW软件的协作功能

实现一个简单的CRDT

理解CRDT最好的方式是实现一个。下面是一个G-Counter的TypeScript实现:

class GCounter {
  private counts: Map<string, number> = new Map();
  private nodeId: string;

  constructor(nodeId: string) {
    this.nodeId = nodeId;
  }

  increment(): void {
    const current = this.counts.get(this.nodeId) || 0;
    this.counts.set(this.nodeId, current + 1);
  }

  value(): number {
    let sum = 0;
    for (const count of this.counts.values()) {
      sum += count;
    }
    return sum;
  }

  merge(other: GCounter): void {
    for (const [nodeId, count] of other.counts) {
      const current = this.counts.get(nodeId) || 0;
      this.counts.set(nodeId, Math.max(current, count));
    }
  }

  getState(): Map<string, number> {
    return new Map(this.counts);
  }
}

// 使用示例
const counterA = new GCounter('node-a');
const counterB = new GCounter('node-b');

counterA.increment();
counterA.increment();
counterB.increment();
counterB.increment();
counterB.increment();

console.log(counterA.value()); // 2
console.log(counterB.value()); // 3

// 同步:A接收B的状态
counterA.merge(counterB);
console.log(counterA.value()); // 5

这个实现展示了CRDT的核心思想:状态可以独立演化,合并操作满足交换律和幂等性。无论合并多少次、按什么顺序合并,最终结果都相同。

现实世界的局限

CRDT不是万能药。它有几个重要的局限性需要注意。

首先是语义正确性。CRDT保证状态最终一致,但不保证这个状态有语义意义。例如,两个人同时修改同一个富文本段落的不同属性——一个加粗,一个改颜色——CRDT会保留两个修改,但结果可能不是用户期望的。解决这类问题需要更高级的冲突解决策略,或者放弃自动化,让用户手动解决。

其次是一致性约束。CRDT难以表达"如果A则B"这样的约束。一个经典例子是银行转账:账户A减少100元,账户B增加100元。这两个操作必须同时成功或同时失败。CRDT无法保证这种原子性——如果A的修改到达了而B的修改丢失,系统状态就会不一致。对于这类需求,需要使用分布式事务或其他一致性协议。

第三是历史依赖。某些CRDT(如操作型CRDT)需要保存操作历史来处理延迟到达的操作。这个历史可能无限增长。虽然可以定期压缩,但压缩需要协调——确定哪些操作已经传播到所有副本。

第四是调试困难。当CRDT应用出现问题时,追踪原因非常困难。操作可能以任意顺序到达,状态可能在任何时刻改变。传统的调试方法(断点、日志)在并发场景下效果有限。

未来的方向

CRDT研究仍在快速发展。几个值得关注的趋势:

形式化验证。研究者正在使用定理证明器(如Isabelle、Coq)来验证CRDT实现的正确性。Martin Kleppmann的团队开发了Automerge Model Checker(AMC),可以自动检查CRDT应用的行为是否符合规范。

性能优化。新的算法如Loro(2024年发布1.0版本)声称在某些场景下超越了Yjs。Loro使用了一种称为"delta-state CRDT"的技术,在状态传输和合并效率上做了优化。

语义扩展。传统的CRDT关注数据结构的收敛性,但忽略了用户意图。新的研究方向包括"意图保持"(Intention Preservation)——不仅保证最终状态一致,还保证最终状态符合用户的操作意图。

跨平台支持。随着Rust实现的成熟,CRDT正在被移植到更多平台。Yjs的Rust版本y-crdt、Automerge的多语言绑定,使得在iOS、Android、WebAssembly等平台上使用CRDT变得更加容易。


CRDT代表了分布式系统设计中的一个重要范式转变。它放弃了对强一致性的追求,转而保证最终一致性——不是通过协调,而是通过数学。

当你在协作文档中看到其他人的光标实时移动时,当你在飞机上编辑笔记、落地后自动同步时,当你的密码管理器在多个设备间无缝同步时——CRDT可能正在幕后工作。它是现代协作软件的隐形基础设施,一种将复杂的分布式系统问题转化为优雅的数学结构的艺术。


参考文献

  1. Shapiro, M., Preguiça, N., Baquero, C., & Zawirski, M. (2011). Conflict-free Replicated Data Types. In Stabilization, Safety, and Security of Distributed Systems (SSS 2011). Grenoble, France.

  2. Weidner, M., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. arXiv:2305.00583.

  3. Kleppmann, M., Gomes, V. B. F., Mulligan, D. P., & Beresford, A. R. (2019). Interleaving anomalies in collaborative text editors. In 6th Workshop on Principles and Practice of Consistency for Distributed Data (PaPoC).

  4. Kleppmann, M., Wiggins, A., van der Harden, P., & Macklin, M. (2019). Local-first software: You own your data, in spite of the cloud. In ACM SIGPLAN International Symposium on Software Langugage Engineering.

  5. Attiya, H., Burckhardt, S., Gotsman, A., Morrison, A., Yang, H., & Zawirski, M. (2016). Specification and complexity of collaborative text editing. In ACM Symposium on Principles of Distributed Computing (PODC).

  6. Nicolaescu, P., Jahns, K., Derntl, M., & Klamma, R. (2016). Near real-time peer-to-peer shared editing on extensible data types. In 19th ACM International Conference on Supporting Group Work.

  7. Jahns, K. (2020). Are CRDTs suitable for shared editing? Blog post.

  8. Automerge 2.0 announcement. https://automerge.org/blog/automerge-2/

  9. Yjs Documentation. https://docs.yjs.dev/

  10. Ink & Switch. Local-first software. https://www.inkandswitch.com/local-first/