Skip to content

CRDT数据结构

在 2011 年发表的 CRDT的论文 中,论文作者还明确提出并设计了一些具有代表性的 CRDT 数据结构,每一个都体现了 CRDT 的核心原则:无需协调、天然可合并、保证最终一致性。论文中正式提出并详解的数据结构包括:

名称类型特点总结支持操作
G-CounterCvRDT只能递增,每个副本维护自己计数增加
PN-CounterCvRDT支持加减,使用两个 G-Counter增加 / 减少
G-SetCvRDT只能添加元素,不能删除添加
2P-SetCvRDT添加 + 删除(删除不可恢复)添加 / 删除
LWW-Element-SetCvRDT添加/删除带时间戳,最后写入者生效添加 / 删除
OR-SetCvRDT使用唯一 ID 跟踪添加/删除操作添加 / 删除
Graph(有向图)CvRDT节点和边都是 OR-Set 构建,支持惰性删除添加节点 / 边 / 删除

G-Counter

G-Counter,英文全称是 Grow-only Counter,这是一个 只能递增 的分布式计数器,每个副本维护自己的局部值, 所有副本最终通过取最大值进行合并,从而保证最终一致。

G-Counter 的核心是一个 以副本 ID 为 key 的映射表:

{
// 这里的 A、B、C 就是副本 ID,也就是客户端 ID
A: 3,
B: 5,
C: 0
}

每个副本(比如客户端 A、B、C)只能操作自己的那一项

该数据结构支持的操作有:

  1. increment() 增加计数。只能增加,不支持减!
  2. merge() 合并另一个副本的状态,对每个副本 ID 的值取最大值
  3. value() 计算当前总和,将所有客户端的计数全部累加起来。

举一个例子,初始的时候,两个客户端 A 和 B,因此就有两个副本,分别是 副本 A 和副本 B

副本 A: { A: 3 } // 在客户端A,用户点击了 3 次
副本 B: { B: 4 } // 在客户端B,用户点击了 4 次
// 假设现在没有联网,两者都是处于离线状态

假设此时,A 又本地加了 1:A: { A: 4 },B 又本地加了 1:B: { B: 5 },那么此时各副本的状态为:

接下来假设来网了,两个副本开始互相同步状态。这里就涉及到要做一个 merge() 操作,把两个 G-Counter 合并成一个一致的状态merge() 具体如何做呢?合并规则很简单:对每个副本 ID,取它在两个副本中值的最大值。我们来计算:

A 的值max(A副本的A值, B副本的A值) = max(4, undefined) = 4
B 的值max(A副本的B值, B副本的B值) = max(undefined, 5) = 5

undefined 代表这个副本没记录过这个 ID

合并出来的结果为 { A: 4, B: 5 },这个结果现在被 A 和 B 同时拥有,也就是它们两个副本的状态同步了。

因此总的值就为:

value = 4 + 5 = 9

这个值就是当前这个计数器代表的“全局计数值”。

G-Counter 的应用场景

  1. 点赞数 / 浏览量:每个副本只需要往上加
  2. 分布式日志计数:多节点写日志,最后总数一致即可
  3. 活跃节点统计:每个节点上报“活跃数”,总数即在线节点数

CRDT类型#

在论文中,提到了 CRDT 可以分为两种类型:

  1. 状态型 CRDT,英文缩写为 CvRDT,Convergent Replicated Data Type
  2. 操作型 CRDT,英文缩写为 CmRDT,Commutative Replicated Data Type

1. CvRDT#

定义:每个副本维护自己的完整状态,副本之间周期性同步整个状态对象,合并时使用一个具有数学特性的 merge() 函数,使多个副本最终收敛为一致的状态。

特点

特性说明
同步单位整个“状态”(state)
合并机制merge() 函数(满足 交换 + 结合 + 幂等)
合并时机异步、周期性、最终到达
抗延迟/断网非常强(副本之间互相独立演进)
要求副本持有完整状态

经典的 CvRDT 例如:

2. CmRDT#

定义:每个副本本地执行修改,同时将操作(operation)发送给其他副本,操作之间是可交换的(Commutative),所以不同副本按任意顺序执行都能得到相同结果。

特点

特性说明
同步单位操作(operation)
合并机制每个副本按本地逻辑执行操作
操作要求必须是“可交换的”
网络要求每个操作都必须可靠地送达一次
对操作顺序的依赖无(顺序无关)

经典的 CmRDT 例如:

两者对比如下表所示:

对比维度CvRDTCmRDT
同步的内容状态(整个结构)操作(insert/delete等)
合并方式merge() 函数顺序无关执行所有操作
是否要求操作可交换否(状态可合并即可)✅ 是
是否要完整状态✅ 是❌ 否(只要操作流)
网络要求支持延迟/离线要求操作可靠送达(但可乱序)
实现复杂度简单清晰实现更复杂,需处理引用 ID、时钟等

OT vs CRDT#

两种方法的相似之处在于它们提供了最终的数据一致性。不同之处在于他们如何做到这一点:

其他不同点

  1. OT 是基于中央服务器进行转换操作;而 CRDT 只需要与同伴交换新数据,不需要中央服务器,每个客户端都可以是独立完整的版本,支持离线进行操作,因此稳定性很高
  2. OT 相较于 CRDT 发展更早,技术体系更为成熟,服务端压力大,客户端无需像 CRDT 那样存储大量额外元数据,因此压力较小。服务端重客户端轻
  3. OT 用复杂性换来了对用户预期的实现;而 CRDT 则更加关注数据结构,复杂性低,不过随着数据结构的复杂度上升,算法的时间和空间复杂度也会呈指数上升的,会带来性能上的挑战。服务端轻客户端重
  4. OT 处理不同数据模型需要实现不同的转换算法;CRDT 只需要转化数据结构。
  5. CRDT 去中心化的特征使其很容易实现离线编辑

另外在这篇 文章 中,作者也提到了为什么 AFFiNE 选择 CRDT 而非 OT 来构建协作编辑器。

AFFiNE 是一个开源的现代化协同文档编辑器项目,该项目采用了 Notion + Miro + Obsidian 的混合风格,强调:

  • 支持多人协作
  • 支持本地编辑
  • 支持 markdown + 富文本 + 白板
  • 基于 CRDT 技术实现同步

AFFiNE 目标是构建一个“本地优先、支持多人实时协作、集文档、白板、任务于一体”的全功能办公工具。


-EOF-