在 2011 年发表的 CRDT的论文 中,论文作者还明确提出并设计了一些具有代表性的 CRDT 数据结构,每一个都体现了 CRDT 的核心原则:无需协调、天然可合并、保证最终一致性。论文中正式提出并详解的数据结构包括:
| 名称 | 类型 | 特点总结 | 支持操作 |
|---|---|---|---|
| G-Counter | CvRDT | 只能递增,每个副本维护自己计数 | 增加 |
| PN-Counter | CvRDT | 支持加减,使用两个 G-Counter | 增加 / 减少 |
| G-Set | CvRDT | 只能添加元素,不能删除 | 添加 |
| 2P-Set | CvRDT | 添加 + 删除(删除不可恢复) | 添加 / 删除 |
| LWW-Element-Set | CvRDT | 添加/删除带时间戳,最后写入者生效 | 添加 / 删除 |
| OR-Set | CvRDT | 使用唯一 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)只能操作自己的那一项。
该数据结构支持的操作有:
increment()增加计数。只能增加,不支持减!merge()合并另一个副本的状态,对每个副本 ID 的值取最大值。value()计算当前总和,将所有客户端的计数全部累加起来。
举一个例子,初始的时候,两个客户端 A 和 B,因此就有两个副本,分别是 副本 A 和副本 B
副本 A: { A: 3 } // 在客户端A,用户点击了 3 次副本 B: { B: 4 } // 在客户端B,用户点击了 4 次// 假设现在没有联网,两者都是处于离线状态-
A 的 G-Counter 里,只知道自己加了 3 次
-
B 的 G-Counter 里,只知道自己加了 4 次
假设此时,A 又本地加了 1:A: { A: 4 },B 又本地加了 1:B: { B: 5 },那么此时各副本的状态为:
- A 的副本状态是
{ A: 4 } - B 的副本状态是
{ B: 5 } - 注意:现在仍然是离线状态
接下来假设来网了,两个副本开始互相同步状态。这里就涉及到要做一个 merge() 操作,把两个 G-Counter 合并成一个一致的状态。merge() 具体如何做呢?合并规则很简单:对每个副本 ID,取它在两个副本中值的最大值。我们来计算:
A 的值:max(A副本的A值, B副本的A值) = max(4, undefined) = 4B 的值:max(A副本的B值, B副本的B值) = max(undefined, 5) = 5undefined 代表这个副本没记录过这个 ID
合并出来的结果为 { A: 4, B: 5 },这个结果现在被 A 和 B 同时拥有,也就是它们两个副本的状态同步了。
- A 的副本状态是
{ A: 4, B: 5} - B 的副本状态是
{ A: 4, B: 5}
因此总的值就为:
value = 4 + 5 = 9这个值就是当前这个计数器代表的“全局计数值”。
G-Counter 的应用场景
- 点赞数 / 浏览量:每个副本只需要往上加
- 分布式日志计数:多节点写日志,最后总数一致即可
- 活跃节点统计:每个节点上报“活跃数”,总数即在线节点数
CRDT类型#
在论文中,提到了 CRDT 可以分为两种类型:
- 状态型 CRDT,英文缩写为 CvRDT,Convergent Replicated Data Type
- 操作型 CRDT,英文缩写为 CmRDT,Commutative Replicated Data Type
1. CvRDT#
定义:每个副本维护自己的完整状态,副本之间周期性同步整个状态对象,合并时使用一个具有数学特性的 merge() 函数,使多个副本最终收敛为一致的状态。
特点
| 特性 | 说明 |
|---|---|
| 同步单位 | 整个“状态”(state) |
| 合并机制 | merge() 函数(满足 交换 + 结合 + 幂等) |
| 合并时机 | 异步、周期性、最终到达 |
| 抗延迟/断网 | 非常强(副本之间互相独立演进) |
| 要求副本持有完整状态 | 是 |
经典的 CvRDT 例如:
- G-Counter:每个副本存一份
{ replicaId: number },合并时取最大值 - OR-Set:add/remove 是状态,合并时做集合并集
- Graph CRDT:节点/边状态可直接合并
2. CmRDT#
定义:每个副本本地执行修改,同时将操作(operation)发送给其他副本,操作之间是可交换的(Commutative),所以不同副本按任意顺序执行都能得到相同结果。
特点
| 特性 | 说明 |
|---|---|
| 同步单位 | 操作(operation) |
| 合并机制 | 每个副本按本地逻辑执行操作 |
| 操作要求 | 必须是“可交换的” |
| 网络要求 | 每个操作都必须可靠地送达一次 |
| 对操作顺序的依赖 | 无(顺序无关) |
经典的 CmRDT 例如:
- RGA(Replicated Growable Array):文本插入操作是
insert(afterId, value),谁先谁后无所谓 - Logoot、Yjs:插入/删除基于唯一 ID,执行顺序不影响最终顺序
- 聊天消息流 CRDT:每条消息是一个 append 操作
两者对比如下表所示:
| 对比维度 | CvRDT | CmRDT |
|---|---|---|
| 同步的内容 | 状态(整个结构) | 操作(insert/delete等) |
| 合并方式 | merge() 函数 | 顺序无关执行所有操作 |
| 是否要求操作可交换 | 否(状态可合并即可) | ✅ 是 |
| 是否要完整状态 | ✅ 是 | ❌ 否(只要操作流) |
| 网络要求 | 支持延迟/离线 | 要求操作可靠送达(但可乱序) |
| 实现复杂度 | 简单清晰 | 实现更复杂,需处理引用 ID、时钟等 |
OT vs CRDT#
两种方法的相似之处在于它们提供了最终的数据一致性。不同之处在于他们如何做到这一点:
- OT 通过算法控制保证数据一致性:操作通过发送到服务端,一旦收到就会进行转换( OT 控制算法)。
- CRDT 通过数据结构保证数据一致性:操作是在本地 CRDT 上进行的,它的状态或者操作通过网络发送并与副本的状态合并。
其他不同点
- OT 是基于中央服务器进行转换操作;而 CRDT 只需要与同伴交换新数据,不需要中央服务器,每个客户端都可以是独立完整的版本,支持离线进行操作,因此稳定性很高。
- OT 相较于 CRDT 发展更早,技术体系更为成熟,服务端压力大,客户端无需像 CRDT 那样存储大量额外元数据,因此压力较小。服务端重客户端轻
- OT 用复杂性换来了对用户预期的实现;而 CRDT 则更加关注数据结构,复杂性低,不过随着数据结构的复杂度上升,算法的时间和空间复杂度也会呈指数上升的,会带来性能上的挑战。服务端轻客户端重
- OT 处理不同数据模型需要实现不同的转换算法;CRDT 只需要转化数据结构。
- CRDT 去中心化的特征使其很容易实现离线编辑;
另外在这篇 文章 中,作者也提到了为什么 AFFiNE 选择 CRDT 而非 OT 来构建协作编辑器。
AFFiNE 是一个开源的现代化协同文档编辑器项目,该项目采用了 Notion + Miro + Obsidian 的混合风格,强调:
- 支持多人协作
- 支持本地编辑
- 支持 markdown + 富文本 + 白板
- 基于 CRDT 技术实现同步
AFFiNE 目标是构建一个“本地优先、支持多人实时协作、集文档、白板、任务于一体”的全功能办公工具。
-EOF-