引言:在CAP夹缝中找到确定性
2007年,Microsoft的Marc Shapiro等人在论文《A comprehensive study of Convergent and Commutative Replicated Data Types》中正式提出了CRDT这一概念。十五年后的今天,它已成为支撑Figma多人实时协作、Automerge本地优先应用、Riak分布式数据库、Apple Notes离线同步等场景的核心技术。CRDT代表了一种优雅的设计哲学:与其在分布式系统中疲于协调,不如设计一种数据,使它在数学上保证最终会合。
本文将从理论基础出发,深入探讨CRDT的数学原理、核心数据结构、工程实现和实战应用。读完本文,你将理解如何在离线编辑、多人协作、边缘计算等场景中,用数学确定性替代分布式协调。
一、CAP定理与一致性的谱系
1.1 CAP三角的再认识
Brewer定理告诉我们,在分布式系统中无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)。但这三者并非要么全有要么全无,而是一个连续的光谱。CRDT选择了一条极端但实用的路线:用最高级别的可用性(各副本独立可写)换取最终一致性(状态最终必然收敛)。
1.2 一致性强度谱系
从强到弱,一致性模型排列如下:
- 线性一致性(Linearizable): 所有操作看起来是原子的且全局有序 — 强但代价高昂
- 顺序一致性(Sequential): 各进程内部有序,跨进程全局有序
- 因果一致性(Causal): 因果相关的操作全局有序,不相关的可并发
- 会话一致性(Session): 单用户保证读己之写(read-your-writes)
- 单调读一致性(Monotonic Reads): 不会看到旧数据
- 最终一致性(Eventual): 如果没有新写入,所有复制最终会一致
CRDT在最终一致性基础上更进一步:它保证了无论以何种顺序、经过多长时间、经历多少网络分区,所有副本最终会收敛到完全相同的状态。这种称为"强最终一致性"(Strong Eventual Consistency, SEC)的特性,正是CRDT工程价值的根基。
二、CRDT的类型学
CRDT分为两大流派,各有适用场景:
2.1 State-based CRDT(状态型/收敛型CRDT,CvRDT)
核心思想:每个副本维护完整状态,通过广播状态给其他副本。副本间通过一个单调(merging)操作合并状态。为了保证收敛,状态必须构成一个join-semilattice(并半格)。
关键约束:
- 状态空间(S, ≤)必须是一个join-semilattice — 即任意两个状态a和b都存在一个最小上界(least upper bound, LUB) a ⊔ b
- update操作必须是单调的:∀x ∈ S, e ∈ E,其中E是effect的集合,δ(x,e) ≥ x
- 合并操作必须是join操作:merge(a,b) = a ⊔ b
优势:网络层简单可靠(任意次数重传同一状态也不会出错),只依赖最终交付(eventual delivery)而非因果序。劣势:需要传输完整状态,对大对象不友好。
2.2 Operation-based CRDT(操作型/交换型CRDT,CmRDT)
核心思想:不广播状态,而是在本地执行操作后广播该操作的effect。其他副本直接apply这个effect到本地状态。要求所有操作满足可交换性(commutativity)。
关键约束:
- 所有update操作可交换(commutative):apply(apply(s,a),b) = apply(apply(s,b),a)
- 需要保证因果序交付(causally-ordered delivery):如果opA happens-before opB,则所有副本先收到opA
- 操作必须是幂等的
优势:网络开销小(只传输操作)。劣势:依赖可靠的因果序传输协议;如果不满足这个条件(例如使用gossip协议),可能丢失操作。
2.3 两种类型的等价关系
理论证明State-based和Op-based CRDT在表达能力上是等价的:任何State-based CRDT都可以用Op-based方式模拟,反之亦然。工程实践中,NACK-less协议(UDP广播)倾向State-based,TCP/可靠传输倾向Op-based。
三、数学基础:半格、偏序与单调性
3.1 并半格(Join-Semilattice)的直觉
偏序集(S, ≤)是一个并半格,当且仅当对S中任意两个元素a和b,都存在一个join(最小上界)。形象地说,可以找到
三个性质保证了分布式环境下的收敛:
- 交换律:a ⊔ b = b ⊔ a — 合并顺序不影响结果
- 结合律:(a ⊔ b) ⊔ c = a ⊔ (b ⊔ c) — 结合方式不影响结果
- 幂等律:a ⊔ a = a — 重复接收同一状态不影响结果
这三个性质恰好对应了分布式网络中的三种不可靠性:乱序、分区和重复传输。这是State-based CRDT可以工作在gossip协议下的根本原因。
3.2 单调增长的设计原则
CRDT的核心智慧:设计只能增长的数据结构。但真实场景常常需要删除或递减,CRDT通过巧妙的编码实现了这一点——要么引入额外信息来标记删除(如tombstone),要么拆分操作为两个独立的只能增长的部分。
四、经典CRDT数据结构详解
4.1 G-Counter(增长计数器)
最简单的State-based CRDT:一个所有副本只能递增的分布式计数器。
设计:用一个长度为n(n为副本数)的向量,每个副本只向自己的槽位累加。
算法:
- increment():P[i] += 1(只修改自己副本的槽位)
- value():sum(P[0..n-1])(求和得到全局计数)
- merge(other):∀i, P[i] = max(P[i], other.P[i])(取对应槽位最大值)
正确性:max操作是交换的、结合的、幂等的。向量间按元素取max构成join-semilattice。
4.2 PN-Counter(正负计数器)
在G-Counter基础上支持递减操作。
设计:组合两个G-Counter——P记录递增(Positive),N记录递减(Negative)。
算法:
- increment():P[i] += 1
- decrement():N[i] += 1
- value():sum(P) - sum(N)
- merge(other):两个G-Counter分别取max
这个模式非常实用:将双向变化拆分为两个单向(递增)的半格分量。
4.3 LWW-Element-Set(最后写入优先集合)
最常用的State-based CRDT集合,基于时间戳解决冲突。
设计:每个元素关联一个时间戳(member timestamp)。Add操作记录时间戳,Remove操作也记录时间戳(tombstone)。
逻辑:
- add(e, t):A[e] = t(记录添加时间)
- remove(e, t):R[e] = t(记录删除时间)
- lookup(e):A[e] > R[e] 且 A[e] > 0
- merge(other):A[e] = max(A[e], other.A[e]);R[e] = max(R[e], other.R[e])
注意:实际使用建议使用混合逻辑时钟(HLC)而非NTP时间戳,避免时钟偏移导致数据丢失。
4.4 OR-Set(观察移除集合)
解决LWW-Set的经典问题:并发add和remove下,LWW可能让remove胜出(看到的是旧时间戳导致的误删)。
设计:每个元素分配唯一tag(如pair(replica-id, counter))。add(e)生成新tag;remove(e)记录e当前所有可见的tags(观察到的集合)。
核心洞察:remove只移除"此时看到的"副本。如果并发add生成了新tag,这个新tag不在被观察集合中,因此不会被remove。这满足了OR-Set的语义——add-wins over concurrent remove。
算法:
- add(e):生成tag t=(i,u),A.append((e,t))
- remove(e):收集A中e的所有tags,加入R
- lookup(e):∃(e,t)∈A,t∉R
4.5 RGA(复制增长数组)
State-based CRDT中最复杂的结构之一,用于文本协作场景。
问题背景:在多人协作编辑中,插入和删除要处理位置的动态变化。简单地将字符数组化无法处理并发插入同一位置的情况。
RGA设计:每个字符关联一个基于可用ID(availability ID)的节点,通过因果树结构维护顺序。
关键操作:节点有left和right(逻辑邻居),维护一个偏序关系以保证全局唯一排序。通过时间戳和副本ID确定并发插入的确定性排序。
4.6 其他常用CRDT总结
| 数据结构 | 类型 | 用途 | 关键特性 |
|---|---|---|---|
| MV-Register | State-based | 单值寄存器 | 最后写入胜出 |
| Counter-Flag | Op-based | 布尔标记 | 并发开启wins |
| Enable-Wins Flag | Op-based | 布尔标记 | 开启击败关闭 |
| Disable-Wins Flag | Op-based | 布尔标记 | 关闭击败开启 |
| Multi-Value Register | Op-based | 单值 | 并发写入保留多值 |
| Add-Wins Observed-Remove Map | State-based | 键值映射 | 每个key独立OR-Set语义 |
| WW-Set | State-based | 集合 | 写-写并发取max |
| Graph CRDT | State-based | 图结构 | 顶点和边分别作为OR-Set |
| Tree CRDT | Op-based | 树/文档 | 如WOOT序列CRDT |
五、向量时钟与因果一致性
5.1 向量时钟原理
CRDT的混合型实现(如Op-based)需要因果序保证。向量时钟是实现这一保证的核心工具。
定义:每个副本i维护一个n维向量V,V[j]表示副本i看到的来自副本j的最新事件数。
更新规则:本地事件V[i] += 1;发送消息时带上当前V;接收消息时V[k] = max(V[k], msg.V[k])并V[i]+=1。
Happens-before关系:
- V_a < V_b 当且仅当 ∀k, V_a[k] ≤ V_b[k] 且 ∃j, V_a[j] < V_b[j]
- V_a = V_b 当且仅当 ∀k, V_a[k] = V_b[k]
- V_a || V_b(并发):V_a ≮ V_b 且 V_b ≮ V_a
5.2 因果历史(Causal History)
一种简单的Op-based CRDT基础:每个副本记录所有已见过的操作(set of ops)。merge操作是ops的union。只要看到某个操作即视为收到。但这种方法存在无限增长问题,需要配合垃圾回收。
六、OR-Set的工程实现与垃圾回收
6.1 基本实现的内存泄漏问题
Naive实现下,OR-Set的tombstone只增不减。在长期运行的生产系统中,累积的tombstone会导致内存膨胀、状态传输带宽增大。
6.2 Epoch-Based GC
思路:运行时引入epoch概念。当且仅当所有副本都已确认推进epoch后,才清理此前累积的tombstone。
挑战:时钟同步和epoch确认协议增加了系统复杂度。
6.3 Delta-CRDT(增量CRDT)
Shapiro等人2011年的改进思路:不广播完整状态,只广播增量状态(delta)。Delta-CRDT是State-based和Op-based的融合:状态以delta形式传输,按join合并,效果类似Op-based但保留State-based的传输鲁棒性。
七、工程框架与实战
7.1 Yjs:前端实时协作的事实标准
Yjs由Kevin Jahns创建,是目前应用最广泛的开源CRDT库。它解决了多个核心问题:
Yjs的优势:
- 高性能:基于YATA算法,对大和频繁更新的文档表现优异
- 类型丰富:Y.Text、Y.Array、Y.Map、Y.Xml等,构建复杂文档模型
- 结构共享:使用类似HAMP的结构共享,未修改的子树引用原节点
- 强大生态:y-websocket、y-webrtc、y-indexeddb等provider覆盖全部传输场景
- 成熟度:支撑了JupyterLab、Nuclie、Wetype等多个大型应用的实时协作
Yjs的关键架构决策:clientID唯一标识写入者;Lamport时间戳保证互操作性;每操作日志压缩为最小表示。
7.2 Automerge:JSON CRDT
Automerge由Ink&Switch团队(Apple Notes协作功能的前身团队)创建,特点是原生支持JSON对象。
使用Automerge构建协作应用的核心模式:
- 从初始化Automerge.Document开始
- 修改必须通过Automerge.change()在事务中完成
- 通过Automerge.getChanges()获取增量变化
- 通过Automerge.applyChanges()应用到另一端
- 底层自动完成merge和冲突解决
思考:Automerge和Yjs分别代表了两种设计哲学。Automerge像Git一样管理结构化数据;Yjs注重操作的高效传输和文档场景性能。前端文字协作推荐Yjs,结构化数据协作推荐Automerge。
7.3 Riak LWW-Set in Practice
Riak是CRDT工业化的先驱。它将PN-Counter、G-Set、LWW-Set和OR-Set实现为Riak Data Types (DT)。客户端通过CRDT-aware协议获取副本数据,Riak内部使用riak_dt模块管理合并。
经验教训:
- OR-Set必须配合GC使用
- LWW-Set在时钟偏移场景丢失数据
- PN-Counter在大量decrement的场景需要GC防止向量膨胀
- 向量长度等于活跃副本数,加入/离开时需要同步
7.4 在浏览器中构建CRDT应用
这是一个现代Web开发日益重要的方向。IndexedDB提供了本地存储能力,结合Yjs可以实现离线优先(p offline-first)应用。Service Worker可以拦截请求,应用来自本地的CRDT状态。重新联网后只需同步changes,无需传整个文档。
关键实现步骤:
- 初始化y.Doc,绑定y-indexeddb做本地持久化
- y-webrtc或y-websocket连接远程对等体
- 编辑操作经y.Doc自动同步
- 离线时所有改动缓存本地
- 重新联网后CRDT自动merge,无冲突
八、CRDT vs OT vs Diff-Match-Patch
在实时协作场景,CRDT不是唯一的路径。了解不同方案的差异有助于正确选择。
8.1 OT(Operational Transformation, 操作转换)
Google Docs早期使用的核心技术。OT将每次编辑操作(insert/delete)转换为等价操作,使得在不同上下文下应用能产生一致的效果。
OT的优势:中心服务器控制转换逻辑,概念上简单。劣势:服务器可能存在单点故障;客户端需要等待服务器确认;转换函数设计复杂,易出错(n操作类型需要m×n条转换规则)。
8.2 Diff-Match-Patch
Neil Fraser创建的文本差异算法库。通过Myers diff算法计算两个文本的最长公共子序列,生成差异指令。
适用场景:低频协作(如Git merge),不适合实时协作——diff算法O(ND)的性能在长文本和高频编辑下不可接受。
8.3 选型决策树
| 要求 | 推荐方案 | 理由 |
|---|---|---|
| 高频实时文本编辑 | Yjs (CRDT) | 性能最优,传输增量 |
| 结构化JSON协作 | Automerge (CRDT) | 原生JSON支持 |
| 需要中心权限控制 | OT | 服务器集中管理 |
| 低频异步协作 | Diff-Match-Patch | 简单可靠 |
| 离线优先 | CRDT | 天然离线支持 |
| 最大兼容性 | OT | 多种编辑器集成经验 |
九、高级主题:性能优化与边界问题
9.1 状态压缩
运行CRDT的推荐策略:
- 将状态日志与CRDT状态解耦:定期snapshot,清除历史log
- 使用Delta-CRDT替代全量传输:传输增量而非完整状态
- IndexedDB中的分层存储:活跃文档放内存,历史文档放磁盘
- 结构共享:只序列化真正变化的节点
9.2 身份管理与冲突解决
CRDT隐含假设网络中存在唯一的副本标识(clientID)。常见做法:
- WebSocket连接时分配
- 使用设备指纹
- 使用或类似UUID
9.3 实际应用中的冲突检测
在真实UI中我们仍需知道
HOW:通过CRDT内部记录的last-writer-wins信息(RGA中的ID)、本地操作ID追踪或前端记录的操作序列。使用observe()函数订阅特定属性的变更。然后我们可以在UI中高亮显示冲突区域。
9.4 大规模并发下的挑战
Facebook Mira论文展示了CRDT在万级副本规模下的实践。核心建议:
- 分层拓扑:避免全网gossip,用星型+分片
- 操作去重:基于哈希的短期、长期去重缓存
- 元数据效率:使用紧凑的proto-f格式(Yjs v3采用)
十、实战:使用Yjs构建实时Todo应用
让我们用Yjs实现一个简单的跨设备同步的Todo应用,完整演示CRDT实战流程。
10.1 安装与初始化
npm install yjs y-websocket y-indexeddb
10.2 核心架构
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { IndexeddbPersistence } from 'y-indexeddb'
// 创建文档
const ydoc = new Y.Doc()
// 网络层(WebRTC for P2P)
const provider = new WebrtcProvider('my-todo-room', ydoc, {
signaling: ['wss://signaling.ybb.press']
})
// 本地持久化
const indexeddbProvider = new IndexeddbPersistence('my-todo-db', ydoc)
// 共享数据结构
const yTodos = ydoc.getMap('todos')
// 监听远程变更
yTodos.observe(() => {
renderTodos(Array.from(yTodos.values()))
})
// 添加Todo
function addTodo(text) {
const id = Date.now().toString()
ydoc.transact(() => {
yTodos.set(id, {
id,
text,
done: false,
createdAt: Date.now()
})
})
}
// 切换完成状态
function toggleTodo(id) {
ydoc.transact(() => {
const todo = yTodos.get(id)
if (todo) {
yTodos.set(id, { ...todo, done: !todo.done })
}
})
}
// 删除Todo
function removeTodo(id) {
ydoc.transact(() => {
yTodos.delete(id)
})
}
// 在线/离线状态
provider.on('status', (event) => {
console.log('连接状态:', event.status)
})
// 同步状态
provider.on('synced', (event) => {
console.log('已与对等体同步')
})
indexeddbProvider.whenSynced.then(() => {
console.log('本地数据已加载')
renderTodos(Array.from(yTodos.values()))
})
function renderTodos(todos) {
document.getElementById('app').innerHTML = todos
.sort((a, b) => a.createdAt - b.createdAt)
.map(todo => `
<div class="todo ${todo.done ? 'done' : ''}">
<input type="checkbox" ${todo.done ? 'checked' : ''}
onchange="toggleTodo('${todo.id}')">
<span>${todo.text}</span>
<button onclick="removeTodo('${todo.id}')">删除</button>
</div>
`).join('')
}
上面的代码展示了CRDT应用的完整生命周期——IndexedDB提供离线能力,WebRTC网络层提供实时P2P传输,最终CRDT保证数据一致性。
十一、CRDT研究前沿(2024-2025)
11.1 形式验证CRDT
Ã等人提出了CRDT的机械化验证框架,使用Coq证明了经典CRDT的正确性。2024年,L acabou等人使用Lean4重新形式化了包括OR-Set在内的多个CRDT。这标志着CRDT从engineering tools向verified software的演进。
11.2 WASM加速CRDT
将CRDT核心算法编译为WebAssembly可以获得Yjs(纯JS)约3-5倍的合并性能。Cola(Rust编写的CRDT文本序列)和Peritext(CRDT富文本)展示了这一趋势。
11.3 AI与CRDT的交叉
在分布式AI训练和数据集的CRDT管理方向,出现了新的研究需求。CRDT用于分布式模型训练协作的训练数据集版本管理,保证每个worker看到的数据集更新最终一致。
11.4 区块链与CRDT
将CRDT写入操作的日志作为不可变链,每条(可)区块包含多个CRDT操作,同时保证了操作的可追溯性与CRDT的传输鲁棒性。这在联盟链+本地优先应用的混合架构中有潜力。
十二、学习路径与资源推荐
12.1 入门
- Martin Kleppmann的演讲CRDTs: The Hard Parts(Strange Loop 2023) — 最佳入门材料
- crdt.tech — 官方资源门户,收录论文、实现和教程
- Yjs官方文档的"Document Editing"章节 — 最实用的入手文档
12.2 进阶
- Shapiro等人的论文A comprehensive study of Convergent and Commutative Replicated Data Types — CRDT理论基础
- Kleppmann的Designing Data-Intensive Applications第9章 — 分布式一致性的全景综述
- Attiya等人的Specification and Complexity of Replicated Data Types — 复杂性分析
12.3 实战
- Yjs Creator Kevin Jahns的GitHub下的yjs-demos仓库 — 完整可运行demo
- Automerge的Automerge Repo架构 — 生产级CRDT框架设计参考
- Replicache的架构文档 — CRDT+同步协议的完整系统设计
- Local-First Web Development (localfirstweb.dev) — 汇聚最新实践
关键概念速查表
| 概念 | 定义 | 关键性质 |
|---|---|---|
| Join-semilattice | 任意两个元素有最小上界 | 交换律、结合律、幂等律 |
| CvRDT | 基于状态传输的CRDT | 简单可靠,传输开销大 |
| CmRDT | 基于操作传输的CRDT | 传输精简,需要因果序保证 |
| G-Counter | 只能递增的分布式计数器 | 每个副本维护一个槽位 |
| PN-Counter | 支持增减的计数器 | 两个Counter(P-N)拼合 |
| OR-Set | 观察移除集合 | add-wins over concurrent remove |
| RGA | 复制增长数组 | 使用因果ID排序的文本CRDT |
| 向量时钟 | 追踪因果关系的计数器向量 | happens-before判断依据 |
| SEC | 强最终一致性 | 可达+收敛+无冲突的三合一保证 |
| Tombstone | 删除标记 | 必需的删除信息载体 |
| Delta-CRDT | 增量状态传输 | 性能优化的通用范式 |
结语
CRDT从学术研究到工业落地,用二十多年的时间证明了分布式系统的确定性不需要依靠锁、事务和中央协调。它将协调难题从运行时转移到了设计时:用数学结构保证收敛,用单调性约束操作,用半格捕获合并语义。对于任何一个构建多设备、多用户、离线可用的现代应用的开发者来说,CRDT已经从一项学术研究变成了不可或缺的工程工具。
开始你的CRDT之旅,可以从最简单的Yjs Todo应用开始——它只需要几十行代码就能让你体验到数据在无中心化协调下自主收敛的优雅感。

发表评论 取消回复